openmapstack 0.2.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.
- openmapstack/__init__.py +6 -0
- openmapstack/__main__.py +3 -0
- openmapstack/checks/__init__.py +112 -0
- openmapstack/checks/geodata.py +318 -0
- openmapstack/checks/overrides.py +263 -0
- openmapstack/checks/presentation.py +168 -0
- openmapstack/checks/project.py +261 -0
- openmapstack/checks/provenance.py +133 -0
- openmapstack/checks/qgis.py +832 -0
- openmapstack/checks/rerun.py +340 -0
- openmapstack/checks/spatial.py +65 -0
- openmapstack/checks/validation.py +288 -0
- openmapstack/checks/visual.py +642 -0
- openmapstack/cli.py +431 -0
- openmapstack/expectations.py +284 -0
- openmapstack/integrity.py +137 -0
- openmapstack/project.py +79 -0
- openmapstack/rerun.py +332 -0
- openmapstack/schema.py +39 -0
- openmapstack/schemas/__init__.py +1 -0
- openmapstack/schemas/project-v1.schema.json +264 -0
- openmapstack/validation.py +1019 -0
- openmapstack/verify.py +386 -0
- openmapstack-0.2.0.dist-info/METADATA +268 -0
- openmapstack-0.2.0.dist-info/RECORD +29 -0
- openmapstack-0.2.0.dist-info/WHEEL +5 -0
- openmapstack-0.2.0.dist-info/entry_points.txt +2 -0
- openmapstack-0.2.0.dist-info/licenses/LICENSE +21 -0
- openmapstack-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Clean-rerun and reproducibility assertions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import shlex
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .spatial import connect_spatial
|
|
12
|
+
|
|
13
|
+
from . import failed, load_json, load_project_yaml, not_testable, passed, project_root
|
|
14
|
+
|
|
15
|
+
CLEAN_RERUN_EVIDENCE = ".openmapstack-clean-rerun.json"
|
|
16
|
+
DEFAULT_IGNORED_FIELDS = {
|
|
17
|
+
"completed_at",
|
|
18
|
+
"created_at",
|
|
19
|
+
"generated_at",
|
|
20
|
+
"inputs_hash",
|
|
21
|
+
"outputs_hash",
|
|
22
|
+
"run_id",
|
|
23
|
+
"started_at",
|
|
24
|
+
"timestamp",
|
|
25
|
+
"updated_at",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _stable_json(value: Any) -> str:
|
|
30
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize_number(value: Any) -> Any:
|
|
34
|
+
if isinstance(value, float):
|
|
35
|
+
if value == 0:
|
|
36
|
+
return 0.0
|
|
37
|
+
return round(value, 12)
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _canonical_sequence(values: list[Any], *, reversible: bool) -> list[Any]:
|
|
42
|
+
normalized = [_normalize_json(item, set()) for item in values]
|
|
43
|
+
candidates = [normalized]
|
|
44
|
+
if reversible:
|
|
45
|
+
candidates.append(list(reversed(normalized)))
|
|
46
|
+
return min(candidates, key=_stable_json)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _canonical_ring(coordinates: list[Any]) -> list[Any]:
|
|
50
|
+
if not coordinates:
|
|
51
|
+
return []
|
|
52
|
+
points = [_normalize_json(point, set()) for point in coordinates]
|
|
53
|
+
if len(points) > 1 and points[0] == points[-1]:
|
|
54
|
+
points = points[:-1]
|
|
55
|
+
if not points:
|
|
56
|
+
return []
|
|
57
|
+
rotations: list[list[Any]] = []
|
|
58
|
+
for sequence in (points, list(reversed(points))):
|
|
59
|
+
rotations.extend(sequence[index:] + sequence[:index] for index in range(len(sequence)))
|
|
60
|
+
canonical = min(rotations, key=_stable_json)
|
|
61
|
+
return canonical + [canonical[0]]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _normalize_geometry(geometry: Any, ignored_fields: set[str]) -> Any:
|
|
65
|
+
if not isinstance(geometry, dict):
|
|
66
|
+
return _normalize_json(geometry, ignored_fields)
|
|
67
|
+
geometry_type = geometry.get("type")
|
|
68
|
+
coordinates = geometry.get("coordinates")
|
|
69
|
+
result = {
|
|
70
|
+
key: _normalize_json(value, ignored_fields)
|
|
71
|
+
for key, value in geometry.items()
|
|
72
|
+
if key not in ignored_fields and key not in {"coordinates", "geometries"}
|
|
73
|
+
}
|
|
74
|
+
if geometry_type == "Point":
|
|
75
|
+
result["coordinates"] = _normalize_json(coordinates, ignored_fields)
|
|
76
|
+
elif geometry_type == "MultiPoint" and isinstance(coordinates, list):
|
|
77
|
+
result["coordinates"] = sorted(
|
|
78
|
+
(_normalize_json(point, ignored_fields) for point in coordinates), key=_stable_json
|
|
79
|
+
)
|
|
80
|
+
elif geometry_type == "LineString" and isinstance(coordinates, list):
|
|
81
|
+
result["coordinates"] = _canonical_sequence(coordinates, reversible=True)
|
|
82
|
+
elif geometry_type == "MultiLineString" and isinstance(coordinates, list):
|
|
83
|
+
result["coordinates"] = sorted(
|
|
84
|
+
(_canonical_sequence(line, reversible=True) for line in coordinates), key=_stable_json
|
|
85
|
+
)
|
|
86
|
+
elif geometry_type == "Polygon" and isinstance(coordinates, list):
|
|
87
|
+
rings = [_canonical_ring(ring) for ring in coordinates]
|
|
88
|
+
result["coordinates"] = rings[:1] + sorted(rings[1:], key=_stable_json)
|
|
89
|
+
elif geometry_type == "MultiPolygon" and isinstance(coordinates, list):
|
|
90
|
+
polygons = []
|
|
91
|
+
for polygon in coordinates:
|
|
92
|
+
rings = [_canonical_ring(ring) for ring in polygon]
|
|
93
|
+
polygons.append(rings[:1] + sorted(rings[1:], key=_stable_json))
|
|
94
|
+
result["coordinates"] = sorted(polygons, key=_stable_json)
|
|
95
|
+
elif geometry_type == "GeometryCollection":
|
|
96
|
+
result["geometries"] = sorted(
|
|
97
|
+
(_normalize_geometry(item, ignored_fields) for item in geometry.get("geometries", [])),
|
|
98
|
+
key=_stable_json,
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
result["coordinates"] = _normalize_json(coordinates, ignored_fields)
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _normalize_json(value: Any, ignored_fields: set[str]) -> Any:
|
|
106
|
+
if isinstance(value, dict):
|
|
107
|
+
value_type = value.get("type")
|
|
108
|
+
if value_type in {
|
|
109
|
+
"Point",
|
|
110
|
+
"MultiPoint",
|
|
111
|
+
"LineString",
|
|
112
|
+
"MultiLineString",
|
|
113
|
+
"Polygon",
|
|
114
|
+
"MultiPolygon",
|
|
115
|
+
"GeometryCollection",
|
|
116
|
+
}:
|
|
117
|
+
return _normalize_geometry(value, ignored_fields)
|
|
118
|
+
result = {
|
|
119
|
+
key: _normalize_json(item, ignored_fields)
|
|
120
|
+
for key, item in value.items()
|
|
121
|
+
if key not in ignored_fields
|
|
122
|
+
}
|
|
123
|
+
if value_type == "FeatureCollection" and isinstance(result.get("features"), list):
|
|
124
|
+
result["features"] = sorted(result["features"], key=_stable_json)
|
|
125
|
+
return result
|
|
126
|
+
if isinstance(value, list):
|
|
127
|
+
return [_normalize_json(item, ignored_fields) for item in value]
|
|
128
|
+
return _normalize_number(value)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _sql_identifier(value: str) -> str:
|
|
132
|
+
return '"' + value.replace('"', '""') + '"'
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parquet_snapshot(path: Path, ignored_fields: set[str]) -> dict[str, Any]:
|
|
136
|
+
escaped_path = path.as_posix().replace("'", "''")
|
|
137
|
+
connection = connect_spatial()
|
|
138
|
+
if connection is None:
|
|
139
|
+
raise RuntimeError("DuckDB Spatial is not preinstalled")
|
|
140
|
+
try:
|
|
141
|
+
columns = connection.execute(
|
|
142
|
+
f"DESCRIBE SELECT * FROM read_parquet('{escaped_path}')"
|
|
143
|
+
).fetchall()
|
|
144
|
+
selected: list[str] = []
|
|
145
|
+
schema: list[dict[str, str]] = []
|
|
146
|
+
names: list[str] = []
|
|
147
|
+
for name, type_name, *_ in columns:
|
|
148
|
+
names.append(name)
|
|
149
|
+
schema.append({"name": name, "type": type_name})
|
|
150
|
+
identifier = _sql_identifier(name)
|
|
151
|
+
if str(type_name).upper().startswith("GEOMETRY"):
|
|
152
|
+
selected.append(f"ST_AsGeoJSON({identifier})")
|
|
153
|
+
else:
|
|
154
|
+
selected.append(identifier)
|
|
155
|
+
rows = connection.execute(
|
|
156
|
+
f"SELECT {', '.join(selected)} FROM read_parquet('{escaped_path}')"
|
|
157
|
+
).fetchall()
|
|
158
|
+
finally:
|
|
159
|
+
connection.close()
|
|
160
|
+
|
|
161
|
+
normalized_rows = []
|
|
162
|
+
for row in rows:
|
|
163
|
+
item: dict[str, Any] = {}
|
|
164
|
+
for name, value, column in zip(names, row, schema, strict=True):
|
|
165
|
+
if column["type"].upper().startswith("GEOMETRY") and isinstance(value, str):
|
|
166
|
+
value = _normalize_geometry(json.loads(value), ignored_fields)
|
|
167
|
+
else:
|
|
168
|
+
value = _normalize_json(value, ignored_fields)
|
|
169
|
+
item[name] = value
|
|
170
|
+
normalized_rows.append(item)
|
|
171
|
+
return {"schema": schema, "rows": sorted(normalized_rows, key=_stable_json)}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _semantic_snapshot(path: Path, ignored_fields: set[str]) -> Any:
|
|
175
|
+
suffix = path.suffix.lower()
|
|
176
|
+
if suffix in {".json", ".geojson"}:
|
|
177
|
+
return _normalize_json(json.loads(path.read_text(encoding="utf-8")), ignored_fields)
|
|
178
|
+
if suffix in {".parquet", ".geoparquet"}:
|
|
179
|
+
return _parquet_snapshot(path, ignored_fields)
|
|
180
|
+
return {"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def clean_execution_succeeded(workspace: Path, rerun_workspace: str) -> Any:
|
|
184
|
+
"""Require the runner-owned clean rerun and post-run validation to succeed."""
|
|
185
|
+
evidence_path = Path(rerun_workspace) / CLEAN_RERUN_EVIDENCE
|
|
186
|
+
evidence = load_json(evidence_path)
|
|
187
|
+
if evidence is None:
|
|
188
|
+
return not_testable(f"clean-rerun evidence is missing: {evidence_path}", code="evidence_missing")
|
|
189
|
+
if evidence.get("status") != "passed":
|
|
190
|
+
return failed(
|
|
191
|
+
f"clean rerun failed during {evidence.get('stage')}: {evidence.get('error', 'unknown error')}",
|
|
192
|
+
rerun_stage=evidence.get("stage"),
|
|
193
|
+
code="clean_rerun_failed",
|
|
194
|
+
)
|
|
195
|
+
return passed(
|
|
196
|
+
"canonical entrypoint succeeded in a clean workspace and artifacts revalidated",
|
|
197
|
+
preserved_paths=evidence.get("preserved_paths", []),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def outputs_semantically_equal(
|
|
202
|
+
workspace: Path,
|
|
203
|
+
rerun_workspace: str,
|
|
204
|
+
paths: list[str],
|
|
205
|
+
project_dir: str = ".",
|
|
206
|
+
ignored_fields: list[str] | None = None,
|
|
207
|
+
) -> Any:
|
|
208
|
+
"""Compare outputs after normalizing row, feature, and geometry representation."""
|
|
209
|
+
root = project_root(workspace, project_dir)
|
|
210
|
+
rerun_root = Path(rerun_workspace)
|
|
211
|
+
if not rerun_root.exists():
|
|
212
|
+
return not_testable(f"rerun workspace {rerun_workspace} does not exist", code="rerun_workspace_missing")
|
|
213
|
+
ignored = set(ignored_fields or [])
|
|
214
|
+
missing: list[str] = []
|
|
215
|
+
mismatches: list[str] = []
|
|
216
|
+
errors: dict[str, str] = {}
|
|
217
|
+
for relative in paths:
|
|
218
|
+
original = root / relative
|
|
219
|
+
rerun = rerun_root / relative
|
|
220
|
+
if not original.is_file() or not rerun.is_file():
|
|
221
|
+
missing.append(relative)
|
|
222
|
+
continue
|
|
223
|
+
try:
|
|
224
|
+
if _semantic_snapshot(original, ignored) != _semantic_snapshot(rerun, ignored):
|
|
225
|
+
mismatches.append(relative)
|
|
226
|
+
except Exception as exc: # noqa: BLE001
|
|
227
|
+
errors[relative] = f"{type(exc).__name__}: {exc}"
|
|
228
|
+
if missing:
|
|
229
|
+
return failed(f"outputs missing in one of the two runs: {missing}", missing=missing, code="output_missing")
|
|
230
|
+
if errors:
|
|
231
|
+
return not_testable("could not normalize one or more outputs", errors=errors, code="normalize_error")
|
|
232
|
+
if mismatches:
|
|
233
|
+
return failed(f"semantic outputs changed across clean rerun: {mismatches}", mismatches=mismatches, code="output_semantically_changed")
|
|
234
|
+
return passed(f"all {len(paths)} outputs are semantically equal across the clean rerun")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def outputs_hash_stable(
|
|
238
|
+
workspace: Path, rerun_workspace: str, paths: list[str], project_dir: str = "."
|
|
239
|
+
) -> Any:
|
|
240
|
+
"""Backward-compatible byte comparison; prefer outputs_semantically_equal."""
|
|
241
|
+
root = project_root(workspace, project_dir)
|
|
242
|
+
rerun_root = Path(rerun_workspace)
|
|
243
|
+
missing: list[str] = []
|
|
244
|
+
mismatches: list[str] = []
|
|
245
|
+
for relative in paths:
|
|
246
|
+
original = root / relative
|
|
247
|
+
rerun = rerun_root / relative
|
|
248
|
+
if not original.is_file() or not rerun.is_file():
|
|
249
|
+
missing.append(relative)
|
|
250
|
+
elif original.read_bytes() != rerun.read_bytes():
|
|
251
|
+
mismatches.append(relative)
|
|
252
|
+
if missing:
|
|
253
|
+
return not_testable(f"outputs missing in one of the two runs: {missing}", code="output_missing")
|
|
254
|
+
if mismatches:
|
|
255
|
+
return failed(f"outputs not hash-stable across clean rerun: {mismatches}", code="output_hash_changed")
|
|
256
|
+
return passed(f"all {len(paths)} declared outputs byte-identical across independent reruns")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def validation_report_reproducible(
|
|
260
|
+
workspace: Path,
|
|
261
|
+
rerun_workspace: str,
|
|
262
|
+
project_dir: str = ".",
|
|
263
|
+
report_path: str = "validation/latest-report.json",
|
|
264
|
+
ignored_fields: list[str] | None = None,
|
|
265
|
+
) -> Any:
|
|
266
|
+
"""Compare complete validation evidence, excluding nondeterministic metadata."""
|
|
267
|
+
root = project_root(workspace, project_dir)
|
|
268
|
+
report_a = load_json(root / report_path)
|
|
269
|
+
report_b = load_json(Path(rerun_workspace) / report_path)
|
|
270
|
+
if report_a is None or report_b is None:
|
|
271
|
+
return not_testable("validation report missing in one of the two runs", code="report_missing")
|
|
272
|
+
|
|
273
|
+
explicit_ignored = set(ignored_fields or [])
|
|
274
|
+
normalized_a = _normalize_json(
|
|
275
|
+
{key: value for key, value in report_a.items() if key not in DEFAULT_IGNORED_FIELDS},
|
|
276
|
+
explicit_ignored,
|
|
277
|
+
)
|
|
278
|
+
normalized_b = _normalize_json(
|
|
279
|
+
{key: value for key, value in report_b.items() if key not in DEFAULT_IGNORED_FIELDS},
|
|
280
|
+
explicit_ignored,
|
|
281
|
+
)
|
|
282
|
+
for normalized in (normalized_a, normalized_b):
|
|
283
|
+
if isinstance(normalized, dict) and isinstance(normalized.get("checks"), list):
|
|
284
|
+
normalized["checks"] = sorted(normalized["checks"], key=_stable_json)
|
|
285
|
+
if normalized_a != normalized_b:
|
|
286
|
+
return failed(
|
|
287
|
+
"validation evidence changed across rerun "
|
|
288
|
+
f"(status {report_a.get('status')!r} vs {report_b.get('status')!r})",
|
|
289
|
+
code="validation_evidence_changed",
|
|
290
|
+
)
|
|
291
|
+
return passed(f"validation evidence reproduces with status {report_a.get('status')!r}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def no_chat_dependency(workspace: Path, project_dir: str = ".") -> Any:
|
|
295
|
+
"""Require declared canonical dependencies to avoid transcript-like state."""
|
|
296
|
+
root = project_root(workspace, project_dir)
|
|
297
|
+
project = load_project_yaml(workspace, project_dir)
|
|
298
|
+
implementation = (
|
|
299
|
+
((project or {}).get("runtime") or {}).get("implementation") or {}
|
|
300
|
+
if isinstance(project, dict)
|
|
301
|
+
else {}
|
|
302
|
+
)
|
|
303
|
+
paths: list[str] = []
|
|
304
|
+
pipeline = implementation.get("pipeline") if isinstance(implementation, dict) else None
|
|
305
|
+
if isinstance(pipeline, str):
|
|
306
|
+
paths.append(pipeline)
|
|
307
|
+
command = implementation.get("command") if isinstance(implementation, dict) else None
|
|
308
|
+
if isinstance(command, str):
|
|
309
|
+
try:
|
|
310
|
+
command = shlex.split(command)
|
|
311
|
+
except ValueError as exc:
|
|
312
|
+
return failed(f"canonical command cannot be parsed: {exc}", code="command_unparseable")
|
|
313
|
+
if isinstance(command, list):
|
|
314
|
+
for token in command:
|
|
315
|
+
if not isinstance(token, str) or token.startswith("-"):
|
|
316
|
+
continue
|
|
317
|
+
candidate = root / token
|
|
318
|
+
try:
|
|
319
|
+
candidate.resolve().relative_to(root.resolve())
|
|
320
|
+
except ValueError:
|
|
321
|
+
continue
|
|
322
|
+
if candidate.is_file():
|
|
323
|
+
paths.append(token)
|
|
324
|
+
dependencies = implementation.get("dependencies", []) if isinstance(implementation, dict) else []
|
|
325
|
+
if isinstance(dependencies, list):
|
|
326
|
+
paths.extend(item for item in dependencies if isinstance(item, str))
|
|
327
|
+
if not paths:
|
|
328
|
+
return not_testable("canonical pipeline/dependencies are not declared", code="dependencies_undeclared")
|
|
329
|
+
|
|
330
|
+
forbidden = ["chat_history", "conversation.json", "transcript.txt", "chat_log"]
|
|
331
|
+
hits: list[str] = []
|
|
332
|
+
for relative in paths:
|
|
333
|
+
path = root / relative
|
|
334
|
+
if not path.is_file():
|
|
335
|
+
continue
|
|
336
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
337
|
+
hits.extend(f"{relative}:{token}" for token in forbidden if token in text)
|
|
338
|
+
if hits:
|
|
339
|
+
return failed(f"canonical project dependencies reference conversation state: {hits}", code="chat_dependency_found")
|
|
340
|
+
return passed("canonical project dependencies contain no chat/transcript references")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Controlled DuckDB Spatial loading for deterministic evals.
|
|
2
|
+
|
|
3
|
+
Grading code never downloads extensions. A preparation step may explicitly
|
|
4
|
+
install Spatial into a pinned directory, after which tests and evals only use
|
|
5
|
+
``LOAD spatial``. This separation lets CI prove the suite inside a container
|
|
6
|
+
whose network is disabled at runtime.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
EXTENSION_DIR_ENV = "OPENMAPSTACK_SPATIAL_EXTENSION_DIR"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _connection_config() -> dict[str, str]:
|
|
19
|
+
configured = os.environ.get(EXTENSION_DIR_ENV)
|
|
20
|
+
if not configured:
|
|
21
|
+
return {}
|
|
22
|
+
extension_dir = Path(configured).expanduser().resolve()
|
|
23
|
+
extension_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
return {"extension_directory": str(extension_dir)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def connect_spatial(*, install: bool = False) -> Any | None:
|
|
28
|
+
"""Return a Spatial-enabled DuckDB connection, or ``None`` if unavailable.
|
|
29
|
+
|
|
30
|
+
``install=False`` is intentional and is used by every assertion and
|
|
31
|
+
generated pipeline. Only ``evals/prepare_spatial.py`` passes
|
|
32
|
+
``install=True`` while image/dependency preparation still has network.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
import duckdb
|
|
36
|
+
except ImportError:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
connection = duckdb.connect(config=_connection_config())
|
|
40
|
+
try:
|
|
41
|
+
connection.execute("LOAD spatial")
|
|
42
|
+
return connection
|
|
43
|
+
except Exception:
|
|
44
|
+
if not install:
|
|
45
|
+
connection.close()
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
connection.execute("INSTALL spatial")
|
|
50
|
+
connection.execute("LOAD spatial")
|
|
51
|
+
return connection
|
|
52
|
+
except Exception:
|
|
53
|
+
connection.close()
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_spatial() -> Any:
|
|
58
|
+
connection = connect_spatial()
|
|
59
|
+
if connection is None:
|
|
60
|
+
directory = os.environ.get(EXTENSION_DIR_ENV, "DuckDB's default extension directory")
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
"DuckDB Spatial is not preinstalled in "
|
|
63
|
+
f"{directory}; run evals/prepare_spatial.py before disabling network access"
|
|
64
|
+
)
|
|
65
|
+
return connection
|