refactor: 重构项目结构,将geo_tools重命名为app并更新相关引用

- 将主包名从geo_tools改为app
- 更新所有模块中的引用路径
- 迁移并更新测试用例
- 添加项目规则文档
- 保持原有功能不变,仅进行结构调整
This commit is contained in:
2026-04-12 19:49:56 +08:00
parent fcb8e1f255
commit db51d41aef
41 changed files with 4132 additions and 808 deletions

30
app/utils/__init__.py Normal file
View File

@@ -0,0 +1,30 @@
"""geo_tools.utils 包 —— 通用工具函数。"""
from app.utils.config import load_config, load_json_config, load_toml_config, load_yaml_config
from app.utils.logger import get_logger, set_global_level
from app.utils.validators import (
SUPPORTED_VECTOR_EXTENSIONS,
is_supported_vector_format,
is_valid_crs,
validate_crs,
validate_geometry,
validate_vector_path,
)
__all__ = [
# logger
"get_logger",
"set_global_level",
# config loaders
"load_config",
"load_json_config",
"load_toml_config",
"load_yaml_config",
# validators
"is_valid_crs",
"validate_crs",
"validate_geometry",
"is_supported_vector_format",
"validate_vector_path",
"SUPPORTED_VECTOR_EXTENSIONS",
]

85
app/utils/config.py Normal file
View File

@@ -0,0 +1,85 @@
"""
geo_tools.utils.config
~~~~~~~~~~~~~~~~~~~~~~
配置加载辅助函数:读取 TOML / JSON / YAML 格式的任务配置文件。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
def load_json_config(path: str | Path) -> dict[str, Any]:
"""读取 JSON 配置文件。
Parameters
----------
path:
JSON 文件路径。
Returns
-------
dict
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"配置文件不存在:{path}")
with path.open(encoding="utf-8") as f:
return json.load(f)
def load_toml_config(path: str | Path) -> dict[str, Any]:
"""读取 TOML 配置文件Python 3.11+ 内置 tomllib低版本需 tomli
Parameters
----------
path:
TOML 文件路径。
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"配置文件不存在:{path}")
try:
import tomllib # Python 3.11+
except ImportError:
try:
import tomli as tomllib # type: ignore[no-redef]
except ImportError as exc:
raise ImportError(
"读取 TOML 文件需要 Python 3.11+ 或安装 tomlipip install tomli"
) from exc
with path.open("rb") as f:
return tomllib.load(f)
def load_yaml_config(path: str | Path) -> dict[str, Any]:
"""读取 YAML 配置文件(需安装 PyYAML"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"配置文件不存在:{path}")
try:
import yaml
except ImportError as exc:
raise ImportError("读取 YAML 文件需要安装 pyyamlpip install pyyaml") from exc
with path.open(encoding="utf-8") as f:
return yaml.safe_load(f) or {}
def load_config(path: str | Path) -> dict[str, Any]:
"""根据文件扩展名自动选择解析器。
支持 ``.json``、``.toml``、``.yaml``、``.yml``。
"""
path = Path(path)
ext = path.suffix.lower()
loaders = {
".json": load_json_config,
".toml": load_toml_config,
".yaml": load_yaml_config,
".yml": load_yaml_config,
}
if ext not in loaders:
raise ValueError(f"不支持的配置文件格式:{ext!r},支持:{list(loaders)}")
return loaders[ext](path)

107
app/utils/logger.py Normal file
View File

@@ -0,0 +1,107 @@
"""
geo_tools.utils.logger
~~~~~~~~~~~~~~~~~~~~~~
统一日志工厂,支持同时输出到控制台和文件。
使用方式
--------
>>> from geo_tools.utils.logger import get_logger
>>> logger = get_logger(__name__)
>>> logger.info("处理开始")
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
# 已初始化的 logger 集合,避免重复添加 handler
_initialized: set[str] = set()
def get_logger(
name: str,
level: str | None = None,
log_file: Path | str | None = None,
*,
propagate: bool = False,
) -> logging.Logger:
"""获取(或创建)一个带格式化 handler 的 Logger。
Parameters
----------
name:
Logger 名称,通常传入 ``__name__``。
level:
日志等级字符串;``None`` 时读取 ``settings.log_level``。
log_file:
日志文件路径;``None`` 时读取 ``settings``
若 ``settings.log_to_file`` 为 True则写到 ``settings.log_dir/geo_tools.log``。
propagate:
是否向父 logger 传播,默认 False避免重复输出
Returns
-------
logging.Logger
"""
# 延迟导入,避免循环依赖
from app.config.settings import settings as _settings
if level is None:
level = _settings.log_level
numeric_level = logging.getLevelName(level.upper())
logger = logging.getLogger(name)
logger.propagate = propagate
# 已初始化则直接返回level 可动态调整
if name in _initialized:
logger.setLevel(numeric_level)
return logger
logger.setLevel(numeric_level)
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
# ── 控制台 handler ────────────────────────────────────────
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(numeric_level)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# ── 文件 handler ──────────────────────────────────────────
_resolve_log_file = log_file
if _resolve_log_file is None and _settings.log_to_file:
_settings.ensure_dirs()
_resolve_log_file = _settings.log_dir / "geo_tools.log"
if _resolve_log_file is not None:
file_path = Path(_resolve_log_file)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(file_path, encoding="utf-8")
file_handler.setLevel(numeric_level)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
_initialized.add(name)
return logger
def set_global_level(level: str) -> None:
"""动态调整所有 geo_tools 下 logger 的日志等级。
Parameters
----------
level:
目标日志等级,例如 ``"DEBUG"``。
"""
numeric = logging.getLevelName(level.upper())
root = logging.getLogger("geo_tools")
root.setLevel(numeric)
for handler in root.handlers:
handler.setLevel(numeric)

183
app/utils/validators.py Normal file
View File

@@ -0,0 +1,183 @@
"""
geo_tools.utils.validators
~~~~~~~~~~~~~~~~~~~~~~~~~~
数据验证工具CRS 合法性、几何有效性、文件格式等。
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import geopandas as gpd
from shapely.geometry.base import BaseGeometry
# ── CRS 校验 ──────────────────────────────────────────────────────────────────
def is_valid_crs(crs_input: str | int) -> bool:
"""检查 CRS 是否可以被 pyproj 正常解析。
Parameters
----------
crs_input:
EPSG 代码(整数或 ``"EPSG:4326"`` 字符串)或 proj 字符串。
Returns
-------
bool
"""
try:
from pyproj import CRS
CRS.from_user_input(crs_input)
return True
except Exception:
return False
def validate_crs(crs_input: str | int) -> str:
"""校验并标准化 CRS返回 EPSG 代码字符串。
Raises
------
ValueError
如果 CRS 无法被 pyproj 解析。
"""
from pyproj import CRS
try:
crs_obj = CRS.from_user_input(crs_input)
# 尝试返回简洁的 EPSG 字符串
epsg = crs_obj.to_epsg()
if epsg:
return f"EPSG:{epsg}"
return crs_obj.to_string()
except Exception as exc:
raise ValueError(f"无效的 CRS{crs_input!r}。原因:{exc}") from exc
# ── 几何校验 ──────────────────────────────────────────────────────────────────
def validate_geometry(gdf: "gpd.GeoDataFrame", *, raise_on_invalid: bool = False) -> dict[str, int]:
"""检查 GeoDataFrame 中几何对象的有效性。
Parameters
----------
gdf:
待检查的 GeoDataFrame。
raise_on_invalid:
若为 True当存在无效几何时抛出 ``ValueError``。
Returns
-------
dict
包含 ``total``、``valid``、``invalid``、``null`` 计数。
"""
null_count = gdf.geometry.isna().sum()
non_null = gdf.geometry.dropna()
invalid_mask = ~non_null.is_valid # type: ignore
invalid_count = int(invalid_mask.sum())
valid_count = len(non_null) - invalid_count
result = {
"total": len(gdf),
"valid": valid_count,
"invalid": invalid_count,
"null": int(null_count),
}
if raise_on_invalid and (invalid_count > 0 or null_count > 0):
raise ValueError(
f"GeoDataFrame 存在 {invalid_count} 个无效几何、{null_count} 个空几何。"
)
return result
# ── 文件格式校验 ───────────────────────────────────────────────────────────────
#: 支持读取的矢量文件扩展名fiona 驱动映射)
SUPPORTED_VECTOR_EXTENSIONS: dict[str, str] = {
".shp": "ESRI Shapefile",
".geojson": "GeoJSON",
".json": "GeoJSON",
".gpkg": "GPKG",
".gdb": "OpenFileGDB",
".kml": "KML",
".kmz": "KML",
".csv": "CSV",
".gml": "GML",
".dxf": "DXF",
".fgb": "FlatGeobuf",
}
def is_supported_vector_format(path: str | Path) -> bool:
"""判断路径是否为已知的矢量格式。
Parameters
----------
path:
输入路径,可以是字符串或 Path 对象。
Returns
-------
bool
如果路径是支持的矢量格式,返回 True否则返回 False。
"""
path = Path(path)
suffix = path.suffix.lower()
# .gdb 可能是目录FileGDB
if path.is_dir() and suffix == ".gdb":
return True
return suffix in SUPPORTED_VECTOR_EXTENSIONS
def validate_vector_path(path: str | Path) -> Path:
"""校验矢量数据路径,返回 Path 对象。
Raises
------
FileNotFoundError
文件或目录不存在。
ValueError
文件格式不受支持。
"""
path = Path(path)
# GDB 是目录
if not path.exists():
raise FileNotFoundError(f"路径不存在:{path}")
if not is_supported_vector_format(path):
raise ValueError(
f"不支持的矢量格式:{path.suffix!r}"
f"支持的格式:{list(SUPPORTED_VECTOR_EXTENSIONS.keys())}"
)
return path
def ensure_valid_geometry(geom: 'BaseGeometry', verbose: bool = False) -> 'BaseGeometry':
"""确保几何对象有效,无效时尝试修复。
Parameters
----------
geom:
输入几何。
verbose:
是否输出修复几何的警告信息。
Returns
-------
BaseGeometry
有效的几何对象(可能是修复后的)。
"""
from app.core.geometry import fix_geometry
from app.utils.logger import get_logger
logger = get_logger(__name__)
if not geom.is_valid:
fixed_geom = fix_geometry(geom)
if fixed_geom is not None:
if verbose:
logger.warning("几何对象无效,已自动修复")
return fixed_geom
return geom