agent-tracegraph 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.
tracegraph/artifact.py ADDED
@@ -0,0 +1,192 @@
1
+ """The portable artifact: tracegraph's system of record.
2
+
3
+ A :class:`~tracegraph.model.NormalizedTrace` serializes to a single self-describing
4
+ JSON file (schema-versioned). This artifact — not any graph-DB file — is the source
5
+ of truth: query backends (the in-memory store and optional LadybugDB cache) are
6
+ always rebuildable from it. Parquet is a future option; JSON keeps Phase 0 boring.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import hashlib
13
+ from collections.abc import Iterable, Mapping
14
+ import re
15
+ import os
16
+ from pathlib import Path
17
+ import tempfile
18
+
19
+ from tracegraph.model import NormalizedTrace, StepSource
20
+
21
+ #: Bump when the on-disk shape changes incompatibly.
22
+ ARTIFACT_SCHEMA_VERSION = 3
23
+
24
+ #: Versions this build can read. 1 needs a migration (see ``_migrate_v1``); 2 -> 3 is the
25
+ #: identity, because 3 adds only a new ``Step.source`` value and changes nothing existing.
26
+ _READABLE_VERSIONS = (1, 2, ARTIFACT_SCHEMA_VERSION)
27
+
28
+
29
+ def _required_version(nt: NormalizedTrace) -> int:
30
+ """The lowest version that can read these bytes.
31
+
32
+ Stamped from the *content*, not from the build, so serializing an artifact that uses no
33
+ new vocabulary keeps producing byte-identical v2 output. That matters because the
34
+ envelope is inside the hash: ``review_candidates`` binds each candidate to the sha256 of
35
+ the artifact file, and ``LadybugStore`` round-trips artifacts byte for byte. A blanket
36
+ bump would invalidate digests already exported in review-candidate reports for artifacts
37
+ whose content did not change at all.
38
+
39
+ An artifact that *does* carry a derived task step is stamped 3, so an older build refuses
40
+ it with a clear "unsupported artifact schema_version" instead of a pydantic enum error.
41
+ """
42
+ task = StepSource.TASK
43
+ return 3 if any(step.source is task for step in nt.steps) else 2
44
+
45
+
46
+ def dumps(nt: NormalizedTrace) -> str:
47
+ """Serialize a trace to a JSON string (stable key order for diff-friendly output)."""
48
+ payload = {"schema_version": _required_version(nt), "trace": nt.model_dump(mode="json")}
49
+ return json.dumps(payload, indent=2, sort_keys=True)
50
+
51
+
52
+ def _migrate_v1(trace_payload: dict) -> dict:
53
+ """Add only v2 evidence metadata; never reinterpret legacy causal semantics.
54
+
55
+ Copies exactly the containers it writes to — the envelope, the trace header, and the
56
+ CAUSED_BY edges — and shares everything else with the caller. Nothing outside those is
57
+ mutated, so sharing is safe. The cost still scales with the edge collection, which it has
58
+ to walk; what it no longer does is traverse unrelated nested values it never reads.
59
+
60
+ Whole-payload copies were tried twice and both leaked an exception type this module
61
+ promises never to raise. A JSON round-trip raises ``TypeError`` on any value json cannot
62
+ serialize, and ``from_obj`` accepts already-parsed payloads that may hold such values.
63
+ ``copy.deepcopy`` fixed that but recurses per level, so a deeply-nested value in a field
64
+ the model ignores raised ``RecursionError`` on an artifact that used to load. Neither is
65
+ ``OSError`` or ``ValueError``, so both escaped every load-boundary handler and crashed the
66
+ command. Touching only what we modify avoids the whole class: no traversal, no recursion.
67
+ """
68
+ migrated = dict(trace_payload)
69
+ header = migrated.get("trace")
70
+ if isinstance(header, dict):
71
+ header = dict(header)
72
+ header.setdefault("causal_fidelity", "legacy_unknown")
73
+ header.setdefault("links_preserved", None)
74
+ migrated["trace"] = header
75
+ edges = migrated.get("edges")
76
+ # Match the *shape*, not a list of concrete types. The model accepts any iterable for its
77
+ # edge sequence (list, tuple, deque, a one-shot iterator), and enumerating types here
78
+ # meant every shape not on the list silently skipped migration, so its CAUSED_BY edges
79
+ # lost the legacy origin default. Strings, bytes and mappings are iterable but are never
80
+ # an edge sequence; anything else non-iterable falls through untouched for the model to
81
+ # reject as a clean ValueError. Materializing once also leaves a one-shot iterator usable,
82
+ # which iterating in place did not.
83
+ if isinstance(edges, Iterable) and not isinstance(edges, (str, bytes, bytearray, Mapping)):
84
+ try:
85
+ # Mapping, not dict: the model accepts any mapping as an edge record, and matching
86
+ # dict alone left mapping-shaped records without the legacy origin default.
87
+ migrated["edges"] = [
88
+ {"origin": "legacy_unknown", **edge}
89
+ if isinstance(edge, Mapping) and edge.get("type") == "CAUSED_BY"
90
+ else edge
91
+ for edge in edges
92
+ ]
93
+ except Exception as exc:
94
+ # Consuming a caller-supplied lazy sequence can raise anything at all. This is a
95
+ # load boundary that promises ValueError, and the v2 path already reports a
96
+ # failing iterator that way (as a ValidationError), so v1 must not differ.
97
+ raise ValueError(f"artifact edges could not be read: {exc}") from exc
98
+ return migrated
99
+
100
+
101
+ def loads(text: str) -> NormalizedTrace:
102
+ """Parse a trace from a JSON string, validating the schema version."""
103
+ try:
104
+ payload = json.loads(text)
105
+ except RecursionError as exc:
106
+ # Deeply-nested JSON overflows json's recursive scanner with a RecursionError (a
107
+ # RuntimeError, *not* a ValueError). Normalize it so the load boundary treats an
108
+ # over-nested stray as bad input — otherwise it escapes the CLI's (OSError, ValueError)
109
+ # handler and crashes the whole command with a raw traceback.
110
+ raise ValueError("JSON nesting too deep") from exc
111
+ return from_obj(payload)
112
+
113
+
114
+ def from_obj(payload: object) -> NormalizedTrace:
115
+ """Validate an already-parsed artifact payload (the envelope ``loads`` would parse).
116
+
117
+ Exists so a caller that already holds the parsed JSON (e.g. the CLI, which sniffs
118
+ ``schema_version`` first) doesn't re-parse the whole document. Raises only ``ValueError``
119
+ for malformed input, same contract as :func:`loads`.
120
+ """
121
+ # The artifact envelope is a JSON object. A top-level array/string/number/null is valid
122
+ # JSON but not an artifact (e.g. a stray data export) — reject it as a clean ValueError
123
+ # rather than letting ``payload.get`` raise an opaque AttributeError that callers can't
124
+ # distinguish from a real bug. CLI directory globbing relies on this to skip non-artifacts.
125
+ if not isinstance(payload, dict):
126
+ raise ValueError(
127
+ f"artifact JSON must be a top-level object, got {type(payload).__name__}"
128
+ )
129
+ version = payload.get("schema_version")
130
+ if version not in _READABLE_VERSIONS:
131
+ readable = ", ".join(str(v) for v in _READABLE_VERSIONS)
132
+ raise ValueError(
133
+ f"unsupported artifact schema_version {version!r} (this build reads {readable})"
134
+ )
135
+ # Guard the last raw dereference: a dict with the right schema_version but no "trace" key
136
+ # (e.g. `{"schema_version": 1}`) would otherwise raise a bare KeyError, which — like the
137
+ # AttributeError and RecursionError above — is neither OSError nor ValueError and would
138
+ # escape the CLI's load handler. With this guard, loads() raises *only* ValueError for any
139
+ # malformed input, so every load path (strict file, lenient directory skip) stays clean.
140
+ if "trace" not in payload:
141
+ raise ValueError('artifact JSON is missing the required "trace" object')
142
+ trace_payload = payload["trace"]
143
+ if not isinstance(trace_payload, dict):
144
+ raise ValueError('artifact "trace" must be an object')
145
+ if version == 1:
146
+ trace_payload = _migrate_v1(trace_payload)
147
+ return NormalizedTrace.model_validate(trace_payload)
148
+
149
+
150
+ def save(nt: NormalizedTrace, path: str | Path) -> None:
151
+ Path(path).write_text(dumps(nt), encoding="utf-8")
152
+
153
+
154
+ def save_atomic(nt: NormalizedTrace, path: str | Path, *, replace: bool = True) -> None:
155
+ """Validate and atomically replace an artifact without leaving partial output."""
156
+ target = Path(path)
157
+ text = dumps(nt)
158
+ # Writer-side contract check before touching the destination: the payload we just
159
+ # serialized must round-trip through the model. Validates the parsed shape directly —
160
+ # no need to re-parse the JSON string we produced ourselves.
161
+ NormalizedTrace.model_validate(nt.model_dump(mode="json"))
162
+ target.parent.mkdir(parents=True, exist_ok=True)
163
+ temp_name: str | None = None
164
+ try:
165
+ with tempfile.NamedTemporaryFile(
166
+ mode="w", encoding="utf-8", dir=target.parent, prefix=f".{target.name}.", delete=False
167
+ ) as handle:
168
+ handle.write(text)
169
+ handle.flush()
170
+ os.fsync(handle.fileno())
171
+ temp_name = handle.name
172
+ if replace:
173
+ os.replace(temp_name, target)
174
+ else:
175
+ os.link(temp_name, target)
176
+ finally:
177
+ if temp_name is not None:
178
+ Path(temp_name).unlink(missing_ok=True)
179
+
180
+
181
+ def load(path: str | Path) -> NormalizedTrace:
182
+ return loads(Path(path).read_text(encoding="utf-8"))
183
+
184
+
185
+ def default_path(identifier: str) -> Path:
186
+ """Opaque source identifiers never become directories or reserved filenames."""
187
+ reserved = {"CON", "PRN", "AUX", "NUL"} | {f"{p}{i}" for p in ("COM", "LPT") for i in range(1, 10)}
188
+ if (re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", identifier)
189
+ and identifier.split(".")[0].upper() not in reserved
190
+ and not identifier.endswith(".")):
191
+ return Path(identifier + ".json")
192
+ return Path("trace-" + hashlib.sha256(identifier.encode()).hexdigest() + ".json")