Skill Tracer
Skills › Scientific Agent Skills › geopandas › export_plan.py

export_plan.py

305 lines · skills/geopandas/scripts/export_plan.py at 5f2bc03

1#!/usr/bin/env python3
2"""Create a non-executing, redacted vector export and GeoParquet plan."""
3
4from __future__ import annotations
5
6import argparse
7import sys
8from typing import Any
9
10from _common import (
11 ABSOLUTE_MAX_FEATURES,
12 DEFAULT_MAX_FEATURES,
13 DEFAULT_MAX_INPUT_BYTES,
14 CliError,
15 bounded_limit,
16 checked_input_file,
17 checked_output_file,
18 emit_json,
19 fail_json,
20 inspect_local_vector,
21 native_versions,
22 package_versions,
23 positive_int,
24 sha256_file,
27TOOL = "export_plan"
28FORMAT_SUFFIXES = {
29 "flatgeobuf": {".fgb"},
30 "geopackage": {".gpkg"},
31 "geojson": {".geojson", ".json"},
32 "geoparquet": {".geoparquet", ".parquet"},
33 "shapefile": {".shp"},
35SUFFIX_FORMAT = {
36 suffix: format_name
37 for format_name, suffixes in FORMAT_SUFFIXES.items()
38 for suffix in suffixes
42def build_parser() -> argparse.ArgumentParser:
43 parser = argparse.ArgumentParser(
44 description=(
45 "Plan—but do not execute—a local vector export. Inspect only metadata, "
46 "validate a new output path, and emit format/privacy/provenance gates."
47 ),
48 epilog=(
49 "No feature data or coordinates are loaded. URLs, archives, symlinks, "
50 "path traversal, and existing output paths are rejected."
51 ),
52 )
53 parser.add_argument("input", help="Allowlisted local vector/GeoParquet input")
54 parser.add_argument("output", help="Proposed new local output path")
55 parser.add_argument("--root", default=".", help="Existing local I/O root")
56 parser.add_argument("--layer", help="Exact input layer name")
57 parser.add_argument(
58 "--format",
59 choices=tuple(sorted(FORMAT_SUFFIXES)),
60 help="Target format; default infers from output suffix",
61 )
62 parser.add_argument(
63 "--stable-id-column",
64 help="Declared stable feature-ID column (name is not emitted)",
65 )
66 parser.add_argument(
67 "--id-unique-verified",
68 action="store_true",
69 help="Attest that stable ID is non-null and unique in the planned export",
70 )
71 parser.add_argument(
72 "--index-policy",
73 choices=("omit", "include"),
74 default="omit",
75 help="Whether to serialize the pandas index (default: omit)",
76 )
77 parser.add_argument(
78 "--geometry-encoding",
79 choices=("WKB", "geoarrow"),
80 default="WKB",
81 help="GeoParquet geometry encoding (default: WKB)",
82 )
83 parser.add_argument(
84 "--schema-version",
85 choices=("1.0.0", "1.1.0"),
86 default="1.1.0",
87 help="Stable GeoParquet schema version (default: 1.1.0; 2.0 not planned)",
88 )
89 parser.add_argument(
90 "--write-covering-bbox",
91 action="store_true",
92 help="Plan a GeoParquet 1.1 per-row bbox covering column",
93 )
94 parser.add_argument(
95 "--public-output",
96 action="store_true",
97 help="Mark the proposed output as intended for public release",
98 )
99 parser.add_argument(
100 "--sensitive-coordinates",
101 action="store_true",
102 help="Mark input as containing sensitive exact locations",
103 )
104 parser.add_argument(
105 "--generalized-and-reviewed",
106 action="store_true",
107 help="Attest that coordinates/attributes were generalized and reviewed",
108 )
109 parser.add_argument(
110 "--max-input-bytes",
111 type=positive_int,
112 default=DEFAULT_MAX_INPUT_BYTES,
113 )
114 parser.add_argument(
115 "--max-features",
116 type=positive_int,
117 default=DEFAULT_MAX_FEATURES,
118 help="Declared-feature threshold for the downstream export",
119 )
120 return parser
123def _format_risks(format_name: str) -> list[str]:
124 risks = {
125 "geopackage": [
126 "One geometry column per layer; additional geometry columns need separate layers or explicit encoding.",
127 "Append/overwrite behavior is driver-specific; this plan requires a new file.",
128 ],
129 "geojson": [
130 "Text output exposes exact coordinates and attributes directly.",
131 "CRS/type/datetime/large-integer fidelity is limited; RFC 7946 interoperability normally expects WGS84 longitude/latitude.",
132 ],
133 "shapefile": [
134 "Legacy multi-file output has field-name, null, type, encoding, geometry, and size limitations.",
135 "A single hash does not cover the complete sidecar set.",
136 ],
137 "flatgeobuf": [
138 "Driver/version interoperability and field-type support must be roundtrip-tested.",
139 ],
140 "geoparquet": [
141 "GeoParquet readers vary in support for schema 1.1 native encodings and bbox covering.",
142 "Stable feature IDs require an explicit project column/metadata contract.",
143 ],
144 }
145 return risks[format_name]
148def plan(args: argparse.Namespace) -> dict[str, Any]:
149 max_features = bounded_limit(
150 args.max_features,
151 name="max_features",
152 maximum=ABSOLUTE_MAX_FEATURES,
153 )
154 source = checked_input_file(
155 args.input,
156 root=args.root,
157 max_bytes=args.max_input_bytes,
158 )
159 suffix = str(args.output).casefold().strip()
160 suffix = "." + suffix.rsplit(".", 1)[-1] if "." in suffix else ""
161 inferred = SUFFIX_FORMAT.get(suffix)
162 format_name = args.format or inferred
163 if format_name is None:
164 raise CliError("target format cannot be inferred from output suffix")
165 destination = checked_output_file(
166 args.output,
167 root=args.root,
168 allowed_suffixes=FORMAT_SUFFIXES[format_name],
169 )
170 if inferred is not None and inferred != format_name:
171 raise CliError("target format conflicts with output suffix")
173 technical = inspect_local_vector(source, layer=args.layer)
174 declared = technical.get("declared_features")
175 blockers: list[str] = []
176 warnings = _format_risks(format_name)
178 if args.stable_id_column is None:
179 blockers.append("declare a stable feature-ID column")
180 elif not args.id_unique_verified:
181 blockers.append("verify the stable ID is non-null and unique")
182 if declared is not None and declared > max_features:
183 blockers.append("declared feature count exceeds the downstream limit")
184 if (
185 args.public_output
186 and args.sensitive_coordinates
187 and not args.generalized_and_reviewed
188 ):
189 blockers.append(
190 "public sensitive-coordinate output requires generalization and review"
191 )
192 if args.generalized_and_reviewed and not args.sensitive_coordinates:
193 warnings.append(
194 "Generalization attestation was supplied without marking coordinates sensitive."
195 )
197 geoparquet_options_used = (
198 args.geometry_encoding != "WKB"
199 or args.schema_version != "1.1.0"
200 or args.write_covering_bbox
201 )
202 if format_name != "geoparquet" and geoparquet_options_used:
203 blockers.append("GeoParquet options are valid only for geoparquet output")
204 if format_name == "geoparquet":
205 if args.geometry_encoding == "geoarrow" and args.schema_version != "1.1.0":
206 blockers.append("native geoarrow encoding requires schema 1.1.0")
207 if args.write_covering_bbox and args.schema_version != "1.1.0":
208 blockers.append("bbox covering requires schema 1.1.0")
209 if args.write_covering_bbox and args.sensitive_coordinates:
210 blockers.append(
211 "per-row bbox covering is not approved for sensitive coordinates"
212 )
213 if args.geometry_encoding == "geoarrow" or args.write_covering_bbox:
214 warnings.append(
215 "GeoPandas describes native encoding and bbox covering as experimental/interoperability-limited."
216 )
218 if technical["kind"] == "gdal_vector":
219 if not technical["crs"]["present"]:
220 blockers.append("source CRS metadata is missing")
221 elif technical["kind"] == "geoparquet":
222 states = technical["crs_metadata_states"]
223 if states["null"]:
224 blockers.append("one or more GeoParquet geometry columns have unknown CRS")
225 if states["missing"]:
226 warnings.append(
227 "A missing GeoParquet CRS key means OGC:CRS84, not unknown CRS."
228 )
229 else:
230 blockers.append("input Parquet lacks required GeoParquet metadata")
232 return {
233 "ok": not blockers,
234 "tool": TOOL,
235 "executed": False,
236 "feature_data_loaded": False,
237 "source": {
238 "sha256": sha256_file(source),
239 "bytes": source.stat().st_size,
240 "path_emitted": False,
241 "technical_inventory": technical,
242 },
243 "target": {
244 "format": format_name,
245 "suffix": destination.suffix.casefold(),
246 "path_emitted": False,
247 "exists": False,
248 "index_policy": args.index_policy,
249 "stable_id_declared": args.stable_id_column is not None,
250 "stable_id_name_emitted": False,
251 "stable_id_unique_verified": bool(args.id_unique_verified),
252 },
253 "geoparquet": (
254 {
255 "schema_version": args.schema_version,
256 "geometry_encoding": args.geometry_encoding,
257 "write_covering_bbox": bool(args.write_covering_bbox),
258 "default_stable_contract": bool(
259 args.schema_version == "1.1.0"
260 and args.geometry_encoding == "WKB"
261 and not args.write_covering_bbox
262 ),
263 }
264 if format_name == "geoparquet"
265 else None
266 ),
267 "privacy": {
268 "public_output": bool(args.public_output),
269 "sensitive_coordinates": bool(args.sensitive_coordinates),
270 "generalized_and_reviewed": bool(args.generalized_and_reviewed),
271 "coordinates_emitted": False,
272 },
273 "roundtrip_checks_required": [
274 "row count and stable-ID set",
275 "CRS and active/additional geometry columns",
276 "null, empty, invalid, geometry type, and dimensionality counts",
277 "field types, nullability, encoding, and expected format losses",
278 "protected coarse bounds and representative attributes",
279 "new artifact hash and package/native versions",
280 ],
281 "format_risks": warnings,
282 "blockers": blockers,
283 "resource_limits": {
284 "max_input_bytes": args.max_input_bytes,
285 "max_features_for_execution": max_features,
286 },
287 "network_accessed": False,
288 "coordinates_emitted": False,
289 "identifiers_emitted": False,
290 "stack": {"packages": package_versions(), "native": native_versions()},
291 }
294def main(argv: list[str] | None = None) -> int:
295 args = build_parser().parse_args(argv)
296 try:
297 report = plan(args)
298 emit_json(report)
299 return 0 if report["ok"] else 2
300 except Exception as exc: # noqa: BLE001 - errors are redacted at CLI boundary
301 return fail_json(TOOL, exc)
304if __name__ == "__main__":
305 sys.exit(main())