quality-loop 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.
quality_loop/runner.py ADDED
@@ -0,0 +1,160 @@
1
+ """A run: for each scenario, a room, its operator, and a report.
2
+
3
+ build the room (TASK.md is the scenario's Prompt, verbatim)
4
+ stand fixtures in for the operator's files
5
+ serve the fixture pages, write the screen manifest
6
+ operator phase 1 - puts the screen up; the room must come out unchanged
7
+ the room's session
8
+ collect evidence, put the operator's files back
9
+ audit the transcript
10
+ operator phase 2 - writes REPORT.md
11
+ close any screen still open
12
+ ...
13
+ FINDINGS.md and findings.json, concatenated from the reports
14
+
15
+ There is no verdict. A room that could not be measured - a cancelled phase 1,
16
+ an isolation breach, a lost product or model - still gets a report, written
17
+ here as a harness problem, so the human reading the findings sees it.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import datetime
24
+ import json
25
+ import pathlib
26
+ import shutil
27
+ import signal
28
+ import sys
29
+ import tempfile
30
+
31
+ from quality_loop import (audit, operator, pages, report, room, scenario, screens,
32
+ transcript)
33
+
34
+
35
+ class RunnerError(Exception):
36
+ """A room the runner will not start."""
37
+
38
+
39
+ def refuse_a_reachable_marker(marker: pathlib.Path, built: pathlib.Path,
40
+ add_dirs: list[pathlib.Path]) -> None:
41
+ """The operator's set-aside files must be out of the room's reach.
42
+
43
+ The first run of the toy put them inside the one directory the room could
44
+ write to. The room saw a second style.txt beside the fixture and ignored
45
+ it. A room that read it would have measured the harness, and one that
46
+ wrote to it could have touched the operator's own file.
47
+ """
48
+ marker = pathlib.Path(marker).resolve()
49
+ for reach in [built, *add_dirs]:
50
+ reach = pathlib.Path(reach).resolve()
51
+ if marker == reach or reach in marker.parents:
52
+ raise RunnerError(
53
+ f"the stand-in keeps the operator's files in {marker}, inside {reach}, "
54
+ f"which the room can reach - put the marker outside it")
55
+
56
+
57
+ def _cancelled(case, what: str, problem_id: str, evidence: str) -> str:
58
+ return (f"# Report: {case.title}\n\nThis room could not be measured.\n\n"
59
+ f"### [critical] {what}\n- **Id:** `{problem_id}`\n- **Kind:** harness\n"
60
+ f"- **Evidence:** {evidence}\n- **Where it would be fixed:** the runner\n"
61
+ f"- **How a fix is confirmed:** the room runs and is reported on\n")
62
+
63
+
64
+ def run(adapter, names: list[str] | None = None, model: str | None = None,
65
+ operator_model: str | None = None, say=print) -> pathlib.Path:
66
+ """Run every scenario ``names`` picks, and return FINDINGS.md's path."""
67
+ sys.stdout.reconfigure(line_buffering=True)
68
+ # SIGTERM unwinds like Ctrl-C, so the stand-in puts the operator's files
69
+ # back instead of dying with them set aside.
70
+ signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
71
+ cases = scenario.load(adapter.scenarios, names)
72
+ stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
73
+ parent = adapter.rooms / f"{adapter.version}-{stamp}"
74
+ parent.mkdir(parents=True)
75
+ say(f"rooms: {parent}")
76
+ reports: list[tuple[str, str, str]] = []
77
+ bundle = adapter.bundle()
78
+ try:
79
+ with adapter.product():
80
+ for case, variant in adapter.runs(cases, named=bool(names)):
81
+ label = case.name + (f".{variant}" if variant else "")
82
+ say(f"\n== {label}: {case.title}")
83
+ reports.append((label, case.title, _one(
84
+ adapter, case, variant, parent / label, bundle, model,
85
+ operator_model, say)))
86
+ except KeyboardInterrupt:
87
+ say("\n interrupted - the room in progress was collected and the "
88
+ "operator's files put back")
89
+ findings = report.write_findings(parent, reports)
90
+ say(f"\n findings: {findings}")
91
+ return findings
92
+
93
+
94
+ def _one(adapter, case, variant, here: pathlib.Path, bundle, model, operator_model,
95
+ say) -> str:
96
+ built = room.build(here, prompt=case.prompt, rules=adapter.rules(), bundle=bundle,
97
+ skill_name=adapter.skill_name, rooms=adapter.rooms,
98
+ checkout=adapter.checkout)
99
+ evidence = built / "evidence"
100
+ evidence.mkdir()
101
+ private = pathlib.Path(tempfile.mkdtemp(prefix="quality-loop-private-"))
102
+ directory = here.with_name(here.name + ".operator")
103
+ session = adapter.session(built)
104
+ if model:
105
+ session.model = model
106
+ stand_in = adapter.stand_in(case, variant, built)
107
+ if stand_in is not None:
108
+ refuse_a_reachable_marker(stand_in.marker, built, session.add_dirs)
109
+ stand_in = stand_in or contextlib.nullcontext()
110
+ served = pages.Pages(adapter.fixtures) if adapter.fixtures else contextlib.nullcontext()
111
+ op = None
112
+ try:
113
+ with stand_in, served as site:
114
+ manifest = screens.write_manifest(
115
+ private / "manifest.json", adapter.screens,
116
+ fixtures=adapter.fixtures or adapter.checkout,
117
+ origin=site.origin if site else "", python=adapter.python,
118
+ state=directory, evidence=adapter.evidence)
119
+ op = operator.Operator(
120
+ room=built, directory=directory, scenario=case.path,
121
+ checkout=adapter.checkout,
122
+ evidence_help=adapter.evidence_help, manifest=manifest,
123
+ python=adapter.python, model=operator_model)
124
+ say(" operator: phase 1 - the screen")
125
+ op.phase_one(sys.platform, say=say)
126
+ say(" room: running")
127
+ ran = session.run(built, alive=adapter.alive, say=say)
128
+ if site:
129
+ site.write(evidence / "screen-events.jsonl")
130
+ (evidence / "run.json").write_text(json.dumps(
131
+ {"scenario": case.name, "variant": variant, "platform": sys.platform,
132
+ "exit_code": ran.exit_code, "product_lost": ran.product_lost,
133
+ "model_lost": transcript.lost_the_model(ran.transcript)}, indent=2),
134
+ encoding="utf-8")
135
+ breaches, refused = audit.audit(built, adapter.policy(built))
136
+ (evidence / "audit.json").write_text(json.dumps(
137
+ {"breaches": breaches, "refused": refused}, indent=2), encoding="utf-8")
138
+ for line in breaches:
139
+ say(f" BREACH: {line}")
140
+ say(" operator: phase 2 - the report")
141
+ written = op.phase_two(say=say)
142
+ text = written.read_text(encoding="utf-8") if written.exists() else ""
143
+ if breaches:
144
+ text = _cancelled(case, "The room broke isolation; nothing it did is evidence",
145
+ "isolation-breach", "; ".join(breaches)) + "\n" + text
146
+ if ran.product_lost or transcript.lost_the_model(ran.transcript):
147
+ what = "the product" if ran.product_lost else "the model's API"
148
+ text = _cancelled(case, f"{what} stopped answering during the run",
149
+ "lost-" + ("product" if ran.product_lost else "model"),
150
+ "run.json") + "\n" + text
151
+ return text
152
+ except operator.OperatorError as error:
153
+ say(f" CANCELLED: {error}")
154
+ return _cancelled(case, "Operator phase 1 changed the room", "phase-one-touched-room",
155
+ str(error))
156
+ finally:
157
+ closed = screens.close_all(directory) if directory.exists() else []
158
+ if closed:
159
+ say(f" closed: {', '.join(closed)} (left open by the operator)")
160
+ shutil.rmtree(private, ignore_errors=True)
@@ -0,0 +1,160 @@
1
+ """Scenarios: one Markdown file per task, written in natural language.
2
+
3
+ ```markdown
4
+ +++
5
+ title = "Stop F1 doing anything in VS Code"
6
+ edits_config = true
7
+ +++
8
+
9
+ ## Prompt
10
+
11
+ What a user would actually type - the only part that reaches the room.
12
+
13
+ ## Screen
14
+
15
+ What has to be in front before the room starts, for the operator to put up.
16
+
17
+ ## Must
18
+
19
+ - The observable outcomes, then what to read for.
20
+
21
+ ## Must not
22
+
23
+ - What a run must not do.
24
+ ```
25
+
26
+ The front matter is TOML between ``+++`` lines. ``title`` is required; every
27
+ other key is the product's to define (which configs to run on, which input
28
+ files to stage, a screen only the operator can supply) and is handed to it
29
+ as-is in ``Scenario.meta``.
30
+
31
+ The sections are the ``## `` headings. **Prompt** and **Must** are required.
32
+ The prompt is taken verbatim - the text between its heading and the next one -
33
+ because it is the one thing that reaches the room, and the runner, not the
34
+ operator agent, is what copies it there. A Must line that also appears in the
35
+ prompt refuses the scenario: that is the scoring key leaking, and it has
36
+ happened through a copy-paste one line too long.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import dataclasses
42
+ import pathlib
43
+ import re
44
+ import tomllib
45
+
46
+ #: Sections every scenario has.
47
+ REQUIRED = ("Prompt", "Must")
48
+
49
+ _HEADING = re.compile(r"^## +(.+?)\s*$")
50
+ _FENCE = re.compile(r"^(```|~~~)")
51
+
52
+
53
+ class ScenarioError(Exception):
54
+ """A scenario file that cannot be run as written."""
55
+
56
+
57
+ @dataclasses.dataclass
58
+ class Scenario:
59
+ """One task, as its file states it.
60
+
61
+ Attributes:
62
+ path: The file.
63
+ name: Its stem: ``11_f1_off_in_vscode``.
64
+ number: The leading number of the stem, or None.
65
+ title: From the front matter.
66
+ meta: The whole front matter, for the product to read.
67
+ sections: ``{heading: text}``, in file order.
68
+ """
69
+ path: pathlib.Path
70
+ name: str
71
+ number: int | None
72
+ title: str
73
+ meta: dict
74
+ sections: dict[str, str]
75
+
76
+ @property
77
+ def prompt(self) -> str:
78
+ """What the room is handed, exactly as written."""
79
+ return self.sections["Prompt"]
80
+
81
+ def section(self, heading: str) -> str:
82
+ """A section's text, or "" when the scenario has none."""
83
+ return self.sections.get(heading, "")
84
+
85
+
86
+ def parse(path: pathlib.Path) -> Scenario:
87
+ """Read one scenario file, or say what is wrong with it."""
88
+ path = pathlib.Path(path)
89
+ text = path.read_text(encoding="utf-8").replace("\r\n", "\n")
90
+ meta, body = _front_matter(text, path)
91
+ if not isinstance(meta.get("title"), str) or not meta["title"].strip():
92
+ raise ScenarioError(f"{path.name}: the front matter has no title")
93
+ sections = _sections(body)
94
+ if missing := [h for h in REQUIRED if not sections.get(h, "").strip()]:
95
+ raise ScenarioError(f"{path.name}: no {', '.join(missing)} section")
96
+ _refuse_a_leak(sections, path)
97
+ number = re.match(r"(\d+)", path.stem)
98
+ return Scenario(path=path, name=path.stem,
99
+ number=int(number.group(1)) if number else None,
100
+ title=meta["title"].strip(), meta=meta, sections=sections)
101
+
102
+
103
+ def load(directory: pathlib.Path, names: list[str] | None = None) -> list[Scenario]:
104
+ """Every scenario in ``directory`` in file order, or those ``names`` picks -
105
+ by number, by stem, or by the stem without its number."""
106
+ found = [parse(path) for path in sorted(pathlib.Path(directory).glob("*.md"))
107
+ if not path.name.startswith(("_", "README"))]
108
+ if not names:
109
+ return found
110
+ wanted = []
111
+ for name in map(str, names):
112
+ match = [s for s in found
113
+ if name in (s.name, s.name.split("_", 1)[-1])
114
+ or (name.isdigit() and s.number == int(name))]
115
+ if not match:
116
+ raise ScenarioError(f"no scenario {name!r}; have: "
117
+ + ", ".join(s.name for s in found))
118
+ wanted += match
119
+ return wanted
120
+
121
+
122
+ def _front_matter(text: str, path: pathlib.Path) -> tuple[dict, str]:
123
+ if not text.startswith("+++\n"):
124
+ raise ScenarioError(f"{path.name}: does not start with +++ front matter")
125
+ end = text.find("\n+++\n", 4)
126
+ if end < 0:
127
+ raise ScenarioError(f"{path.name}: the front matter is never closed with +++")
128
+ try:
129
+ meta = tomllib.loads(text[4:end])
130
+ except tomllib.TOMLDecodeError as error:
131
+ raise ScenarioError(f"{path.name}: front matter: {error}") from None
132
+ return meta, text[end + len("\n+++\n"):]
133
+
134
+
135
+ def _sections(body: str) -> dict[str, str]:
136
+ """``## `` headings and the text under each, ignoring fenced code."""
137
+ sections: dict[str, list[str]] = {}
138
+ current = None
139
+ fenced = False
140
+ for line in body.split("\n"):
141
+ if _FENCE.match(line):
142
+ fenced = not fenced
143
+ heading = None if fenced else _HEADING.match(line)
144
+ if heading:
145
+ current = heading.group(1)
146
+ sections.setdefault(current, [])
147
+ elif current is not None:
148
+ sections[current].append(line)
149
+ return {h: "\n".join(lines).strip("\n") + "\n" for h, lines in sections.items()}
150
+
151
+
152
+ def _refuse_a_leak(sections: dict[str, str], path: pathlib.Path) -> None:
153
+ prompt = " ".join(sections["Prompt"].split()).lower()
154
+ for heading in ("Must", "Must not"):
155
+ for line in sections.get(heading, "").splitlines():
156
+ said = " ".join(line.strip().lstrip("-*").split()).lower()
157
+ if len(said) >= 20 and said in prompt:
158
+ raise ScenarioError(
159
+ f"{path.name}: the prompt carries a line of {heading}: {line.strip()!r}"
160
+ f" - that is the scoring key reaching the room")
@@ -0,0 +1,192 @@
1
+ """Putting a scenario's screen up, by name, on either OS.
2
+
3
+ The operator agent reads a scenario's Screen section and decides what has to be
4
+ in front. It can only ask for a screen **by name**, from the list the
5
+ product's adapter declares, and it never passes a path, a URL or a script. An
6
+ agent that can write files and also run a script it names can run anything,
7
+ so the mechanics belong in code:
8
+
9
+ ```python
10
+ SCREENS = {
11
+ "approval-queue": Page("dialog.html", title="Approval queue"),
12
+ "deploy": Terminal("deploy.py", title="deploy - acme"),
13
+ }
14
+ ```
15
+
16
+ The runner writes a manifest of those declarations, with every path made
17
+ absolute, into a directory the operator cannot write to, and points
18
+ ``QUALITY_LOOP_MANIFEST`` at it. ``quality-loop screen open <name>`` opens
19
+ one, waits until its window is up, and records it. ``close`` closes only what
20
+ ``open`` recorded. Both OSes are implemented here, one module each. The
21
+ window's title is the contract between the declaration and the check that
22
+ the screen is up.
23
+
24
+ The screen is checked with the OS's own window list, never with the product
25
+ under test: a broken product would otherwise hide a failed setup and take
26
+ the blame for it.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import dataclasses
32
+ import json
33
+ import os
34
+ import pathlib
35
+ import shutil
36
+ import sys
37
+ import tempfile
38
+ import time
39
+
40
+ MANIFEST = "QUALITY_LOOP_MANIFEST"
41
+
42
+
43
+ class ScreenError(Exception):
44
+ """A screen that could not be put up or taken down."""
45
+
46
+
47
+ @dataclasses.dataclass(frozen=True)
48
+ class Page:
49
+ """An HTML page from the product's fixtures, served by the runner.
50
+
51
+ Attributes:
52
+ file: Its path under the served fixture directory.
53
+ title: Its ``<title>``, which is how its window is recognised.
54
+ """
55
+ file: str
56
+ title: str
57
+ kind: str = "page"
58
+
59
+
60
+ @dataclasses.dataclass(frozen=True)
61
+ class Terminal:
62
+ """A Python script run in a terminal window of its own.
63
+
64
+ Python so that one script serves both OSes; run with the product's
65
+ interpreter, from a copy outside the checkout, because a terminal shows
66
+ its command line and a path under the repository tells the room where the
67
+ source it may not read is.
68
+
69
+ Attributes:
70
+ script: Its path, relative to the adapter's fixture directory.
71
+ title: The window's title, set by the backend rather than the script.
72
+ """
73
+ script: str
74
+ title: str
75
+ kind: str = "terminal"
76
+
77
+
78
+ def write_manifest(path: pathlib.Path, screens: dict, *, fixtures: pathlib.Path,
79
+ origin: str, python: str, state: pathlib.Path,
80
+ evidence: dict | None = None) -> pathlib.Path:
81
+ """The declarations, made absolute, for the operator's tools to read.
82
+
83
+ ``evidence`` is ``{name: {"argv": [...], "help": "..."}}``: the product's
84
+ evidence commands, run by the operator's MCP server and nothing else.
85
+ """
86
+ entries = {}
87
+ for name, screen in screens.items():
88
+ entry = dataclasses.asdict(screen)
89
+ if isinstance(screen, Page):
90
+ entry["url"] = f"{origin}/{screen.file}"
91
+ else:
92
+ entry["script"] = str((pathlib.Path(fixtures) / screen.script).resolve())
93
+ entries[name] = entry
94
+ path.write_text(json.dumps({"screens": entries, "python": python,
95
+ "state": str(state), "evidence": evidence or {}},
96
+ indent=2), encoding="utf-8")
97
+ return path
98
+
99
+
100
+ def _manifest() -> dict:
101
+ where = os.environ.get(MANIFEST)
102
+ if not where:
103
+ raise ScreenError(f"{MANIFEST} is not set - the runner sets it for the operator")
104
+ return json.loads(pathlib.Path(where).read_text(encoding="utf-8"))
105
+
106
+
107
+ def _state_file(manifest: dict) -> pathlib.Path:
108
+ return pathlib.Path(manifest["state"]) / "screens.json"
109
+
110
+
111
+ def _opened(manifest: dict) -> list[dict]:
112
+ path = _state_file(manifest)
113
+ return json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
114
+
115
+
116
+ def _record(manifest: dict, opened: list[dict]) -> None:
117
+ path = _state_file(manifest)
118
+ path.parent.mkdir(parents=True, exist_ok=True)
119
+ path.write_text(json.dumps(opened, indent=2), encoding="utf-8")
120
+
121
+
122
+ def backend():
123
+ """This OS's implementation."""
124
+ if sys.platform == "darwin":
125
+ from quality_loop.screens import _mac as module
126
+ elif os.name == "nt":
127
+ from quality_loop.screens import _windows as module
128
+ else:
129
+ raise ScreenError(f"no screen backend for {sys.platform}")
130
+ return module
131
+
132
+
133
+ def names() -> dict:
134
+ """``{name: entry}`` - what may be opened."""
135
+ return _manifest()["screens"]
136
+
137
+
138
+ def open_screen(name: str, patience: float = 15.0, os_backend=None) -> dict:
139
+ """Open the screen ``name`` and wait until its window is up."""
140
+ manifest = _manifest()
141
+ screens = manifest["screens"]
142
+ if name not in screens:
143
+ raise ScreenError(f"no screen {name!r}; the adapter declares: "
144
+ + ", ".join(sorted(screens)))
145
+ entry = screens[name]
146
+ os_backend = os_backend or backend()
147
+ if entry["kind"] == "page":
148
+ handle = os_backend.open_page(entry["url"], entry["title"], patience)
149
+ elif entry["kind"] == "terminal":
150
+ copy = pathlib.Path(tempfile.mkdtemp(prefix="quality-loop-screen-"))
151
+ script = copy / pathlib.Path(entry["script"]).name
152
+ shutil.copy2(entry["script"], script)
153
+ handle = os_backend.open_terminal([manifest["python"], str(script)],
154
+ entry["title"], patience)
155
+ else:
156
+ raise ScreenError(f"{name}: unknown kind {entry['kind']!r}")
157
+ opened = _opened(manifest)
158
+ record = {"id": f"{name}-{len(opened) + 1}", "name": name, "kind": entry["kind"],
159
+ "title": entry["title"], "handle": handle, "at": time.time()}
160
+ _record(manifest, opened + [record])
161
+ return record
162
+
163
+
164
+ def close_screen(screen_id: str, os_backend=None) -> dict:
165
+ """Close one screen ``open_screen`` recorded, and nothing else."""
166
+ manifest = _manifest()
167
+ opened = _opened(manifest)
168
+ record = next((r for r in opened if r["id"] == screen_id), None)
169
+ if record is None:
170
+ raise ScreenError(f"{screen_id!r} was not opened here; opened: "
171
+ + (", ".join(r["id"] for r in opened) or "nothing"))
172
+ (os_backend or backend()).close(record["kind"], record["handle"])
173
+ _record(manifest, [r for r in opened if r["id"] != screen_id])
174
+ return record
175
+
176
+
177
+ def close_all(state: pathlib.Path, os_backend=None) -> list[str]:
178
+ """Close everything still recorded in ``state`` - the runner's own sweep,
179
+ after the operator has had its turn, so a screen it forgot to close does
180
+ not become the next room's second window."""
181
+ path = pathlib.Path(state) / "screens.json"
182
+ if not path.exists():
183
+ return []
184
+ closed = []
185
+ for record in json.loads(path.read_text(encoding="utf-8")):
186
+ try:
187
+ (os_backend or backend()).close(record["kind"], record["handle"])
188
+ closed.append(record["id"])
189
+ except ScreenError:
190
+ pass
191
+ path.unlink()
192
+ return closed
@@ -0,0 +1,152 @@
1
+ """Screens on macOS: Safari for pages, Terminal for scripts, through AppleScript.
2
+
3
+ Every step of the page load was paid for by a run of the harness this
4
+ replaces. `make new document with properties {URL:...}` returns a document
5
+ and does not load it, and a plain two-step sets the URL before the new
6
+ document is ready. So the window is made, given a moment, given its URL, and
7
+ then waited for by the title the page will take. A screen that never arrives
8
+ refuses the run instead of buying it: a room that started without its screen
9
+ once hunted for its task across every application the operator had open.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import pathlib
15
+ import subprocess
16
+ import tempfile
17
+ import time
18
+
19
+ from quality_loop.screens import ScreenError
20
+
21
+
22
+ def _quote(text: str) -> str:
23
+ return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
24
+
25
+
26
+ def _osascript(script: str) -> str:
27
+ done = subprocess.run(["osascript", "-e", script], capture_output=True,
28
+ text=True, check=False)
29
+ if done.returncode != 0:
30
+ raise ScreenError(done.stderr.strip() or "osascript failed")
31
+ return done.stdout.strip()
32
+
33
+
34
+ def page_script(url: str) -> str:
35
+ """The AppleScript that opens ``url`` in a Safari window of its own and
36
+ answers that window's id."""
37
+ return f'''tell application "Safari"
38
+ activate
39
+ make new document
40
+ delay 0.5
41
+ set URL of front document to {_quote(url)}
42
+ return id of front window
43
+ end tell'''
44
+
45
+
46
+ #: The shell a terminal screen runs in: bash, without the operator's startup
47
+ #: files and with a fixed prompt, so the screen reads the same on every machine.
48
+ SHELL = "/bin/bash --noprofile --norc"
49
+
50
+
51
+ def wrapper(command: list[str]) -> str:
52
+ """A bash script that clears the screen, runs ``command``, and then stays in
53
+ the fixed shell, so the output is still on screen for the room to read."""
54
+ run = " ".join(_shell_quote(part) for part in command)
55
+ return ("#!/bin/bash\n"
56
+ "printf '\\033c'\n"
57
+ f"{run}\n"
58
+ "export PS1='$ '\n"
59
+ # macOS's bash announces, every time, that zsh is the default now.
60
+ "export BASH_SILENCE_DEPRECATION_WARNING=1\n"
61
+ f"exec {SHELL} -i\n")
62
+
63
+
64
+ def terminal_script(launch: str, title: str) -> str:
65
+ """The AppleScript that runs ``launch`` in a new Terminal window titled
66
+ ``title`` and answers that window's id.
67
+
68
+ Terminal types its command into the operator's own default shell, which
69
+ may not be a POSIX shell at all - on one machine it was a shell that knows
70
+ no ``exec``, and the screen never ran. So what is typed is a single
71
+ absolute path with no arguments, which any shell runs, and everything
72
+ else happens in the script it names.
73
+ """
74
+ return f'''tell application "Terminal"
75
+ activate
76
+ set t to do script {_quote(launch)}
77
+ set custom title of t to {_quote(title)}
78
+ set title displays custom title of t to true
79
+ return id of front window
80
+ end tell'''
81
+
82
+
83
+ def _shell_quote(part: str) -> str:
84
+ return "'" + part.replace("'", "'\\''") + "'"
85
+
86
+
87
+ def _wait(application: str, window_id: int, title: str, patience: float) -> None:
88
+ deadline = time.monotonic() + patience
89
+ while time.monotonic() < deadline:
90
+ try:
91
+ name = _osascript(f'tell application "{application}" to get name of '
92
+ f'(first window whose id is {window_id})')
93
+ except ScreenError:
94
+ name = ""
95
+ if title in name:
96
+ return
97
+ time.sleep(0.5)
98
+ raise ScreenError(f"{application} window {window_id} never showed {title!r}. A "
99
+ f"sheet left over a window blocks this and shows nowhere else, "
100
+ f"and {application} may need Automation permission.")
101
+
102
+
103
+ def open_page(url: str, title: str, patience: float) -> int:
104
+ window = int(_osascript(page_script(url)))
105
+ _wait("Safari", window, title, patience)
106
+ return window
107
+
108
+
109
+ def open_terminal(command: list[str], title: str, patience: float) -> int:
110
+ directory = pathlib.Path(tempfile.mkdtemp(prefix="quality-loop-terminal-"))
111
+ launch = directory / "screen"
112
+ launch.write_text(wrapper(command), encoding="utf-8")
113
+ launch.chmod(0o755)
114
+ window = int(_osascript(terminal_script(str(launch), title)))
115
+ _wait("Terminal", window, title, patience)
116
+ return window
117
+
118
+
119
+ def _exists(application: str, handle: int) -> bool:
120
+ return _osascript(f'''if application "{application}" is running then
121
+ tell application "{application}" to return (exists (first window whose id is {int(handle)}))
122
+ end if
123
+ return false''') == "true"
124
+
125
+
126
+ def close(kind: str, handle: int, patience: float = 5.0) -> None:
127
+ """Close the window ``handle``, and make sure it went.
128
+
129
+ A Terminal window whose tab still lists a process answers a close with a
130
+ "terminate running processes?" sheet and stays, saying nothing - which is
131
+ how a closed screen used to stay on the desktop. So the processes on that
132
+ window's own terminal are ended first. They are the script this module
133
+ started and the shell around it, found by the tab's tty, so nothing of
134
+ the operator's is touched.
135
+ """
136
+ application = "Safari" if kind == "page" else "Terminal"
137
+ if not _exists(application, handle):
138
+ return
139
+ if kind == "terminal":
140
+ tty = _osascript(f'tell application "Terminal" to get tty of selected tab of '
141
+ f'(first window whose id is {int(handle)})')
142
+ if tty.startswith("/dev/tty"):
143
+ subprocess.run(["pkill", "-t", tty.removeprefix("/dev/")], check=False)
144
+ time.sleep(0.5)
145
+ _osascript(f'tell application "{application}" to close '
146
+ f'(first window whose id is {int(handle)})')
147
+ deadline = time.monotonic() + patience
148
+ while time.monotonic() < deadline:
149
+ if not _exists(application, handle):
150
+ return
151
+ time.sleep(0.25)
152
+ raise ScreenError(f"{application} window {handle} is still open after closing it")