topo-tools 0.1.0__py3-none-any.whl

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 (48) hide show
  1. topo_tools/__init__.py +6 -0
  2. topo_tools/__main__.py +6 -0
  3. topo_tools/api/__init__.py +8 -0
  4. topo_tools/api/change.py +224 -0
  5. topo_tools/api/clean.py +170 -0
  6. topo_tools/api/extend.py +128 -0
  7. topo_tools/api/match.py +140 -0
  8. topo_tools/cli/__init__.py +1 -0
  9. topo_tools/cli/main.py +456 -0
  10. topo_tools/core/__init__.py +1 -0
  11. topo_tools/core/change/_01_inputs.py +20 -0
  12. topo_tools/core/change/_02_overlap.py +85 -0
  13. topo_tools/core/change/_03_classify.py +428 -0
  14. topo_tools/core/change/_04_outputs.py +62 -0
  15. topo_tools/core/change/__init__.py +1 -0
  16. topo_tools/core/change/_columns.py +53 -0
  17. topo_tools/core/change/_constants.py +28 -0
  18. topo_tools/core/change/_unionfind.py +51 -0
  19. topo_tools/core/clean/_01_inputs.py +18 -0
  20. topo_tools/core/clean/_02_issues.py +295 -0
  21. topo_tools/core/clean/_03_clean.py +82 -0
  22. topo_tools/core/clean/_04_outputs.py +69 -0
  23. topo_tools/core/clean/__init__.py +5 -0
  24. topo_tools/core/clean/_constants.py +31 -0
  25. topo_tools/core/clean/_units.py +44 -0
  26. topo_tools/core/duckdb_utils.py +195 -0
  27. topo_tools/core/extend/_01_inputs.py +88 -0
  28. topo_tools/core/extend/_02_lines.py +50 -0
  29. topo_tools/core/extend/_03_points.py +165 -0
  30. topo_tools/core/extend/_04_voronoi.py +45 -0
  31. topo_tools/core/extend/_05_merge.py +89 -0
  32. topo_tools/core/extend/_06_outputs.py +32 -0
  33. topo_tools/core/extend/__init__.py +1 -0
  34. topo_tools/core/extend/_constants.py +51 -0
  35. topo_tools/core/extend/_coverage.py +102 -0
  36. topo_tools/core/extend/attempt.py +119 -0
  37. topo_tools/core/match/_01_inputs.py +20 -0
  38. topo_tools/core/match/_02_assign.py +83 -0
  39. topo_tools/core/match/_03_groups.py +174 -0
  40. topo_tools/core/match/_04_merge.py +22 -0
  41. topo_tools/core/match/_05_outputs.py +40 -0
  42. topo_tools/core/match/__init__.py +1 -0
  43. topo_tools/core/match/_clip.py +27 -0
  44. topo_tools/core/match/_constants.py +6 -0
  45. topo_tools-0.1.0.dist-info/METADATA +66 -0
  46. topo_tools-0.1.0.dist-info/RECORD +48 -0
  47. topo_tools-0.1.0.dist-info/WHEEL +4 -0
  48. topo_tools-0.1.0.dist-info/entry_points.txt +3 -0
topo_tools/__init__.py ADDED
@@ -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"]
topo_tools/__main__.py ADDED
@@ -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)
@@ -0,0 +1,128 @@
1
+ """Public API: extend polygon boundaries outward with Voronoi diagrams."""
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.duckdb_utils import (
12
+ cleanup_tmp,
13
+ export_debug_tables,
14
+ get_connection,
15
+ log_file,
16
+ )
17
+ from topo_tools.core.extend import _01_inputs as inputs
18
+ from topo_tools.core.extend import _02_lines as lines
19
+ from topo_tools.core.extend import _05_merge as merge
20
+ from topo_tools.core.extend import _06_outputs as outputs
21
+ from topo_tools.core.extend import attempt
22
+
23
+ logger = getLogger(__name__)
24
+
25
+ _STEP_ORDER = ["inputs", "lines", "attempt", "merge", "outputs"]
26
+
27
+ _STEP_TABLES = {
28
+ "inputs": ["{n}_01"],
29
+ "lines": ["{n}_02"],
30
+ "attempt": [
31
+ "{n}_03a",
32
+ "{n}_03_tmp1",
33
+ "{n}_03_tmp2",
34
+ "{n}_03_tmp3",
35
+ "{n}_03_tmp4",
36
+ "{n}_03b",
37
+ "{n}_04",
38
+ "{n}_04_tmp1",
39
+ "{n}_04_tmp2",
40
+ ],
41
+ "merge": ["{n}_05", "{n}_05_tmp1", "{n}_05_tmp2", "{n}_05_tmp3"],
42
+ "outputs": [],
43
+ }
44
+
45
+
46
+ def extend( # noqa: C901, PLR0912, PLR0913, PLR0915
47
+ input_path: str | Path,
48
+ output_path: str | Path | None = None,
49
+ *,
50
+ memory_gb: float = 4.0,
51
+ threads: int | None = None,
52
+ tmp_dir: str | Path | None = None,
53
+ overwrite: bool = False,
54
+ debug: bool = False,
55
+ step: str | None = None,
56
+ ) -> None:
57
+ """Extend polygon boundaries outward with Voronoi diagrams to fill coverage gaps.
58
+
59
+ Processes exactly one file per call. If output_path is omitted, it defaults
60
+ to input_path with an "_extended" suffix in the same directory.
61
+ """
62
+ if step is not None and step not in _STEP_ORDER:
63
+ msg = f"step must be one of {_STEP_ORDER}, got {step!r}"
64
+ raise ValueError(msg)
65
+
66
+ input_path = Path(input_path)
67
+ output_path = (
68
+ Path(output_path)
69
+ if output_path is not None
70
+ else input_path.with_stem(input_path.stem + "_extended")
71
+ )
72
+ if output_path.exists() and not overwrite:
73
+ msg = f"output already exists: {output_path}"
74
+ raise FileExistsError(msg)
75
+
76
+ owns_tmp_dir = tmp_dir is None
77
+ tmp_dir_path = (
78
+ Path(tmp_dir)
79
+ if tmp_dir is not None
80
+ else Path(tempfile.mkdtemp(prefix="topo_tools_"))
81
+ )
82
+ tmp_dir_path.mkdir(exist_ok=True, parents=True)
83
+
84
+ name = input_path.name.replace(".", "_")
85
+ if not step:
86
+ cleanup_tmp(name, tmp_dir_path, parquet=True)
87
+
88
+ with log_file(name, tmp_dir_path):
89
+ conn = get_connection(name, tmp_dir_path, threads=threads, debug=debug)
90
+
91
+ def _interrupt(_sig: int, _frame: FrameType | None) -> NoReturn:
92
+ conn.interrupt()
93
+ raise KeyboardInterrupt
94
+
95
+ old_handler = signal.signal(signal.SIGINT, _interrupt)
96
+ try:
97
+ logger.info("starting: %s", name)
98
+ for s in _STEP_ORDER:
99
+ if step and step != s:
100
+ continue
101
+ if debug:
102
+ logger.info("=== %s ===", s)
103
+ if s == "inputs":
104
+ inputs.main(conn, name, input_path)
105
+ elif s == "lines":
106
+ lines.main(conn, name)
107
+ elif s == "attempt":
108
+ attempt.main(conn, name, memory_gb=memory_gb, debug=debug)
109
+ elif s == "merge":
110
+ merge.main(conn, name, debug=debug)
111
+ elif s == "outputs":
112
+ outputs.main(conn, name, output_path, debug=debug)
113
+ if debug:
114
+ only = None
115
+ if step and step in _STEP_TABLES:
116
+ only = {t.format(n=name) for t in _STEP_TABLES[step]}
117
+ export_debug_tables(conn, tmp_dir_path, only=only)
118
+ logger.info("done: %s", name)
119
+ finally:
120
+ signal.signal(signal.SIGINT, old_handler)
121
+ conn.close()
122
+ if not step and not debug:
123
+ cleanup_tmp(name, tmp_dir_path)
124
+ if owns_tmp_dir:
125
+ if debug:
126
+ logger.info("tmp_dir preserved for --debug: %s", tmp_dir_path)
127
+ else:
128
+ shutil.rmtree(tmp_dir_path, ignore_errors=True)
@@ -0,0 +1,140 @@
1
+ """Public API: match child polygons to parent boundaries, then extend to fill gaps."""
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.duckdb_utils import (
12
+ cleanup_tmp,
13
+ export_debug_tables,
14
+ get_connection,
15
+ log_file,
16
+ )
17
+ from topo_tools.core.match import _01_inputs as inputs
18
+ from topo_tools.core.match import _02_assign as assign
19
+ from topo_tools.core.match import _03_groups as groups
20
+ from topo_tools.core.match import _04_merge as merge
21
+ from topo_tools.core.match import _05_outputs as outputs
22
+
23
+ logger = getLogger(__name__)
24
+
25
+ _STEP_ORDER = ["inputs", "assign", "groups", "merge", "outputs"]
26
+
27
+ _STEP_TABLES = {
28
+ "inputs": ["{n}_child_01", "{n}_parent_01"],
29
+ "assign": ["{n}_02_pairs", "{n}_02_assign", "{n}_02_unassigned"],
30
+ # "groups" is deliberately absent: group ids aren't known ahead of time
31
+ # (dynamic "{n}_g{parent_fid}" names), so it falls through to the
32
+ # "export everything currently in the connection" default below, same as
33
+ # a full (no --step) run.
34
+ "merge": ["{n}_04"],
35
+ "outputs": [],
36
+ }
37
+
38
+
39
+ def match( # noqa: C901, PLR0912, PLR0913, PLR0915
40
+ input_path: str | Path,
41
+ clip_path: str | Path,
42
+ output_path: str | Path | None = None,
43
+ *,
44
+ memory_gb: float = 4.0,
45
+ threads: int | None = None,
46
+ tmp_dir: str | Path | None = None,
47
+ overwrite: bool = False,
48
+ debug: bool = False,
49
+ step: str | None = None,
50
+ ) -> None:
51
+ """Match child polygons to their best-overlapping parent, then extend to fill gaps.
52
+
53
+ Processes exactly one child file + one parent/clip file per call. Children
54
+ are assigned to whichever parent polygon they share the largest area with,
55
+ grouped by that assignment, extended within each group independently (in
56
+ an isolated subprocess per group), clipped to that group's own parent,
57
+ reassembled, and coverage-cleaned once as a whole. If output_path is
58
+ omitted, it defaults to input_path with a "_matched" suffix in the same
59
+ directory.
60
+ """
61
+ if step is not None and step not in _STEP_ORDER:
62
+ msg = f"step must be one of {_STEP_ORDER}, got {step!r}"
63
+ raise ValueError(msg)
64
+
65
+ input_path = Path(input_path)
66
+ clip_path = Path(clip_path)
67
+ output_path = (
68
+ Path(output_path)
69
+ if output_path is not None
70
+ else input_path.with_stem(input_path.stem + "_matched")
71
+ )
72
+ if output_path.exists() and not overwrite:
73
+ msg = f"output already exists: {output_path}"
74
+ raise FileExistsError(msg)
75
+
76
+ owns_tmp_dir = tmp_dir is None
77
+ tmp_dir_path = (
78
+ Path(tmp_dir)
79
+ if tmp_dir is not None
80
+ else Path(tempfile.mkdtemp(prefix="topo_tools_"))
81
+ )
82
+ tmp_dir_path.mkdir(exist_ok=True, parents=True)
83
+
84
+ # "_match" keeps every table/file this call creates distinct from an
85
+ # extend() run against the same input_path/tmp_dir -- e.g. extend's bare
86
+ # "{name}_04" (Voronoi cells) would otherwise collide with match's own
87
+ # bare "{name}_04" (final coverage-cleaned output) if both tools shared a
88
+ # tmp_dir and were run with --debug for side-by-side inspection.
89
+ name = input_path.name.replace(".", "_") + "_match"
90
+ if not step:
91
+ cleanup_tmp(name, tmp_dir_path, parquet=True)
92
+
93
+ with log_file(name, tmp_dir_path):
94
+ conn = get_connection(name, tmp_dir_path, threads=threads, debug=debug)
95
+
96
+ def _interrupt(_sig: int, _frame: FrameType | None) -> NoReturn:
97
+ conn.interrupt()
98
+ raise KeyboardInterrupt
99
+
100
+ old_handler = signal.signal(signal.SIGINT, _interrupt)
101
+ try:
102
+ logger.info("starting: %s", name)
103
+ for s in _STEP_ORDER:
104
+ if step and step != s:
105
+ continue
106
+ if debug:
107
+ logger.info("=== %s ===", s)
108
+ if s == "inputs":
109
+ inputs.main(conn, name, input_path, clip_path)
110
+ elif s == "assign":
111
+ assign.main(conn, name)
112
+ elif s == "groups":
113
+ groups.main(
114
+ conn,
115
+ name,
116
+ tmp_dir_path,
117
+ memory_gb=memory_gb,
118
+ threads=threads,
119
+ debug=debug,
120
+ )
121
+ elif s == "merge":
122
+ merge.main(conn, name, debug=debug)
123
+ elif s == "outputs":
124
+ outputs.main(conn, name, output_path, debug=debug)
125
+ if debug:
126
+ only = None
127
+ if step and step in _STEP_TABLES:
128
+ only = {t.format(n=name) for t in _STEP_TABLES[step]}
129
+ export_debug_tables(conn, tmp_dir_path, only=only)
130
+ logger.info("done: %s", name)
131
+ finally:
132
+ signal.signal(signal.SIGINT, old_handler)
133
+ conn.close()
134
+ if not step and not debug:
135
+ cleanup_tmp(name, tmp_dir_path)
136
+ if owns_tmp_dir:
137
+ if debug:
138
+ logger.info("tmp_dir preserved for --debug: %s", tmp_dir_path)
139
+ else:
140
+ shutil.rmtree(tmp_dir_path, ignore_errors=True)
@@ -0,0 +1 @@
1
+ """Click CLI commands — the only layer allowed to import click."""