mapsmith 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.
mapsmith/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MapSmith — professional-grade geoprocessing for AI agents via MCP, with verifiable provenance."""
2
+
3
+ __version__ = "0.1.0"
mapsmith/catalog.py ADDED
@@ -0,0 +1,98 @@
1
+ """Operation catalog for progressive discovery.
2
+
3
+ Agent accuracy collapses when hundreds of raw tools are exposed. MapSmith keeps
4
+ a small set of semantic MCP tools and lets agents *search* this catalog to find
5
+ what exists. Entries marked ``planned`` document the roadmap so the agent can
6
+ say "not yet" instead of hallucinating a capability.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ OPERATIONS: list[dict[str, str]] = [
12
+ {
13
+ "name": "describe_dataset",
14
+ "status": "available",
15
+ "category": "inspection",
16
+ "summary": "CRS, geometry types, schema, extent and feature count of a vector dataset",
17
+ },
18
+ {
19
+ "name": "buffer_layer",
20
+ "status": "available",
21
+ "category": "vector",
22
+ "summary": "Metric buffer with automatic UTM estimation on geographic CRS",
23
+ },
24
+ {
25
+ "name": "clip_layer",
26
+ "status": "available",
27
+ "category": "vector",
28
+ "summary": "Clip a layer with a mask layer (CRS-aligned automatically)",
29
+ },
30
+ {
31
+ "name": "reproject_layer",
32
+ "status": "available",
33
+ "category": "vector",
34
+ "summary": "Reproject a layer to a target CRS (EPSG code or WKT)",
35
+ },
36
+ {
37
+ "name": "spatial_join",
38
+ "status": "available",
39
+ "category": "vector",
40
+ "summary": "Join by spatial predicate (intersects/within/contains); auto-routed to "
41
+ "SedonaDB or DuckDB for speed, GeoPandas fallback",
42
+ },
43
+ {
44
+ "name": "run_sql",
45
+ "status": "available",
46
+ "category": "sql",
47
+ "summary": "Spatial SQL (DuckDB dialect, ST_* functions) over GeoParquet and GDAL "
48
+ "formats; materializes GeoParquet outputs with provenance",
49
+ },
50
+ {
51
+ "name": "get_provenance",
52
+ "status": "available",
53
+ "category": "provenance",
54
+ "summary": "Full lineage manifest of any MapSmith output",
55
+ },
56
+ {
57
+ "name": "zonal_statistics",
58
+ "status": "planned",
59
+ "category": "raster",
60
+ "summary": "Statistics of a raster within vector zones (Rasterio engine)",
61
+ },
62
+ {
63
+ "name": "hillshade",
64
+ "status": "planned",
65
+ "category": "raster",
66
+ "summary": "Terrain hillshading from a DEM",
67
+ },
68
+ {
69
+ "name": "watershed",
70
+ "status": "planned",
71
+ "category": "hydrology",
72
+ "summary": "Watershed delineation (WhiteboxTools engine)",
73
+ },
74
+ {
75
+ "name": "isochrone",
76
+ "status": "planned",
77
+ "category": "network",
78
+ "summary": "Travel-time polygons (Valhalla engine)",
79
+ },
80
+ {
81
+ "name": "qgis_processing",
82
+ "status": "planned",
83
+ "category": "bridge",
84
+ "summary": "~900 QGIS/GRASS/SAGA algorithms via GPL-isolated subprocess sidecar",
85
+ },
86
+ ]
87
+
88
+
89
+ def search(query: str = "") -> list[dict[str, str]]:
90
+ """Case-insensitive substring search across name, category and summary."""
91
+ q = query.strip().lower()
92
+ if not q:
93
+ return OPERATIONS
94
+ return [
95
+ op
96
+ for op in OPERATIONS
97
+ if q in op["name"].lower() or q in op["category"].lower() or q in op["summary"].lower()
98
+ ]
@@ -0,0 +1 @@
1
+ """Deterministic geoprocessing engines. The LLM orchestrates; these compute."""
@@ -0,0 +1,64 @@
1
+ """Engine dispatcher: route each workload class to the fastest available engine.
2
+
3
+ The 2025-2026 benchmarks are unambiguous (see docs in the repo wiki):
4
+ - SedonaDB wins heavy joins/KNN by 10-180x, in-process, optional dependency.
5
+ - DuckDB spatial wins filters/aggregations/point-in-polygon (~2M rows/sec).
6
+ - GeoPandas/Shapely stays as the long-tail lane (<~1M features).
7
+
8
+ Engines are optional imports: the dispatcher degrades gracefully and the
9
+ provenance manifest always records which engine actually ran.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from enum import Enum
15
+ from functools import cache
16
+
17
+
18
+ class Workload(str, Enum):
19
+ SQL = "sql" # filters, aggregations, ad-hoc SQL
20
+ HEAVY_JOIN = "heavy_join" # polygon overlay, distance joins, KNN
21
+ SMALL_VECTOR = "small_vector" # long-tail ops on small data
22
+
23
+
24
+ # Preference order per workload class; first available wins.
25
+ _PREFERENCES: dict[Workload, list[str]] = {
26
+ Workload.SQL: ["duckdb", "geopandas"],
27
+ Workload.HEAVY_JOIN: ["sedonadb", "duckdb", "geopandas"],
28
+ Workload.SMALL_VECTOR: ["geopandas"],
29
+ }
30
+
31
+
32
+ @cache
33
+ def available_engines() -> dict[str, bool]:
34
+ """Probe optional engines once per process."""
35
+ status: dict[str, bool] = {"geopandas": True} # hard dependency
36
+ try:
37
+ import duckdb # noqa: F401
38
+
39
+ status["duckdb"] = True
40
+ except ImportError:
41
+ status["duckdb"] = False
42
+ try:
43
+ import sedona.db # noqa: F401
44
+
45
+ status["sedonadb"] = True
46
+ except ImportError:
47
+ status["sedonadb"] = False
48
+ return status
49
+
50
+
51
+ def pick(workload: Workload, requested: str = "auto") -> str:
52
+ """Pick the engine for a workload. `requested` may force a specific engine."""
53
+ engines = available_engines()
54
+ if requested != "auto":
55
+ if not engines.get(requested, False):
56
+ raise RuntimeError(
57
+ f"Engine '{requested}' is not available in this installation. "
58
+ f"Available: {[k for k, v in engines.items() if v]}"
59
+ )
60
+ return requested
61
+ for name in _PREFERENCES[workload]:
62
+ if engines.get(name, False):
63
+ return name
64
+ raise RuntimeError(f"No engine available for workload {workload}")
@@ -0,0 +1,103 @@
1
+ """DuckDB spatial engine: SQL, filters, aggregations, point-in-polygon joins.
2
+
3
+ Benchmarks (2025-26): DuckDB's SPATIAL_JOIN operator does point-in-polygon at
4
+ ~2M rows/sec on a laptop. The spatial extension is NOT bundled in the wheel:
5
+ we install it on first use (needs network once per environment).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import duckdb
14
+
15
+ from ..provenance import InputRecord, ProvenanceRecord
16
+
17
+ _PREVIEW_ROWS = 50
18
+
19
+
20
+ def _engine_info() -> dict[str, str]:
21
+ return {"name": "duckdb", "version": duckdb.__version__}
22
+
23
+
24
+ def _connect() -> duckdb.DuckDBPyConnection:
25
+ con = duckdb.connect()
26
+ try:
27
+ con.install_extension("spatial")
28
+ except duckdb.Error:
29
+ pass # already installed in this environment, or offline with a cached copy
30
+ con.load_extension("spatial")
31
+ return con
32
+
33
+
34
+ def _quote(path: str) -> str:
35
+ return str(path).replace("'", "''")
36
+
37
+
38
+ def _rel(path: str) -> str:
39
+ """SQL relation for a dataset file: GeoParquet natively, other formats via GDAL."""
40
+ if str(path).lower().endswith(".parquet"):
41
+ return f"read_parquet('{_quote(path)}')"
42
+ return f"ST_Read('{_quote(path)}')"
43
+
44
+
45
+ def run_sql(query: str, output_path: str | None = None) -> dict[str, Any]:
46
+ """Run spatial SQL. With output_path, materialize the result as GeoParquet."""
47
+ con = _connect()
48
+ record = ProvenanceRecord(
49
+ operation="run_sql",
50
+ parameters={"query": query},
51
+ inputs=[],
52
+ engine=_engine_info(),
53
+ )
54
+ if output_path:
55
+ con.sql(f"COPY ({query}) TO '{_quote(output_path)}' (FORMAT parquet)")
56
+ count = con.sql(
57
+ f"SELECT count(*) FROM read_parquet('{_quote(output_path)}')"
58
+ ).fetchone()[0]
59
+ manifest = record.finish().write_for(output_path)
60
+ return {"output": str(output_path), "row_count": int(count), "provenance": str(manifest)}
61
+ result = con.sql(query)
62
+ rows = result.fetchmany(_PREVIEW_ROWS)
63
+ return {
64
+ "columns": [d[0] for d in result.description],
65
+ "rows": [[repr(v) if isinstance(v, (bytes, bytearray)) else v for v in r] for r in rows],
66
+ "truncated_at": _PREVIEW_ROWS,
67
+ }
68
+
69
+
70
+ def spatial_join(
71
+ left_path: str, right_path: str, output_path: str, predicate: str = "intersects"
72
+ ) -> dict[str, Any]:
73
+ """Attribute join by spatial predicate. GeoParquet-native fast path."""
74
+ predicates = {"intersects": "ST_Intersects", "within": "ST_Within", "contains": "ST_Contains"}
75
+ if predicate not in predicates:
76
+ raise ValueError(f"predicate must be one of {sorted(predicates)}, got {predicate!r}")
77
+ con = _connect()
78
+ record = ProvenanceRecord(
79
+ operation="spatial_join",
80
+ parameters={"predicate": predicate, "engine": "duckdb"},
81
+ inputs=[InputRecord.from_path(left_path), InputRecord.from_path(right_path)],
82
+ engine=_engine_info(),
83
+ )
84
+ fn = predicates[predicate]
85
+ query = f"""
86
+ SELECT l.*, r.* EXCLUDE (geometry)
87
+ FROM {_rel(left_path)} AS l
88
+ JOIN {_rel(right_path)} AS r
89
+ ON {fn}(l.geometry, r.geometry)
90
+ """
91
+ con.sql(f"COPY ({query}) TO '{_quote(output_path)}' (FORMAT parquet)")
92
+ count = con.sql(f"SELECT count(*) FROM read_parquet('{_quote(output_path)}')").fetchone()[0]
93
+ manifest = record.finish().write_for(output_path)
94
+ return {
95
+ "output": str(output_path),
96
+ "feature_count": int(count),
97
+ "provenance": str(manifest),
98
+ }
99
+
100
+
101
+ def supports_inputs(*paths: str) -> bool:
102
+ """The DuckDB fast path expects GeoParquet inputs with a 'geometry' column."""
103
+ return all(Path(p).suffix.lower() == ".parquet" for p in paths)
@@ -0,0 +1,62 @@
1
+ """SedonaDB engine: heavy overlays, distance joins, KNN — 10-180x on benchmarks.
2
+
3
+ Optional dependency (`pip install mapsmith[sedona]`). In-process Rust engine
4
+ (Arrow/DataFusion). API is pre-1.0: this wrapper stays deliberately thin.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from ..provenance import InputRecord, ProvenanceRecord
12
+
13
+
14
+ def _engine_info() -> dict[str, str]:
15
+ import sedonadb # the wheel behind apache-sedona[db]
16
+
17
+ return {"name": "sedonadb", "version": getattr(sedonadb, "__version__", "unknown")}
18
+
19
+
20
+ def spatial_join(
21
+ left_path: str, right_path: str, output_path: str, predicate: str = "intersects"
22
+ ) -> dict[str, Any]:
23
+ """Spatial join on SedonaDB. Keeps left columns (right attributes omitted).
24
+
25
+ Note: inner join semantics — a left feature is repeated once per match,
26
+ like GeoPandas sjoin. Recorded in the provenance manifest.
27
+ """
28
+ import sedona.db
29
+
30
+ predicates = {"intersects": "ST_Intersects", "within": "ST_Within", "contains": "ST_Contains"}
31
+ if predicate not in predicates:
32
+ raise ValueError(f"predicate must be one of {sorted(predicates)}, got {predicate!r}")
33
+
34
+ sd = sedona.db.connect()
35
+ record = ProvenanceRecord(
36
+ operation="spatial_join",
37
+ parameters={"predicate": predicate, "engine": "sedonadb", "columns": "left-only"},
38
+ inputs=[InputRecord.from_path(left_path), InputRecord.from_path(right_path)],
39
+ engine=_engine_info(),
40
+ )
41
+
42
+ def _load(path: str, view: str) -> None:
43
+ if str(path).lower().endswith(".parquet"):
44
+ sd.read_parquet(path).to_view(view)
45
+ else:
46
+ sd.read_pyogrio(path).to_view(view)
47
+
48
+ _load(left_path, "l")
49
+ _load(right_path, "r")
50
+ fn = predicates[predicate]
51
+ result = sd.sql(f"SELECT l.* FROM l JOIN r ON {fn}(l.geometry, r.geometry)")
52
+ result.to_parquet(output_path)
53
+ count = sd.sql(
54
+ "SELECT count(*) FROM l JOIN r ON " + f"{fn}(l.geometry, r.geometry)"
55
+ ).to_pandas()
56
+ feature_count = int(count.iloc[0, 0])
57
+ manifest = record.finish().write_for(output_path)
58
+ return {
59
+ "output": str(output_path),
60
+ "feature_count": feature_count,
61
+ "provenance": str(manifest),
62
+ }
@@ -0,0 +1,156 @@
1
+ """Vector operations on the permissive GeoPandas/Shapely stack.
2
+
3
+ Design rules:
4
+ - Metric operations on geographic CRS are never silent: we estimate a UTM CRS,
5
+ record the decision in provenance, and reproject back.
6
+ - Every writer emits a provenance manifest next to the output.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import geopandas as gpd
14
+
15
+ from ..provenance import InputRecord, ProvenanceRecord
16
+
17
+
18
+ def _engine_info() -> dict[str, str]:
19
+ return {"name": "geopandas", "version": gpd.__version__}
20
+
21
+
22
+ def describe(path: str) -> dict[str, Any]:
23
+ gdf = gpd.read_file(path)
24
+ bounds = gdf.total_bounds
25
+ return {
26
+ "path": str(path),
27
+ "crs": str(gdf.crs) if gdf.crs else None,
28
+ "feature_count": len(gdf),
29
+ "geometry_types": sorted(gdf.geom_type.dropna().unique().tolist()),
30
+ "fields": {c: str(t) for c, t in gdf.dtypes.items() if c != gdf.geometry.name},
31
+ "extent": {
32
+ "minx": float(bounds[0]),
33
+ "miny": float(bounds[1]),
34
+ "maxx": float(bounds[2]),
35
+ "maxy": float(bounds[3]),
36
+ },
37
+ }
38
+
39
+
40
+ def buffer(input_path: str, distance_meters: float, output_path: str) -> dict[str, Any]:
41
+ gdf = gpd.read_file(input_path)
42
+ if gdf.crs is None:
43
+ raise ValueError(
44
+ f"{input_path} has no CRS. Refusing to buffer without knowing the units — "
45
+ "assign a CRS first (see reproject_layer)."
46
+ )
47
+ record = ProvenanceRecord(
48
+ operation="buffer_layer",
49
+ parameters={"distance_meters": distance_meters},
50
+ inputs=[InputRecord.from_path(input_path, crs=str(gdf.crs))],
51
+ engine=_engine_info(),
52
+ )
53
+ original_crs = gdf.crs
54
+ if original_crs.is_geographic:
55
+ analysis_crs = gdf.estimate_utm_crs()
56
+ record.crs_decisions = {
57
+ "analysis_crs": str(analysis_crs),
58
+ "reason": "estimated UTM zone for metric buffering on a geographic CRS",
59
+ }
60
+ buffered = gdf.to_crs(analysis_crs)
61
+ buffered["geometry"] = buffered.geometry.buffer(distance_meters)
62
+ buffered = buffered.to_crs(original_crs)
63
+ else:
64
+ record.crs_decisions = {
65
+ "analysis_crs": str(original_crs),
66
+ "reason": "input CRS is already projected; distance interpreted in its units",
67
+ }
68
+ buffered = gdf.copy()
69
+ buffered["geometry"] = buffered.geometry.buffer(distance_meters)
70
+ buffered.to_file(output_path)
71
+ manifest = record.finish().write_for(output_path)
72
+ return {
73
+ "output": str(output_path),
74
+ "feature_count": len(buffered),
75
+ "provenance": str(manifest),
76
+ }
77
+
78
+
79
+ def clip(input_path: str, mask_path: str, output_path: str) -> dict[str, Any]:
80
+ gdf = gpd.read_file(input_path)
81
+ mask = gpd.read_file(mask_path)
82
+ record = ProvenanceRecord(
83
+ operation="clip_layer",
84
+ parameters={},
85
+ inputs=[
86
+ InputRecord.from_path(input_path, crs=str(gdf.crs)),
87
+ InputRecord.from_path(mask_path, crs=str(mask.crs)),
88
+ ],
89
+ engine=_engine_info(),
90
+ )
91
+ if gdf.crs != mask.crs:
92
+ mask = mask.to_crs(gdf.crs)
93
+ record.crs_decisions = {
94
+ "analysis_crs": str(gdf.crs),
95
+ "reason": "mask reprojected to the input layer CRS before clipping",
96
+ }
97
+ clipped = gpd.clip(gdf, mask)
98
+ clipped.to_file(output_path)
99
+ manifest = record.finish().write_for(output_path)
100
+ return {
101
+ "output": str(output_path),
102
+ "feature_count": len(clipped),
103
+ "provenance": str(manifest),
104
+ }
105
+
106
+
107
+ def reproject(input_path: str, target_crs: str, output_path: str) -> dict[str, Any]:
108
+ gdf = gpd.read_file(input_path)
109
+ record = ProvenanceRecord(
110
+ operation="reproject_layer",
111
+ parameters={"target_crs": target_crs},
112
+ inputs=[InputRecord.from_path(input_path, crs=str(gdf.crs))],
113
+ engine=_engine_info(),
114
+ )
115
+ reprojected = gdf.to_crs(target_crs)
116
+ reprojected.to_file(output_path)
117
+ manifest = record.finish().write_for(output_path)
118
+ return {
119
+ "output": str(output_path),
120
+ "crs": str(reprojected.crs),
121
+ "provenance": str(manifest),
122
+ }
123
+
124
+
125
+ def spatial_join(
126
+ left_path: str, right_path: str, output_path: str, predicate: str = "intersects"
127
+ ) -> dict[str, Any]:
128
+ allowed = {"intersects", "within", "contains"}
129
+ if predicate not in allowed:
130
+ raise ValueError(f"predicate must be one of {sorted(allowed)}, got {predicate!r}")
131
+ left = gpd.read_file(left_path)
132
+ right = gpd.read_file(right_path)
133
+ record = ProvenanceRecord(
134
+ operation="spatial_join",
135
+ parameters={"predicate": predicate},
136
+ inputs=[
137
+ InputRecord.from_path(left_path, crs=str(left.crs)),
138
+ InputRecord.from_path(right_path, crs=str(right.crs)),
139
+ ],
140
+ engine=_engine_info(),
141
+ )
142
+ if left.crs != right.crs:
143
+ right = right.to_crs(left.crs)
144
+ record.crs_decisions = {
145
+ "analysis_crs": str(left.crs),
146
+ "reason": "right layer reprojected to the left layer CRS before joining",
147
+ }
148
+ joined = gpd.sjoin(left, right, predicate=predicate, how="inner")
149
+ joined = joined.drop(columns=[c for c in ("index_right",) if c in joined.columns])
150
+ joined.to_file(output_path)
151
+ manifest = record.finish().write_for(output_path)
152
+ return {
153
+ "output": str(output_path),
154
+ "feature_count": len(joined),
155
+ "provenance": str(manifest),
156
+ }
mapsmith/jobs.py ADDED
@@ -0,0 +1,87 @@
1
+ """Durable job ledger on Postgres (optional).
2
+
3
+ Every tool call is modeled as a job row from day one (id, operation, params,
4
+ status, artifact, manifest). Execution stays in-process for now; the same
5
+ table will back the MCP Tasks extension and a worker queue later — designed-in
6
+ seam, deferred infrastructure.
7
+
8
+ If DATABASE_URL is unset (or psycopg is not installed) the ledger is a no-op:
9
+ local/stdio users need zero infrastructure.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import uuid
17
+ from contextlib import contextmanager
18
+ from typing import Any
19
+
20
+ _DDL = """
21
+ CREATE TABLE IF NOT EXISTS mapsmith_jobs (
22
+ id UUID PRIMARY KEY,
23
+ operation TEXT NOT NULL,
24
+ params JSONB NOT NULL DEFAULT '{}'::jsonb,
25
+ status TEXT NOT NULL DEFAULT 'running',
26
+ error TEXT,
27
+ artifact TEXT,
28
+ manifest JSONB,
29
+ started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
30
+ finished_at TIMESTAMPTZ
31
+ );
32
+ """
33
+
34
+ _schema_ready = False
35
+
36
+
37
+ def _connect():
38
+ url = os.environ.get("DATABASE_URL")
39
+ if not url:
40
+ return None
41
+ try:
42
+ import psycopg
43
+ except ImportError:
44
+ return None
45
+ try:
46
+ return psycopg.connect(url, autocommit=True)
47
+ except Exception: # noqa: BLE001 — the ledger must never take the server down
48
+ return None
49
+
50
+
51
+ def _ensure_schema(conn) -> None:
52
+ global _schema_ready
53
+ if not _schema_ready:
54
+ conn.execute(_DDL)
55
+ _schema_ready = True
56
+
57
+
58
+ @contextmanager
59
+ def job(operation: str, params: dict[str, Any]):
60
+ """Record a tool execution as a durable job row (no-op without DATABASE_URL)."""
61
+ conn = _connect()
62
+ job_id = str(uuid.uuid4())
63
+ if conn is not None:
64
+ _ensure_schema(conn)
65
+ conn.execute(
66
+ "INSERT INTO mapsmith_jobs (id, operation, params) VALUES (%s, %s, %s)",
67
+ (job_id, operation, json.dumps(params, default=str)),
68
+ )
69
+ try:
70
+ result: dict[str, Any] = {}
71
+ yield job_id, result
72
+ except Exception as exc:
73
+ if conn is not None:
74
+ conn.execute(
75
+ "UPDATE mapsmith_jobs SET status='failed', error=%s, finished_at=now() "
76
+ "WHERE id=%s",
77
+ (str(exc)[:2000], job_id),
78
+ )
79
+ conn.close()
80
+ raise
81
+ if conn is not None:
82
+ conn.execute(
83
+ "UPDATE mapsmith_jobs SET status='completed', artifact=%s, finished_at=now() "
84
+ "WHERE id=%s",
85
+ (result.get("output"), job_id),
86
+ )
87
+ conn.close()
mapsmith/provenance.py ADDED
@@ -0,0 +1,75 @@
1
+ """Lineage manifests for every MapSmith output.
2
+
3
+ Every operation that writes a dataset also writes ``<output>.provenance.json``
4
+ next to it. The manifest is the product's core promise: any result can be
5
+ audited and re-run bit-identical without an LLM in the loop.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from dataclasses import asdict, dataclass, field
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from . import __version__
18
+
19
+
20
+ def _utcnow() -> str:
21
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
22
+
23
+
24
+ def sha256_of(path: str | Path, chunk_size: int = 1 << 20) -> str:
25
+ h = hashlib.sha256()
26
+ with open(path, "rb") as f:
27
+ while chunk := f.read(chunk_size):
28
+ h.update(chunk)
29
+ return h.hexdigest()
30
+
31
+
32
+ @dataclass
33
+ class InputRecord:
34
+ path: str
35
+ sha256: str
36
+ crs: str | None = None
37
+
38
+ @classmethod
39
+ def from_path(cls, path: str | Path, crs: str | None = None) -> InputRecord:
40
+ return cls(path=str(path), sha256=sha256_of(path), crs=crs)
41
+
42
+
43
+ @dataclass
44
+ class ProvenanceRecord:
45
+ operation: str
46
+ parameters: dict[str, Any]
47
+ inputs: list[InputRecord]
48
+ crs_decisions: dict[str, str] = field(default_factory=dict)
49
+ engine: dict[str, str] = field(default_factory=dict)
50
+ mapsmith_version: str = __version__
51
+ started_at: str = field(default_factory=_utcnow)
52
+ finished_at: str | None = None
53
+
54
+ def finish(self) -> ProvenanceRecord:
55
+ self.finished_at = _utcnow()
56
+ return self
57
+
58
+ def write_for(self, output_path: str | Path) -> Path:
59
+ """Write the manifest next to the output it describes."""
60
+ manifest_path = Path(f"{output_path}.provenance.json")
61
+ manifest_path.write_text(
62
+ json.dumps(asdict(self), indent=2, ensure_ascii=False), encoding="utf-8"
63
+ )
64
+ return manifest_path
65
+
66
+
67
+ def read_provenance(output_path: str | Path) -> dict[str, Any]:
68
+ """Read the lineage manifest of a MapSmith output, if present."""
69
+ manifest_path = Path(f"{output_path}.provenance.json")
70
+ if not manifest_path.exists():
71
+ raise FileNotFoundError(
72
+ f"No provenance manifest found for {output_path}. "
73
+ "Either it was not produced by MapSmith or the manifest was moved."
74
+ )
75
+ return json.loads(manifest_path.read_text(encoding="utf-8"))