exacly 0.2.1__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.
exacly/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Exacly models, 0.1 product maps or 0.2 config and state documents: validate, refresh and ask them."""
2
+
3
+ from exacly.core import Finding, Report, validate, validate_documents
4
+
5
+ __all__ = ["Finding", "Report", "validate", "validate_documents"]
exacly/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from exacly.cli import exacly_main
4
+
5
+ sys.exit(exacly_main())
exacly/cli.py ADDED
@@ -0,0 +1,509 @@
1
+ """Command lines.
2
+
3
+ exacly validate PATH... [--revision REV] [--date YYYY-MM-DD] [--json]
4
+ exacly refresh PATH... --context CONTEXT --state FILE [--junit FILE...] [--distribution] [run options]
5
+ exacly show|trace|impact|gaps [-m PATH]... [--json] ...; exacly mcp [-m PATH]...
6
+ exacly view plan [--base REV] | site [--out DIR]
7
+ exacly-validate and exacly-refresh are the first two under their names before 0.2.0, kept for one release
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import datetime as dt
14
+ import glob
15
+ import json
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ from collections import Counter
20
+ from collections.abc import Callable, Iterable
21
+ from fnmatch import fnmatchcase
22
+ from importlib import metadata
23
+ from pathlib import Path, PurePosixPath
24
+ from typing import Any
25
+
26
+ import yaml
27
+
28
+ from exacly import mcp, query, views
29
+ from exacly.core import is_state, validate_documents
30
+ from exacly.refresh import RefreshError, Run, read_junit, refresh
31
+
32
+ SUFFIXES = {".yaml", ".yml", ".json"}
33
+ DEFAULT_MODEL = Path("spec/exacly")
34
+
35
+
36
+ def wanted(file: Path, folder: Path) -> bool:
37
+ """Whether a file under a folder given is a model document: YAML or JSON, not in a hidden folder."""
38
+ return file.suffix in SUFFIXES and not any(p.startswith(".") for p in file.relative_to(folder).parts)
39
+
40
+
41
+ def is_pattern(path: Path) -> bool:
42
+ return any(c in str(path) for c in "*?[")
43
+
44
+
45
+ def static_part(path: Path) -> Path:
46
+ """The folder a pattern such as spec/*/model.yaml starts in: its parts before the first one with a wildcard."""
47
+ parts = path.parts
48
+ first = next(i for i, part in enumerate(parts) if is_pattern(Path(part)))
49
+ return Path(*parts[:first]) if first else Path(".")
50
+
51
+
52
+ def matches(name: str, pattern: str) -> bool:
53
+ """Whether a path matches a pattern part by part, as `glob` does: * and ? don't cross a /, and a hidden
54
+ name, starting with a dot, matches only a part that starts with a dot too."""
55
+ names, patterns = PurePosixPath(name).parts, PurePosixPath(pattern).parts
56
+ return len(names) == len(patterns) and all(
57
+ fnmatchcase(n, p) and (p.startswith(".") or not n.startswith(".")) for n, p in zip(names, patterns, strict=True)
58
+ )
59
+
60
+
61
+ def load(paths: list[Path]) -> dict[str, Any]:
62
+ """Each file given, every file a pattern such as spec/exacly/*.yaml matches, and every YAML or JSON file under
63
+ each folder given, by path."""
64
+ files: list[Path] = []
65
+ for path in paths:
66
+ if is_pattern(path):
67
+ found = sorted(Path(f) for f in glob.glob(str(path)) if Path(f).is_file())
68
+ if not found:
69
+ raise query.QueryError(f"{path} matches no files")
70
+ files += found
71
+ elif path.is_dir():
72
+ files += sorted(f for f in path.rglob("*") if f.is_file() and wanted(f, path))
73
+ else:
74
+ files.append(path)
75
+ return {str(f): read(f) for f in files}
76
+
77
+
78
+ def read(file: Path) -> Any:
79
+ """One document, parsed. Raises QueryError, saying which file and where, when it can't be read or parsed."""
80
+ try:
81
+ text = file.read_text(encoding="utf-8")
82
+ except (OSError, UnicodeDecodeError) as error:
83
+ raise query.QueryError(f"{file} can't be read: {error}") from error
84
+ return parse(text, str(file))
85
+
86
+
87
+ class Loader(yaml.SafeLoader):
88
+ """PyYAML's safe loader, but a scalar that matches a type without being one, such as the unquoted date
89
+ 2026-13-24, is a ConstructorError that says where, not a bare ValueError."""
90
+
91
+ def construct_object(self, node: yaml.Node, deep: bool = False) -> Any:
92
+ try:
93
+ return super().construct_object(node, deep)
94
+ except ValueError as error:
95
+ kind = node.tag.rsplit(":", 1)[-1]
96
+ problem = f"{node.value!r} is not a valid {kind}: {error}"
97
+ raise yaml.constructor.ConstructorError(None, None, problem, node.start_mark) from error
98
+
99
+
100
+ def parse(text: str, name: str) -> Any:
101
+ try:
102
+ return yaml.load(text, Loader)
103
+ except yaml.YAMLError as error:
104
+ where = getattr(error, "problem_mark", None)
105
+ at = f" at line {where.line + 1}, column {where.column + 1}" if where else ""
106
+ raise query.QueryError(f"{name} is not valid YAML{at}: {getattr(error, 'problem', None) or error}") from error
107
+
108
+
109
+ def load_at(revision: str, paths: list[Path]) -> dict[str, Any]:
110
+ """As `load`, but the files as they were at a Git revision. Paths may be relative or absolute, inside the
111
+ repository that holds them; documents are named by their path in that repository. A pattern is matched
112
+ against the files at that revision, so a file the change deleted is still compared."""
113
+
114
+ def git(where: Path, *args: str) -> str:
115
+ return subprocess.run(
116
+ ["git", "-C", str(where), *args], check=True, capture_output=True, encoding="utf-8"
117
+ ).stdout
118
+
119
+ documents = {}
120
+ for given in paths:
121
+ pattern = is_pattern(given)
122
+ if pattern:
123
+ # Resolve the folder the pattern starts in, so that it compares with the repository's real path through
124
+ # symbolic links, such as macOS's /var for /private/var.
125
+ path = Path(os.path.normpath(Path.cwd() / given))
126
+ folder = static_part(path)
127
+ path = folder.resolve() / path.relative_to(folder)
128
+ else:
129
+ path = given.resolve()
130
+ start = static_part(path) if pattern else path if path.is_dir() else path.parent
131
+ while not start.is_dir(): # A folder the change deleted, which held files at the revision.
132
+ start = start.parent
133
+ top = Path(git(start, "rev-parse", "--show-toplevel").strip()).resolve()
134
+ try:
135
+ inside = path.relative_to(top)
136
+ except ValueError as error:
137
+ raise query.QueryError(f"{path} is not in the Git repository at {top}") from error
138
+ listed = static_part(inside) if pattern else inside
139
+ # NUL-separated, so Git doesn't quote names with non-ASCII or special characters.
140
+ for name in git(top, "ls-tree", "-r", "-z", "--name-only", revision, "--", str(listed)).split("\0"):
141
+ if not name:
142
+ continue
143
+ file = Path(name)
144
+ if matches(name, inside.as_posix()) if pattern else file == inside or wanted(file, inside):
145
+ documents[name] = parse(git(top, "show", f"{revision}:{name}"), f"{name} at {revision}")
146
+ return documents
147
+
148
+
149
+ def main(argv: list[str] | None = None) -> int:
150
+ parser = argparse.ArgumentParser(
151
+ prog="exacly validate",
152
+ description="Validate an Exacly model and print the section 11 validation report. "
153
+ "Exits 1 when the model does not conform.",
154
+ )
155
+ parser.add_argument(
156
+ "paths",
157
+ nargs="+",
158
+ type=Path,
159
+ metavar="PATH",
160
+ help="a 0.1 product map; or 0.2 config and state documents, or folders holding them, such as spec/exacly",
161
+ )
162
+ parser.add_argument(
163
+ "--revision", help="config revision being validated, such as a Git commit; state from another one is reported"
164
+ )
165
+ parser.add_argument(
166
+ "--date", type=dt.date.fromisoformat, default=dt.date.today(), help="report date (default: today)"
167
+ )
168
+ parser.add_argument("--json", action="store_true", help="print the report as JSON")
169
+ args = parser.parse_args(argv)
170
+ try:
171
+ documents = load(args.paths)
172
+ except query.QueryError as error:
173
+ print(f"exacly validate: {error}", file=sys.stderr)
174
+ return 1
175
+ if not documents:
176
+ parser.error("no YAML or JSON documents found")
177
+ report = validate_documents(documents, args.date, args.revision)
178
+ print(json.dumps(report.as_dict(), indent=2) if args.json else report.as_text())
179
+ return 1 if report.errors else 0
180
+
181
+
182
+ def github_defaults() -> dict[str, str | None]:
183
+ """Run details GitHub Actions provides in its environment, when running there."""
184
+ env = os.environ
185
+ server, repo, run_id, sha = (
186
+ env.get(k) for k in ("GITHUB_SERVER_URL", "GITHUB_REPOSITORY", "GITHUB_RUN_ID", "GITHUB_SHA")
187
+ )
188
+ workflow_ref = env.get("GITHUB_WORKFLOW_REF") # owner/repo/.github/workflows/spec.yml@refs/heads/main
189
+ return {
190
+ "revision": sha,
191
+ "workflow": workflow_ref.split("@")[0].removeprefix(f"{repo}/") if workflow_ref and repo else None,
192
+ "job": env.get("GITHUB_JOB"),
193
+ "run_url": f"{server}/{repo}/actions/runs/{run_id}" if server and repo and run_id else None,
194
+ "source_url": f"{server}/{repo}/blob/{sha}" if server and repo and sha else None,
195
+ }
196
+
197
+
198
+ def refresh_main(argv: list[str] | None = None) -> int:
199
+ defaults = github_defaults()
200
+ parser = argparse.ArgumentParser(
201
+ prog="exacly refresh",
202
+ description="Observe what a model's bindings name and write the state document for one context. "
203
+ "In GitHub Actions, the run options default to the current run.",
204
+ )
205
+ parser.add_argument("paths", nargs="+", type=Path, metavar="PATH", help="config documents, or folders holding them")
206
+ parser.add_argument("--context", required=True, help="the context observed, such as main or production")
207
+ parser.add_argument(
208
+ "--state", required=True, type=Path, help="state document to write; an earlier one is read first"
209
+ )
210
+ parser.add_argument("--junit", nargs="*", default=[], type=Path, help="JUnit XML test reports from the run")
211
+ parser.add_argument(
212
+ "--distribution",
213
+ action="store_true",
214
+ help="the context is how the product is distributed: an implementer present at the revision counts as released",
215
+ )
216
+ parser.add_argument(
217
+ "--revision", default=defaults["revision"], help="config revision observed, such as a Git commit"
218
+ )
219
+ parser.add_argument("--workflow", default=defaults["workflow"], help="workflow path of the run, for bindings")
220
+ parser.add_argument("--job", default=defaults["job"], help="job of the run, for bindings")
221
+ parser.add_argument("--job-status", help="the job's status so far, for bindings without cases (success, failure)")
222
+ parser.add_argument("--run-url", default=defaults["run_url"], help="link to the run, cited as evidence")
223
+ parser.add_argument("--source-url", default=defaults["source_url"], help="base link to files at the revision")
224
+ parser.add_argument("--root", type=Path, default=Path(), help="repository root that references resolve against")
225
+ args = parser.parse_args(argv)
226
+ if not args.revision:
227
+ parser.error("--revision is required outside GitHub Actions")
228
+ try:
229
+ documents = {name: doc for name, doc in load(args.paths).items() if not is_state(doc)}
230
+ except query.QueryError as error:
231
+ print(f"exacly refresh: {error}", file=sys.stderr)
232
+ return 1
233
+ previous = json.loads(args.state.read_text()) if args.state.exists() else None
234
+ run = Run(
235
+ revision=args.revision,
236
+ context=args.context,
237
+ observed_at=dt.datetime.now(dt.UTC).replace(microsecond=0),
238
+ workflow=args.workflow,
239
+ job=args.job,
240
+ job_status=args.job_status,
241
+ url=args.run_url,
242
+ source_url=args.source_url,
243
+ )
244
+ try:
245
+ state = refresh(documents, read_junit(args.junit), run, previous, args.distribution, args.root)
246
+ except RefreshError as error:
247
+ print(f"exacly refresh: {error}", file=sys.stderr)
248
+ return 1
249
+ args.state.parent.mkdir(parents=True, exist_ok=True)
250
+ args.state.write_text(json.dumps(state, indent=2) + "\n")
251
+ counts = Counter(f"{a['dimension']} {a['state']}" for r in state["records"].values() for a in r["assessments"])
252
+ summary = ", ".join(f"{n} {k}" for k, n in sorted(counts.items())) or "no assessments"
253
+ print(f"Wrote {args.state}: {args.context} serial {state['serial']} at {args.revision}: {summary}")
254
+ return 0
255
+
256
+
257
+ def label(node: dict[str, Any]) -> str:
258
+ """One line naming a record, with its status in each context."""
259
+ text = f"{node['id']} {node['type']}: {node['name']}"
260
+ if node["lifecycle"] != "active":
261
+ text += f" ({node['lifecycle']})"
262
+ status = node.get("status") or {}
263
+ if status:
264
+ text += (
265
+ " ["
266
+ + "; ".join(
267
+ f"{context}: " + ", ".join(f"{d} {s}" for d, s in sorted(dims.items()))
268
+ for context, dims in sorted(status.items())
269
+ )
270
+ + "]"
271
+ )
272
+ return text
273
+
274
+
275
+ def tree_lines(node: dict[str, Any], depth: int = 0) -> list[str]:
276
+ via = node.get("via")
277
+ via = f"{via['type']} {'->' if via['direction'] == 'out' else '<-'} " if via else ""
278
+ line = " " * depth + via + label(node) + (" (above)" if node.get("repeated") else "")
279
+ return [line, *(x for child in node.get("children", []) for x in tree_lines(child, depth + 1))]
280
+
281
+
282
+ def finding_line(f: dict[str, Any]) -> str:
283
+ where = f" ({f['document']})" if f.get("document") else ""
284
+ return f" {f['severity']} {f['rule']} {f.get('record') or ''}: {f['message']}{where}".replace(" :", ":")
285
+
286
+
287
+ def show_text(answer: dict[str, Any]) -> str:
288
+ r = answer["record"]
289
+ lines = [label({**r, "status": None}), r["description"]]
290
+ lines.append(f"Owner {r['owner']}, reviewed {r['last_reviewed_at'] or 'never'}, in {answer['document']}")
291
+ details = {
292
+ k: v
293
+ for k, v in r.items()
294
+ if k not in {"id", "type", "name", "lifecycle", "description", "owner", "last_reviewed_at", "evidence"}
295
+ }
296
+ if details:
297
+ lines += ["", yaml.safe_dump(details, sort_keys=False, allow_unicode=True, width=100).rstrip()]
298
+ lines += ["", "Relationships"]
299
+ for rel in answer["relationships"]:
300
+ arrow = "->" if rel["direction"] == "out" else "<-"
301
+ role = f" ({rel['role']})" if rel.get("role") else ""
302
+ lines.append(f" {rel['type']}{role} {arrow} {label(rel['record'])}")
303
+ if not answer["relationships"]:
304
+ lines.append(" none")
305
+ lines += ["", "State"]
306
+ for context, observed in sorted(answer["state"].items()):
307
+ lines.append(f" {context}, observed against {observed['config_revision'][:12]}:")
308
+ for a in observed.get("assessments", []):
309
+ reason = f": {a['reason']}" if a.get("reason") else ""
310
+ cited = "; ".join(f"{e['claim']} ({e['locator']})" for e in a["evidence"])
311
+ lines.append(f" {a['dimension']} {a['state']}{reason}" + (f" - {cited}" if cited else ""))
312
+ for work in observed.get("pending", []):
313
+ lines.append(f" pending {work['task']} since {work['opened_at']}")
314
+ if not answer["state"]:
315
+ lines.append(" not observed")
316
+ lines += [
317
+ "",
318
+ "Findings",
319
+ *(finding_line(f) for f in answer["findings"]),
320
+ *([] if answer["findings"] else [" none"]),
321
+ ]
322
+ return "\n".join(lines)
323
+
324
+
325
+ def trace_text(answer: dict[str, Any]) -> str:
326
+ return "\n".join(["Why", *tree_lines(answer["why"]), "", "How", *tree_lines(answer["how"])])
327
+
328
+
329
+ def impact_text(answer: dict[str, Any]) -> str:
330
+ lines = []
331
+ if "records" in answer:
332
+ for kind, sign in (("added", "+"), ("changed", "~"), ("removed", "-")):
333
+ for r in answer["records"][kind]:
334
+ fields = f" ({', '.join(r['fields'])})" if r.get("fields") else ""
335
+ lines.append(f"{sign} {r['id']} {r['type']}: {r['name']}{fields}")
336
+ lines += [f"+ {rel}" for rel in answer["relationships"]["added"]]
337
+ lines += [f"~ {r['relationship']} ({', '.join(r['fields'])})" for r in answer["relationships"]["changed"]]
338
+ lines += [f"- {rel}" for rel in answer["relationships"]["removed"]]
339
+ if not lines:
340
+ return "No changes to the model."
341
+ lines.append("")
342
+ answer = answer["impact"]
343
+ else:
344
+ lines += ["Changed", *(f" {label(r)}" for r in answer["changed"]), ""]
345
+ lines.append("Affected")
346
+ lines += [f" {label(r)}\n {views.via(r['path'], ' -> ')}" for r in answer["affected"]] or [" nothing else"]
347
+ lines += ["", "Outcomes: " + (", ".join(answer["outcomes"]) or "none reached"), "", "Check again"]
348
+ for r in answer["recheck"]:
349
+ tests = r["observe"].get("tests")
350
+ how = (
351
+ f"{tests['workflow']}" + (f" cases {', '.join(tests['cases'])}" if tests.get("cases") else "")
352
+ if tests
353
+ else "no tests binding"
354
+ )
355
+ lines.append(f" {r['id']} {r['name']}: {how}")
356
+ if not answer["recheck"]:
357
+ lines.append(" no Requirement")
358
+ return "\n".join(lines)
359
+
360
+
361
+ def gaps_text(answer: dict[str, Any]) -> str:
362
+ contexts = ", ".join(
363
+ f"{c} (serial {s['serial']}, config {s['config_revision'][:12]})" for c, s in sorted(answer["contexts"].items())
364
+ )
365
+ lines = [f"Result: {answer['result']}", f"State: {contexts or 'none'}", ""]
366
+ for kind in ("errors", "warnings", "waived"):
367
+ lines += [f"{kind.capitalize()} ({len(answer[kind])})", *(finding_line(f) for f in answer[kind]), ""]
368
+ titles = {
369
+ "unverified": "Requirements not verified in any context",
370
+ "unreleased": "Requirements not released in any context",
371
+ "unobserved": "Requirements with no observation binding",
372
+ "proposed_measures": "Measures still proposed",
373
+ }
374
+ for kind, title in titles.items():
375
+ items = answer["coverage"][kind]
376
+ lines.append(f"{title} ({len(items)})")
377
+ lines += [f" {label(r)}" for r in items]
378
+ lines.append("")
379
+ lines.append(f"Pending work ({len(answer['pending'])})")
380
+ lines += [f" {w['context']}: {w['record']} {w['task']} since {w['opened_at']}" for w in answer["pending"]]
381
+ return "\n".join(lines).rstrip()
382
+
383
+
384
+ def load_base(revision: str, paths: list[Path], model: query.Model) -> query.Model:
385
+ """The model at `paths` as it was at a Git revision; empty when it didn't exist yet."""
386
+ try:
387
+ documents = load_at(revision, paths)
388
+ except subprocess.CalledProcessError as error:
389
+ raise query.QueryError(f"git failed: {error.stderr.strip()}") from error
390
+ return query.load(documents) if documents else query.empty(model.graph.namespace)
391
+
392
+
393
+ def ask(
394
+ command: str,
395
+ paths: list[Path],
396
+ record: str | None = None,
397
+ records: Iterable[str] = (),
398
+ base: str | None = None,
399
+ ) -> tuple[dict[str, Any], str]:
400
+ """Answer one question about the model at `paths` (show, trace, impact or gaps), as data and as text.
401
+ Impact takes `records`, or a Git revision as `base`. Raises QueryError when it can't answer."""
402
+ model = query.load(load(paths))
403
+ if command in ("show", "trace"):
404
+ if not record:
405
+ raise query.QueryError(f"{command} needs a record ID")
406
+ answer = query.show(model, record) if command == "show" else query.trace(model, record)
407
+ return answer, (show_text if command == "show" else trace_text)(answer)
408
+ if command == "impact":
409
+ records = list(records)
410
+ if bool(base) == bool(records):
411
+ raise query.QueryError("impact takes record IDs or a base revision, not both")
412
+ answer = query.change_impact(load_base(base, paths, model), model) if base else query.impact(model, records)
413
+ return answer, impact_text(answer)
414
+ if command == "gaps":
415
+ answer = query.gaps(model)
416
+ return answer, gaps_text(answer)
417
+ raise query.QueryError(f"unknown question {command}")
418
+
419
+
420
+ def view_main(args: argparse.Namespace) -> int:
421
+ paths = args.model or [DEFAULT_MODEL]
422
+ model = query.load(load(paths))
423
+ if args.kind == "plan":
424
+ base = args.base or "main"
425
+ print(views.plan(query.change_impact(load_base(base, paths, model), model), query.gaps(model), base), end="")
426
+ return 0
427
+ out = args.out or Path("reports/site")
428
+ written = views.site(model, out)
429
+ print(f"Wrote {len(written)} pages to {out}; open {out / 'index.html'}")
430
+ return 0
431
+
432
+
433
+ def exacly_main(argv: list[str] | None = None) -> int:
434
+ argv = sys.argv[1:] if argv is None else argv
435
+ if argv and argv[0] in ("validate", "refresh"):
436
+ return main(argv[1:]) if argv[0] == "validate" else refresh_main(argv[1:])
437
+ parser = argparse.ArgumentParser(
438
+ prog="exacly",
439
+ description="Validate an Exacly model, write its state, and ask it about its records.",
440
+ )
441
+ common = argparse.ArgumentParser(add_help=False)
442
+ common.add_argument(
443
+ "-m",
444
+ "--model",
445
+ action="append",
446
+ type=Path,
447
+ metavar="PATH",
448
+ help="config and state documents, or folders holding them (default: spec/exacly)",
449
+ )
450
+ common.add_argument("--json", action="store_true", help="print the answer as JSON")
451
+ commands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
452
+ commands.add_parser("validate", help="validate the model and print the section 11 report")
453
+ commands.add_parser("refresh", help="write state for one context from what the bindings observe")
454
+ show = commands.add_parser("show", parents=[common], help="one record: relationships, state and findings")
455
+ show.add_argument("record")
456
+ trace = commands.add_parser("trace", parents=[common], help="why a record exists, and how it is realized")
457
+ trace.add_argument("record")
458
+ impact = commands.add_parser("impact", parents=[common], help="what a change to records affects")
459
+ impact.add_argument("records", nargs="*", metavar="RECORD")
460
+ impact.add_argument("--base", metavar="REV", help="compare with the model at this Git revision instead")
461
+ commands.add_parser("gaps", parents=[common], help="findings, and what is not yet verified, released or observed")
462
+ commands.add_parser(
463
+ "mcp", parents=[common], help="answer these questions for coding agents, as an MCP server on stdio"
464
+ )
465
+ view = commands.add_parser("view", parents=[common], help="section 10 views: a pull request plan, or a site")
466
+ view.add_argument("kind", choices=["plan", "site"], help="plan: Markdown for a pull request; site: HTML pages")
467
+ view.add_argument("--base", metavar="REV", help="plan: the Git revision the change is against (default: main)")
468
+ view.add_argument("--out", type=Path, metavar="DIR", help="site: the folder to write (default: reports/site)")
469
+ args = parser.parse_args(argv)
470
+ if args.command == "view":
471
+ try:
472
+ return view_main(args)
473
+ except query.QueryError as error:
474
+ print(f"exacly: {error}", file=sys.stderr)
475
+ return 1
476
+ if args.command == "mcp":
477
+ mcp.serve(mcp.Server(args.model or [DEFAULT_MODEL], ask, metadata.version("exacly")))
478
+ return 0
479
+ try:
480
+ answer, text = ask(
481
+ args.command,
482
+ args.model or [DEFAULT_MODEL],
483
+ record=getattr(args, "record", None),
484
+ records=getattr(args, "records", ()),
485
+ base=getattr(args, "base", None),
486
+ )
487
+ except query.QueryError as error:
488
+ print(f"exacly: {error}", file=sys.stderr)
489
+ return 1
490
+ print(json.dumps(answer, indent=2, default=str) if args.json else text)
491
+ return 0
492
+
493
+
494
+ def renamed(command: str, run: Callable[[list[str] | None], int]) -> Callable[[], int]:
495
+ """The command under its name before 0.2.0, which says what to run instead."""
496
+
497
+ def run_renamed() -> int:
498
+ print(f"exacly-{command} is now exacly {command}; the old name goes in the next release.", file=sys.stderr)
499
+ return run(None)
500
+
501
+ return run_renamed
502
+
503
+
504
+ validate_renamed = renamed("validate", main)
505
+ refresh_renamed = renamed("refresh", refresh_main)
506
+
507
+
508
+ if __name__ == "__main__":
509
+ sys.exit(exacly_main())