froid-loop 0.11.1__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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
froid_loop/journal.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Append-only run journal and atomic run-state persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .model import RunState
|
|
12
|
+
from .platform_util import (
|
|
13
|
+
DIR_FD_ANCHORED_WRITES,
|
|
14
|
+
atomic_replace,
|
|
15
|
+
atomic_write_text,
|
|
16
|
+
atomic_write_text_at,
|
|
17
|
+
is_link_like,
|
|
18
|
+
open_dir_confined,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
STATE_FILE = "state.json"
|
|
22
|
+
JOURNAL_FILE = "journal.jsonl"
|
|
23
|
+
LOGS_DIR = "logs"
|
|
24
|
+
# Verifier subprocess streams, deliberately NOT under LOGS_DIR — see
|
|
25
|
+
# Journal.write_verify_stream for why sharing that directory is a TUI bug.
|
|
26
|
+
VERIFY_DIR = "verify"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Journal:
|
|
30
|
+
def __init__(self, run_dir: Path):
|
|
31
|
+
self.run_dir = run_dir
|
|
32
|
+
self.path = run_dir / JOURNAL_FILE
|
|
33
|
+
self._log_task: str | None = None
|
|
34
|
+
self._log_path: Path | None = None
|
|
35
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
def set_active_log(self, task_id: str) -> None:
|
|
38
|
+
"""Entries from now on carry log_task/log_pos: the pane log of this
|
|
39
|
+
task and its byte size at append time. Deliberately not cleared on
|
|
40
|
+
session end — post-session entries (decisions, story-done) point at
|
|
41
|
+
the end of the log they are about; the next session replaces it."""
|
|
42
|
+
self._log_task = task_id
|
|
43
|
+
self._log_path = self.run_dir / LOGS_DIR / f"{task_id}.log"
|
|
44
|
+
|
|
45
|
+
def append(self, kind: str, **fields: Any) -> None:
|
|
46
|
+
entry = {"ts": time.time(), "kind": kind, **fields}
|
|
47
|
+
if self._log_path is not None:
|
|
48
|
+
try:
|
|
49
|
+
size = self._log_path.stat().st_size
|
|
50
|
+
except OSError:
|
|
51
|
+
size = 0 # pipe-pane has not created the file yet
|
|
52
|
+
entry.setdefault("log_task", self._log_task)
|
|
53
|
+
entry.setdefault("log_pos", size)
|
|
54
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
55
|
+
f.write(json.dumps(entry, default=str) + "\n")
|
|
56
|
+
|
|
57
|
+
def write_verify_stream(self, name: str, content: str) -> str:
|
|
58
|
+
"""Atomically retain one verifier subprocess stream under ``verify/`` and
|
|
59
|
+
return its run-relative pointer. The journal records the pointer and byte
|
|
60
|
+
counts, never unbounded subprocess output inline.
|
|
61
|
+
|
|
62
|
+
Its own directory, not ``logs/``: every other inhabitant of ``logs/`` is a
|
|
63
|
+
coding-CLI pane capture named after a session task id. The adapters own
|
|
64
|
+
that namespace (they write ``{task_id}.log``) and the TUI reads the whole
|
|
65
|
+
directory as one — with no session open, ``tui.data.active_task_id`` falls
|
|
66
|
+
back to the newest ``logs/*.log`` and returns its stem as the live task,
|
|
67
|
+
which the dashboard then reopens as ``logs/{stem}.log``. Verifier streams
|
|
68
|
+
land in exactly that window: session-end is journalled when the session
|
|
69
|
+
ends, before its result reaches verification, so at the moment these files
|
|
70
|
+
are newest no session is open and the fallback fires. Under ``logs/`` that
|
|
71
|
+
rendered verifier stderr in the agent log pane. Keeping the store in a
|
|
72
|
+
separate directory makes that unrepresentable, rather than a name filter
|
|
73
|
+
every future reader of ``logs/`` would have to remember to apply.
|
|
74
|
+
|
|
75
|
+
``name`` is engine-generated (not plugin or command supplied), so it is
|
|
76
|
+
safe to join below. ``content`` arrives already bounded — the cap is
|
|
77
|
+
``verify.stream_capture_kb``, applied by the caller, which is also where
|
|
78
|
+
the full-size and truncation bookkeeping lives; this method is journal
|
|
79
|
+
storage only and never decides how much to keep. Callers retain the
|
|
80
|
+
original stream separately in a hook context.
|
|
81
|
+
|
|
82
|
+
:func:`atomic_write_text`, never ``write_text`` (#379) — the rule
|
|
83
|
+
``install.py`` states flatly. The fixed ``.tmp`` sibling this replaces is
|
|
84
|
+
the collision that helper's own docstring exists to prevent, and its
|
|
85
|
+
fsync-before-replace is what keeps a pointer from ever naming blocks that
|
|
86
|
+
were never written. ``follow_symlinks=False`` because these are
|
|
87
|
+
machine-minted records under a run directory a coding-CLI session can
|
|
88
|
+
reach: honouring a planted link would aim the write at a path of that
|
|
89
|
+
session's choosing, and there is no operator-curated target here to
|
|
90
|
+
preserve (contrast the ledgers the default was built for).
|
|
91
|
+
|
|
92
|
+
Text mode is deliberate, and it is why the record's byte counts are
|
|
93
|
+
defined over the *stream*, not the file: ``\\n`` is translated on Windows,
|
|
94
|
+
so the file can be larger there than the count. ``read_text`` normalizes
|
|
95
|
+
it back, so the content round-trips either way.
|
|
96
|
+
|
|
97
|
+
The write is **anchored at a directory descriptor** where the platform has
|
|
98
|
+
one, because ``follow_symlinks=False`` covers the final component and
|
|
99
|
+
nothing above it. Sessions are handed this run directory outright
|
|
100
|
+
(``FROID_LOOP_RUN_DIR``, which is where they write ``result.json``), so a
|
|
101
|
+
session that plants a symlink at ``verify/`` before verification redirects
|
|
102
|
+
every record: ``mkdir(exist_ok=True)`` ACCEPTS a symlink-to-directory —
|
|
103
|
+
it re-raises only when ``is_dir()`` is false, and that follows links — and
|
|
104
|
+
the replace then lands wherever the link points, outside the run dir
|
|
105
|
+
entirely. Measured, not theorised.
|
|
106
|
+
|
|
107
|
+
``open_dir_confined`` is the fix the repo already keeps for exactly this
|
|
108
|
+
(``tui/launch.py`` writes its control-window record the same way): it walks
|
|
109
|
+
each component below the run dir ``O_NOFOLLOW`` and hands back a descriptor
|
|
110
|
+
for the directory it actually reached, and :func:`atomic_write_text_at`
|
|
111
|
+
never names a path again. A path check would be answered *about a path*
|
|
112
|
+
and stale the moment it returned — the session can re-plant the link
|
|
113
|
+
between check and write — so this closes the window rather than narrowing
|
|
114
|
+
it. The ``mkdir`` above may still be fooled; that is harmless, because the
|
|
115
|
+
confinement walk that follows is not, and refusal is what the fooled case
|
|
116
|
+
produces.
|
|
117
|
+
|
|
118
|
+
win32 has no ``*at()`` family to anchor against, so it keeps a
|
|
119
|
+
check-then-write, and the check is :func:`is_link_like` rather than
|
|
120
|
+
``is_symlink()`` — on Windows the redirect that matters is a DIRECTORY
|
|
121
|
+
JUNCTION, which ``is_symlink()`` reports False for and which ``mklink /J``
|
|
122
|
+
creates with no elevation at all, while a directory symlink needs
|
|
123
|
+
SeCreateSymbolicLinkPrivilege or Developer Mode. Checking only for
|
|
124
|
+
symlinks there would leave the unprivileged half of the same escape open,
|
|
125
|
+
and with no race to win. The residual is the platform's: a path check is
|
|
126
|
+
stale the moment it returns, but the planting session runs as the same uid
|
|
127
|
+
as this writer and the names here are engine-minted, so the exposure is a
|
|
128
|
+
redirected diagnostic rather than a foothold.
|
|
129
|
+
|
|
130
|
+
Raises ``OSError`` — including when confinement cannot be established, so
|
|
131
|
+
an unconfined ``verify/`` REFUSES rather than writing through the link.
|
|
132
|
+
The caller degrades (this is observation), it does not swallow it here:
|
|
133
|
+
the record still lands, with a null pointer and ``capture_error``.
|
|
134
|
+
"""
|
|
135
|
+
verify_dir = self.run_dir / VERIFY_DIR
|
|
136
|
+
verify_dir.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
if DIR_FD_ANCHORED_WRITES:
|
|
138
|
+
dir_fd = open_dir_confined(self.run_dir, verify_dir)
|
|
139
|
+
if dir_fd is None:
|
|
140
|
+
raise OSError(
|
|
141
|
+
f"refusing to write into an unconfined verify directory: {verify_dir}"
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
atomic_write_text_at(dir_fd, name, content)
|
|
145
|
+
finally:
|
|
146
|
+
os.close(dir_fd)
|
|
147
|
+
else:
|
|
148
|
+
if is_link_like(verify_dir):
|
|
149
|
+
raise OSError(f"refusing to write into a redirected verify directory: {verify_dir}")
|
|
150
|
+
atomic_write_text(verify_dir / name, content, follow_symlinks=False)
|
|
151
|
+
return (verify_dir / name).relative_to(self.run_dir).as_posix()
|
|
152
|
+
|
|
153
|
+
def entries(self) -> list[dict[str, Any]]:
|
|
154
|
+
if not self.path.is_file():
|
|
155
|
+
return []
|
|
156
|
+
out = []
|
|
157
|
+
for line in self.path.read_text(encoding="utf-8").splitlines():
|
|
158
|
+
line = line.strip()
|
|
159
|
+
if not line:
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
out.append(json.loads(line))
|
|
163
|
+
except json.JSONDecodeError:
|
|
164
|
+
continue
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def save_state(run_dir: Path, state: RunState) -> None:
|
|
169
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
target = run_dir / STATE_FILE
|
|
171
|
+
tmp = target.with_suffix(".json.tmp")
|
|
172
|
+
tmp.write_text(json.dumps(state.to_dict(), indent=2), encoding="utf-8")
|
|
173
|
+
atomic_replace(tmp, target)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def load_state(run_dir: Path) -> RunState:
|
|
177
|
+
target = run_dir / STATE_FILE
|
|
178
|
+
return RunState.from_dict(json.loads(target.read_text(encoding="utf-8")))
|
froid_loop/machine.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""The machine-readable output contract for CLI ``--json`` modes.
|
|
2
|
+
|
|
3
|
+
``--json`` in its pure-document form means exactly one JSON object on stdout
|
|
4
|
+
and nothing else — no trailers, no fenced blocks, no log lines. Every document
|
|
5
|
+
carries an inline integer ``schema_version``; each command owns its own version
|
|
6
|
+
constant (e.g. ``cli.STATUS_SCHEMA_VERSION``) so the documents evolve
|
|
7
|
+
independently, the same convention as ``diagnostics.SCHEMA_VERSION``. Evolution
|
|
8
|
+
is additive-only: new fields may appear, but anything breaking — removing or
|
|
9
|
+
renaming a field, changing a type or the meaning of a value — bumps that
|
|
10
|
+
command's version.
|
|
11
|
+
|
|
12
|
+
Errors never produce a partial or error document: the message goes to stderr,
|
|
13
|
+
stdout stays empty, and the exit code is nonzero. Consumers may rely on
|
|
14
|
+
"stdout is either one complete valid document or empty".
|
|
15
|
+
|
|
16
|
+
An **error** is a command that could not do its job — no runs to dump, an
|
|
17
|
+
unresolvable run ref, a policy that will not parse. A command whose job is to
|
|
18
|
+
report a *verdict* is a different thing: it did its job, and it exits nonzero to
|
|
19
|
+
carry the answer. ``validate --json`` is the case — a failing check is the
|
|
20
|
+
finding it was asked for, and the document is still owed. So the rule for
|
|
21
|
+
consumers is positive rather than inferred from the exit code: **parse non-empty
|
|
22
|
+
stdout whatever the exit code, and take the verdict from the document's own
|
|
23
|
+
field** (``ok`` on ``validate``). That field, unlike rc, separates "the checks
|
|
24
|
+
failed" from "the command broke": rc 1 is both, ``ok: false`` is only the first,
|
|
25
|
+
and a command that broke leaves nothing on stdout to read the field from. Note
|
|
26
|
+
that every gate in ``cmd_validate`` runs inside a ``try``, so the command has no
|
|
27
|
+
error path of its own — its rc ∈ {0, 1} is purely the verdict.
|
|
28
|
+
|
|
29
|
+
**Success does not imply a document on stdout.** ``diagnose`` and
|
|
30
|
+
``probe-adapter`` also take ``--out FILE``; with both flags the document goes to
|
|
31
|
+
the file and stdout is legitimately empty at exit 0, with only a confirmation on
|
|
32
|
+
stderr. So empty stdout means "no document *here*" — check the exit code to tell
|
|
33
|
+
a redirect from a failure, and do not treat rc 0 as a promise of bytes to parse.
|
|
34
|
+
That file is held to the same standard as the stream: :func:`write_document` is
|
|
35
|
+
the only way it is written, and validates exactly as :func:`emit_document` does.
|
|
36
|
+
|
|
37
|
+
Every command that takes ``--json`` shares this contract; there is no exception
|
|
38
|
+
(#195 removed the last two, ``diagnose`` and ``probe-adapter``, which used to
|
|
39
|
+
append a fenced JSON block to a markdown/text report). A command adopting the
|
|
40
|
+
flag adopts the whole of it — the contract is the flag's meaning, not a style
|
|
41
|
+
the earlier commands happen to follow.
|
|
42
|
+
|
|
43
|
+
Two ways in, by what the caller already holds:
|
|
44
|
+
|
|
45
|
+
- :func:`emit` takes a ``dict`` and serializes it — ``status`` and ``list``.
|
|
46
|
+
- :func:`emit_document` takes an already-serialized ``str`` — ``diagnose`` and
|
|
47
|
+
``probe-adapter``, whose renderers return text. For both this is a hard
|
|
48
|
+
constraint: each serializes *before* running the shared leak self-check
|
|
49
|
+
(``sanitize.guard``), making those exact bytes the ones the check verified —
|
|
50
|
+
re-encoding them here would emit bytes nothing verified.
|
|
51
|
+
|
|
52
|
+
:func:`write_document` is the ``--out`` sibling of :func:`emit_document`, taking
|
|
53
|
+
the same already-serialized string to a file instead of stdout.
|
|
54
|
+
|
|
55
|
+
The serializer flags differ by family on purpose and are not to be unified: the
|
|
56
|
+
renderers behind :func:`emit_document` sort their keys (a diff-stable dump is
|
|
57
|
+
worth more than field order there) while :func:`emit`'s dicts are built in the
|
|
58
|
+
order they are meant to be read, and the two guarded renderers pass
|
|
59
|
+
``ensure_ascii=False`` because the leak guard has to scan the values unescaped.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
from __future__ import annotations
|
|
63
|
+
|
|
64
|
+
import argparse
|
|
65
|
+
import json
|
|
66
|
+
import sys
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def emit(doc: dict[str, object]) -> None:
|
|
71
|
+
"""The single stdout write in JSON mode."""
|
|
72
|
+
emit_document(json.dumps(doc, indent=2))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _validated(rendered: str) -> str:
|
|
76
|
+
"""Return ``rendered`` unchanged, or raise if it is not a whole JSON document.
|
|
77
|
+
|
|
78
|
+
Parse to assert, never to re-serialize: the caller's exact bytes are what gets
|
|
79
|
+
written. The ``diagnose``/``probe-adapter`` renderers run their leak self-check
|
|
80
|
+
*before* handing the string over, so re-encoding here would ship bytes nothing
|
|
81
|
+
checked.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
json.loads(rendered)
|
|
85
|
+
except json.JSONDecodeError as e:
|
|
86
|
+
raise ValueError(f"refusing to emit a malformed JSON document: {e}") from e
|
|
87
|
+
return rendered
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def emit_document(rendered: str) -> None:
|
|
91
|
+
"""The single stdout write, for a document that is *already* serialized.
|
|
92
|
+
|
|
93
|
+
Verifies well-formedness before writing, then writes the ORIGINAL string —
|
|
94
|
+
never a re-serialization of the parsed result. Half the commands reach stdout
|
|
95
|
+
through here rather than through :func:`emit`, so without this the contract
|
|
96
|
+
would hold for them by convention only; but the guarded renderers validated
|
|
97
|
+
these exact bytes with their leak self-check, so emitting anything re-derived
|
|
98
|
+
would ship bytes nothing checked. Parse to assert, print verbatim.
|
|
99
|
+
|
|
100
|
+
Raises :class:`ValueError` on a malformed document — a bug in the caller's
|
|
101
|
+
renderer, and far better surfaced as a crash with empty stdout (which the
|
|
102
|
+
contract permits) than as a half-parsable stream a consumer has to diagnose.
|
|
103
|
+
|
|
104
|
+
stdout is switched to UTF-8 first, because a document is not necessarily
|
|
105
|
+
ASCII — the guarded renderers dump with ``ensure_ascii=False`` so the
|
|
106
|
+
leak guard can scan the values unescaped, which lets a non-sensitive
|
|
107
|
+
non-ASCII field (a localized ``platform.release()``, say) through to here
|
|
108
|
+
verbatim. Encoding it for a legacy non-UTF-8 console then raised
|
|
109
|
+
:class:`UnicodeEncodeError` before a byte was written (#200). Escaping the
|
|
110
|
+
output instead would have been the smaller change and the wrong one: it
|
|
111
|
+
breaks the invariant this function exists for, since the guard verified the
|
|
112
|
+
unescaped bytes. So the stream is made able to carry the document rather
|
|
113
|
+
than the document cut down to fit the stream.
|
|
114
|
+
"""
|
|
115
|
+
document = _validated(rendered)
|
|
116
|
+
# Guarded: a substituted stdout (pytest capture, an exotic stream) may not be
|
|
117
|
+
# a TextIOWrapper at all. Falling through leaves the pre-#200 behaviour, which
|
|
118
|
+
# is a crash with stdout still empty — permitted by the contract above.
|
|
119
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
120
|
+
# hasattr-guarded: reconfigure exists on TextIOWrapper, not the TextIO base.
|
|
121
|
+
sys.stdout.reconfigure( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
|
|
122
|
+
encoding="utf-8"
|
|
123
|
+
)
|
|
124
|
+
print(document)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_document(path: Path, rendered: str) -> None:
|
|
128
|
+
"""The single file write in JSON mode — the bytes :func:`emit_document` prints.
|
|
129
|
+
|
|
130
|
+
``--out FILE`` is the other half of the same contract, so it gets the same
|
|
131
|
+
validation and the same trailing newline: ``--json --out FILE`` and
|
|
132
|
+
``--json > FILE`` produce byte-identical files. Without the parse the file was
|
|
133
|
+
the weaker half — stdout refused a malformed document while the file accepted
|
|
134
|
+
it, which is backwards, since a document written to a file is the one nobody
|
|
135
|
+
eyeballs before feeding it to a parser.
|
|
136
|
+
|
|
137
|
+
Raises :class:`ValueError` on a malformed document, before the file is created.
|
|
138
|
+
"""
|
|
139
|
+
path.write_text(_validated(rendered) + "\n", encoding="utf-8")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def add_json_flag(parser: argparse.ArgumentParser, what: str) -> None:
|
|
143
|
+
"""Register ``--json`` with the standard help text."""
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--json",
|
|
146
|
+
action="store_true",
|
|
147
|
+
help=f"emit a stable machine-readable JSON document ({what}) instead of text",
|
|
148
|
+
)
|