topo-tools 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. topo_tools-0.1.0/PKG-INFO +66 -0
  2. topo_tools-0.1.0/README.md +43 -0
  3. topo_tools-0.1.0/pyproject.toml +86 -0
  4. topo_tools-0.1.0/topo_tools/__init__.py +6 -0
  5. topo_tools-0.1.0/topo_tools/__main__.py +6 -0
  6. topo_tools-0.1.0/topo_tools/api/__init__.py +8 -0
  7. topo_tools-0.1.0/topo_tools/api/change.py +224 -0
  8. topo_tools-0.1.0/topo_tools/api/clean.py +170 -0
  9. topo_tools-0.1.0/topo_tools/api/extend.py +128 -0
  10. topo_tools-0.1.0/topo_tools/api/match.py +140 -0
  11. topo_tools-0.1.0/topo_tools/cli/__init__.py +1 -0
  12. topo_tools-0.1.0/topo_tools/cli/main.py +456 -0
  13. topo_tools-0.1.0/topo_tools/core/__init__.py +1 -0
  14. topo_tools-0.1.0/topo_tools/core/change/_01_inputs.py +20 -0
  15. topo_tools-0.1.0/topo_tools/core/change/_02_overlap.py +85 -0
  16. topo_tools-0.1.0/topo_tools/core/change/_03_classify.py +428 -0
  17. topo_tools-0.1.0/topo_tools/core/change/_04_outputs.py +62 -0
  18. topo_tools-0.1.0/topo_tools/core/change/__init__.py +1 -0
  19. topo_tools-0.1.0/topo_tools/core/change/_columns.py +53 -0
  20. topo_tools-0.1.0/topo_tools/core/change/_constants.py +28 -0
  21. topo_tools-0.1.0/topo_tools/core/change/_unionfind.py +51 -0
  22. topo_tools-0.1.0/topo_tools/core/clean/_01_inputs.py +18 -0
  23. topo_tools-0.1.0/topo_tools/core/clean/_02_issues.py +295 -0
  24. topo_tools-0.1.0/topo_tools/core/clean/_03_clean.py +82 -0
  25. topo_tools-0.1.0/topo_tools/core/clean/_04_outputs.py +69 -0
  26. topo_tools-0.1.0/topo_tools/core/clean/__init__.py +5 -0
  27. topo_tools-0.1.0/topo_tools/core/clean/_constants.py +31 -0
  28. topo_tools-0.1.0/topo_tools/core/clean/_units.py +44 -0
  29. topo_tools-0.1.0/topo_tools/core/duckdb_utils.py +195 -0
  30. topo_tools-0.1.0/topo_tools/core/extend/_01_inputs.py +88 -0
  31. topo_tools-0.1.0/topo_tools/core/extend/_02_lines.py +50 -0
  32. topo_tools-0.1.0/topo_tools/core/extend/_03_points.py +165 -0
  33. topo_tools-0.1.0/topo_tools/core/extend/_04_voronoi.py +45 -0
  34. topo_tools-0.1.0/topo_tools/core/extend/_05_merge.py +89 -0
  35. topo_tools-0.1.0/topo_tools/core/extend/_06_outputs.py +32 -0
  36. topo_tools-0.1.0/topo_tools/core/extend/__init__.py +1 -0
  37. topo_tools-0.1.0/topo_tools/core/extend/_constants.py +51 -0
  38. topo_tools-0.1.0/topo_tools/core/extend/_coverage.py +102 -0
  39. topo_tools-0.1.0/topo_tools/core/extend/attempt.py +119 -0
  40. topo_tools-0.1.0/topo_tools/core/match/_01_inputs.py +20 -0
  41. topo_tools-0.1.0/topo_tools/core/match/_02_assign.py +83 -0
  42. topo_tools-0.1.0/topo_tools/core/match/_03_groups.py +174 -0
  43. topo_tools-0.1.0/topo_tools/core/match/_04_merge.py +22 -0
  44. topo_tools-0.1.0/topo_tools/core/match/_05_outputs.py +40 -0
  45. topo_tools-0.1.0/topo_tools/core/match/__init__.py +1 -0
  46. topo_tools-0.1.0/topo_tools/core/match/_clip.py +27 -0
  47. topo_tools-0.1.0/topo_tools/core/match/_constants.py +6 -0
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: topo-tools
3
+ Version: 0.1.0
4
+ Summary: DuckDB-powered geospatial topology utilities (edge-matching, topology cleaning, more)
5
+ Author: fieldmaps.io
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Scientific/Engineering :: GIS
15
+ Requires-Dist: click
16
+ Requires-Dist: duckdb>=1.5.3
17
+ Requires-Dist: psutil
18
+ Requires-Python: >=3.10
19
+ Project-URL: Homepage, https://github.com/fieldmaps/topo-tools-py
20
+ Project-URL: Repository, https://github.com/fieldmaps/topo-tools-py
21
+ Project-URL: Issues, https://github.com/fieldmaps/topo-tools-py/issues
22
+ Description-Content-Type: text/markdown
23
+
24
+ # topo-tools
25
+
26
+ [![CI](https://github.com/fieldmaps/topo-tools-py/actions/workflows/ci.yml/badge.svg)](https://github.com/fieldmaps/topo-tools-py/actions/workflows/ci.yml)
27
+ [![PyPI](https://img.shields.io/pypi/v/topo-tools)](https://pypi.org/project/topo-tools/)
28
+ [![Python versions](https://img.shields.io/pypi/pyversions/topo-tools)](https://pypi.org/project/topo-tools/)
29
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
30
+
31
+ ![World ADM0 boundaries extended with Voronoi-filled coastline](https://raw.githubusercontent.com/fieldmaps/topo-tools-py/main/img/wld_01.png)
32
+
33
+ `topo-tools` is a collection of DuckDB-powered geospatial topology utilities
34
+ for cleaning and reconciling administrative boundary polygons. It ships four
35
+ tools, usable from the CLI or as a Python package:
36
+
37
+ | Tool | What it does | Usage |
38
+ | --- | --- | --- |
39
+ | **clean** | Detects and fixes gap/overlap defects in a single polygon layer; reports slivers separately for manual review. | [`docs/clean.md`](docs/clean.md) |
40
+ | **extend** | Fills gaps around a polygon layer (missing coastline, disputed areas, water bodies) with a Voronoi extension, producing full coverage. | [`docs/extend.md`](docs/extend.md) |
41
+ | **match** | Fits a finer child polygon layer into a coarser parent layer, grouping and extending each child to fill gaps within its own parent. | [`docs/match.md`](docs/match.md) |
42
+ | **change** | Compares two versions of a polygon layer and classifies every unit as unchanged, renamed, modified, split, merged, created, or removed. | [`docs/change.md`](docs/change.md) |
43
+
44
+ ## Installation
45
+
46
+ ```sh
47
+ uv tool install topo-tools # CLI (recommended)
48
+ uv add topo-tools # Python library
49
+ ```
50
+
51
+ Or with pip:
52
+
53
+ ```sh
54
+ pip install topo-tools
55
+ ```
56
+
57
+ Each linked doc above covers that tool's CLI/Python usage, options, and examples.
58
+
59
+ ## Supported Formats
60
+
61
+ Polygon inputs/outputs: GeoParquet (`.parquet`), GeoPackage (`.gpkg`),
62
+ Shapefile (`.shp`), GeoJSON (`.geojson`). Output format matches input format.
63
+ `change`'s tabular changelog is CSV or GeoParquet only; its spatial overlay
64
+ layer supports the same four formats as the other tools.
65
+
66
+ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup.
@@ -0,0 +1,43 @@
1
+ # topo-tools
2
+
3
+ [![CI](https://github.com/fieldmaps/topo-tools-py/actions/workflows/ci.yml/badge.svg)](https://github.com/fieldmaps/topo-tools-py/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/topo-tools)](https://pypi.org/project/topo-tools/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/topo-tools)](https://pypi.org/project/topo-tools/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ ![World ADM0 boundaries extended with Voronoi-filled coastline](https://raw.githubusercontent.com/fieldmaps/topo-tools-py/main/img/wld_01.png)
9
+
10
+ `topo-tools` is a collection of DuckDB-powered geospatial topology utilities
11
+ for cleaning and reconciling administrative boundary polygons. It ships four
12
+ tools, usable from the CLI or as a Python package:
13
+
14
+ | Tool | What it does | Usage |
15
+ | --- | --- | --- |
16
+ | **clean** | Detects and fixes gap/overlap defects in a single polygon layer; reports slivers separately for manual review. | [`docs/clean.md`](docs/clean.md) |
17
+ | **extend** | Fills gaps around a polygon layer (missing coastline, disputed areas, water bodies) with a Voronoi extension, producing full coverage. | [`docs/extend.md`](docs/extend.md) |
18
+ | **match** | Fits a finer child polygon layer into a coarser parent layer, grouping and extending each child to fill gaps within its own parent. | [`docs/match.md`](docs/match.md) |
19
+ | **change** | Compares two versions of a polygon layer and classifies every unit as unchanged, renamed, modified, split, merged, created, or removed. | [`docs/change.md`](docs/change.md) |
20
+
21
+ ## Installation
22
+
23
+ ```sh
24
+ uv tool install topo-tools # CLI (recommended)
25
+ uv add topo-tools # Python library
26
+ ```
27
+
28
+ Or with pip:
29
+
30
+ ```sh
31
+ pip install topo-tools
32
+ ```
33
+
34
+ Each linked doc above covers that tool's CLI/Python usage, options, and examples.
35
+
36
+ ## Supported Formats
37
+
38
+ Polygon inputs/outputs: GeoParquet (`.parquet`), GeoPackage (`.gpkg`),
39
+ Shapefile (`.shp`), GeoJSON (`.geojson`). Output format matches input format.
40
+ `change`'s tabular changelog is CSV or GeoParquet only; its spatial overlay
41
+ layer supports the same four formats as the other tools.
42
+
43
+ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup.
@@ -0,0 +1,86 @@
1
+ [project]
2
+ name = "topo-tools"
3
+ version = "0.1.0"
4
+ description = "DuckDB-powered geospatial topology utilities (edge-matching, topology cleaning, more)"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [{ name = "fieldmaps.io" }]
8
+ requires-python = ">=3.10"
9
+ dependencies = ["click", "duckdb>=1.5.3", "psutil"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Programming Language :: Python :: 3.10",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Scientific/Engineering :: GIS",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/fieldmaps/topo-tools-py"
23
+ Repository = "https://github.com/fieldmaps/topo-tools-py"
24
+ Issues = "https://github.com/fieldmaps/topo-tools-py/issues"
25
+
26
+ [project.scripts]
27
+ topo-tools = "topo_tools.cli.main:cli"
28
+
29
+ [dependency-groups]
30
+ dev = ["import-linter", "pre-commit", "pytest", "ruff"]
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.11.27,<0.12.0"]
34
+ build-backend = "uv_build"
35
+
36
+ [tool.uv.build-backend]
37
+ module-root = ""
38
+
39
+ [tool.ruff]
40
+ fix = true
41
+
42
+ [tool.ruff.lint]
43
+ select = ["ALL"]
44
+ ignore = ["COM812", "D203", "D213", "S603", "S607", "S608"]
45
+
46
+ [tool.ruff.lint.per-file-ignores]
47
+ "tests/*" = ["ANN", "D103", "INP001", "S101"]
48
+
49
+ [tool.importlinter]
50
+ root_package = "topo_tools"
51
+ include_external_packages = true
52
+
53
+ [[tool.importlinter.contracts]]
54
+ id = "core-no-click"
55
+ name = "Core modules must not import Click"
56
+ type = "forbidden"
57
+ source_modules = ["topo_tools.core"]
58
+ forbidden_modules = ["click"]
59
+
60
+ [[tool.importlinter.contracts]]
61
+ id = "api-no-cli"
62
+ name = "API modules must not import CLI"
63
+ type = "forbidden"
64
+ source_modules = ["topo_tools.api"]
65
+ forbidden_modules = ["topo_tools.cli"]
66
+
67
+ [[tool.importlinter.contracts]]
68
+ id = "match-may-use-extend-not-reverse"
69
+ name = "match may depend on extend; extend must not depend on match"
70
+ type = "forbidden"
71
+ source_modules = ["topo_tools.core.extend"]
72
+ forbidden_modules = ["topo_tools.core.match"]
73
+
74
+ [[tool.importlinter.contracts]]
75
+ id = "clean-may-use-extend-not-reverse"
76
+ name = "clean may depend on extend; extend must not depend on clean"
77
+ type = "forbidden"
78
+ source_modules = ["topo_tools.core.extend"]
79
+ forbidden_modules = ["topo_tools.core.clean"]
80
+
81
+ [[tool.importlinter.contracts]]
82
+ id = "change-may-use-extend-not-reverse"
83
+ name = "change may depend on extend; extend must not depend on change"
84
+ type = "forbidden"
85
+ source_modules = ["topo_tools.core.extend"]
86
+ forbidden_modules = ["topo_tools.core.change"]
@@ -0,0 +1,6 @@
1
+ """topo-tools: DuckDB-powered geospatial topology utilities."""
2
+
3
+ from .api import extend, match
4
+ from .cli.main import cli
5
+
6
+ __all__ = ["cli", "extend", "match"]
@@ -0,0 +1,6 @@
1
+ """Allows `python -m topo_tools ...`."""
2
+
3
+ from .cli.main import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -0,0 +1,8 @@
1
+ """Public functions library callers import — no click dependency."""
2
+
3
+ from .change import change
4
+ from .clean import clean
5
+ from .extend import extend
6
+ from .match import match
7
+
8
+ __all__ = ["change", "clean", "extend", "match"]
@@ -0,0 +1,224 @@
1
+ """Public API: compare two polygon layer versions and classify what changed."""
2
+
3
+ import shutil
4
+ import signal
5
+ import tempfile
6
+ from logging import getLogger
7
+ from pathlib import Path
8
+ from types import FrameType
9
+ from typing import NoReturn
10
+
11
+ from duckdb import DuckDBPyConnection
12
+
13
+ from topo_tools.core.change import _01_inputs as inputs
14
+ from topo_tools.core.change import _02_overlap as overlap
15
+ from topo_tools.core.change import _03_classify as classify
16
+ from topo_tools.core.change import _04_outputs as outputs
17
+ from topo_tools.core.change._columns import detect_code_column, detect_name_column
18
+ from topo_tools.core.change._constants import (
19
+ TABLE_COPY_OPTS,
20
+ TAU_MATCH_DEFAULT,
21
+ TAU_SAME_DEFAULT,
22
+ )
23
+ from topo_tools.core.duckdb_utils import (
24
+ cleanup_tmp,
25
+ export_debug_tables,
26
+ get_connection,
27
+ log_file,
28
+ )
29
+ from topo_tools.core.extend._constants import COPY_OPTS
30
+
31
+ logger = getLogger(__name__)
32
+
33
+ _STEP_ORDER = ["inputs", "overlap", "classify", "outputs"]
34
+
35
+ _STEP_TABLES = {
36
+ "inputs": ["{n}_a_01", "{n}_b_01"],
37
+ "overlap": ["{n}_02"],
38
+ "classify": ["{n}_03a", "{n}_03b", "{n}_03c"],
39
+ "outputs": [],
40
+ }
41
+
42
+
43
+ def _resolve_column(
44
+ conn: DuckDBPyConnection, table: str, explicit: str | None, *, kind: str, side: str
45
+ ) -> str | None:
46
+ if explicit is not None:
47
+ return explicit
48
+ detector = detect_code_column if kind == "code" else detect_name_column
49
+ column = detector(conn, table)
50
+ if column is None:
51
+ msg = (
52
+ f"--link-by-{kind} was requested but no {kind} column could be "
53
+ f"auto-detected on the {side} side; pass --{kind}-column-{side} explicitly"
54
+ )
55
+ raise ValueError(msg)
56
+ return column
57
+
58
+
59
+ def change( # noqa: C901, PLR0912, PLR0913, PLR0915
60
+ old_path: str | Path,
61
+ new_path: str | Path,
62
+ output_path: str | Path | None = None,
63
+ overlay_path: str | Path | None = None,
64
+ *,
65
+ tau_match: float = TAU_MATCH_DEFAULT,
66
+ tau_same: float = TAU_SAME_DEFAULT,
67
+ link_by_code: bool = False,
68
+ link_by_name: bool = False,
69
+ link_mode: str = "either",
70
+ code_column_a: str | None = None,
71
+ code_column_b: str | None = None,
72
+ name_column_a: str | None = None,
73
+ name_column_b: str | None = None,
74
+ threads: int | None = None,
75
+ tmp_dir: str | Path | None = None,
76
+ overwrite: bool = False,
77
+ debug: bool = False,
78
+ step: str | None = None,
79
+ ) -> None:
80
+ """Compare two polygon layer versions and classify every unit's relationship.
81
+
82
+ Processes exactly one old file + one new file per call. Classifies each
83
+ unit as unchanged/renamed/modified/relocated/split/merge/complex/created/
84
+ removed, using spatial overlap (tau_match/tau_same) and, optionally,
85
+ code/name identity linking. Always writes two files: a tabular changelog
86
+ (output_path, CSV/Parquet, "_changelog" suffix if omitted) and a spatial
87
+ overlay layer colored by relationship_class (overlay_path, "_overlay"
88
+ suffix if omitted).
89
+ """
90
+ if step is not None and step not in _STEP_ORDER:
91
+ msg = f"step must be one of {_STEP_ORDER}, got {step!r}"
92
+ raise ValueError(msg)
93
+ if link_mode not in ("either", "both"):
94
+ msg = f"link_mode must be 'either' or 'both', got {link_mode!r}"
95
+ raise ValueError(msg)
96
+
97
+ old_path = Path(old_path)
98
+ new_path = Path(new_path)
99
+ output_path = (
100
+ Path(output_path)
101
+ if output_path is not None
102
+ else old_path.parent / f"{old_path.stem}_{new_path.stem}_changelog.csv"
103
+ )
104
+ if output_path.suffix not in TABLE_COPY_OPTS:
105
+ msg = (
106
+ f"output file must be one of {sorted(TABLE_COPY_OPTS)} (a tabular "
107
+ "format -- the changelog has no geometry column), got "
108
+ f"{output_path.suffix!r}"
109
+ )
110
+ raise ValueError(msg)
111
+ overlay_path = (
112
+ Path(overlay_path)
113
+ if overlay_path is not None
114
+ else output_path.with_stem(output_path.stem + "_overlay").with_suffix(
115
+ old_path.suffix
116
+ )
117
+ )
118
+ if overlay_path.suffix not in COPY_OPTS:
119
+ msg = (
120
+ f"overlay file must be one of {sorted(COPY_OPTS)}, "
121
+ f"got {overlay_path.suffix!r}"
122
+ )
123
+ raise ValueError(msg)
124
+ if output_path.exists() and not overwrite:
125
+ msg = f"output already exists: {output_path}"
126
+ raise FileExistsError(msg)
127
+ if overlay_path.exists() and not overwrite:
128
+ msg = f"output already exists: {overlay_path}"
129
+ raise FileExistsError(msg)
130
+
131
+ owns_tmp_dir = tmp_dir is None
132
+ tmp_dir_path = (
133
+ Path(tmp_dir)
134
+ if tmp_dir is not None
135
+ else Path(tempfile.mkdtemp(prefix="topo_tools_"))
136
+ )
137
+ tmp_dir_path.mkdir(exist_ok=True, parents=True)
138
+
139
+ # "_changelog" keeps every table/file this call creates distinct from an
140
+ # extend()/match()/clean() run against the same old_path/tmp_dir, same
141
+ # collision-avoidance reasoning as match's "_match" and clean's "_clean".
142
+ name = old_path.name.replace(".", "_") + "_changelog"
143
+ if not step:
144
+ cleanup_tmp(name, tmp_dir_path, parquet=True)
145
+
146
+ with log_file(name, tmp_dir_path):
147
+ conn = get_connection(name, tmp_dir_path, threads=threads, debug=debug)
148
+
149
+ def _interrupt(_sig: int, _frame: FrameType | None) -> NoReturn:
150
+ conn.interrupt()
151
+ raise KeyboardInterrupt
152
+
153
+ old_handler = signal.signal(signal.SIGINT, _interrupt)
154
+ try:
155
+ logger.info("starting: %s", name)
156
+ for s in _STEP_ORDER:
157
+ if step and step != s:
158
+ continue
159
+ if debug:
160
+ logger.info("=== %s ===", s)
161
+ if s == "inputs":
162
+ inputs.main(conn, name, old_path, new_path)
163
+ elif s == "overlap":
164
+ overlap.main(conn, name)
165
+ elif s == "classify":
166
+ resolved_code_a = (
167
+ _resolve_column(
168
+ conn, f"{name}_a_01", code_column_a, kind="code", side="a"
169
+ )
170
+ if link_by_code
171
+ else code_column_a
172
+ )
173
+ resolved_code_b = (
174
+ _resolve_column(
175
+ conn, f"{name}_b_01", code_column_b, kind="code", side="b"
176
+ )
177
+ if link_by_code
178
+ else code_column_b
179
+ )
180
+ resolved_name_a = (
181
+ _resolve_column(
182
+ conn, f"{name}_a_01", name_column_a, kind="name", side="a"
183
+ )
184
+ if link_by_name
185
+ else name_column_a
186
+ )
187
+ resolved_name_b = (
188
+ _resolve_column(
189
+ conn, f"{name}_b_01", name_column_b, kind="name", side="b"
190
+ )
191
+ if link_by_name
192
+ else name_column_b
193
+ )
194
+ classify.main(
195
+ conn,
196
+ name,
197
+ tau_match=tau_match,
198
+ tau_same=tau_same,
199
+ link_by_code=link_by_code,
200
+ link_by_name=link_by_name,
201
+ link_mode=link_mode,
202
+ code_col_a=resolved_code_a,
203
+ code_col_b=resolved_code_b,
204
+ name_col_a=resolved_name_a,
205
+ name_col_b=resolved_name_b,
206
+ )
207
+ elif s == "outputs":
208
+ outputs.main(conn, name, output_path, overlay_path, debug=debug)
209
+ if debug:
210
+ only = None
211
+ if step and step in _STEP_TABLES:
212
+ only = {t.format(n=name) for t in _STEP_TABLES[step]}
213
+ export_debug_tables(conn, tmp_dir_path, only=only)
214
+ logger.info("done: %s", name)
215
+ finally:
216
+ signal.signal(signal.SIGINT, old_handler)
217
+ conn.close()
218
+ if not step and not debug:
219
+ cleanup_tmp(name, tmp_dir_path)
220
+ if owns_tmp_dir:
221
+ if debug:
222
+ logger.info("tmp_dir preserved for --debug: %s", tmp_dir_path)
223
+ else:
224
+ shutil.rmtree(tmp_dir_path, ignore_errors=True)
@@ -0,0 +1,170 @@
1
+ """Public API: detect and fix coverage defects (gaps, overlaps) in a polygon layer."""
2
+
3
+ import shutil
4
+ import signal
5
+ import tempfile
6
+ from logging import getLogger
7
+ from pathlib import Path
8
+ from types import FrameType
9
+ from typing import NoReturn
10
+
11
+ from topo_tools.core.clean import _01_inputs as inputs
12
+ from topo_tools.core.clean import _02_issues as issues
13
+ from topo_tools.core.clean import _03_clean as clean_stage
14
+ from topo_tools.core.clean import _04_outputs as outputs
15
+ from topo_tools.core.clean._constants import SLIVER_TOLERANCE_DEFAULT_M
16
+ from topo_tools.core.duckdb_utils import (
17
+ cleanup_tmp,
18
+ export_debug_tables,
19
+ get_connection,
20
+ log_file,
21
+ )
22
+
23
+ logger = getLogger(__name__)
24
+
25
+ _STEP_ORDER = ["inputs", "issues", "clean", "outputs"]
26
+
27
+ _STEP_TABLES = {
28
+ "inputs": ["{n}_01"],
29
+ "issues": ["{n}_02"],
30
+ "clean": ["{n}_03"],
31
+ "outputs": [],
32
+ }
33
+
34
+
35
+ def _parse_gap_width(value: str) -> tuple[str, float | None]:
36
+ if value in ("auto", "all"):
37
+ return value, None
38
+ try:
39
+ return "value", float(value)
40
+ except ValueError:
41
+ msg = f"--gap-width must be 'auto', 'all', or a number in meters, got {value!r}"
42
+ raise ValueError(msg) from None
43
+
44
+
45
+ def _parse_snap_tolerance(value: str) -> tuple[str, float | None]:
46
+ if value == "auto":
47
+ return "auto", None
48
+ try:
49
+ return "value", float(value)
50
+ except ValueError:
51
+ msg = f"--snap-tolerance must be 'auto' or a number in meters, got {value!r}"
52
+ raise ValueError(msg) from None
53
+
54
+
55
+ def clean( # noqa: C901, PLR0912, PLR0913, PLR0915
56
+ input_path: str | Path,
57
+ output_path: str | Path | None = None,
58
+ issues_path: str | Path | None = None,
59
+ *,
60
+ gap_width: str = "all",
61
+ snap_tolerance: str = "auto",
62
+ sliver_tolerance_m: float = SLIVER_TOLERANCE_DEFAULT_M,
63
+ threads: int | None = None,
64
+ tmp_dir: str | Path | None = None,
65
+ overwrite: bool = False,
66
+ debug: bool = False,
67
+ step: str | None = None,
68
+ ) -> None:
69
+ """Detect and fix gap/overlap defects in a single polygon layer.
70
+
71
+ Processes exactly one file per call. Slivers (near-miss boundary
72
+ mismatches) are detected and reported but never auto-fixed -- widening
73
+ ST_CoverageClean's snap tolerance to force one closed re-nodes the whole
74
+ coverage, not just the defect site. Always writes two files: the cleaned
75
+ dataset (output_path, "_cleaned" suffix if omitted) and an issues report
76
+ (issues_path, "_issues" suffix if omitted) so a human can review flagged
77
+ slivers -- and any gaps left unfilled -- before deciding what to do with
78
+ them.
79
+ """
80
+ if step is not None and step not in _STEP_ORDER:
81
+ msg = f"step must be one of {_STEP_ORDER}, got {step!r}"
82
+ raise ValueError(msg)
83
+
84
+ parsed_gap_width = _parse_gap_width(gap_width)
85
+ parsed_snap_tolerance = _parse_snap_tolerance(snap_tolerance)
86
+
87
+ input_path = Path(input_path)
88
+ output_path = (
89
+ Path(output_path)
90
+ if output_path is not None
91
+ else input_path.with_stem(input_path.stem + "_cleaned")
92
+ )
93
+ issues_path = (
94
+ Path(issues_path)
95
+ if issues_path is not None
96
+ else output_path.with_stem(output_path.stem + "_issues")
97
+ )
98
+ if issues_path.suffix == ".shp":
99
+ msg = (
100
+ "issues file cannot be Shapefile: the issues report mixes Polygon "
101
+ "(gap/overlap) and LineString (sliver) geometry in one table, which "
102
+ "Shapefile's single-geometry-type-per-file format can't represent"
103
+ )
104
+ raise ValueError(msg)
105
+ if output_path.exists() and not overwrite:
106
+ msg = f"output already exists: {output_path}"
107
+ raise FileExistsError(msg)
108
+ if issues_path.exists() and not overwrite:
109
+ msg = f"output already exists: {issues_path}"
110
+ raise FileExistsError(msg)
111
+
112
+ owns_tmp_dir = tmp_dir is None
113
+ tmp_dir_path = (
114
+ Path(tmp_dir)
115
+ if tmp_dir is not None
116
+ else Path(tempfile.mkdtemp(prefix="topo_tools_"))
117
+ )
118
+ tmp_dir_path.mkdir(exist_ok=True, parents=True)
119
+
120
+ name = input_path.name.replace(".", "_") + "_clean"
121
+ if not step:
122
+ cleanup_tmp(name, tmp_dir_path, parquet=True)
123
+
124
+ with log_file(name, tmp_dir_path):
125
+ conn = get_connection(name, tmp_dir_path, threads=threads, debug=debug)
126
+
127
+ def _interrupt(_sig: int, _frame: FrameType | None) -> NoReturn:
128
+ conn.interrupt()
129
+ raise KeyboardInterrupt
130
+
131
+ old_handler = signal.signal(signal.SIGINT, _interrupt)
132
+ try:
133
+ logger.info("starting: %s", name)
134
+ for s in _STEP_ORDER:
135
+ if step and step != s:
136
+ continue
137
+ if debug:
138
+ logger.info("=== %s ===", s)
139
+ if s == "inputs":
140
+ inputs.main(conn, name, input_path)
141
+ elif s == "issues":
142
+ issues.main(
143
+ conn, name, sliver_tolerance_m=sliver_tolerance_m, debug=debug
144
+ )
145
+ elif s == "clean":
146
+ clean_stage.main(
147
+ conn,
148
+ name,
149
+ gap_width=parsed_gap_width,
150
+ snap_tolerance=parsed_snap_tolerance,
151
+ debug=debug,
152
+ )
153
+ elif s == "outputs":
154
+ outputs.main(conn, name, output_path, issues_path, debug=debug)
155
+ if debug:
156
+ only = None
157
+ if step and step in _STEP_TABLES:
158
+ only = {t.format(n=name) for t in _STEP_TABLES[step]}
159
+ export_debug_tables(conn, tmp_dir_path, only=only)
160
+ logger.info("done: %s", name)
161
+ finally:
162
+ signal.signal(signal.SIGINT, old_handler)
163
+ conn.close()
164
+ if not step and not debug:
165
+ cleanup_tmp(name, tmp_dir_path)
166
+ if owns_tmp_dir:
167
+ if debug:
168
+ logger.info("tmp_dir preserved for --debug: %s", tmp_dir_path)
169
+ else:
170
+ shutil.rmtree(tmp_dir_path, ignore_errors=True)