weir-run 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.
weir_run/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """The canary runner: plants fake canaries, runs a program's flows against a
2
+ loopback mock, and judges the captured telemetry with `weir canary judge`.
3
+
4
+ Its contract is weir's docs/canary.md. The runner is a test-time tool for a
5
+ developer's machine or a CI runner, never imported by production code.
6
+ """
weir_run/capture.py ADDED
@@ -0,0 +1,52 @@
1
+ """The capture directory the judge reads (docs/canary.md, "The capture").
2
+
3
+ capture/otlp/<signal>.jsonl telemetry files (--otlp): one decompressed
4
+ OTLP/JSON export request per line
5
+ capture/swept/... swept files: every other telemetry body and
6
+ each flow's stdout and stderr, one file each
7
+
8
+ The roles are fixed by directory, never guessed from content.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import threading
15
+ from pathlib import Path
16
+
17
+ SIGNALS = ("traces", "logs", "metrics")
18
+
19
+
20
+ def safe_name(text: str) -> str:
21
+ """A file-name segment: id characters other than [A-Za-z0-9._-] become `_`."""
22
+ return re.sub(r"[^A-Za-z0-9._-]", "_", text)
23
+
24
+
25
+ class Capture:
26
+ def __init__(self, root: Path) -> None:
27
+ self.root = root
28
+ self.otlp = root / "otlp"
29
+ self.swept = root / "swept"
30
+ self.otlp.mkdir(parents=True)
31
+ self.swept.mkdir(parents=True)
32
+ self._lock = threading.Lock()
33
+ self._counts: dict[str, int] = {}
34
+
35
+ def otlp_line(self, signal: str, line: bytes) -> None:
36
+ if signal not in SIGNALS or b"\n" in line:
37
+ raise ValueError("an OTLP capture line is one export request of a known signal")
38
+ with self._lock, (self.otlp / f"{signal}.jsonl").open("ab") as f:
39
+ f.write(line + b"\n")
40
+
41
+ def swept_file(self, stem: str, data: bytes, suffix: str = ".body") -> str:
42
+ """Write one swept file and return its name under capture/swept."""
43
+ with self._lock:
44
+ n = self._counts.get(stem, 0) + 1
45
+ self._counts[stem] = n
46
+ name = f"{safe_name(stem)}-{n}{suffix}"
47
+ (self.swept / name).write_bytes(data)
48
+ return name
49
+
50
+ def flow_streams(self, index: int, flow_id: str) -> tuple[Path, Path]:
51
+ stem = f"flow-{index:02d}-{safe_name(flow_id)}"
52
+ return self.swept / f"{stem}.stdout", self.swept / f"{stem}.stderr"
weir_run/check.py ADDED
@@ -0,0 +1,181 @@
1
+ """One check run, start to finish (docs/canary.md, "What the runner owes
2
+ the judge"):
3
+
4
+ 1. plant one fresh unique value per placement and write the ledger before
5
+ any flow starts;
6
+ 2. start the mock on 127.0.0.1 and run each flow against it, recording one
7
+ `flow` record per flow (the mock records minted values, credential-field
8
+ evidence and unexpected credentials as they happen);
9
+ 3. stop the mock, relay each flow's output with every needle replaced;
10
+ 4. call the judge, then delete the ledger and the capture.
11
+
12
+ The CLI runs a whole config through `run_spec`; the pytest plugin drives a
13
+ `CheckRun` call by call.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import shutil
19
+ import sys
20
+ import tempfile
21
+ from collections.abc import Sequence
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import TextIO
25
+
26
+ from weir_run import flows
27
+ from weir_run import judge as judge_cli
28
+ from weir_run.capture import Capture
29
+ from weir_run.ledger import Ledger
30
+ from weir_run.mock import Mock
31
+ from weir_run.shapes import ByteSource, shaped
32
+ from weir_run.spec import FlowSpec, RunSpec, SpecError, validate
33
+
34
+
35
+ @dataclass
36
+ class FlowOutcome:
37
+ id: str
38
+ status: str
39
+ stdout: Path = field(repr=False)
40
+ stderr: Path = field(repr=False)
41
+
42
+
43
+ class CheckRun:
44
+ def __init__(
45
+ self,
46
+ spec: RunSpec,
47
+ judge: Sequence[str],
48
+ *,
49
+ seed: int | None = None,
50
+ python: str = sys.executable,
51
+ validate_spec: bool = True,
52
+ ) -> None:
53
+ if validate_spec:
54
+ validate(spec)
55
+ self.spec = spec
56
+ self.judge_command = list(judge)
57
+ self.python = python
58
+ self.source = ByteSource(seed)
59
+ self.outcomes: list[FlowOutcome] = []
60
+ self.verdict: judge_cli.Verdict | None = None
61
+ self.json_verdict: judge_cli.Verdict | None = None
62
+ # 0700, private to this user: it holds the ledger and the capture.
63
+ self.run_dir = Path(tempfile.mkdtemp(prefix="canary-run-"))
64
+ self._flow_dirs = Path(tempfile.mkdtemp(prefix="canary-flows-"))
65
+ try:
66
+ self.ledger = Ledger(self.run_dir / judge_cli.LEDGER, self.source, spec.reserved_ids())
67
+ self.capture = Capture(self.run_dir / "capture")
68
+ self.planted: dict[str, str] = {}
69
+ for p in spec.placements:
70
+ made = shaped(self.source, p.shape)
71
+ self.planted[p.variable] = self.ledger.plant(p.id, p.kind, made, p.required).value
72
+ self.mock = Mock(spec.mock, self.ledger, self.capture, spec.placements, self.source)
73
+ self.mock.start()
74
+ except BaseException:
75
+ for directory in (self.run_dir, self._flow_dirs):
76
+ shutil.rmtree(directory, ignore_errors=True)
77
+ raise
78
+ self._stopped = False
79
+
80
+ @property
81
+ def mock_url(self) -> str:
82
+ return self.mock.url
83
+
84
+ def run_flow(self, flow: FlowSpec) -> FlowOutcome:
85
+ if self._stopped:
86
+ raise RuntimeError("the check run has already been judged")
87
+ if any(o.id == flow.id for o in self.outcomes):
88
+ raise SpecError(f"flow id {flow.id} is defined twice")
89
+ index = len(self.outcomes) + 1
90
+ home = self._flow_dirs / f"{index:02d}" / "home"
91
+ tmp = self._flow_dirs / f"{index:02d}" / "tmp"
92
+ home.mkdir(parents=True)
93
+ tmp.mkdir()
94
+ env = flows.flow_environment(self.mock_url, self.planted, flow.env, home, tmp)
95
+ stdout, stderr = self.capture.flow_streams(index, flow.id)
96
+ status = flows.run(flow, env, stdout, stderr, self.python)
97
+ self.ledger.flow(flow.id, status)
98
+ outcome = FlowOutcome(flow.id, status, stdout, stderr)
99
+ self.outcomes.append(outcome)
100
+ return outcome
101
+
102
+ def stop(self) -> None:
103
+ if not self._stopped:
104
+ self._stopped = True
105
+ self.mock.stop()
106
+ self.ledger.close()
107
+
108
+ def safe(self, text: str) -> str:
109
+ """Program-derived text, printed only if it holds no trace of a value."""
110
+ return "[withheld]" if self.ledger.withheld(text) else text
111
+
112
+ def relay(self, out: TextIO) -> None:
113
+ """Each flow's own output, every needle replaced by `<canary:ID>`."""
114
+ for o in self.outcomes:
115
+ out.write(f"flow {o.id}: {o.status}\n")
116
+ for name, path in (("stdout", o.stdout), ("stderr", o.stderr)):
117
+ data = path.read_bytes() if path.exists() else b""
118
+ if not data:
119
+ continue
120
+ out.write(f"--- flow {o.id} {name} (canaries redacted) ---\n")
121
+ text = self.ledger.redact(data).decode("utf-8", "replace")
122
+ out.write(text if text.endswith("\n") else text + "\n")
123
+ out.write(f"--- end of flow {o.id} {name} ---\n")
124
+ mock = self.mock
125
+ for host, count in sorted(mock.egress.items()):
126
+ out.write(f"mock: refused egress to {self.safe(host)} ({count}x)\n")
127
+ for request, count in sorted(mock.unmocked.items()):
128
+ out.write(f"mock: no mock for {self.safe(request)} ({count}x), answered 404\n")
129
+ if mock.errors:
130
+ out.write(f"mock: {mock.errors} request(s) failed inside the mock, answered 500\n")
131
+
132
+ def judge(self, *, json_report: bool = False) -> judge_cli.Verdict:
133
+ self.stop()
134
+ self.verdict = judge_cli.judge(self.judge_command, self.run_dir)
135
+ if json_report:
136
+ self.json_verdict = judge_cli.judge(self.judge_command, self.run_dir, json_report=True)
137
+ return self.verdict
138
+
139
+ def redacted(self, data: bytes) -> str:
140
+ """Judge output is value-free by contract; redact it all the same."""
141
+ return self.ledger.redact(data).decode("utf-8", "replace")
142
+
143
+ def cleanup(self) -> list[str]:
144
+ """Delete the ledger, the capture and the flows' homes. Returns the
145
+ directories that could not be removed."""
146
+ self.stop()
147
+ failed: list[str] = []
148
+ for directory in (self.run_dir, self._flow_dirs):
149
+ shutil.rmtree(directory, ignore_errors=True)
150
+ if directory.exists():
151
+ failed.append(str(directory))
152
+ return failed
153
+
154
+
155
+ def run_spec(
156
+ spec: RunSpec,
157
+ judge: Sequence[str],
158
+ *,
159
+ out: TextIO,
160
+ err: TextIO,
161
+ seed: int | None = None,
162
+ json_report: Path | None = None,
163
+ python: str = sys.executable,
164
+ ) -> int:
165
+ """Run every flow of spec, judge, clean up. Returns the exit code."""
166
+ run = CheckRun(spec, judge, seed=seed, python=python)
167
+ try:
168
+ for flow in spec.flows:
169
+ run.run_flow(flow)
170
+ run.stop()
171
+ run.relay(err)
172
+ verdict = run.judge(json_report=json_report is not None)
173
+ out.write(run.redacted(verdict.stdout))
174
+ if verdict.stderr:
175
+ err.write(run.redacted(verdict.stderr))
176
+ if json_report is not None and run.json_verdict is not None:
177
+ json_report.write_text(run.redacted(run.json_verdict.stdout), encoding="utf-8")
178
+ return verdict.code
179
+ finally:
180
+ for directory in run.cleanup():
181
+ err.write(f"weir-run: could not delete {directory}; delete it by hand\n")
weir_run/cli.py ADDED
@@ -0,0 +1,95 @@
1
+ """`weir-run run`: one check run from a maintainer's config.
2
+
3
+ Exit codes are the judge's (0 clean, 1 leak, 3 incomplete), and 2 for a
4
+ usage, configuration or input error, the runner's own or the judge's, and
5
+ for any other failure of the run (docs/canary.md, "Exit codes"). A runner
6
+ fault never exits 1, which would read as a leak.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+ from types import FrameType
16
+
17
+ import click
18
+
19
+ from weir_run import config
20
+ from weir_run import judge as judge_cli
21
+ from weir_run.check import run_spec
22
+ from weir_run.ledger import LedgerError
23
+ from weir_run.spec import SpecError
24
+
25
+ USAGE_ERROR = 2
26
+
27
+
28
+ @click.group()
29
+ def main() -> None:
30
+ # Flow output and judge reports can hold any text the program wrote.
31
+ for stream in (sys.stdout, sys.stderr):
32
+ reconfigure = getattr(stream, "reconfigure", None)
33
+ if reconfigure is not None:
34
+ reconfigure(encoding="utf-8", errors="replace")
35
+
36
+
37
+ @main.command("run")
38
+ @click.option(
39
+ "--config",
40
+ "config_path",
41
+ required=True,
42
+ type=click.Path(path_type=Path, dir_okay=False),
43
+ help="The check's TOML config: placements, flows and mocked endpoints.",
44
+ )
45
+ @click.option(
46
+ "--report",
47
+ type=click.Path(path_type=Path, dir_okay=False),
48
+ help="Also write the judge's JSON report (canary-report/1) here.",
49
+ )
50
+ @click.option(
51
+ "--seed",
52
+ type=int,
53
+ help="Draw canary values from a seeded stream, for local reproduction only.",
54
+ )
55
+ @click.option(
56
+ "--weir",
57
+ "weir_path",
58
+ help="The weir executable to judge with. Default: the one beside this Python, else PATH.",
59
+ )
60
+ def run_command(
61
+ config_path: Path, report: Path | None, seed: int | None, weir_path: str | None
62
+ ) -> None:
63
+ """Plant canaries, run every flow against the loopback mock, judge the capture."""
64
+ # A CI cancel or timeout sends SIGTERM: stop the same way as Ctrl-C, so
65
+ # the flow's processes are stopped and the ledger and capture deleted.
66
+ previous = signal.signal(signal.SIGTERM, _terminate)
67
+ try:
68
+ if report is not None and not report.parent.is_dir():
69
+ raise SpecError(f"--report {report}: no such directory {report.parent}")
70
+ spec = config.load(config_path)
71
+ command = judge_cli.resolve(weir_path)
72
+ judge_cli.check_available(command)
73
+ code = run_spec(
74
+ spec, command, out=sys.stdout, err=sys.stderr, seed=seed, json_report=report
75
+ )
76
+ except (SpecError, LedgerError, OSError, subprocess.SubprocessError, RuntimeError) as exc:
77
+ click.echo(f"weir-run: {exc}", err=True)
78
+ sys.exit(USAGE_ERROR)
79
+ except KeyboardInterrupt:
80
+ click.echo("weir-run: interrupted", err=True)
81
+ sys.exit(USAGE_ERROR)
82
+ except Exception as exc:
83
+ click.echo(f"weir-run: the run failed ({type(exc).__name__})", err=True)
84
+ sys.exit(USAGE_ERROR)
85
+ finally:
86
+ signal.signal(signal.SIGTERM, previous)
87
+ sys.exit(code)
88
+
89
+
90
+ def _terminate(signum: int, frame: FrameType | None) -> None:
91
+ raise KeyboardInterrupt
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
weir_run/config.py ADDED
@@ -0,0 +1,169 @@
1
+ """The maintainer's config file: TOML in, a validated `RunSpec` out.
2
+
3
+ Strict on purpose: an unknown table or key is a usage error, never ignored,
4
+ so a typo cannot quietly drop a placement or a flow from the check. Paths
5
+ are relative to the config file's directory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tomllib
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+ from typing import cast
14
+
15
+ from weir_run.spec import (
16
+ CredentialField,
17
+ EndpointSpec,
18
+ FlowSpec,
19
+ MockSpec,
20
+ PlacementSpec,
21
+ RunSpec,
22
+ SpecError,
23
+ default_variable,
24
+ validate,
25
+ )
26
+
27
+ _TOP = {"mock", "placement", "flow"}
28
+ _MOCK = {"oauth", "model_reply", "tool_calls", "credential_fields", "endpoint"}
29
+ _ENDPOINT = {"id", "credential_fields", "status", "reply", "content_type", "capture"}
30
+ _PLACEMENT = {"id", "kind", "variable", "shape", "required", "provider_host"}
31
+ _FLOW = {"id", "command", "cwd", "timeout_seconds", "env", "skip"}
32
+
33
+
34
+ def _table(value: object, where: str) -> dict[str, object]:
35
+ if not isinstance(value, dict):
36
+ raise SpecError(f"{where}: expected a table")
37
+ return cast(dict[str, object], value)
38
+
39
+
40
+ def _tables(value: object, where: str) -> list[dict[str, object]]:
41
+ if not isinstance(value, list):
42
+ raise SpecError(f"{where}: expected an array of tables ([[{where}]])")
43
+ return [_table(v, where) for v in cast(list[object], value)]
44
+
45
+
46
+ def _keys(table: Mapping[str, object], allowed: set[str], where: str) -> None:
47
+ unknown = sorted(set(table) - allowed)
48
+ if unknown:
49
+ raise SpecError(f"{where}: unknown key {unknown[0]!r}")
50
+
51
+
52
+ def _str(table: Mapping[str, object], key: str, where: str, default: str | None = None) -> str:
53
+ value = table.get(key, default)
54
+ if not isinstance(value, str):
55
+ raise SpecError(f"{where}: {key} must be a string")
56
+ return value
57
+
58
+
59
+ def _opt_str(table: Mapping[str, object], key: str, where: str) -> str | None:
60
+ return None if key not in table else _str(table, key, where)
61
+
62
+
63
+ def _bool(table: Mapping[str, object], key: str, where: str, default: bool) -> bool:
64
+ value = table.get(key, default)
65
+ if not isinstance(value, bool):
66
+ raise SpecError(f"{where}: {key} must be true or false")
67
+ return value
68
+
69
+
70
+ def _str_list(table: Mapping[str, object], key: str, where: str) -> list[str]:
71
+ value = table.get(key, [])
72
+ if not isinstance(value, list) or not all(
73
+ isinstance(v, str) for v in cast(list[object], value)
74
+ ):
75
+ raise SpecError(f"{where}: {key} must be an array of strings")
76
+ return cast(list[str], value)
77
+
78
+
79
+ def _fields(table: Mapping[str, object], key: str, where: str) -> tuple[CredentialField, ...]:
80
+ return tuple(CredentialField.parse(f) for f in _str_list(table, key, where))
81
+
82
+
83
+ def _endpoint(t: dict[str, object]) -> EndpointSpec:
84
+ where = f"mock.endpoint {t.get('id', '?')}"
85
+ _keys(t, _ENDPOINT, where)
86
+ status = t.get("status", 200)
87
+ if not isinstance(status, int) or isinstance(status, bool):
88
+ raise SpecError(f"{where}: status must be an integer")
89
+ return EndpointSpec(
90
+ id=_str(t, "id", where),
91
+ credential_fields=_fields(t, "credential_fields", where),
92
+ status=status,
93
+ reply=_str(t, "reply", where, "{}"),
94
+ content_type=_str(t, "content_type", where, "application/json"),
95
+ capture=_bool(t, "capture", where, False),
96
+ )
97
+
98
+
99
+ def _mock(t: dict[str, object]) -> MockSpec:
100
+ _keys(t, _MOCK, "mock")
101
+ fields = _table(t.get("credential_fields", {}), "mock.credential_fields")
102
+ return MockSpec(
103
+ oauth=_bool(t, "oauth", "mock", False),
104
+ model_reply=_str(t, "model_reply", "mock", "Done."),
105
+ tool_calls=_str(t, "tool_calls", "mock", "each-offered-tool-once"),
106
+ credential_fields={
107
+ name: _fields(fields, name, "mock.credential_fields") for name in fields
108
+ },
109
+ endpoints=tuple(_endpoint(e) for e in _tables(t.get("endpoint", []), "mock.endpoint")),
110
+ )
111
+
112
+
113
+ def _placement(t: dict[str, object]) -> PlacementSpec:
114
+ where = f"placement {t.get('id', '?')}"
115
+ _keys(t, _PLACEMENT, where)
116
+ pid = _str(t, "id", where)
117
+ kind = _str(t, "kind", where)
118
+ variable = _opt_str(t, "variable", where)
119
+ if variable is None:
120
+ if kind == "env" and not pid.startswith("env."):
121
+ raise SpecError(
122
+ f"{where}: an env placement whose id does not start with env. names its variable"
123
+ )
124
+ variable = default_variable(pid, kind)
125
+ return PlacementSpec(
126
+ id=pid,
127
+ kind=kind,
128
+ variable=variable,
129
+ shape=_str(t, "shape", where, "generic"),
130
+ required=_bool(t, "required", where, False),
131
+ provider_host=_opt_str(t, "provider_host", where),
132
+ )
133
+
134
+
135
+ def _flow(t: dict[str, object], base: Path) -> FlowSpec:
136
+ where = f"flow {t.get('id', '?')}"
137
+ _keys(t, _FLOW, where)
138
+ timeout = t.get("timeout_seconds", 300)
139
+ if not isinstance(timeout, int | float) or isinstance(timeout, bool):
140
+ raise SpecError(f"{where}: timeout_seconds must be a number")
141
+ env = _table(t.get("env", {}), f"{where} env")
142
+ if not all(isinstance(v, str) for v in env.values()):
143
+ raise SpecError(f"{where}: env values must be strings")
144
+ return FlowSpec(
145
+ id=_str(t, "id", where),
146
+ command=tuple(_str_list(t, "command", where)),
147
+ cwd=(base / _str(t, "cwd", where, ".")).resolve(),
148
+ timeout_seconds=float(timeout),
149
+ env=cast(dict[str, str], env),
150
+ skip=_opt_str(t, "skip", where),
151
+ )
152
+
153
+
154
+ def load(path: Path) -> RunSpec:
155
+ try:
156
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
157
+ except OSError as exc:
158
+ raise SpecError(f"cannot read config {path}: {exc.strerror}") from None
159
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
160
+ raise SpecError(f"config {path} is not valid TOML: {exc}") from None
161
+ _keys(raw, _TOP, str(path))
162
+ base = path.resolve().parent
163
+ spec = RunSpec(
164
+ placements=tuple(_placement(p) for p in _tables(raw.get("placement", []), "placement")),
165
+ flows=tuple(_flow(f, base) for f in _tables(raw.get("flow", []), "flow")),
166
+ mock=_mock(_table(raw.get("mock", {}), "mock")),
167
+ )
168
+ validate(spec)
169
+ return spec
weir_run/flows.py ADDED
@@ -0,0 +1,104 @@
1
+ """Running one flow: an allowlisted environment and a bounded subprocess.
2
+
3
+ A flow starts from an empty environment, the `env -i` of the contract, and
4
+ gets only what is listed here: PATH, a private HOME and temporary
5
+ directory, a fixed locale, its planted canaries, the mock's URLs and its
6
+ own non-secret settings from the config. Nothing else from the job's
7
+ environment reaches it, so a real provider key in CI secrets never does.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import signal
14
+ import subprocess
15
+ import sys
16
+ from collections.abc import Mapping
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from weir_run.spec import FlowSpec
21
+
22
+ PYTHON_TOKEN = "{python}"
23
+ MOCK_TOKEN = "{mock}"
24
+
25
+
26
+ def flow_environment(
27
+ mock_url: str,
28
+ planted: Mapping[str, str],
29
+ extra: Mapping[str, str],
30
+ home: Path,
31
+ tmp: Path,
32
+ ) -> dict[str, str]:
33
+ """The whole environment of a flow: variable name -> value."""
34
+ env = {
35
+ "PATH": os.environ.get("PATH", os.defpath),
36
+ "HOME": str(home),
37
+ "TMPDIR": str(tmp),
38
+ "TEMP": str(tmp),
39
+ "TMP": str(tmp),
40
+ "LANG": "C.UTF-8",
41
+ "PYTHONUTF8": "1",
42
+ "CANARY_MOCK_URL": mock_url,
43
+ "OPENAI_BASE_URL": f"{mock_url}/v1",
44
+ "OPENAI_API_BASE": f"{mock_url}/v1",
45
+ "OTEL_EXPORTER_OTLP_ENDPOINT": mock_url,
46
+ "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
47
+ }
48
+ for signal_name in ("TRACES", "LOGS", "METRICS"):
49
+ env[f"OTEL_EXPORTER_OTLP_{signal_name}_ENDPOINT"] = f"{mock_url}/v1/{signal_name.lower()}"
50
+ env[f"OTEL_EXPORTER_OTLP_{signal_name}_PROTOCOL"] = "http/protobuf"
51
+ # Every client that honours the proxy variables reaches other hosts only
52
+ # through the mock, which refuses them. The mock itself is reached direct.
53
+ for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
54
+ env[name] = env[name.lower()] = mock_url
55
+ env["NO_PROXY"] = env["no_proxy"] = "127.0.0.1,localhost"
56
+ if sys.platform == "win32":
57
+ env["USERPROFILE"] = str(home)
58
+ # Windows processes cannot start without these; neither holds a secret.
59
+ for name in ("SYSTEMROOT", "PATHEXT"):
60
+ if name in os.environ:
61
+ env[name] = os.environ[name]
62
+ env.update(planted)
63
+ env.update({k: v.replace(MOCK_TOKEN, mock_url) for k, v in extra.items()})
64
+ return env
65
+
66
+
67
+ def command_line(flow: FlowSpec, python: str) -> list[str]:
68
+ return [python if part == PYTHON_TOKEN else part for part in flow.command]
69
+
70
+
71
+ def run(flow: FlowSpec, env: Mapping[str, str], stdout: Path, stderr: Path, python: str) -> str:
72
+ """Run a flow to completion; return its ledger status."""
73
+ if flow.skip is not None:
74
+ return "skipped"
75
+ with stdout.open("wb") as out, stderr.open("wb") as err:
76
+ # Its own process group, so a timeout or the end of the flow can
77
+ # stop everything it started.
78
+ options: dict[str, Any] = {"cwd": flow.cwd, "env": dict(env), "stdout": out, "stderr": err}
79
+ if sys.platform == "win32":
80
+ options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
81
+ else:
82
+ options["start_new_session"] = True
83
+ try:
84
+ proc = subprocess.Popen(command_line(flow, python), stdin=subprocess.DEVNULL, **options)
85
+ except OSError as exc:
86
+ err.write(f"weir-run: could not start the flow: {exc.strerror}\n".encode())
87
+ return "failed"
88
+ try:
89
+ code = proc.wait(timeout=flow.timeout_seconds)
90
+ status = "ok" if code == 0 else "failed"
91
+ except subprocess.TimeoutExpired:
92
+ status = "timeout"
93
+ finally:
94
+ # A flow's leftover children do not outlive it: the mock and the
95
+ # telemetry receiver exist only for the length of the run.
96
+ if sys.platform == "win32":
97
+ proc.kill()
98
+ else:
99
+ try:
100
+ os.killpg(proc.pid, signal.SIGKILL)
101
+ except (ProcessLookupError, PermissionError):
102
+ pass
103
+ proc.wait()
104
+ return status