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/cli.py ADDED
@@ -0,0 +1,431 @@
1
+ """Console entry point for OpenMapStack project operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import shlex
8
+ import subprocess
9
+ import sys
10
+ from collections.abc import Sequence
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from . import __version__
15
+ from .project import ProjectError, get_in, load_project, project_path, step_outputs
16
+ from .validation import ValidationResult, validate_project
17
+ from .verify import VerifyResult, verify_project
18
+
19
+ STATUS_MARKS = {
20
+ "passed": "PASS",
21
+ "warning": "WARN",
22
+ "not_testable": "N/A ",
23
+ "failed": "FAIL",
24
+ }
25
+
26
+
27
+ def build_parser() -> argparse.ArgumentParser:
28
+ parser = argparse.ArgumentParser(
29
+ prog="openmapstack",
30
+ description="Validate, run, and inspect reproducible OpenMapStack projects.",
31
+ )
32
+ parser.add_argument("--version", action="version", version=f"openmapstack {__version__}")
33
+ subparsers = parser.add_subparsers(dest="command", required=True)
34
+
35
+ validate_parser = subparsers.add_parser("validate", help="validate a project manifest and its artifacts")
36
+ validate_parser.add_argument("project", nargs="?", default="project.yaml", help="project.yaml or its directory")
37
+ validate_parser.add_argument("--preflight", action="store_true", help="skip generated output, report, and run-record checks")
38
+ validate_parser.add_argument("--strict", action="store_true", help="return failure when warnings or not-testable checks exist")
39
+ validate_parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
40
+ validate_parser.add_argument("--output", type=Path, help="also write the JSON result to this path")
41
+ validate_parser.add_argument("--verbose", action="store_true", help="show passed checks in text output")
42
+ validate_parser.set_defaults(handler=_cmd_validate)
43
+
44
+ run_parser = subparsers.add_parser("run", help="run the canonical pipeline and validate its artifacts")
45
+ run_parser.add_argument("project", nargs="?", default="project.yaml", help="project.yaml or its directory")
46
+ run_parser.add_argument("--dry-run", action="store_true", help="validate preflight and print the command without executing it")
47
+ run_parser.add_argument("--strict", action="store_true", help="return failure when post-run validation has warnings")
48
+ run_parser.add_argument("--json", action="store_true", help="capture pipeline output and emit machine-readable JSON")
49
+ run_parser.add_argument(
50
+ "--pipeline-arg",
51
+ action="append",
52
+ default=[],
53
+ dest="pipeline_args",
54
+ metavar="ARG",
55
+ help="pass one argument to the pipeline; repeat as needed (use --pipeline-arg=--flag for flags)",
56
+ )
57
+ run_parser.set_defaults(handler=_cmd_run)
58
+
59
+ verify_parser = subparsers.add_parser(
60
+ "verify",
61
+ help="check produced artifacts without requiring a golden answer",
62
+ )
63
+ verify_parser.add_argument("project", nargs="?", default="project.yaml", help="project.yaml or its directory")
64
+ verify_parser.add_argument(
65
+ "--rerun",
66
+ action="store_true",
67
+ help="also rebuild the project from source in a clean workspace and compare",
68
+ )
69
+ verify_parser.add_argument(
70
+ "--rerun-timeout",
71
+ type=float,
72
+ default=1800.0,
73
+ help="seconds allowed for the clean rerun's canonical entrypoint (default: 1800)",
74
+ )
75
+ verify_parser.add_argument("--strict", action="store_true", help="return failure when warnings or not-testable checks exist")
76
+ verify_parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
77
+ verify_parser.add_argument("--output", type=Path, help="also write the JSON result to this path")
78
+ verify_parser.add_argument("--verbose", action="store_true", help="show passed checks in text output")
79
+ verify_parser.set_defaults(handler=_cmd_verify)
80
+
81
+ inspect_parser = subparsers.add_parser("inspect", help="summarize a project for audit and review")
82
+ inspect_parser.add_argument("project", nargs="?", default="project.yaml", help="project.yaml or its directory")
83
+ inspect_parser.add_argument("--json", action="store_true", help="emit the full summary as JSON")
84
+ inspect_parser.add_argument("--checks", action="store_true", help="include passed validation checks in text output")
85
+ inspect_parser.set_defaults(handler=_cmd_inspect)
86
+ return parser
87
+
88
+
89
+ def main(argv: Sequence[str] | None = None) -> int:
90
+ parser = build_parser()
91
+ args = parser.parse_args(argv)
92
+ try:
93
+ return int(args.handler(args))
94
+ except KeyboardInterrupt:
95
+ print("openmapstack: interrupted", file=sys.stderr)
96
+ return 130
97
+ except BrokenPipeError:
98
+ return 0
99
+
100
+
101
+ def _cmd_validate(args: argparse.Namespace) -> int:
102
+ result = validate_project(args.project, artifacts=not args.preflight)
103
+ payload = result.to_dict()
104
+ if args.json:
105
+ print(_json(payload))
106
+ else:
107
+ _print_validation(result, verbose=args.verbose)
108
+ if args.output:
109
+ args.output.parent.mkdir(parents=True, exist_ok=True)
110
+ args.output.write_text(_json(payload) + "\n", encoding="utf-8")
111
+ if not args.json:
112
+ print(f"Wrote {args.output}")
113
+ return 0 if result.ok(strict=args.strict) else 1
114
+
115
+
116
+ def _cmd_verify(args: argparse.Namespace) -> int:
117
+ try:
118
+ result = verify_project(
119
+ args.project,
120
+ rerun=args.rerun,
121
+ rerun_timeout_s=args.rerun_timeout,
122
+ )
123
+ except ProjectError as exc:
124
+ print(f"openmapstack: {exc}", file=sys.stderr)
125
+ return 2
126
+ payload = result.to_dict()
127
+ if args.json:
128
+ print(_json(payload))
129
+ else:
130
+ _print_verify(result, verbose=args.verbose)
131
+ if args.output:
132
+ args.output.parent.mkdir(parents=True, exist_ok=True)
133
+ args.output.write_text(_json(payload) + "\n", encoding="utf-8")
134
+ if not args.json:
135
+ print(f"Wrote {args.output}")
136
+ return 0 if result.ok(strict=args.strict) else 1
137
+
138
+
139
+ def _print_verify(result: VerifyResult, *, verbose: bool, stream: Any = None) -> None:
140
+ out = stream or sys.stdout
141
+ for run in result.checks:
142
+ if run.result.status == "passed" and not verbose:
143
+ continue
144
+ mark = STATUS_MARKS.get(run.result.status, run.result.status.upper())
145
+ target = run.args.get("path")
146
+ label = f"{run.name} [{target}]" if target else run.name
147
+ print(f"{mark} {label}: {run.result.detail}", file=out)
148
+ counts = result.counts
149
+ coverage = result.coverage
150
+ print(
151
+ f"{result.status.upper()}: {result.project_file} "
152
+ f"({counts['passed']} passed, {counts['warning']} warnings, "
153
+ f"{counts['not_testable']} not testable, {counts['failed']} failed; "
154
+ f"{coverage['executed']}/{coverage['applicable']} applicable checks executed)",
155
+ file=out,
156
+ )
157
+ if counts["not_testable"]:
158
+ print(
159
+ " NOTE some checks could not run here; install openmapstack[geo] "
160
+ "for geodata checks, QGIS for the .qgz checks",
161
+ file=out,
162
+ )
163
+
164
+
165
+ def _cmd_run(args: argparse.Namespace) -> int:
166
+ preflight = validate_project(args.project, artifacts=False)
167
+ if not preflight.ok():
168
+ if args.json:
169
+ print(_json({"schema": "openmapstack-run-result/v1", "status": "failed", "phase": "preflight", "validation": preflight.to_dict()}))
170
+ else:
171
+ print("Preflight validation failed.", file=sys.stderr)
172
+ _print_validation(preflight, verbose=False, stream=sys.stderr)
173
+ return 1
174
+
175
+ try:
176
+ project_file, project = load_project(args.project)
177
+ command = _pipeline_command(project_file, project, args.pipeline_args)
178
+ except ProjectError as exc:
179
+ if args.json:
180
+ print(_json({"schema": "openmapstack-run-result/v1", "status": "failed", "phase": "preflight", "error": str(exc)}))
181
+ else:
182
+ print(f"openmapstack run: {exc}", file=sys.stderr)
183
+ return 2
184
+
185
+ display_command = shlex.join(command)
186
+ if args.dry_run:
187
+ payload = {
188
+ "schema": "openmapstack-run-result/v1",
189
+ "status": preflight.status,
190
+ "phase": "dry_run",
191
+ "project_file": str(project_file),
192
+ "cwd": str(project_file.parent),
193
+ "command": command,
194
+ "validation": preflight.to_dict(),
195
+ }
196
+ if args.json:
197
+ print(_json(payload))
198
+ else:
199
+ print(f"Preflight: {preflight.status}")
200
+ print(f"Would run: {display_command}")
201
+ return 0
202
+
203
+
204
+ if not args.json:
205
+ print(f"Preflight: {preflight.status}")
206
+ print(f"Running: {display_command}")
207
+ try:
208
+ completed = subprocess.run(
209
+ command,
210
+ cwd=project_file.parent,
211
+ check=False,
212
+ text=True,
213
+ capture_output=args.json,
214
+ )
215
+ except OSError as exc:
216
+ if args.json:
217
+ print(_json({"schema": "openmapstack-run-result/v1", "status": "failed", "phase": "execute", "command": command, "error": str(exc)}))
218
+ else:
219
+ print(f"openmapstack run: could not start pipeline: {exc}", file=sys.stderr)
220
+ return 2
221
+
222
+
223
+ if completed.returncode != 0:
224
+ payload = {
225
+ "schema": "openmapstack-run-result/v1",
226
+ "status": "failed",
227
+ "phase": "execute",
228
+ "project_file": str(project_file),
229
+ "command": command,
230
+ "returncode": completed.returncode,
231
+ }
232
+ if args.json:
233
+ payload["stdout"] = completed.stdout
234
+ payload["stderr"] = completed.stderr
235
+ print(_json(payload))
236
+ else:
237
+ print(f"Pipeline failed with exit code {completed.returncode}.", file=sys.stderr)
238
+ return 1
239
+
240
+ validation = validate_project(project_file, artifacts=True)
241
+ payload = {
242
+ "schema": "openmapstack-run-result/v1",
243
+ "status": validation.status,
244
+ "phase": "complete",
245
+ "project_file": str(project_file),
246
+ "command": command,
247
+ "returncode": completed.returncode,
248
+ "validation": validation.to_dict(),
249
+ }
250
+ if args.json:
251
+ payload["stdout"] = completed.stdout
252
+ payload["stderr"] = completed.stderr
253
+ print(_json(payload))
254
+ else:
255
+ _print_validation(validation, verbose=False)
256
+ return 0 if validation.ok(strict=args.strict) else 1
257
+
258
+
259
+ def _cmd_inspect(args: argparse.Namespace) -> int:
260
+ try:
261
+ project_file, project = load_project(args.project)
262
+ except ProjectError as exc:
263
+ if args.json:
264
+ print(_json({"schema": "openmapstack-inspection/v1", "status": "failed", "error": str(exc)}))
265
+ else:
266
+ print(f"openmapstack inspect: {exc}", file=sys.stderr)
267
+ return 2
268
+
269
+ validation = validate_project(project_file, artifacts=True)
270
+ summary = _inspection(project_file, project, validation)
271
+ if args.json:
272
+ print(_json(summary))
273
+ else:
274
+ _print_inspection(summary, validation, show_checks=args.checks)
275
+ return 0
276
+
277
+
278
+ def _pipeline_command(project_file: Path, project: dict[str, Any], pipeline_args: Sequence[str]) -> list[str]:
279
+ implementation = get_in(project, "runtime", "implementation")
280
+ if not isinstance(implementation, dict):
281
+ raise ProjectError("runtime.implementation is missing")
282
+ declared_command = implementation.get("command")
283
+ if declared_command is not None:
284
+ if isinstance(declared_command, str):
285
+ command = shlex.split(declared_command)
286
+ elif isinstance(declared_command, list) and all(isinstance(item, str) and item for item in declared_command):
287
+ command = list(declared_command)
288
+ else:
289
+ raise ProjectError("runtime.implementation.command must be a string or list of strings")
290
+ if not command:
291
+ raise ProjectError("runtime.implementation.command is empty")
292
+ else:
293
+ pipeline = implementation.get("pipeline")
294
+ target = project_path(project_file.parent, pipeline)
295
+ if target is None:
296
+ raise ProjectError("runtime.implementation.pipeline must be a safe project-relative path")
297
+ if not target.is_file():
298
+ raise ProjectError(f"pipeline does not exist: {pipeline}")
299
+ relative = str(target.relative_to(project_file.parent))
300
+ if target.suffix.lower() == ".py":
301
+ command = [sys.executable, relative]
302
+ elif target.stat().st_mode & 0o111:
303
+ command = [relative if relative.startswith("./") else f"./{relative}"]
304
+ else:
305
+ raise ProjectError("non-Python pipelines need an executable file or runtime.implementation.command")
306
+ return command + list(pipeline_args)
307
+
308
+
309
+ def _inspection(project_file: Path, project: dict[str, Any], validation: ValidationResult) -> dict[str, Any]:
310
+ root = project_file.parent
311
+ metadata = project.get("project") or {}
312
+ sources = project.get("sources") or {}
313
+ overrides = project.get("overrides") or []
314
+ steps = get_in(project, "processing", "steps", default=[]) or []
315
+ outputs = project.get("outputs") or {}
316
+ source_items = []
317
+ for key, source in sources.items() if isinstance(sources, dict) else []:
318
+ source = source if isinstance(source, dict) else {}
319
+ source_items.append(
320
+ {
321
+ "key": key,
322
+ "provider": source.get("provider"),
323
+ "dataset": source.get("dataset"),
324
+ "retrieved_at": get_in(source, "access", "retrieved_at") or get_in(source, "access", "downloaded_at"),
325
+ "version": get_in(source, "version", "identifier") or get_in(source, "version", "published_at"),
326
+ "license": get_in(source, "license", "name"),
327
+ "source_url": source.get("source_url"),
328
+ }
329
+ )
330
+ override_items = []
331
+ for override in overrides if isinstance(overrides, list) else []:
332
+ if isinstance(override, dict):
333
+ override_items.append(
334
+ {
335
+ "id": override.get("id"),
336
+ "action": override.get("action"),
337
+ "created_by": override.get("created_by"),
338
+ "geometry_file": get_in(override, "geometry_file", "path"),
339
+ }
340
+ )
341
+ step_items = []
342
+ for index, step in enumerate(steps if isinstance(steps, list) else []):
343
+ if isinstance(step, dict):
344
+ step_items.append(
345
+ {
346
+ "order": index + 1,
347
+ "id": step.get("id"),
348
+ "operation": step.get("operation"),
349
+ "outputs": step_outputs(step),
350
+ }
351
+ )
352
+ output_items = []
353
+ for key, output in outputs.items() if isinstance(outputs, dict) else []:
354
+ output = output if isinstance(output, dict) else {}
355
+ target = project_path(root, output.get("path"))
356
+ output_items.append(
357
+ {
358
+ "key": key,
359
+ "path": output.get("path"),
360
+ "format": output.get("format"),
361
+ "generated_by": output.get("generated_by"),
362
+ "exists": bool(target and target.exists()),
363
+ }
364
+ )
365
+ warnings = project.get("warnings") if isinstance(project.get("warnings"), list) else []
366
+ return {
367
+ "schema": "openmapstack-inspection/v1",
368
+ "project_file": str(project_file),
369
+ "project": {
370
+ "schema": project.get("schema"),
371
+ "id": metadata.get("id"),
372
+ "title": metadata.get("title"),
373
+ "question": metadata.get("question"),
374
+ "status": metadata.get("status"),
375
+ "updated_at": metadata.get("updated_at"),
376
+ },
377
+ "crs": {
378
+ "analysis": get_in(project, "processing", "analysis_crs"),
379
+ "storage": get_in(project, "processing", "storage_crs"),
380
+ },
381
+ "sources": source_items,
382
+ "overrides": override_items,
383
+ "steps": step_items,
384
+ "outputs": output_items,
385
+ "warnings": warnings,
386
+ "latest_run": get_in(project, "runs", "latest"),
387
+ "validation": validation.to_dict(),
388
+ }
389
+
390
+
391
+ def _print_validation(result: ValidationResult, *, verbose: bool, stream: Any = None) -> None:
392
+ stream = stream or sys.stdout
393
+ for check in result.checks:
394
+ if not verbose and check.status == "passed":
395
+ continue
396
+ location = f" [{check.path}]" if check.path else ""
397
+ print(f"{STATUS_MARKS.get(check.status, check.status.upper()):4s} {check.id}{location}: {check.message}", file=stream)
398
+ counts = result.counts
399
+ print(
400
+ f"{result.status.upper()}: {result.project_file} "
401
+ f"({counts['passed']} passed, {counts['warning']} warnings, "
402
+ f"{counts['not_testable']} not testable, {counts['failed']} failed)",
403
+ file=stream,
404
+ )
405
+
406
+
407
+ def _print_inspection(summary: dict[str, Any], validation: ValidationResult, *, show_checks: bool) -> None:
408
+ project = summary["project"]
409
+ print(f"{project.get('title') or '(untitled project)'}")
410
+ print(f" id: {project.get('id')} schema: {project.get('schema')} declared status: {project.get('status')}")
411
+ print(f" manifest: {summary['project_file']}")
412
+ print(f" CRS: analysis={summary['crs']['analysis']} storage={summary['crs']['storage']}")
413
+ print(f" sources: {len(summary['sources'])} overrides: {len(summary['overrides'])} steps: {len(summary['steps'])} outputs: {len(summary['outputs'])}")
414
+ for source in summary["sources"]:
415
+ print(f" source {source['key']}: {source.get('provider')} / {source.get('dataset')} ({source.get('version')})")
416
+ for override in summary["overrides"]:
417
+ print(f" override {override.get('id')}: {override.get('action')} by {override.get('created_by')}")
418
+ missing_outputs = [item["path"] for item in summary["outputs"] if not item["exists"]]
419
+ if missing_outputs:
420
+ print(f" missing outputs: {', '.join(str(item) for item in missing_outputs)}")
421
+ latest = summary.get("latest_run") or {}
422
+ print(f" latest run: {latest.get('id')} ({latest.get('status')})")
423
+ _print_validation(validation, verbose=show_checks)
424
+
425
+
426
+ def _json(value: object) -> str:
427
+ return json.dumps(value, indent=2, ensure_ascii=False, default=str)
428
+
429
+
430
+ if __name__ == "__main__":
431
+ raise SystemExit(main())