trace-to-fixture 0.1.0__tar.gz
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.
- trace_to_fixture-0.1.0/LICENSE +21 -0
- trace_to_fixture-0.1.0/PKG-INFO +150 -0
- trace_to_fixture-0.1.0/README.md +127 -0
- trace_to_fixture-0.1.0/pyproject.toml +36 -0
- trace_to_fixture-0.1.0/setup.cfg +4 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture/__init__.py +1 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture/build.py +279 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture/cases.py +197 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture/cli.py +284 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture/load.py +70 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture/preserve.py +24 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture/pytest_plugin.py +17 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture/scrub.py +332 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture/select.py +130 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture/verify.py +176 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture/write.py +110 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/PKG-INFO +150 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/SOURCES.txt +32 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/dependency_links.txt +1 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/entry_points.txt +5 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/requires.txt +9 -0
- trace_to_fixture-0.1.0/src/trace_to_fixture.egg-info/top_level.txt +1 -0
- trace_to_fixture-0.1.0/tests/test_cases.py +243 -0
- trace_to_fixture-0.1.0/tests/test_cli.py +474 -0
- trace_to_fixture-0.1.0/tests/test_e2e.py +142 -0
- trace_to_fixture-0.1.0/tests/test_load.py +25 -0
- trace_to_fixture-0.1.0/tests/test_pi_review.py +66 -0
- trace_to_fixture-0.1.0/tests/test_scrub_detectors.py +63 -0
- trace_to_fixture-0.1.0/tests/test_scrub_pseudonym.py +56 -0
- trace_to_fixture-0.1.0/tests/test_scrub_spans.py +147 -0
- trace_to_fixture-0.1.0/tests/test_scrub_walker.py +172 -0
- trace_to_fixture-0.1.0/tests/test_select.py +142 -0
- trace_to_fixture-0.1.0/tests/test_verify.py +140 -0
- trace_to_fixture-0.1.0/tests/test_write.py +155 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Faultline-lab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trace-to-fixture
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn real agent runs into safe, small, repeatable tests: scrub private data, keep one example of each behaviour, write pytest fixtures for faultgate-zero.
|
|
5
|
+
Author: Faultline-lab
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Faultline-lab/trace-to-fixture
|
|
8
|
+
Keywords: ai,agents,testing,fixtures,opentelemetry,pytest
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: Pytest
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: faultgate<0.5,>=0.4.2
|
|
16
|
+
Requires-Dist: faultgate-zero<0.2,>=0.1
|
|
17
|
+
Requires-Dist: pytest>=8
|
|
18
|
+
Provides-Extra: pii
|
|
19
|
+
Requires-Dist: faultgate[pii]<0.5,>=0.4.2; extra == "pii"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest-xdist>=3; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Trace-to-Fixture
|
|
25
|
+
|
|
26
|
+
Turn real agent runs into safe, small, repeatable tests.
|
|
27
|
+
|
|
28
|
+
Read a trace export, mask the private data it can detect, keep one example of each distinct behaviour, and write
|
|
29
|
+
tests that run in CI with [faultgate-zero](https://github.com/Faultline-lab/faultgate-zero). The offline tests pass on
|
|
30
|
+
day one with no model calls. The live tests re-run your agent on each real prompt and check it.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
T2F_SALT=<a secret> trace-to-fixture build export.json --out tests/agent_fixtures
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Free and MIT-licensed. Runs on your machine and sends nothing anywhere.
|
|
37
|
+
|
|
38
|
+
> **What it does and does not do.** It *masks the private data its detectors can find* (secrets, emails, US SSNs,
|
|
39
|
+
> card numbers, IP addresses, phone numbers; names and places with the `pii` extra), plus anything you list in a
|
|
40
|
+
> deny file or as a sensitive key. It is not an anonymisation or compliance product, and detectors miss things such as
|
|
41
|
+
> internal customer ids. Always run `scan`, read what it found, and review the output before you commit it.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install trace-to-fixture # needs Python 3.11+
|
|
47
|
+
pip install 'trace-to-fixture[pii]' # adds Presidio for names and places
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
It builds on `faultgate` (0.4.2 or newer) (format loaders, private-data detectors) and `faultgate-zero` (the checks).
|
|
51
|
+
|
|
52
|
+
## Quickstart (5 minutes)
|
|
53
|
+
|
|
54
|
+
The `examples/` folder holds a small refund agent, a "production" export of three of its runs, and a policy.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/Faultline-lab/trace-to-fixture && cd trace-to-fixture
|
|
58
|
+
pip install -e .
|
|
59
|
+
cd examples
|
|
60
|
+
|
|
61
|
+
# 1. See what is in the export. Nothing is written (scan needs no salt).
|
|
62
|
+
trace-to-fixture scan export.otlp.json
|
|
63
|
+
|
|
64
|
+
# 2. Build the fixture set. build needs a salt: a secret in real use, never committed.
|
|
65
|
+
# On Windows PowerShell use $env:T2F_SALT = "quickstart-demo-salt" or pass --salt-file FILE.
|
|
66
|
+
export T2F_SALT="quickstart-demo-salt"
|
|
67
|
+
trace-to-fixture build export.otlp.json --out ../tests_out --policy policy.json --agent refund_agent:run
|
|
68
|
+
|
|
69
|
+
# 3. Run the offline tests. No model calls, free.
|
|
70
|
+
pytest ../tests_out
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`tests_out/` now holds `manifest.json`, `fixtures/*.otlp.json`, `test_agent_fixtures.py` and `substitutions.json`.
|
|
74
|
+
|
|
75
|
+
### Live replay
|
|
76
|
+
|
|
77
|
+
Live tests re-run your agent on each real prompt, so they need a test-environment value for every placeholder:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pytest ../tests_out -m agent_live -rs # skipped: "needs substitutions for <EMAIL_b1beab65>, <PHONE_29a793ca>"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Put test data (never real values) in `substitutions.json`, then run again:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{ "<EMAIL_b1beab65>": "test.user1@example.com", "<PHONE_29a793ca>": "+1 555 0100" }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The known failure (a $900 refund over the $500 limit) now **xfails**: it still fails like production. Fix the bug
|
|
90
|
+
(`FIXED = True` in `refund_agent.py`) and the test fails with:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
f-xxxxxxxx no longer fails. Promote it to a guard:
|
|
94
|
+
trace-to-fixture promote ../tests_out f-xxxxxxxx
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
After `promote`, the fixture is a guard: if the bug ever comes back the test fails with the policy reason.
|
|
98
|
+
Live tests run only with `-m agent_live` or `T2F_LIVE=1`, so a plain `pytest` never spends tokens.
|
|
99
|
+
|
|
100
|
+
## Commands
|
|
101
|
+
|
|
102
|
+
| Command | What it does | Writes |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `scan INPUT` | Format, precheck, counts of detected values by kind and by field, dropped attribute keys. | nothing |
|
|
105
|
+
| `build INPUT --out DIR` | Load, precheck, scrub, prove verdicts are unchanged, group, select, write, leak scan. | `DIR` |
|
|
106
|
+
| `verify DIR` | Leak scan of every file, hash checks, loading in faultgate-zero and faultgate, snapshot verdicts. | nothing |
|
|
107
|
+
| `explain INPUT --trace ID` | One trace: changed fields (path, kind, lengths, never values), raw vs scrubbed verdicts, signature, why kept. | nothing |
|
|
108
|
+
| `promote DIR ID` | Change a fixture from `known_failure` to `guard`. | `manifest.json` |
|
|
109
|
+
|
|
110
|
+
Options: `--format` (default `auto`; OTLP, OpenInference, Langfuse, LangSmith, Braintrust), `--policy FILE`
|
|
111
|
+
(faultgate-zero policy format), `--checks a,b,c`, `--agent module:function`, `--per-group N` (1), `--max-clean N` (20),
|
|
112
|
+
`--pii builtin|presidio`, `--deny-literals FILE`, `--sensitive-key NAME` (repeatable), `--salt-file FILE` or `T2F_SALT`,
|
|
113
|
+
`--drop-mismatched`, `--bundle FILE`, `-v` / `T2F_DEBUG=1` (stage counts and timings on stderr, never values).
|
|
114
|
+
|
|
115
|
+
Exit codes: `0` success, `1` a problem was found (a leak, a verdict mismatch, snapshot drift), `2` a usage or input
|
|
116
|
+
error (a bad file, a missing salt, precheck errors).
|
|
117
|
+
|
|
118
|
+
## How it works
|
|
119
|
+
|
|
120
|
+
1. **Load** the export with faultgate's readers; precheck errors stop the build.
|
|
121
|
+
2. **Scrub.** Each detected value becomes `<KIND_xxxxxxxx>`, an HMAC of the exact value under your secret salt: the same
|
|
122
|
+
value always gets the same placeholder, so checks that compare values keep working. Attributes the checks never read
|
|
123
|
+
are dropped; trace and span ids are remapped; time is rebased. JSON inside strings (up to two levels) is walked.
|
|
124
|
+
3. **Prove nothing changed.** Every check runs on each trace before and after scrubbing; verdicts must match.
|
|
125
|
+
4. **Select.** Runs with the same behaviour (tool path, fired checks, outcome, tool errors, size) form a group. Every
|
|
126
|
+
group where a check fired is kept; the largest clean groups fill the rest. Selection is deterministic.
|
|
127
|
+
5. **Write** fixtures, a manifest, a generated pytest file, and a leak scan of what was written.
|
|
128
|
+
|
|
129
|
+
Same input, same salt and same options give byte-identical output. Use one stable salt per project (a different salt
|
|
130
|
+
changes every placeholder and fixture id). `manifest.json` records only a fingerprint of the salt.
|
|
131
|
+
|
|
132
|
+
## Privacy rules
|
|
133
|
+
|
|
134
|
+
- Raw values live only in memory: never in files, logs, exceptions or the manifest.
|
|
135
|
+
- The salt is a secret. Keep it in a secret store or CI secret, never in git. One salt per customer audit.
|
|
136
|
+
- `substitutions.json` holds test data only.
|
|
137
|
+
- If a detector misses something, add it to `--deny-literals` (one literal per line) or `--sensitive-key`.
|
|
138
|
+
|
|
139
|
+
## Develop
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
pip install -e '.[dev]'
|
|
143
|
+
pytest # unit tests
|
|
144
|
+
python evals/run.py # acceptance table (add --perf for the 5,000-trace speed row)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Limits
|
|
148
|
+
|
|
149
|
+
Multi-turn conversations are kept as offline fixtures only (`replayable: false`). Tool responses are not mocked:
|
|
150
|
+
live replay calls your own tools in your own test environment. Input is an export file; there is no vendor API puller.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Trace-to-Fixture
|
|
2
|
+
|
|
3
|
+
Turn real agent runs into safe, small, repeatable tests.
|
|
4
|
+
|
|
5
|
+
Read a trace export, mask the private data it can detect, keep one example of each distinct behaviour, and write
|
|
6
|
+
tests that run in CI with [faultgate-zero](https://github.com/Faultline-lab/faultgate-zero). The offline tests pass on
|
|
7
|
+
day one with no model calls. The live tests re-run your agent on each real prompt and check it.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
T2F_SALT=<a secret> trace-to-fixture build export.json --out tests/agent_fixtures
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Free and MIT-licensed. Runs on your machine and sends nothing anywhere.
|
|
14
|
+
|
|
15
|
+
> **What it does and does not do.** It *masks the private data its detectors can find* (secrets, emails, US SSNs,
|
|
16
|
+
> card numbers, IP addresses, phone numbers; names and places with the `pii` extra), plus anything you list in a
|
|
17
|
+
> deny file or as a sensitive key. It is not an anonymisation or compliance product, and detectors miss things such as
|
|
18
|
+
> internal customer ids. Always run `scan`, read what it found, and review the output before you commit it.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install trace-to-fixture # needs Python 3.11+
|
|
24
|
+
pip install 'trace-to-fixture[pii]' # adds Presidio for names and places
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It builds on `faultgate` (0.4.2 or newer) (format loaders, private-data detectors) and `faultgate-zero` (the checks).
|
|
28
|
+
|
|
29
|
+
## Quickstart (5 minutes)
|
|
30
|
+
|
|
31
|
+
The `examples/` folder holds a small refund agent, a "production" export of three of its runs, and a policy.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git clone https://github.com/Faultline-lab/trace-to-fixture && cd trace-to-fixture
|
|
35
|
+
pip install -e .
|
|
36
|
+
cd examples
|
|
37
|
+
|
|
38
|
+
# 1. See what is in the export. Nothing is written (scan needs no salt).
|
|
39
|
+
trace-to-fixture scan export.otlp.json
|
|
40
|
+
|
|
41
|
+
# 2. Build the fixture set. build needs a salt: a secret in real use, never committed.
|
|
42
|
+
# On Windows PowerShell use $env:T2F_SALT = "quickstart-demo-salt" or pass --salt-file FILE.
|
|
43
|
+
export T2F_SALT="quickstart-demo-salt"
|
|
44
|
+
trace-to-fixture build export.otlp.json --out ../tests_out --policy policy.json --agent refund_agent:run
|
|
45
|
+
|
|
46
|
+
# 3. Run the offline tests. No model calls, free.
|
|
47
|
+
pytest ../tests_out
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`tests_out/` now holds `manifest.json`, `fixtures/*.otlp.json`, `test_agent_fixtures.py` and `substitutions.json`.
|
|
51
|
+
|
|
52
|
+
### Live replay
|
|
53
|
+
|
|
54
|
+
Live tests re-run your agent on each real prompt, so they need a test-environment value for every placeholder:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pytest ../tests_out -m agent_live -rs # skipped: "needs substitutions for <EMAIL_b1beab65>, <PHONE_29a793ca>"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Put test data (never real values) in `substitutions.json`, then run again:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{ "<EMAIL_b1beab65>": "test.user1@example.com", "<PHONE_29a793ca>": "+1 555 0100" }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The known failure (a $900 refund over the $500 limit) now **xfails**: it still fails like production. Fix the bug
|
|
67
|
+
(`FIXED = True` in `refund_agent.py`) and the test fails with:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
f-xxxxxxxx no longer fails. Promote it to a guard:
|
|
71
|
+
trace-to-fixture promote ../tests_out f-xxxxxxxx
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
After `promote`, the fixture is a guard: if the bug ever comes back the test fails with the policy reason.
|
|
75
|
+
Live tests run only with `-m agent_live` or `T2F_LIVE=1`, so a plain `pytest` never spends tokens.
|
|
76
|
+
|
|
77
|
+
## Commands
|
|
78
|
+
|
|
79
|
+
| Command | What it does | Writes |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `scan INPUT` | Format, precheck, counts of detected values by kind and by field, dropped attribute keys. | nothing |
|
|
82
|
+
| `build INPUT --out DIR` | Load, precheck, scrub, prove verdicts are unchanged, group, select, write, leak scan. | `DIR` |
|
|
83
|
+
| `verify DIR` | Leak scan of every file, hash checks, loading in faultgate-zero and faultgate, snapshot verdicts. | nothing |
|
|
84
|
+
| `explain INPUT --trace ID` | One trace: changed fields (path, kind, lengths, never values), raw vs scrubbed verdicts, signature, why kept. | nothing |
|
|
85
|
+
| `promote DIR ID` | Change a fixture from `known_failure` to `guard`. | `manifest.json` |
|
|
86
|
+
|
|
87
|
+
Options: `--format` (default `auto`; OTLP, OpenInference, Langfuse, LangSmith, Braintrust), `--policy FILE`
|
|
88
|
+
(faultgate-zero policy format), `--checks a,b,c`, `--agent module:function`, `--per-group N` (1), `--max-clean N` (20),
|
|
89
|
+
`--pii builtin|presidio`, `--deny-literals FILE`, `--sensitive-key NAME` (repeatable), `--salt-file FILE` or `T2F_SALT`,
|
|
90
|
+
`--drop-mismatched`, `--bundle FILE`, `-v` / `T2F_DEBUG=1` (stage counts and timings on stderr, never values).
|
|
91
|
+
|
|
92
|
+
Exit codes: `0` success, `1` a problem was found (a leak, a verdict mismatch, snapshot drift), `2` a usage or input
|
|
93
|
+
error (a bad file, a missing salt, precheck errors).
|
|
94
|
+
|
|
95
|
+
## How it works
|
|
96
|
+
|
|
97
|
+
1. **Load** the export with faultgate's readers; precheck errors stop the build.
|
|
98
|
+
2. **Scrub.** Each detected value becomes `<KIND_xxxxxxxx>`, an HMAC of the exact value under your secret salt: the same
|
|
99
|
+
value always gets the same placeholder, so checks that compare values keep working. Attributes the checks never read
|
|
100
|
+
are dropped; trace and span ids are remapped; time is rebased. JSON inside strings (up to two levels) is walked.
|
|
101
|
+
3. **Prove nothing changed.** Every check runs on each trace before and after scrubbing; verdicts must match.
|
|
102
|
+
4. **Select.** Runs with the same behaviour (tool path, fired checks, outcome, tool errors, size) form a group. Every
|
|
103
|
+
group where a check fired is kept; the largest clean groups fill the rest. Selection is deterministic.
|
|
104
|
+
5. **Write** fixtures, a manifest, a generated pytest file, and a leak scan of what was written.
|
|
105
|
+
|
|
106
|
+
Same input, same salt and same options give byte-identical output. Use one stable salt per project (a different salt
|
|
107
|
+
changes every placeholder and fixture id). `manifest.json` records only a fingerprint of the salt.
|
|
108
|
+
|
|
109
|
+
## Privacy rules
|
|
110
|
+
|
|
111
|
+
- Raw values live only in memory: never in files, logs, exceptions or the manifest.
|
|
112
|
+
- The salt is a secret. Keep it in a secret store or CI secret, never in git. One salt per customer audit.
|
|
113
|
+
- `substitutions.json` holds test data only.
|
|
114
|
+
- If a detector misses something, add it to `--deny-literals` (one literal per line) or `--sensitive-key`.
|
|
115
|
+
|
|
116
|
+
## Develop
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install -e '.[dev]'
|
|
120
|
+
pytest # unit tests
|
|
121
|
+
python evals/run.py # acceptance table (add --perf for the 5,000-trace speed row)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Limits
|
|
125
|
+
|
|
126
|
+
Multi-turn conversations are kept as offline fixtures only (`replayable: false`). Tool responses are not mocked:
|
|
127
|
+
live replay calls your own tools in your own test environment. Input is an export file; there is no vendor API puller.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trace-to-fixture"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Turn real agent runs into safe, small, repeatable tests: scrub private data, keep one example of each behaviour, write pytest fixtures for faultgate-zero."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
authors = [{ name = "Faultline-lab" }]
|
|
14
|
+
keywords = ["ai", "agents", "testing", "fixtures", "opentelemetry", "pytest"]
|
|
15
|
+
classifiers = ["Development Status :: 3 - Alpha", "Framework :: Pytest", "Programming Language :: Python :: 3"]
|
|
16
|
+
dependencies = ["faultgate>=0.4.2,<0.5", "faultgate-zero>=0.1,<0.2", "pytest>=8"]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
pii = ["faultgate[pii]>=0.4.2,<0.5"]
|
|
20
|
+
dev = ["pytest-xdist>=3"]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
trace-to-fixture = "trace_to_fixture.cli:main"
|
|
24
|
+
|
|
25
|
+
[project.entry-points.pytest11]
|
|
26
|
+
trace_to_fixture = "trace_to_fixture.pytest_plugin"
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/Faultline-lab/trace-to-fixture"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
36
|
+
markers = ["slow: performance tests, run nightly"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""scan, build and explain pipelines (spec sections 4, 6, 9, 10, 11)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from faultgate_zero import otlp
|
|
12
|
+
from faultgate_zero.checks import DEFAULT_CHECKS, Options, resolve
|
|
13
|
+
from faultgate_zero.policy import Policy
|
|
14
|
+
from faultgate_zero.runner import Settings
|
|
15
|
+
from faultgate_zero.trace import Trace, messages, parts
|
|
16
|
+
|
|
17
|
+
from . import __version__, load, preserve, write
|
|
18
|
+
from .scrub import PLACEHOLDER_RE, Scrubber, ScrubReport, salt_id, presidio_find
|
|
19
|
+
from .select import Candidate, Selection, select, signature
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BuildError(Exception):
|
|
23
|
+
"""Exit code 1: a problem was found (leak, verdict mismatch)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Options_:
|
|
28
|
+
input: Path
|
|
29
|
+
out: Path | None = None
|
|
30
|
+
fmt: str = "auto"
|
|
31
|
+
policy: Path | None = None
|
|
32
|
+
checks: str | None = None
|
|
33
|
+
agent: str | None = None
|
|
34
|
+
per_group: int = 1
|
|
35
|
+
max_clean: int = 20
|
|
36
|
+
pii: str = "builtin"
|
|
37
|
+
deny_literals: list[str] = field(default_factory=list)
|
|
38
|
+
sensitive_keys: list[str] = field(default_factory=list)
|
|
39
|
+
salt: bytes = b""
|
|
40
|
+
drop_mismatched: bool = False
|
|
41
|
+
bundle: Path | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_scrubber(o: Options_) -> Scrubber:
|
|
45
|
+
extra = [presidio_find()] if o.pii == "presidio" else []
|
|
46
|
+
return Scrubber(o.salt, deny_literals=o.deny_literals, sensitive_keys=o.sensitive_keys, extra_finders=extra)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def make_settings(o: Options_) -> Settings:
|
|
50
|
+
names = resolve([c.strip() for c in o.checks.split(",")]) if o.checks else DEFAULT_CHECKS
|
|
51
|
+
return Settings(checks=names, policy=Policy.load(o.policy) if o.policy else Policy(),
|
|
52
|
+
options=Options(), redact=make_scrubber(o).scrub_text)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ScanResult:
|
|
57
|
+
fmt: str
|
|
58
|
+
traces: int
|
|
59
|
+
tool_steps: int
|
|
60
|
+
warnings: list
|
|
61
|
+
report: ScrubReport
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def scan(o: Options_) -> ScanResult:
|
|
65
|
+
fmt, traces, warnings = load.load(o.input, o.fmt)
|
|
66
|
+
sc = make_scrubber(o)
|
|
67
|
+
for t in traces:
|
|
68
|
+
sc.scrub_trace(t)
|
|
69
|
+
return ScanResult(fmt, len(traces), sum(len(t.steps) for t in traces), warnings, sc.report)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def user_messages(trace: Trace) -> set[str]:
|
|
73
|
+
out: set[str] = set()
|
|
74
|
+
spans = [trace.root] if trace.root else []
|
|
75
|
+
for s in [*spans, *trace.model_calls]:
|
|
76
|
+
for m in messages(s.attrs.get("gen_ai.input.messages")):
|
|
77
|
+
if m.get("role") == "user":
|
|
78
|
+
text = "".join(str(p.get("content", "")) for p in parts(m) if p.get("type") == "text")
|
|
79
|
+
if text.strip():
|
|
80
|
+
out.add(text)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def placeholders_in(text: str) -> list[str]:
|
|
85
|
+
return sorted(set(PLACEHOLDER_RE.findall(text)))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Processed:
|
|
90
|
+
trace_hash: str
|
|
91
|
+
scrubbed: Trace
|
|
92
|
+
fixture_id: str
|
|
93
|
+
fired: frozenset[str]
|
|
94
|
+
reasons: dict[str, str]
|
|
95
|
+
expected: dict[str, bool]
|
|
96
|
+
json_size: int
|
|
97
|
+
mismatch: list[str]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _mask_denied(text: str, patterns) -> str:
|
|
101
|
+
"""A policy reason can quote the denied text; the policy's own words must not travel into the manifest."""
|
|
102
|
+
for pat in patterns:
|
|
103
|
+
if pat:
|
|
104
|
+
text = re.sub(re.escape(pat), "<DENIED>", text, flags=re.IGNORECASE)
|
|
105
|
+
return text
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def process(traces: list[Trace], sc: Scrubber, settings: Settings,
|
|
109
|
+
progress: Callable[[str], None] | None = None) -> list[Processed]:
|
|
110
|
+
from faultgate_zero import runner
|
|
111
|
+
out = []
|
|
112
|
+
for t in traces:
|
|
113
|
+
raw = preserve.verdicts(t, settings)
|
|
114
|
+
s = sc.scrub_trace(t)
|
|
115
|
+
fid = write.canonical_id(s)
|
|
116
|
+
root = s.root
|
|
117
|
+
if root is not None:
|
|
118
|
+
root.attrs["faultgate.scenario"] = fid
|
|
119
|
+
s.scenario = fid
|
|
120
|
+
res = runner.check(s, settings)
|
|
121
|
+
got = {v.check: v.fired for v in res.verdicts}
|
|
122
|
+
mism = sorted(k for k in raw.keys() | got.keys() if raw.get(k) != got.get(k))
|
|
123
|
+
out.append(Processed(
|
|
124
|
+
trace_hash=hashlib.sha256(t.trace_id.encode()).hexdigest()[:12], scrubbed=s, fixture_id=fid,
|
|
125
|
+
fired=frozenset(k for k, v in got.items() if v), reasons={v.check: _mask_denied(v.reason, settings.policy.deny) for v in res.fired},
|
|
126
|
+
expected=dict(sorted(got.items())), json_size=len(write.fixture_bytes(s)), mismatch=mism))
|
|
127
|
+
if progress:
|
|
128
|
+
progress(f"scrubbed {len(out)} traces")
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class BuildResult:
|
|
134
|
+
fmt: str
|
|
135
|
+
loaded: int
|
|
136
|
+
scrubbed_values: int
|
|
137
|
+
identical: int
|
|
138
|
+
mismatches: list[tuple[str, list[str]]]
|
|
139
|
+
dropped: int
|
|
140
|
+
selection: Selection
|
|
141
|
+
removed: list[str]
|
|
142
|
+
leaks: list
|
|
143
|
+
out: Path
|
|
144
|
+
warnings: list = field(default_factory=list)
|
|
145
|
+
|
|
146
|
+
def summary_lines(self) -> list[str]:
|
|
147
|
+
sel = self.selection
|
|
148
|
+
failing = sum(1 for _, _, s in sel.kept if s == "known_failure")
|
|
149
|
+
guards = len(sel.kept) - failing
|
|
150
|
+
lines = [
|
|
151
|
+
f"loaded {self.loaded:,} traces ({self.fmt})",
|
|
152
|
+
f"scrubbed {self.scrubbed_values:,} values in {self.loaded:,} traces",
|
|
153
|
+
f"verdicts {self.identical:,} of {self.loaded:,} identical before and after scrubbing",
|
|
154
|
+
]
|
|
155
|
+
if self.mismatches:
|
|
156
|
+
lines.append(f"mismatch {len(self.mismatches)} traces changed verdicts"
|
|
157
|
+
+ (f"; {self.dropped} dropped" if self.dropped else ""))
|
|
158
|
+
lines += [
|
|
159
|
+
f"grouped {len(sel.groups)} behaviours, {sel.failure_groups} with a failing check",
|
|
160
|
+
f"kept {len(sel.kept)} fixtures: {failing} known failures, {guards} guards "
|
|
161
|
+
f"(they stand for {sel.traces_represented:,} of {self.loaded - self.dropped:,} traces)",
|
|
162
|
+
]
|
|
163
|
+
if self.removed:
|
|
164
|
+
lines.append(f"removed {len(self.removed)} fixtures no longer selected: {', '.join(self.removed)}")
|
|
165
|
+
lines += [f"leak scan {len(self.leaks)} findings", f"wrote {self.out}"]
|
|
166
|
+
return lines
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def build(o: Options_, progress: Callable[[str], None] | None = None) -> BuildResult:
|
|
170
|
+
from .verify import scan_files
|
|
171
|
+
fmt, traces, warnings = load.load(o.input, o.fmt)
|
|
172
|
+
settings = make_settings(o)
|
|
173
|
+
sc = make_scrubber(o)
|
|
174
|
+
done = process(traces, sc, settings, progress)
|
|
175
|
+
mism = [(p.trace_hash, p.mismatch) for p in done if p.mismatch]
|
|
176
|
+
if mism and not o.drop_mismatched:
|
|
177
|
+
raise BuildError("verdicts changed by scrubbing for "
|
|
178
|
+
+ "; ".join(f"{h}: {','.join(c)}" for h, c in mism[:10])
|
|
179
|
+
+ " (see 'trace-to-fixture explain'; --drop-mismatched continues without them)")
|
|
180
|
+
good = [p for p in done if not p.mismatch]
|
|
181
|
+
selection = select([Candidate(p.fixture_id, p.scrubbed, p.fired, p.json_size, [p.trace_hash]) for p in good],
|
|
182
|
+
o.per_group, o.max_clean)
|
|
183
|
+
by_id = {p.fixture_id: p for p in good}
|
|
184
|
+
out = Path(o.out)
|
|
185
|
+
old = write.read_manifest(out) or {}
|
|
186
|
+
old_status = {f["id"]: f["status"] for f in old.get("fixtures", [])}
|
|
187
|
+
|
|
188
|
+
entries, kept_traces = [], {}
|
|
189
|
+
for cand, group, status in selection.kept:
|
|
190
|
+
p = by_id[cand.fixture_id]
|
|
191
|
+
status = old_status.get(cand.fixture_id, status)
|
|
192
|
+
user = p.scrubbed.user_id
|
|
193
|
+
entries.append({
|
|
194
|
+
"id": cand.fixture_id, "file": f"{write.FIXTURE_DIR}/{cand.fixture_id}.otlp.json",
|
|
195
|
+
"sha256": write.sha256_bytes(write.fixture_bytes(p.scrubbed)), "status": status,
|
|
196
|
+
"signature": group.signature, "group_size": group.size, "expected": p.expected,
|
|
197
|
+
"reasons": dict(sorted(p.reasons.items())), "replayable": len(user_messages(p.scrubbed)) == 1
|
|
198
|
+
and bool(p.scrubbed.prompt.strip()),
|
|
199
|
+
"prompt_placeholders": placeholders_in(p.scrubbed.prompt), "user": user,
|
|
200
|
+
})
|
|
201
|
+
kept_traces[cand.fixture_id] = p.scrubbed
|
|
202
|
+
entries.sort(key=lambda e: e["id"])
|
|
203
|
+
opts = {"loop_min_steps": settings.options.loop_min_steps, "loop_empty_share": settings.options.loop_empty_share,
|
|
204
|
+
"repeat_threshold": settings.options.repeat_threshold}
|
|
205
|
+
manifest = {
|
|
206
|
+
"schema": write.SCHEMA, "tool": f"trace-to-fixture {__version__}", "engine": _engine(),
|
|
207
|
+
"source": {"format": fmt, "traces": len(traces), "sha256": load.file_sha256(o.input)},
|
|
208
|
+
"salt_id": salt_id(o.salt), "checks": list(settings.checks),
|
|
209
|
+
"policy": ({"path": write.rel_policy(str(Path(o.policy).resolve()), out.resolve()),
|
|
210
|
+
"sha256": write.sha256_file(o.policy)} if o.policy else None),
|
|
211
|
+
"options": opts, "agent": o.agent, "selection": {"per_group": o.per_group, "max_clean": o.max_clean},
|
|
212
|
+
"coverage": {"groups": len(selection.groups), "failure_groups": selection.failure_groups,
|
|
213
|
+
"kept": len(entries), "traces_represented": selection.traces_represented},
|
|
214
|
+
"fixtures": entries,
|
|
215
|
+
}
|
|
216
|
+
if manifest["policy"] is None:
|
|
217
|
+
del manifest["policy"]
|
|
218
|
+
pending = {f["file"]: write.fixture_bytes(kept_traces[f["id"]]) for f in entries}
|
|
219
|
+
pending["manifest.json"] = write.dumps(manifest)
|
|
220
|
+
pending[write.TEST_FILE] = write.generated_test(o.agent).encode("utf-8")
|
|
221
|
+
leaks = scan_files(pending, o.deny_literals, [presidio_find()] if o.pii == "presidio" else [])
|
|
222
|
+
removed: list[str] = []
|
|
223
|
+
if not leaks: # nothing reaches the disk if the scan finds a value
|
|
224
|
+
removed = write.write_all(out, entries, kept_traces, manifest, o.agent, o.bundle)
|
|
225
|
+
return BuildResult(fmt, len(traces), sc.report.total, len(good), mism, len(done) - len(good),
|
|
226
|
+
selection, removed, leaks, out, warnings)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _engine() -> str:
|
|
230
|
+
from importlib import metadata
|
|
231
|
+
try:
|
|
232
|
+
return f"faultgate-zero {metadata.version('faultgate-zero')}"
|
|
233
|
+
except metadata.PackageNotFoundError:
|
|
234
|
+
return "faultgate-zero"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass
|
|
238
|
+
class Explanation:
|
|
239
|
+
trace_hash: str
|
|
240
|
+
changes: list[tuple[str, str, int, int]]
|
|
241
|
+
raw: dict[str, bool]
|
|
242
|
+
scrubbed: dict[str, bool]
|
|
243
|
+
signature: str
|
|
244
|
+
group_size: int
|
|
245
|
+
selected: bool
|
|
246
|
+
why: str
|
|
247
|
+
fixture_id: str
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def explain(o: Options_, trace_id: str) -> Explanation:
|
|
251
|
+
fmt, traces, _ = load.load(o.input, o.fmt)
|
|
252
|
+
settings = make_settings(o)
|
|
253
|
+
sc = make_scrubber(o)
|
|
254
|
+
done = process(traces, sc, settings)
|
|
255
|
+
want = trace_id.strip()
|
|
256
|
+
idx = next((i for i, t in enumerate(traces)
|
|
257
|
+
if t.trace_id == want or done[i].fixture_id == want or done[i].trace_hash == want), None)
|
|
258
|
+
if idx is None:
|
|
259
|
+
raise load.InputError(f"no trace with id {want!r} in the export")
|
|
260
|
+
one = make_scrubber(o)
|
|
261
|
+
one.scrub_trace(traces[idx])
|
|
262
|
+
cands = [Candidate(p.fixture_id, p.scrubbed, p.fired, p.json_size) for p in done if not p.mismatch]
|
|
263
|
+
sel = select(cands, o.per_group, o.max_clean)
|
|
264
|
+
p = done[idx]
|
|
265
|
+
sig = signature(p.scrubbed, p.fired)
|
|
266
|
+
group = next((g for g in sel.groups if g.signature == sig), None)
|
|
267
|
+
kept = {c.fixture_id for c, _, _ in sel.kept}
|
|
268
|
+
selected = p.fixture_id in kept
|
|
269
|
+
if selected:
|
|
270
|
+
why = "kept: " + ("it fired a check" if p.fired else "shortest run of a large clean group")
|
|
271
|
+
elif group is None or p.mismatch:
|
|
272
|
+
why = "not selected: verdicts changed by scrubbing" if p.mismatch else "not selected"
|
|
273
|
+
elif not p.fired:
|
|
274
|
+
why = "not selected: its clean group is outside --max-clean, or another run of the group is shorter"
|
|
275
|
+
else:
|
|
276
|
+
why = "not selected: another run of its failing group is shorter (--per-group)"
|
|
277
|
+
return Explanation(p.trace_hash, one.report.changes,
|
|
278
|
+
preserve.verdicts(traces[idx], settings), p.expected, sig, group.size if group else 1,
|
|
279
|
+
selected, why, p.fixture_id)
|