refactor: 重构项目结构,将geo_tools重命名为app并更新相关引用
- 将主包名从geo_tools改为app - 更新所有模块中的引用路径 - 迁移并更新测试用例 - 添加项目规则文档 - 保持原有功能不变,仅进行结构调整
This commit is contained in:
30
app/utils/__init__.py
Normal file
30
app/utils/__init__.py
Normal 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
85
app/utils/config.py
Normal 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+ 或安装 tomli:pip 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 文件需要安装 pyyaml:pip 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
107
app/utils/logger.py
Normal 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
183
app/utils/validators.py
Normal 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
|
||||
Reference in New Issue
Block a user