runprov 0.1.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.
runprov/__init__.py ADDED
@@ -0,0 +1,189 @@
1
+ # Copyright (c) 2026 Assistance Publique – Hôpitaux de Paris (AP-HP),
2
+ # Hôpital Henri-Mondor, and Taylor Thompson
3
+ # SPDX-License-Identifier: BSD-3-Clause
4
+ # Licensed under the BSD 3-Clause License — see LICENSE.
5
+ r"""runprov — record what a script read, wrote and ran as, in a form that can be checked.
6
+
7
+ RAW, and that is load-bearing rather than stylistic: the example passes `sep="\t"`, and in a
8
+ normal docstring that is an actual tab by the time `help(runprov)` prints it — the reader is
9
+ shown `sep=" "` and cannot tell what to type. The example is the thing being got right
10
+ here, so it has to survive being rendered.
11
+
12
+ from runprov import Run, configure
13
+
14
+ configure(root=REPO) # history -> REPO/provenance/runs.jsonl, where the CLI looks
15
+
16
+ PROV = OUT.with_name("build_labels_provenance.json")
17
+
18
+ # provenance=PROV is what makes a crash record. Without it, __exit__ writes nothing.
19
+ with Run("build_labels", vars(args), provenance=PROV) as run:
20
+ df = pd.read_csv(run.input(INPUT), sep="\t") # registering IS how you open it
21
+ with open(run.output(OUT), "w", encoding="utf-8") as fh:
22
+ fh.write(run.header()) # the pin, inside the artifact
23
+ df.to_csv(fh, sep="\t", index=False)
24
+ run.note("n_rows", len(df))
25
+
26
+ `provenance=` on the CONSTRUCTOR is load-bearing and this docstring used to get it wrong —
27
+ it showed `run = Run(...)` with a closing `run.write(PROV)`, which is the shape README's
28
+ "Two shapes that record nothing" table lists first: a crash halfway records no sidecar, no
29
+ history line, and prints no warning. `with` alone does not fix it either, because `__exit__`
30
+ only writes when `provenance=` reached the constructor. That the package's own front page
31
+ taught the failure it exists to prevent is why `Run`'s class docstring states the rule
32
+ unhedged, and why a test now asserts this one does too.
33
+
34
+ Three properties, each of which exists because its absence caused a specific defect:
35
+
36
+ 1. **Registration is the ergonomic path.** `input()` returns the path, so the natural way
37
+ to open a file is the recorded way.
38
+ 2. **The pin lives in the artifact**, not only in a sidecar, so an artifact can say what
39
+ it was made from after the sidecar has been overwritten.
40
+ 3. **The pin is deterministic.** No timestamps, no run id — those make every artifact
41
+ differ on every run, which produces a permanently red reproducibility check, which
42
+ trains everyone to ignore it.
43
+
44
+ Promoted out of `scripts/audit/_provenance.py` (ADR-028). That module is now a shim over
45
+ this package, so the audit and any new pipeline share one implementation rather than two
46
+ copies that agree until they do not.
47
+ """
48
+
49
+ import typing
50
+
51
+ from .hashing import content_digest, describe, sha256
52
+ from .project import (
53
+ DEFAULT_CODE_PATHS,
54
+ DEFAULT_TRACKED,
55
+ Project,
56
+ active,
57
+ configure,
58
+ detect_root,
59
+ is_configured,
60
+ )
61
+ from .run import HISTORY_SCHEMA, SCHEMA, START_SCHEMA, Run, Terminated
62
+ from .sinks import JsonlSink, RecordSink
63
+
64
+ # EVERY NAME HERE IS A PROMISE. It was 27, and the quickstart uses two of them; a previous
65
+ # review already called 24 too many to freeze. Five came out on 2026-08-19 (ledger L-24):
66
+ #
67
+ # VOLATILE, VOLATILE_JSON compiled regexes -- the MECHANISM of volatile-stamp stripping.
68
+ # Exporting them means never being able to change how it works.
69
+ # PIN_UNSAFE documentation rendered into a refusal message. Nothing branches
70
+ # on it -- proved by emptying it, behaviour unchanged.
71
+ # default_run_id, defaults `Project` already supplies; a caller passes a callable
72
+ # default_generation rather than reaching for these.
73
+ #
74
+ # They still EXIST at `runprov.run.PIN_UNSAFE`, `runprov.hashing.VOLATILE` and
75
+ # `runprov.project.default_run_id` -- they simply stop being promised. SCHEMA and
76
+ # HISTORY_SCHEMA stay (a consumer parsing records needs the version) and so do the two
77
+ # DEFAULT_* tuples (people extend them).
78
+ #
79
+ # FIVE MORE ON 2026-08-20 (ledger L-84 follow-up), for a different reason: not "this is
80
+ # mechanism" but "nobody was ever told to call this". Every one had ZERO references in
81
+ # README, GETTING-STARTED, WHY or any ADR -- checked, not assumed -- while the features they
82
+ # belong to are documented entirely as configuration and record fields:
83
+ #
84
+ # installed_packages, the environment snapshot is reached through
85
+ # write_snapshot `Project(env_snapshot_dir=...)` and the record it writes
86
+ # Capture terminal capture is reached through `terminal_log`
87
+ # MemorySink `sink=` takes one; the class is for tests and for people
88
+ # writing their own, neither of which needs a promise
89
+ # git 6 internal call sites and no documented caller. A bare `git`
90
+ # in an importing namespace also collides with GitPython's
91
+ # top-level module, and the contract -- swallow everything,
92
+ # return None, 20s timeout -- is provenance capture rather than
93
+ # a general-purpose runner. ADR-0003 left this open; this closes
94
+ # it by withdrawal rather than by rename.
95
+ #
96
+ # ONE ADDED ON 2026-08-22 (ledger A-30), and it is the only name ever promoted here:
97
+ # `START_SCHEMA`. Since the start line landed, `runs.jsonl` carries a third schema and the
98
+ # README prints it to external readers, so the promise existed as a copy-pasted string
99
+ # before it existed as a name. Every reader inside the package filters on it, and the
100
+ # package's own test says an outside one that does not "would count each completed run
101
+ # TWICE". That is verbatim the reason `HISTORY_SCHEMA` is promised.
102
+ #
103
+ # WHY THE QUIET ONES STAY, recorded because a promised name with no document reference has
104
+ # to justify itself — the criterion the L-84 withdrawal used, applied to what survived it
105
+ # (ledger A-31). A test requires a line here for every such name:
106
+ # `SCHEMA`, `HISTORY_SCHEMA`, `START_SCHEMA` — a consumer parsing records needs the
107
+ # version, and the third one decides whether a run is counted once or twice.
108
+ # `DEFAULT_CODE_PATHS`, `DEFAULT_TRACKED` — people extend them.
109
+ # `active` and `is_configured` — the pair a LIBRARY needs rather than a script:
110
+ # `is_configured()` to avoid re-configuring somebody else's project, `active()` to read
111
+ # what was resolved. There is no other documented spelling for either, which is why the
112
+ # L-84 criterion does not reach them: it is two-pronged, and its second prong is that the
113
+ # feature is documented as configuration and record fields instead. These have no such
114
+ # alternate route.
115
+ # `detect_root` — the root-detection RULE is documented behaviour, and a caller
116
+ # reproducing it by hand is exactly the drift this package exists to remove.
117
+ #
118
+ # 18 names. All ten withdrawn names still exist on their own modules.
119
+ __all__ = [
120
+ "DEFAULT_CODE_PATHS",
121
+ "DEFAULT_TRACKED",
122
+ "HISTORY_SCHEMA",
123
+ "SCHEMA",
124
+ "START_SCHEMA",
125
+ "JsonlSink",
126
+ "Project",
127
+ "RecordSink",
128
+ "Run",
129
+ "Terminated",
130
+ "active",
131
+ "configure",
132
+ "content_digest",
133
+ "describe",
134
+ "detect_root",
135
+ "is_configured",
136
+ "sha256",
137
+ "to_yaml",
138
+ ]
139
+ __version__ = "0.1.0"
140
+
141
+
142
+ def to_yaml(records: typing.Any) -> str: # noqa: ANN401 - one record or an iterable of them
143
+ """Render a record — or a whole history — as the transformation-log YAML shape.
144
+
145
+ The predecessor wrote a per-run `*_manifest_*.yml` with `yaml.safe_dump`, and every
146
+ script doing so carried its own `json_safe()` to make pandas and numpy scalars
147
+ dumpable. Both of those are covered here: the renderer needs **no pyyaml**, and the
148
+ record has already been through `_jsonable`, which resolves a `numpy.int64` to an
149
+ `int` rather than to the string `"6"`.
150
+
151
+ `python -m runprov log --format yaml` is this same function over a whole history. This
152
+ is it for one run, for a caller who wants the manifest as a file beside the artifact:
153
+
154
+ with Run("step", provenance=PROV) as run:
155
+ ...
156
+ MANIFEST.write_text(runprov.to_yaml(run.record), encoding="utf-8")
157
+
158
+ AFTER the block, deliberately. `__exit__` is where outputs are hashed and the status
159
+ becomes known, so a manifest rendered inside the block describes a run that has not
160
+ finished — the same trap as `run.write()` instead of `provenance=`.
161
+
162
+ What it does NOT do is append `---` documents to a shared file. That is not an
163
+ oversight: the predecessor's writer appended `---` into a file that began as a list,
164
+ `yaml.safe_load_all` raises partway through the result, and nine
165
+ `fix_transformation_log_*.py` repair scripts exist because of it. The append-only
166
+ history is JSONL for exactly that reason, and this renders a VIEW of it.
167
+
168
+ THE ONLY `to_yaml` IN THE PACKAGE, since 2026-08-19. `runprov.show` carried a second
169
+ function of the same name and a different shape — it renders any nested structure as
170
+ indented YAML, this one renders RUN RECORDS in the transformation-log shape. Both were
171
+ importable and neither raised on the other's input, so the wrong import produced a
172
+ plausible file of the WRONG SHAPE rather than an error. It is now `show.render_yaml`
173
+ (ledger L-47).
174
+
175
+ This one also normalises with `_jsonable` first, which `render_yaml` does not — that is
176
+ what keeps a manifest written here byte-comparable with the sidecar written by
177
+ `run.write()`, and it is why the two disagreed: on a record holding a numpy-like scalar,
178
+ this renders `118` where the low-level one renders `"<scalar 118>"`.
179
+ """
180
+ from .__main__ import _yaml # local: keeps the CLI module off the package import path
181
+ from .run import _jsonable
182
+
183
+ # THE SAME normalisation the sidecar and the history line go through. `_jsonable` runs
184
+ # at serialisation time, so a LIVE `run.record` still holds whatever the caller handed
185
+ # to `note()` -- and rendering that directly produced a manifest reading
186
+ # `n_exact_matches: "<scalar 118>"` beside a sidecar reading `118`, for one run. Two
187
+ # renderings of the same record must not disagree, which is the whole claim here.
188
+ rows = [records] if isinstance(records, dict) else list(records)
189
+ return _yaml([_jsonable(r) for r in rows])