qedro 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.
qedro/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Qedro — turns emitted evidence into the artefacts an auditor asks for."""
2
+
3
+ #: The single source. `pyproject.toml` reads this rather than restating it.
4
+ __version__ = "0.2.0"
5
+
6
+ #: U+220E END OF PROOF. Printed only when an artefact stands on its own
7
+ #: evidence — see `complete` in qedro.mark. Not decoration: withholding it is
8
+ #: the signal, so nothing should print it unconditionally.
9
+ TOMBSTONE = "∎"
qedro/__main__.py ADDED
@@ -0,0 +1,505 @@
1
+ """CLI entry point.
2
+
3
+ One command so far. ``qedro events`` reads a directory or a Marquez-compatible
4
+ API and says what it found — not a projection, and it does not pretend to be
5
+ one, but it is the thing to run first to find out whether your lineage is
6
+ readable at all.
7
+
8
+ The projections come next. This exists so the reader is usable before they
9
+ land, and so the completeness rule is exercised end to end rather than only
10
+ in tests.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+ import sys
18
+ from collections import Counter
19
+ from datetime import UTC, datetime, timedelta
20
+ from pathlib import Path
21
+
22
+ from . import __version__, declared, deployer, provenance, quality, render, ropa, vocabulary, words
23
+ from . import config as config_module
24
+ from .errors import ConfigError, QedroError, UsageError
25
+ from .mark import Completeness
26
+ from .sources import read
27
+
28
+
29
+ def _instant(value: str) -> datetime:
30
+ """A `--since` / `--until` argument.
31
+
32
+ A bare date is the start of that day. `--since 2026-01-01` meaning
33
+ *"anything on or after the first"* is what everyone expects it to mean, and
34
+ `fromisoformat` already reads both spellings on the 3.12 floor.
35
+ """
36
+ try:
37
+ return datetime.fromisoformat(value)
38
+ except ValueError:
39
+ raise argparse.ArgumentTypeError(
40
+ f"{value!r} is not an ISO-8601 date or timestamp (try 2026-01-01)"
41
+ ) from None
42
+
43
+
44
+ def _window(args: argparse.Namespace) -> tuple[datetime | None, datetime | None]:
45
+ """`--days 90`, resolved against `--until` or against now.
46
+
47
+ Sugar over `--since`, because *the last quarter* is how anybody asks for an
48
+ assertion history and computing the date by hand is a papercut. An explicit
49
+ `--since` wins: two flags meaning the same thing must not silently
50
+ disagree, and the one the user typed is the one they meant.
51
+ """
52
+ since, until = args.since, args.until
53
+ if args.days is not None and since is None:
54
+ end = until or datetime.now(UTC)
55
+ since = end - timedelta(days=args.days)
56
+ return since, until
57
+
58
+
59
+ def _events(target: str, *, symbol: bool, since: datetime | None, until: datetime | None) -> int:
60
+ events, report = read(target, since=since, until=until)
61
+
62
+ completeness = Completeness()
63
+ for reason in report.reasons():
64
+ completeness = completeness.degraded(reason)
65
+
66
+ if not events and report.clean:
67
+ # A clean read of nothing is not a proof of anything. The mark says
68
+ # *this stands on its own evidence*, and there is no evidence here —
69
+ # which is the difference between "nothing happened" and "nothing was
70
+ # recorded" that a reviewer needs flagged.
71
+ completeness = completeness.degraded(
72
+ "no events in the window — nothing was recorded, "
73
+ "which is not the same as nothing having happened"
74
+ )
75
+
76
+ if not events and not report.clean:
77
+ # Nothing read and something wrong: a failure, not a quiet success
78
+ # with an empty result.
79
+ for reason in report.reasons():
80
+ print(f" {reason}", file=sys.stderr)
81
+ return 1
82
+
83
+ by_type = Counter(e.event_type for e in events)
84
+ jobs = {e.job.key for e in events}
85
+ datasets = {d.key for e in events for d in e.datasets}
86
+
87
+ # One of the two is always zero — a directory has files and an API has
88
+ # pages, and naming the wrong one reads as a bug in the tool.
89
+ fetched = (
90
+ words.count(report.files, "file") if report.files else words.count(report.pages, "page")
91
+ )
92
+ summary = " · ".join(
93
+ [
94
+ words.count(report.events, "event"),
95
+ words.count(len(jobs), "job"),
96
+ words.count(len(datasets), "dataset"),
97
+ fetched,
98
+ ]
99
+ )
100
+ suffix = completeness.suffix(symbol=symbol)
101
+ print(f" {summary}{' ' + suffix if suffix else ''}")
102
+
103
+ if by_type:
104
+ print(" " + ", ".join(f"{t.lower()} {n}" for t, n in sorted(by_type.items())))
105
+
106
+ # Reasons are printed, not only counted. A reader who cannot see why the
107
+ # mark is missing has been told nothing useful.
108
+ for reason in completeness.reasons:
109
+ print(f" ! {reason}", file=sys.stderr)
110
+
111
+ return 0
112
+
113
+
114
+ def _ropa(
115
+ source: str,
116
+ *,
117
+ symbol: bool,
118
+ since: datetime | None,
119
+ until: datetime | None,
120
+ config_path: str | None,
121
+ vocabulary_path: str | None,
122
+ fmt: str | None,
123
+ out: str | None,
124
+ view: str = "art30",
125
+ activities_path: str | None = None,
126
+ ) -> int:
127
+ fmt = _format(fmt, out)
128
+ settings = config_module.load(config_module.find(config_path))
129
+ # `--vocabulary` beats `vocabulary:` in the config, which beats the shipped
130
+ # default. The flag is what someone reaches for while trying one out.
131
+ words = vocabulary.load(vocabulary_path or settings.vocabulary or None)
132
+ # Read before the events too: a typo in a document that asserts a lawful
133
+ # basis should fail in the first millisecond, not after a long read.
134
+ declared_uses = declared.load(activities_path, vocabulary=words) if activities_path else ()
135
+
136
+ events, report = read(source, since=since, until=until)
137
+ record = ropa.build(
138
+ events,
139
+ config=settings,
140
+ vocabulary=words,
141
+ report=report,
142
+ since=since,
143
+ until=until,
144
+ declared=declared_uses,
145
+ )
146
+
147
+ if not record.activities and not report.clean:
148
+ # Nothing projected and something wrong reading: a failure, not an
149
+ # empty record that looks like an answer.
150
+ for reason in report.reasons():
151
+ print(f" {reason}", file=sys.stderr)
152
+ return 1
153
+
154
+ if view == "deployer":
155
+ # Built from the Art. 30 record rather than from the events alone, so
156
+ # the two views share purpose and legal basis instead of restating them.
157
+ view_record = deployer.build(
158
+ record,
159
+ events,
160
+ report=report,
161
+ since=since,
162
+ until=until,
163
+ )
164
+ return _emit(view_record, fmt=fmt, out=out, symbol=symbol)
165
+
166
+ return _emit(record, fmt=fmt, out=out, symbol=symbol)
167
+
168
+
169
+ def _quality(
170
+ source: str,
171
+ *,
172
+ symbol: bool,
173
+ since: datetime | None,
174
+ until: datetime | None,
175
+ domains: list[str],
176
+ config_path: str | None,
177
+ fmt: str | None,
178
+ out: str | None,
179
+ ) -> int:
180
+ fmt = _format(fmt, out)
181
+ settings = config_module.load(config_module.find(config_path))
182
+
183
+ events, report = read(source, since=since, until=until)
184
+ record = quality.build(
185
+ events,
186
+ config=settings,
187
+ report=report,
188
+ since=since,
189
+ until=until,
190
+ domains=domains,
191
+ )
192
+
193
+ if not record.datasets and not record.unchecked and not report.clean:
194
+ for reason in report.reasons():
195
+ print(f" {reason}", file=sys.stderr)
196
+ return 1
197
+
198
+ return _emit(record, fmt=fmt, out=out, symbol=symbol)
199
+
200
+
201
+ def _provenance(
202
+ source: str,
203
+ *,
204
+ symbol: bool,
205
+ since: datetime | None,
206
+ until: datetime | None,
207
+ dataset: str,
208
+ depth: int,
209
+ config_path: str | None,
210
+ fmt: str | None,
211
+ out: str | None,
212
+ ) -> int:
213
+ fmt = _format(fmt, out)
214
+ settings = config_module.load(config_module.find(config_path))
215
+
216
+ events, report = read(source, since=since, until=until)
217
+ events = list(events)
218
+
219
+ # Nobody types the namespace, so a bare name is matched — but an ambiguous
220
+ # one is handed back as a question. Picking the first would produce a chain
221
+ # for a dataset the user did not ask about, and nothing downstream would
222
+ # ever say so.
223
+ matches = provenance.resolve(events, dataset)
224
+ if not matches:
225
+ raise ConfigError(
226
+ f"no dataset named {dataset!r} appears in {report.origin or source} "
227
+ "— check the spelling, or widen the window"
228
+ )
229
+ if len(matches) > 1:
230
+ listed = "\n ".join(matches)
231
+ raise ConfigError(
232
+ f"{dataset!r} matches more than one dataset. Name one in full:\n {listed}"
233
+ )
234
+
235
+ record = provenance.build(
236
+ events,
237
+ dataset=matches[0],
238
+ config=settings,
239
+ report=report,
240
+ since=since,
241
+ until=until,
242
+ depth=depth,
243
+ )
244
+ return _emit(record, fmt=fmt, out=out, symbol=symbol)
245
+
246
+
247
+ def _format(fmt: str | None, out: str | None) -> str:
248
+ """Resolve the output format before a single event is read.
249
+
250
+ An explicit `--format` wins; otherwise `--out history.md` means markdown
251
+ and `--out history.xlsx` means a workbook. A combination that cannot work
252
+ should fail in the first millisecond rather than after a long read against
253
+ a remote backend.
254
+ """
255
+ fmt = fmt or render.infer(out)
256
+ if render.is_binary(fmt) and not out:
257
+ raise UsageError(
258
+ f"--format {fmt} produces a file rather than text, and there is nowhere "
259
+ f"to put it — add --out record.{fmt}"
260
+ )
261
+ return fmt
262
+
263
+
264
+ def _emit(record, *, fmt: str, out: str | None, symbol: bool) -> int:
265
+ """Render and deliver, the same way for every projection."""
266
+ renderer = render.FORMATS[fmt]
267
+ rendered = renderer(record, symbol=symbol) if fmt == "text" else renderer(record)
268
+
269
+ if not out:
270
+ print(rendered, end="")
271
+ return 0
272
+
273
+ path = Path(out)
274
+ if isinstance(rendered, bytes):
275
+ path.write_bytes(rendered)
276
+ else:
277
+ path.write_text(rendered, encoding="utf-8")
278
+
279
+ mark = record.completeness.suffix(symbol=symbol)
280
+ print(f" wrote {out}{' ' + mark if mark else ''}")
281
+ # Reasons go to stderr even when the file was written. The run succeeded;
282
+ # the artefact simply does not claim to be a proof.
283
+ for reason in record.completeness.reasons:
284
+ print(f" ! {reason}", file=sys.stderr)
285
+ return 0
286
+
287
+
288
+ def main(argv: list[str] | None = None) -> int:
289
+ # Shared flags go on a parent parser so they are accepted on either side
290
+ # of the subcommand. `qedro events ./ --no-symbol` is how people actually
291
+ # type it, and rejecting that is a papercut nobody reports, they just stop.
292
+ #
293
+ # SUPPRESS as the default matters: without it the subparser's default
294
+ # overwrites a value already set at the top level, and the flag silently
295
+ # does nothing when written before the subcommand.
296
+ common = argparse.ArgumentParser(add_help=False)
297
+ common.add_argument(
298
+ "--no-symbol",
299
+ action="store_true",
300
+ default=argparse.SUPPRESS,
301
+ help="print [complete] instead of the tombstone, for terminals with no glyph for U+220E",
302
+ )
303
+
304
+ parser = argparse.ArgumentParser(
305
+ prog="qedro",
306
+ parents=[common],
307
+ description="Turns emitted evidence into the artefacts an auditor asks for.",
308
+ )
309
+ parser.add_argument("-V", "--version", action="version", version=f"qedro {__version__}")
310
+
311
+ sub = parser.add_subparsers(dest="command")
312
+ events = sub.add_parser(
313
+ "events",
314
+ parents=[common],
315
+ help="read OpenLineage events from a directory or an API and summarise them",
316
+ )
317
+ events.add_argument(
318
+ "source",
319
+ help="a directory of .json, .ndjson or .jsonl events, "
320
+ "or the base URL of a Marquez-compatible API",
321
+ )
322
+ events.add_argument("--since", type=_instant, help="ignore events before this date")
323
+ events.add_argument("--until", type=_instant, help="ignore events after this date")
324
+
325
+ ropa_cmd = sub.add_parser(
326
+ "ropa",
327
+ parents=[common],
328
+ help="produce a GDPR Art. 30 record of processing activities",
329
+ )
330
+ ropa_cmd.add_argument(
331
+ "source",
332
+ help="a directory of .json, .ndjson or .jsonl events, "
333
+ "or the base URL of a Marquez-compatible API",
334
+ )
335
+ ropa_cmd.add_argument("--since", type=_instant, help="ignore events before this date")
336
+ ropa_cmd.add_argument("--until", type=_instant, help="ignore events after this date")
337
+ ropa_cmd.add_argument(
338
+ "--config",
339
+ help="qedro.yaml (or .json/.toml). Defaults to one beside the working directory",
340
+ )
341
+ ropa_cmd.add_argument(
342
+ "--vocabulary",
343
+ help="a vocabulary document to read terms from, instead of the shipped GDPR baseline",
344
+ )
345
+ ropa_cmd.add_argument(
346
+ "--format",
347
+ dest="fmt",
348
+ choices=sorted(render.names()),
349
+ default=None,
350
+ help="output format. Defaults to the one implied by --out, else text",
351
+ )
352
+ ropa_cmd.add_argument("--out", help="write to this file instead of standard output")
353
+ ropa_cmd.add_argument(
354
+ "--view",
355
+ choices=("art30", "deployer"),
356
+ default="art30",
357
+ help="art30: the GDPR Art. 30 record (default). deployer: the same record, "
358
+ "per AI use case, with the model version, inputs and run records a deployer "
359
+ "under the AI Act is asked about",
360
+ )
361
+ ropa_cmd.add_argument(
362
+ "--activities",
363
+ metavar="PATH",
364
+ help="a document of declared activities — processing that emits no lineage. "
365
+ "Marked as declared in the output, and never counted as evidence",
366
+ )
367
+
368
+ quality_cmd = sub.add_parser(
369
+ "quality",
370
+ parents=[common],
371
+ help="what was asserted about each dataset, and what was never checked",
372
+ )
373
+ quality_cmd.add_argument(
374
+ "source",
375
+ help="a directory of .json, .ndjson or .jsonl events, "
376
+ "or the base URL of a Marquez-compatible API",
377
+ )
378
+ quality_cmd.add_argument("--since", type=_instant, help="ignore events before this date")
379
+ quality_cmd.add_argument("--until", type=_instant, help="ignore events after this date")
380
+ quality_cmd.add_argument(
381
+ "--days",
382
+ type=int,
383
+ help="shorthand for --since N days before --until (or before now)",
384
+ )
385
+ quality_cmd.add_argument(
386
+ "--domain",
387
+ action="append",
388
+ default=[],
389
+ dest="domains",
390
+ metavar="NAME",
391
+ help="only datasets in this domain. Repeatable",
392
+ )
393
+ quality_cmd.add_argument(
394
+ "--config",
395
+ help="qedro.yaml (or .json/.toml). Defaults to one beside the working directory",
396
+ )
397
+ quality_cmd.add_argument(
398
+ "--format",
399
+ dest="fmt",
400
+ choices=sorted(render.names()),
401
+ default=None,
402
+ help="output format. Defaults to the one implied by --out, else text",
403
+ )
404
+ quality_cmd.add_argument("--out", help="write to this file instead of standard output")
405
+
406
+ provenance_cmd = sub.add_parser(
407
+ "provenance",
408
+ parents=[common],
409
+ help="the chain from a dataset back to the commits that produced it",
410
+ )
411
+ provenance_cmd.add_argument(
412
+ "source",
413
+ help="a directory of .json, .ndjson or .jsonl events, "
414
+ "or the base URL of a Marquez-compatible API",
415
+ )
416
+ provenance_cmd.add_argument(
417
+ "--dataset",
418
+ required=True,
419
+ help="the dataset to trace. A bare name is enough unless it is ambiguous",
420
+ )
421
+ provenance_cmd.add_argument(
422
+ "--depth",
423
+ type=int,
424
+ default=provenance.DEPTH,
425
+ help=f"how many hops upstream to follow (default {provenance.DEPTH})",
426
+ )
427
+ provenance_cmd.add_argument("--since", type=_instant, help="ignore events before this date")
428
+ provenance_cmd.add_argument("--until", type=_instant, help="ignore events after this date")
429
+ provenance_cmd.add_argument(
430
+ "--days",
431
+ type=int,
432
+ help="shorthand for --since N days before --until (or before now)",
433
+ )
434
+ provenance_cmd.add_argument(
435
+ "--config",
436
+ help="qedro.yaml (or .json/.toml). Defaults to one beside the working directory",
437
+ )
438
+ provenance_cmd.add_argument(
439
+ "--format",
440
+ dest="fmt",
441
+ choices=sorted(render.names()),
442
+ default=None,
443
+ help="output format. Defaults to the one implied by --out, else text",
444
+ )
445
+ provenance_cmd.add_argument("--out", help="write to this file instead of standard output")
446
+
447
+ args = parser.parse_args(sys.argv[1:] if argv is None else argv)
448
+
449
+ # The env var is for CI logs and containers, where nobody is around to
450
+ # pass a flag and a tofu box reads as a bug in the tool.
451
+ no_symbol = getattr(args, "no_symbol", False) or os.environ.get("QEDRO_NO_SYMBOL") == "1"
452
+
453
+ try:
454
+ if args.command == "events":
455
+ return _events(args.source, symbol=not no_symbol, since=args.since, until=args.until)
456
+ if args.command == "quality":
457
+ since, until = _window(args)
458
+ return _quality(
459
+ args.source,
460
+ symbol=not no_symbol,
461
+ since=since,
462
+ until=until,
463
+ domains=args.domains,
464
+ config_path=args.config,
465
+ fmt=args.fmt,
466
+ out=args.out,
467
+ )
468
+ if args.command == "provenance":
469
+ since, until = _window(args)
470
+ return _provenance(
471
+ args.source,
472
+ symbol=not no_symbol,
473
+ since=since,
474
+ until=until,
475
+ dataset=args.dataset,
476
+ depth=args.depth,
477
+ config_path=args.config,
478
+ fmt=args.fmt,
479
+ out=args.out,
480
+ )
481
+ if args.command == "ropa":
482
+ return _ropa(
483
+ args.source,
484
+ symbol=not no_symbol,
485
+ since=args.since,
486
+ until=args.until,
487
+ config_path=args.config,
488
+ vocabulary_path=args.vocabulary,
489
+ fmt=args.fmt,
490
+ out=args.out,
491
+ view=args.view,
492
+ activities_path=args.activities,
493
+ )
494
+ except QedroError as exc:
495
+ # What the user wrote, handed back as a sentence rather than a
496
+ # traceback. Evidence never gets here — it is counted, not raised.
497
+ print(f" {exc}", file=sys.stderr)
498
+ return 2
499
+
500
+ parser.print_help(sys.stderr)
501
+ return 1
502
+
503
+
504
+ if __name__ == "__main__":
505
+ raise SystemExit(main())