ancha 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.
ancha-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: ancha
3
+ Version: 0.1.0
4
+ Summary: Ancha for Python: test your agent's own tools against production's recorded calls, and pytest worlds for agent tests
5
+ License-Expression: MIT
6
+ Classifier: Framework :: Pytest
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Provides-Extra: pytest
10
+ Requires-Dist: pytest>=7; extra == "pytest"
11
+
12
+ # ancha
13
+
14
+ Ancha for Python. Two parts, one `import ancha`:
15
+
16
+ - **Tool wrappers** (no dependencies): wrap your agent's own tools once with `@ancha.tool` or `ancha.wrap_tools`, and
17
+ run the agent through `ancha.entry`. In production nothing changes. In an [Ancha](https://ancha.io) test made from a
18
+ production trace, each call answers as production did. See
19
+ [Tool wrappers](https://docs.ancha.io/reference/tool-wrappers).
20
+ - **A pytest plugin** (`pip install "ancha[pytest]"`): a fresh simulated world for each test. See
21
+ [Test with pytest](https://docs.ancha.io/guides/pytest).
22
+
23
+ ```sh
24
+ pip install ancha
25
+ ```
26
+
27
+ ```python
28
+ import ancha
29
+
30
+ @ancha.tool
31
+ def lookup_order(order_id: str) -> dict: ...
32
+
33
+ ancha.entry(lambda input: run_agent(input["task"]))
34
+ ```
35
+
36
+ ## The pytest plugin
37
+
38
+ pytest for Ancha: a fresh simulated world for each test, your agent run against it, and checks on what people learned and what the tools hold. It drives the `ancha` CLI. This development version needs the matching CLI with `world capture` and `world record`; published CLI 0.1.4 does not support run recording. `ANCHA_CLI` names another CLI command.
39
+
40
+ ```python
41
+ import ancha, pytest
42
+
43
+ @pytest.mark.ancha(test="ancha/tests/relay-export-bug.yaml")
44
+ @ancha.attempts(10, min_pass_rate=0.9)
45
+ def test_relay(world):
46
+ world.start("npm start") # the agent gets world.env: the tools' URLs and fake credentials, ANCHA_TASK
47
+ world.wait_until_quiet()
48
+ assert world.people["ben"].knows("laura.beta_on")
49
+ assert world.slack.messages(to="laura", in_thread_of="$laura_report", from_="$slack_bot")
50
+ assert not world.people["laura"].knows("ben.root_cause")
51
+ ```
52
+
53
+ - `@pytest.mark.ancha(test=…, scene=…, world=…, on="local"|"cloud", seed=…, ttl=…)` says what the `world` fixture starts (`ancha world up`). Without it, the one scene `ancha.yaml` names.
54
+ - `world.env`, `world.task`; `world.start(command)`, `world.wait_until_quiet(timeout)`, `world.log()`, `world.stop()`.
55
+ - `world.check()` applies the test's own checks; `world.check({...})` applies check objects (the kinds in `ancha docs scenes`). A report is truthy when every check passed; `report.failures` has expected and actual.
56
+ - `world.people[ref].knows(fact)`, `.decided(decision, option)`; `world.slack.messages(to=, in_=, in_thread_of=, from_=, text_contains=, fact=)`.
57
+ - `world.state()`, `world.trace()`; `world.fresh()` for another world; the fixture removes its world (`ancha world down`).
58
+ - `@ancha.attempts(n, min_pass_rate)` runs the test in `n` fresh worlds and passes when enough of them do.
59
+
60
+ An evaluated check that fails returns a false `CheckReport`; `report.failures` explains the expected and actual values. A check that cannot be evaluated raises `ancha.AnchaError` instead. For example, a `knows` check naming an unknown person raises an error with `code == "USAGE"` and a message naming the invalid reference. Correct the test definition; this error is not a verdict on the agent. The people and Slack helpers use the same check path and can raise the same error. CLI failures also raise `AnchaError`, retaining the CLI's `code`, `message` and optional `hint`.
61
+
62
+ Worlds start locally by default, including after `ancha login`. Run `pytest --on cloud` to put worlds in your hosted workspace; pytest and the agent still run on your machine. `--on local` forces local worlds. The pytest option overrides the marker's `on="local"` or `on="cloud"`; without either, the CLI's normal world selection applies. An explicit server URL can also select a world server. Never give the agent the test files or the checks.
63
+
64
+ ## Recorded runs
65
+
66
+ While the plugin is enabled, each executed pytest item creates one local run under `.ancha/runs/`, with `source: pytest` and `target: local`. This includes tests without a world, parameterized cases and skipped tests. Collection alone creates no records. Disable the plugin with `-p no:ancha` for suites that should not record. Run files stay local even when worlds run in the cloud; uploading pytest records is not supported.
67
+
68
+ ```sh
69
+ ancha --local runs list --json
70
+ ancha --local runs show <run-id> --json
71
+ ancha --local explain <run-id> --json
72
+ ```
73
+
74
+ Pytest assertions decide the test outcome. A deliberately false `world.check()` remains evidence and does not fail a passing negative assertion. Records preserve setup, call and teardown outcomes separately. Skipped and expected-failure cases are ungraded; setup, capture and cleanup failures are invalid, not failed agent answers. `@ancha.attempts` records each invocation and its fresh world under the same run, including failed attempts when the overall pass-rate threshold is met. The requested repetition count is recorded before execution. Reports keep every requested attempt in the pass-rate denominator, including invalid, skipped and missing attempts; all-k requires all requested attempts to finish validly and pass. Thresholds use exact integer counts. An old baseline without verified requested counts must be regenerated before comparison.
75
+
76
+ The plugin stops its agent, captures a coherent world observation, then removes the world. This observation is not a sealed managed-trial capture. Capture failures remain visible and cleanup is still attempted. Recording failures are reported and make an otherwise successful pytest session fail.
77
+
78
+ Parameter values in test IDs are replaced by a hash. Records omit environment values, raw exception messages, captured pytest output and agent logs. Known credentials are redacted from evidence; keep `.ancha/` private and out of version control. Abrupt termination such as SIGKILL cannot finish a record or guarantee cleanup; world TTLs still apply.
ancha-0.1.0/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # ancha
2
+
3
+ Ancha for Python. Two parts, one `import ancha`:
4
+
5
+ - **Tool wrappers** (no dependencies): wrap your agent's own tools once with `@ancha.tool` or `ancha.wrap_tools`, and
6
+ run the agent through `ancha.entry`. In production nothing changes. In an [Ancha](https://ancha.io) test made from a
7
+ production trace, each call answers as production did. See
8
+ [Tool wrappers](https://docs.ancha.io/reference/tool-wrappers).
9
+ - **A pytest plugin** (`pip install "ancha[pytest]"`): a fresh simulated world for each test. See
10
+ [Test with pytest](https://docs.ancha.io/guides/pytest).
11
+
12
+ ```sh
13
+ pip install ancha
14
+ ```
15
+
16
+ ```python
17
+ import ancha
18
+
19
+ @ancha.tool
20
+ def lookup_order(order_id: str) -> dict: ...
21
+
22
+ ancha.entry(lambda input: run_agent(input["task"]))
23
+ ```
24
+
25
+ ## The pytest plugin
26
+
27
+ pytest for Ancha: a fresh simulated world for each test, your agent run against it, and checks on what people learned and what the tools hold. It drives the `ancha` CLI. This development version needs the matching CLI with `world capture` and `world record`; published CLI 0.1.4 does not support run recording. `ANCHA_CLI` names another CLI command.
28
+
29
+ ```python
30
+ import ancha, pytest
31
+
32
+ @pytest.mark.ancha(test="ancha/tests/relay-export-bug.yaml")
33
+ @ancha.attempts(10, min_pass_rate=0.9)
34
+ def test_relay(world):
35
+ world.start("npm start") # the agent gets world.env: the tools' URLs and fake credentials, ANCHA_TASK
36
+ world.wait_until_quiet()
37
+ assert world.people["ben"].knows("laura.beta_on")
38
+ assert world.slack.messages(to="laura", in_thread_of="$laura_report", from_="$slack_bot")
39
+ assert not world.people["laura"].knows("ben.root_cause")
40
+ ```
41
+
42
+ - `@pytest.mark.ancha(test=…, scene=…, world=…, on="local"|"cloud", seed=…, ttl=…)` says what the `world` fixture starts (`ancha world up`). Without it, the one scene `ancha.yaml` names.
43
+ - `world.env`, `world.task`; `world.start(command)`, `world.wait_until_quiet(timeout)`, `world.log()`, `world.stop()`.
44
+ - `world.check()` applies the test's own checks; `world.check({...})` applies check objects (the kinds in `ancha docs scenes`). A report is truthy when every check passed; `report.failures` has expected and actual.
45
+ - `world.people[ref].knows(fact)`, `.decided(decision, option)`; `world.slack.messages(to=, in_=, in_thread_of=, from_=, text_contains=, fact=)`.
46
+ - `world.state()`, `world.trace()`; `world.fresh()` for another world; the fixture removes its world (`ancha world down`).
47
+ - `@ancha.attempts(n, min_pass_rate)` runs the test in `n` fresh worlds and passes when enough of them do.
48
+
49
+ An evaluated check that fails returns a false `CheckReport`; `report.failures` explains the expected and actual values. A check that cannot be evaluated raises `ancha.AnchaError` instead. For example, a `knows` check naming an unknown person raises an error with `code == "USAGE"` and a message naming the invalid reference. Correct the test definition; this error is not a verdict on the agent. The people and Slack helpers use the same check path and can raise the same error. CLI failures also raise `AnchaError`, retaining the CLI's `code`, `message` and optional `hint`.
50
+
51
+ Worlds start locally by default, including after `ancha login`. Run `pytest --on cloud` to put worlds in your hosted workspace; pytest and the agent still run on your machine. `--on local` forces local worlds. The pytest option overrides the marker's `on="local"` or `on="cloud"`; without either, the CLI's normal world selection applies. An explicit server URL can also select a world server. Never give the agent the test files or the checks.
52
+
53
+ ## Recorded runs
54
+
55
+ While the plugin is enabled, each executed pytest item creates one local run under `.ancha/runs/`, with `source: pytest` and `target: local`. This includes tests without a world, parameterized cases and skipped tests. Collection alone creates no records. Disable the plugin with `-p no:ancha` for suites that should not record. Run files stay local even when worlds run in the cloud; uploading pytest records is not supported.
56
+
57
+ ```sh
58
+ ancha --local runs list --json
59
+ ancha --local runs show <run-id> --json
60
+ ancha --local explain <run-id> --json
61
+ ```
62
+
63
+ Pytest assertions decide the test outcome. A deliberately false `world.check()` remains evidence and does not fail a passing negative assertion. Records preserve setup, call and teardown outcomes separately. Skipped and expected-failure cases are ungraded; setup, capture and cleanup failures are invalid, not failed agent answers. `@ancha.attempts` records each invocation and its fresh world under the same run, including failed attempts when the overall pass-rate threshold is met. The requested repetition count is recorded before execution. Reports keep every requested attempt in the pass-rate denominator, including invalid, skipped and missing attempts; all-k requires all requested attempts to finish validly and pass. Thresholds use exact integer counts. An old baseline without verified requested counts must be regenerated before comparison.
64
+
65
+ The plugin stops its agent, captures a coherent world observation, then removes the world. This observation is not a sealed managed-trial capture. Capture failures remain visible and cleanup is still attempted. Recording failures are reported and make an otherwise successful pytest session fail.
66
+
67
+ Parameter values in test IDs are replaced by a hash. Records omit environment values, raw exception messages, captured pytest output and agent logs. Known credentials are redacted from evidence; keep `.ancha/` private and out of version control. Abrupt termination such as SIGKILL cannot finish a record or guarantee cleanup; world TTLs still apply.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ # One distribution for `import ancha`: the tool wrappers need nothing; the pytest plugin needs pytest (ancha[pytest]).
7
+ name = "ancha"
8
+ version = "0.1.0"
9
+ description = "Ancha for Python: test your agent's own tools against production's recorded calls, and pytest worlds for agent tests"
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ requires-python = ">=3.10"
13
+ dependencies = []
14
+ classifiers = ["Framework :: Pytest"]
15
+
16
+ [project.optional-dependencies]
17
+ pytest = ["pytest>=7"]
18
+
19
+ [project.entry-points.pytest11]
20
+ ancha = "ancha.plugin"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
ancha-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,19 @@
1
+ """Ancha for Python: wrap your agent's own tools so a test made from a production trace answers them as production did
2
+ (`ancha.tool`, `ancha.wrap_tools`, `ancha.entry`; see ancha/tools.py), and pytest for Ancha: a fresh simulated world for each test, your agent run against it, and checks on what people learned
3
+ and what the tools hold.
4
+
5
+ import ancha, pytest
6
+
7
+ @pytest.mark.ancha(test="ancha/tests/relay-export-bug.yaml")
8
+ @ancha.attempts(10, min_pass_rate=0.9)
9
+ def test_relay(world):
10
+ world.start("npm start")
11
+ world.wait_until_quiet()
12
+ assert world.people["ben"].knows("laura.beta_on")
13
+ assert world.slack.messages(to="laura", in_thread_of="$laura_report", from_="$slack_bot")
14
+ assert not world.people["laura"].knows("ben.root_cause")
15
+ """
16
+ from .tools import ToolError, call_tool, entry, tool, wrap_tools
17
+ from .world import AnchaError, CheckReport, People, Person, Slack, World, attempts
18
+
19
+ __all__ = ["AnchaError", "CheckReport", "People", "Person", "Slack", "ToolError", "World", "attempts", "call_tool", "entry", "tool", "wrap_tools"]
@@ -0,0 +1,85 @@
1
+ """The pytest plugin: the `world` fixture, configured per test with `@pytest.mark.ancha(...)`."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import Any, Iterator
6
+
7
+ import pytest
8
+
9
+ from .recording import Recorder, current, error_code
10
+ from .world import World
11
+
12
+ OPTIONS = ("test", "scene", "world", "on", "seed", "ttl")
13
+
14
+
15
+ def pytest_addoption(parser: pytest.Parser) -> None:
16
+ parser.getgroup("ancha").addoption("--on", choices=("local", "cloud"), default=None,
17
+ help="where Ancha worlds run; overrides the ancha marker (pytest and the agent stay local)")
18
+
19
+
20
+ def pytest_configure(config: pytest.Config) -> None:
21
+ config.addinivalue_line("markers", "ancha(test=None, scene=None, world=None, on=None, seed=None, ttl=None, cwd=None): "
22
+ "what the `world` fixture starts: a test or scene file, or a saved world; where (local or cloud)")
23
+ config._ancha_recording_failures = []
24
+ config._ancha_recorded = 0
25
+
26
+
27
+ @pytest.hookimpl(hookwrapper=True, tryfirst=True)
28
+ def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None):
29
+ marker = item.get_closest_marker("ancha")
30
+ recorder = Recorder(item.nodeid, str((marker.kwargs.get("cwd") if marker else None) or os.getcwd()))
31
+ item._ancha_recorder = recorder
32
+ token = current.set(recorder)
33
+ try:
34
+ yield
35
+ finally:
36
+ try:
37
+ recorder.finish()
38
+ recorder.write()
39
+ item.config._ancha_recorded += 1
40
+ if recorder.infrastructure_failed:
41
+ item.config._ancha_recording_failures.append((recorder.payload["runId"], "INFRASTRUCTURE_ERROR"))
42
+ except Exception as error:
43
+ # Recording cannot turn a failing test into an internal pytest error or expose its exception text.
44
+ item.config._ancha_recording_failures.append((recorder.payload["runId"], error_code(error)))
45
+ finally:
46
+ current.reset(token)
47
+
48
+
49
+ @pytest.hookimpl(hookwrapper=True, tryfirst=True)
50
+ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo):
51
+ outcome = yield
52
+ recorder = getattr(item, "_ancha_recorder", None)
53
+ if recorder is not None:
54
+ recorder.report(outcome.get_result(), call.excinfo.value if call.excinfo is not None else None)
55
+
56
+
57
+ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
58
+ if session.config._ancha_recording_failures and session.exitstatus == pytest.ExitCode.OK:
59
+ session.exitstatus = pytest.ExitCode.TESTS_FAILED
60
+
61
+
62
+ def pytest_terminal_summary(terminalreporter: Any) -> None:
63
+ config = terminalreporter.config
64
+ if config._ancha_recorded:
65
+ terminalreporter.write_line(f"Ancha recorded {config._ancha_recorded} pytest run(s) locally in .ancha/runs.")
66
+ for run_id, code in config._ancha_recording_failures:
67
+ terminalreporter.write_line(f"Ancha run {run_id}: infrastructure or recording failed ({code}); pytest session failed.", red=True)
68
+
69
+
70
+ @pytest.fixture
71
+ def world(request: pytest.FixtureRequest) -> Iterator[World]:
72
+ """A fresh world for this test (`ancha world up`), removed afterwards. Without a marker: the one scene ancha.yaml names."""
73
+ marker = request.node.get_closest_marker("ancha")
74
+ options = dict(marker.kwargs) if marker else {}
75
+ unknown = set(options) - {*OPTIONS, "cwd"}
76
+ if unknown:
77
+ raise pytest.UsageError(f"@pytest.mark.ancha takes {', '.join(OPTIONS)} and cwd, not {', '.join(sorted(unknown))}")
78
+ if request.config.getoption("--on") is not None:
79
+ options["on"] = request.config.getoption("--on")
80
+ args = [arg for key in OPTIONS if options.get(key) is not None for arg in (f"--{key}", str(options[key]))]
81
+ live = World(args, cwd=str(options.get("cwd") or os.getcwd()))
82
+ try:
83
+ yield live
84
+ finally:
85
+ live.down()
@@ -0,0 +1,173 @@
1
+ """Private per-test observations. The CLI validates and stores the finished run."""
2
+ from __future__ import annotations
3
+
4
+ import contextvars
5
+ import datetime as dt
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import tempfile
11
+ import uuid
12
+ from typing import Any
13
+
14
+
15
+ current: contextvars.ContextVar[Recorder | None] = contextvars.ContextVar("ancha_pytest_recorder", default=None)
16
+ _SECRET_NAME = re.compile(r"secret|token|password|passwd|api.?key|private.?key|credential|cookie|authorization", re.I)
17
+ _CODE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$")
18
+
19
+
20
+ def now() -> str:
21
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
22
+
23
+
24
+ def error_code(error: BaseException | None) -> str:
25
+ code = getattr(error, "code", None) or (type(error).__name__ if error is not None else "PYTEST_ERROR")
26
+ return code if isinstance(code, str) and _CODE.fullmatch(code) else "PYTEST_ERROR"
27
+
28
+
29
+ def node_id(value: str) -> str:
30
+ # Parameter IDs can include credentials, payloads or user data. Keep test identity without their contents.
31
+ if "[" not in value:
32
+ return value
33
+ plain, parameters = value.split("[", 1)
34
+ return f"{plain}[case-{hashlib.sha256(parameters.encode()).hexdigest()[:16]}]"
35
+
36
+
37
+ class Recorder:
38
+ def __init__(self, node: str, cwd: str) -> None:
39
+ self.cwd = cwd
40
+ self.secrets: set[str] = set()
41
+ self.add_secrets(os.environ)
42
+ self.payload: dict[str, Any] = {
43
+ "version": 1, "runId": f"run_pytest_{uuid.uuid4().hex}", "nodeId": node_id(node),
44
+ "startedAt": now(), "finishedAt": now(), "outcome": "passed", "minPassRate": 1.0,
45
+ "phases": [], "attempts": [], "requestedTrials": 1,
46
+ }
47
+ self.worlds: list[Any] = []
48
+ self.repeated = False
49
+ self.infrastructure_failed = False
50
+ self.begin_attempt()
51
+
52
+ @property
53
+ def attempt(self) -> dict[str, Any]:
54
+ return self.payload["attempts"][-1]
55
+
56
+ def add_secrets(self, values: Any) -> None:
57
+ self.secrets.update(value for key, value in values.items()
58
+ if _SECRET_NAME.search(key) and isinstance(value, str) and value)
59
+
60
+ def begin_attempt(self) -> None:
61
+ self.payload["attempts"].append({"number": len(self.payload["attempts"]) + 1,
62
+ "outcome": "passed", "startedAt": now(), "finishedAt": now(), "worlds": [], "errors": []})
63
+
64
+ def add_world(self, world: Any) -> dict[str, Any]:
65
+ value: dict[str, Any] = {"envId": world.id, "workspace": world.workspace, "checks": [], "errors": []}
66
+ self.worlds.append(world)
67
+ self.attempt["worlds"].append(value)
68
+ self.add_secrets(world.env)
69
+ return value
70
+
71
+ def infrastructure_error(self, phase: str, error: BaseException, world: dict | None = None) -> None:
72
+ issue = {"phase": phase, "code": error_code(error)}
73
+ if world is not None:
74
+ world["errors"].append(issue)
75
+ else:
76
+ self.attempt["errors"].append(issue)
77
+ self.attempt["outcome"] = "invalid"
78
+ self.infrastructure_failed = True
79
+
80
+ def close_worlds(self) -> BaseException | None:
81
+ first: BaseException | None = None
82
+ # World.down owns error recording and is idempotent, including after a failed removal.
83
+ for world in self.worlds:
84
+ try:
85
+ world.down()
86
+ except BaseException as error:
87
+ first = first or error
88
+ return first
89
+
90
+ def finish_attempt(self, outcome: str) -> BaseException | None:
91
+ if self.attempt["outcome"] != "invalid":
92
+ self.attempt["outcome"] = outcome
93
+ error = self.close_worlds()
94
+ self.attempt["finishedAt"] = now()
95
+ return error
96
+
97
+ def report(self, report: Any, error: BaseException | None) -> None:
98
+ phase: dict[str, Any] = {"when": report.when, "outcome": report.outcome,
99
+ "durationMs": max(0, report.duration * 1000)}
100
+ if report.failed:
101
+ phase["errorCode"] = error_code(error)
102
+ self.payload["phases"].append(phase)
103
+ if report.skipped:
104
+ outcome = "xfailed" if getattr(report, "wasxfail", None) is not None else "skipped"
105
+ if self.payload["outcome"] not in ("failed", "error"):
106
+ self.payload["outcome"] = outcome
107
+ if not self.repeated:
108
+ self.attempt["outcome"] = outcome
109
+ elif report.failed:
110
+ # A failed assertion/pytest.fail is a test verdict. Other exceptions and fixture errors are invalid.
111
+ import pytest
112
+ assertion = isinstance(error, (AssertionError, pytest.fail.Exception))
113
+ strict_xpass = error is None and report.when == "call" and str(report.longrepr).startswith("[XPASS(strict)]")
114
+ if report.when != "call" or (not assertion and not strict_xpass):
115
+ stage = getattr(error, "_ancha_phase", report.when)
116
+ self.infrastructure_error(stage, error or RuntimeError())
117
+ self.payload["outcome"] = "error"
118
+ elif self.payload["outcome"] != "error":
119
+ self.payload["outcome"] = "failed"
120
+ if not self.repeated and not strict_xpass:
121
+ self.attempt["outcome"] = "failed"
122
+ elif report.when == "call" and getattr(report, "wasxfail", None) is not None:
123
+ self.payload["outcome"] = "xpassed"
124
+ if not self.repeated:
125
+ self.attempt["outcome"] = "xpassed"
126
+
127
+ def _redact(self, value: Any) -> Any:
128
+ if isinstance(value, str):
129
+ for secret in sorted(self.secrets, key=len, reverse=True):
130
+ if len(secret) >= 4:
131
+ value = value.replace(secret, "[redacted]")
132
+ elif value == secret:
133
+ value = "[redacted]"
134
+ return value
135
+ if isinstance(value, list):
136
+ return [self._redact(item) for item in value]
137
+ if isinstance(value, dict):
138
+ return {self._redact(key): self._redact(item) for key, item in value.items()}
139
+ return value
140
+
141
+ def safe_payload(self) -> dict:
142
+ # Keep protocol enums/timestamps/IDs intact even if a credential happens to equal one of their values.
143
+ def code(value: str) -> str:
144
+ return value if self._redact(value) == value else "REDACTED_ERROR"
145
+
146
+ def errors(values: list[dict]) -> list[dict]:
147
+ return [{**value, "code": code(value["code"])} for value in values]
148
+
149
+ def world(value: dict) -> dict:
150
+ return {**{key: self._redact(item) for key, item in value.items() if key != "errors"}, "errors": errors(value["errors"])}
151
+
152
+ return {**self.payload, "nodeId": self._redact(self.payload["nodeId"]),
153
+ "phases": [{**phase, **({"errorCode": code(phase["errorCode"])} if "errorCode" in phase else {})}
154
+ for phase in self.payload["phases"]],
155
+ "attempts": [{**attempt, "worlds": [world(value) for value in attempt["worlds"]], "errors": errors(attempt["errors"])}
156
+ for attempt in self.payload["attempts"]]}
157
+
158
+ def finish(self) -> None:
159
+ self.close_worlds()
160
+ if self.infrastructure_failed:
161
+ self.payload["outcome"] = "error"
162
+ self.attempt["finishedAt"] = now()
163
+ self.payload["finishedAt"] = now()
164
+
165
+ def write(self) -> dict:
166
+ from .world import run_cli
167
+ fd, path = tempfile.mkstemp(prefix="ancha-pytest-", suffix=".json")
168
+ try:
169
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
170
+ json.dump(self.safe_payload(), file, ensure_ascii=False)
171
+ return run_cli(["world", "record", "--file", path], self.cwd)
172
+ finally:
173
+ os.unlink(path)