prettyplay 0.0.2__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.
Files changed (75) hide show
  1. prettyplay/.usages/lifecycle.md +117 -0
  2. prettyplay/.usages/steps.md +169 -0
  3. prettyplay/__init__.py +18 -0
  4. prettyplay/cache/.usages/addressing.md +44 -0
  5. prettyplay/cache/.usages/budgets.md +51 -0
  6. prettyplay/cache/.usages/storage.md +32 -0
  7. prettyplay/cache/__init__.py +8 -0
  8. prettyplay/cache/budgets.py +93 -0
  9. prettyplay/cache/models.py +55 -0
  10. prettyplay/cache/store.py +276 -0
  11. prettyplay/cache/text.py +36 -0
  12. prettyplay/config/.usages/configuration.md +246 -0
  13. prettyplay/config/__init__.py +6 -0
  14. prettyplay/config/loader.py +336 -0
  15. prettyplay/config/models.py +288 -0
  16. prettyplay/driver/.usages/error_kinds.md +26 -0
  17. prettyplay/driver/.usages/plumbing.md +52 -0
  18. prettyplay/driver/__init__.py +11 -0
  19. prettyplay/driver/errors.py +76 -0
  20. prettyplay/driver/page.py +175 -0
  21. prettyplay/driver/session.py +330 -0
  22. prettyplay/engine/.usages/generation.md +157 -0
  23. prettyplay/engine/.usages/healing.md +61 -0
  24. prettyplay/engine/__init__.py +19 -0
  25. prettyplay/engine/attempts.py +80 -0
  26. prettyplay/engine/classification.py +70 -0
  27. prettyplay/engine/compliance.py +125 -0
  28. prettyplay/engine/execution.py +48 -0
  29. prettyplay/engine/generator.py +1110 -0
  30. prettyplay/engine/groups/.usages/recovery.md +71 -0
  31. prettyplay/engine/groups/__init__.py +7 -0
  32. prettyplay/engine/groups/diagnosis.py +128 -0
  33. prettyplay/engine/groups/outcome.py +55 -0
  34. prettyplay/engine/groups/recovery.py +491 -0
  35. prettyplay/engine/healer.py +149 -0
  36. prettyplay/engine/polling/.usages/settle.md +55 -0
  37. prettyplay/engine/polling/__init__.py +6 -0
  38. prettyplay/engine/polling/settle.py +98 -0
  39. prettyplay/engine/polling/window.py +72 -0
  40. prettyplay/engine/renderer/.usages/memory.md +24 -0
  41. prettyplay/engine/renderer/.usages/rendering.md +44 -0
  42. prettyplay/engine/renderer/.usages/validation.md +20 -0
  43. prettyplay/engine/renderer/__init__.py +7 -0
  44. prettyplay/engine/renderer/memory.py +37 -0
  45. prettyplay/engine/renderer/render.py +184 -0
  46. prettyplay/engine/renderer/validation.py +76 -0
  47. prettyplay/engine/steering/.usages/steering.md +95 -0
  48. prettyplay/engine/steering/__init__.py +5 -0
  49. prettyplay/engine/steering/steering.py +765 -0
  50. prettyplay/engine/text.py +41 -0
  51. prettyplay/executor.py +654 -0
  52. prettyplay/failures/.usages/taxonomy.md +131 -0
  53. prettyplay/failures/__init__.py +25 -0
  54. prettyplay/failures/errors.py +359 -0
  55. prettyplay/groups.py +276 -0
  56. prettyplay/llm/.usages/classification.md +31 -0
  57. prettyplay/llm/.usages/providers.md +127 -0
  58. prettyplay/llm/.usages/transport.md +81 -0
  59. prettyplay/llm/__init__.py +38 -0
  60. prettyplay/llm/_request.py +823 -0
  61. prettyplay/llm/anthropic_provider.py +468 -0
  62. prettyplay/llm/models.py +273 -0
  63. prettyplay/llm/openai_provider.py +444 -0
  64. prettyplay/llm/provider.py +279 -0
  65. prettyplay/reporting/.usages/hooks.md +61 -0
  66. prettyplay/reporting/__init__.py +6 -0
  67. prettyplay/reporting/hooks.py +47 -0
  68. prettyplay/reporting/reporter.py +70 -0
  69. prettyplay/runtime.py +81 -0
  70. prettyplay/scenario.py +469 -0
  71. prettyplay-0.0.2.dist-info/METADATA +515 -0
  72. prettyplay-0.0.2.dist-info/RECORD +75 -0
  73. prettyplay-0.0.2.dist-info/WHEEL +5 -0
  74. prettyplay-0.0.2.dist-info/licenses/LICENSE +28 -0
  75. prettyplay-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,276 @@
1
+ """Repository store of cached steps: deterministic addressing, atomic best-effort writes."""
2
+
3
+ import ast
4
+ import contextlib
5
+ import os
6
+ import tempfile
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from ..config import Config
11
+ from ..reporting import StepReporter
12
+ from .models import CachedStep, StepIdentity
13
+
14
+ #: Header constants of a cache file: metadata first, then the step code.
15
+ _HEADER_FIELDS = ("STEP_TEXT", "CACHE_KEY", "STEP_TYPE", "CREATED_AT")
16
+
17
+ #: Line marking the start of the step code tail; the surrounding newlines keep
18
+ #: the match out of the header (repr escapes newlines, so a raw newline inside
19
+ #: the header literals is impossible) and out of the loaded code.
20
+ _CODE_SENTINEL = "\n# --- step code ---\n"
21
+
22
+ #: Legacy marker of the step code tail: files written before the sentinel carry
23
+ #: the code starting at ``def step(`` (the old prompt forbade top-level imports).
24
+ _STEP_MARKER = "\ndef step("
25
+
26
+ #: Replace attempts and backoff for a busy target (Windows keeps the file open).
27
+ _REPLACE_ATTEMPTS = 3
28
+ _REPLACE_BACKOFF_SECONDS = 0.1
29
+
30
+
31
+ class StepCache:
32
+ """The repository store of cache steps: addressing, atomic writes and the read-only mode.
33
+
34
+ Attributes:
35
+ _root: the cache root from the settings (absolute after ``load_config``).
36
+ _subdir: the optional subdirectory; part of the address, so steps of
37
+ different subdirectories never collide.
38
+ _reporter: the visibility point for the cache events.
39
+ _writable: the lazily probed writability flag; ``None`` until first use.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ config: Config,
45
+ path: str | None = None,
46
+ reporter: StepReporter | None = None,
47
+ ) -> None:
48
+ """Keep the settings, the optional subdirectory and the reporter.
49
+
50
+ Args:
51
+ config: project settings; the ``cache_root`` setting is the cache root.
52
+ path: the optional subdirectory inside the cache; ``None`` — shared root.
53
+ reporter: the visibility point — cache events go through it; a
54
+ missing reporter falls back to the hook-less default reporter,
55
+ so construction and saves never crash on it.
56
+
57
+ Raises:
58
+ ValueError: ``path`` is absolute or escapes the cache root.
59
+ """
60
+ self._root = Path(config.cache_root)
61
+ self._subdir = self._validated_subdir(path)
62
+ self._reporter = reporter if reporter is not None else StepReporter(hooks=[])
63
+ self._writable: bool | None = None
64
+
65
+ @property
66
+ def root(self) -> str:
67
+ """Return the effective cache root."""
68
+ return str(self._root)
69
+
70
+ @property
71
+ def writable(self) -> bool:
72
+ """Return whether the cache directory accepts writes.
73
+
74
+ Returns:
75
+ Whether writes are accepted — probed lazily once: the target
76
+ directory is created, ``os.access`` checked; any ``OSError``
77
+ counts as read-only.
78
+ """
79
+ if self._writable is None:
80
+ try:
81
+ target_dir = self._target_dir()
82
+ target_dir.mkdir(parents=True, exist_ok=True)
83
+ self._writable = os.access(target_dir, os.W_OK)
84
+ except OSError:
85
+ self._writable = False
86
+
87
+ return self._writable
88
+
89
+ def load(self, identity: StepIdentity) -> CachedStep | None:
90
+ """Load the cached step by address; any structural damage is a miss, not a failure.
91
+
92
+ Args:
93
+ identity: the address of the step.
94
+
95
+ Returns:
96
+ The cached step, or ``None`` when the file is missing, damaged, or
97
+ its metadata does not match the address.
98
+ """
99
+ target = self._target_dir() / identity.filename
100
+ if not target.exists():
101
+ return None
102
+
103
+ try:
104
+ text = target.read_text(encoding="utf-8")
105
+ fields = _parse_header(text)
106
+
107
+ sentinel = text.find(_CODE_SENTINEL)
108
+ if sentinel != -1:
109
+ code = text[sentinel + len(_CODE_SENTINEL) :]
110
+ else:
111
+ marker = text.find(_STEP_MARKER) # a file written before the sentinel
112
+ if marker == -1:
113
+ raise KeyError("def step(")
114
+ code = text[marker + 1 :]
115
+
116
+ if fields["CACHE_KEY"] != identity.cache_key or fields["STEP_TYPE"] != identity.step_type:
117
+ return None
118
+ if fields["STEP_TEXT"] != identity.normalized_text:
119
+ return None
120
+
121
+ return CachedStep(identity=identity, code=code, created_at=fields["CREATED_AT"])
122
+ except (ValueError, SyntaxError, KeyError, IndexError, OSError):
123
+ return None # cache corruption never cripples the run
124
+
125
+ def save(self, step: CachedStep) -> bool:
126
+ """Store the step atomically, best-effort: no write error ever fails the run.
127
+
128
+ Args:
129
+ step: the working step to store.
130
+
131
+ Returns:
132
+ Whether the step was stored: ``True`` — the atomic replace
133
+ landed; ``False`` — the write was skipped (a read-only cache or
134
+ a busy target; the ``on_cache_skipped`` event carries the
135
+ reason).
136
+ """
137
+ if not self.writable:
138
+ self._emit_skipped(step, "read-only cache")
139
+ return False
140
+
141
+ body = _serialize(step)
142
+ try:
143
+ handle, tmp_name = tempfile.mkstemp(dir=self._target_dir(), prefix=".tmp-", suffix=".py")
144
+ except OSError:
145
+ self._emit_skipped(step, "cache target busy")
146
+ return False
147
+
148
+ try:
149
+ with os.fdopen(handle, "w", encoding="utf-8") as tmp_file:
150
+ tmp_file.write(body)
151
+ tmp_file.flush()
152
+ os.fsync(tmp_file.fileno())
153
+ except (OSError, ValueError): # ValueError: unencodable text (e.g. surrogates)
154
+ _remove_quietly(tmp_name)
155
+ self._emit_skipped(step, "cache target busy")
156
+ return False
157
+
158
+ for _ in range(_REPLACE_ATTEMPTS):
159
+ try:
160
+ os.replace(tmp_name, self._target_dir() / step.identity.filename)
161
+ break
162
+ except PermissionError:
163
+ time.sleep(_REPLACE_BACKOFF_SECONDS) # Windows: target busy
164
+ except (OSError, ValueError): # ValueError: unencodable address (e.g. surrogates)
165
+ _remove_quietly(tmp_name)
166
+ self._emit_skipped(step, "cache target busy")
167
+ return False
168
+ else:
169
+ _remove_quietly(tmp_name)
170
+ self._emit_skipped(step, "cache target busy")
171
+ return False
172
+
173
+ self._reporter.emit(
174
+ "on_cache_saved",
175
+ {"step_text": step.identity.normalized_text, "filename": step.identity.filename},
176
+ )
177
+ return True
178
+
179
+ def _target_dir(self) -> Path:
180
+ """Return the directory holding the steps of this address."""
181
+ return self._root / self._subdir if self._subdir else self._root
182
+
183
+ @staticmethod
184
+ def _validated_subdir(path: str | None) -> Path | None:
185
+ """Return the subdirectory path, rejecting addresses outside the cache root.
186
+
187
+ Args:
188
+ path: the integrator-supplied subdirectory; ``None`` — shared root.
189
+
190
+ Returns:
191
+ The validated relative subdirectory, or ``None`` for the shared root.
192
+
193
+ Raises:
194
+ ValueError: the path is absolute or resolves outside the cache root.
195
+ """
196
+ if path is None or path == "":
197
+ return None
198
+
199
+ subdir = Path(path)
200
+
201
+ if subdir.is_absolute():
202
+ raise ValueError(f"cache path must be a subdirectory, got absolute {path!r}")
203
+ if ".." in subdir.parts:
204
+ raise ValueError(f"cache path must stay inside the cache root, got {path!r}")
205
+
206
+ return subdir
207
+
208
+ def _emit_skipped(self, step: CachedStep, reason: str) -> None:
209
+ """Report a skipped write through the visibility point."""
210
+ self._reporter.emit(
211
+ "on_cache_skipped",
212
+ {"step_text": step.identity.normalized_text, "reason": reason},
213
+ )
214
+
215
+
216
+ def _parse_header(text: str) -> dict[str, str]:
217
+ """Parse the header constants of a cache file into a field map.
218
+
219
+ Args:
220
+ text: the whole cache file text.
221
+
222
+ Returns:
223
+ The map of the header constants found in the module.
224
+
225
+ Raises:
226
+ SyntaxError: the file is not a parseable Python module.
227
+ ValueError: a header value is not a literal.
228
+ KeyError: any of the header constants is missing.
229
+ """
230
+ module = ast.parse(text)
231
+
232
+ fields: dict[str, str] = {}
233
+ for node in module.body:
234
+ if not isinstance(node, ast.Assign) or len(node.targets) != 1:
235
+ continue
236
+
237
+ target = node.targets[0]
238
+
239
+ if isinstance(target, ast.Name) and target.id in _HEADER_FIELDS:
240
+ fields[target.id] = ast.literal_eval(node.value)
241
+
242
+ missing = [name for name in _HEADER_FIELDS if name not in fields]
243
+
244
+ if missing:
245
+ raise KeyError(", ".join(missing))
246
+
247
+ return fields
248
+
249
+
250
+ def _serialize(step: CachedStep) -> str:
251
+ """Serialize a step into the module text: metadata literals, the sentinel line, the code.
252
+
253
+ Args:
254
+ step: the step to serialize.
255
+
256
+ Returns:
257
+ The text of a valid Python module carrying the step: the sentinel
258
+ line marks where the code tail starts, so ``load`` restores the code
259
+ verbatim — top-level imports included.
260
+ """
261
+ header = "".join(
262
+ f"{name} = {value!r}\n"
263
+ for name, value in (
264
+ ("STEP_TEXT", step.identity.normalized_text),
265
+ ("CACHE_KEY", step.identity.cache_key),
266
+ ("STEP_TYPE", step.identity.step_type),
267
+ ("CREATED_AT", step.created_at),
268
+ )
269
+ )
270
+ return header + _CODE_SENTINEL + step.code + "\n"
271
+
272
+
273
+ def _remove_quietly(path: str) -> None:
274
+ """Remove a temporary file, ignoring a failure: the write is already skipped."""
275
+ with contextlib.suppress(OSError):
276
+ Path(path).unlink()
@@ -0,0 +1,36 @@
1
+ """Normalization of step sentences for cache identity and addressing."""
2
+
3
+ import re
4
+ import unicodedata
5
+
6
+ #: Pattern of any whitespace run, collapsed to a single space by :func:`normalize_step_text`.
7
+ _WHITESPACE_RUN = re.compile(r"\s+")
8
+
9
+ #: Jinja template markers — any occurrence switches the sentence to verbatim addressing.
10
+ _TEMPLATE_MARKERS = ("{{", "{%", "{#")
11
+
12
+
13
+ def normalize_step_text(text: str) -> str:
14
+ """Normalize a step sentence for identity and addressing — template-aware.
15
+
16
+ Args:
17
+ text: the raw step sentence as written by the engineer.
18
+
19
+ Returns:
20
+ The normalized sentence. A template sentence (containing ``{{``,
21
+ ``{%`` or ``{#``) keeps its source verbatim except NFC normalization
22
+ and trimming — Jinja names are case-sensitive and whitespace inside
23
+ expressions is significant. An ordinary sentence keeps the
24
+ equivalence pipeline: NFC, trimmed, whitespace runs collapsed,
25
+ casefolded.
26
+ """
27
+ normalized = unicodedata.normalize("NFC", text)
28
+
29
+ normalized = normalized.strip()
30
+
31
+ if any(marker in normalized for marker in _TEMPLATE_MARKERS):
32
+ return normalized
33
+
34
+ normalized = _WHITESPACE_RUN.sub(" ", normalized)
35
+
36
+ return normalized.casefold()
@@ -0,0 +1,246 @@
1
+ # Project configuration
2
+
3
+ Domain: prettyplay settings. Audience: integrators configuring a test project and CI.
4
+
5
+ The immutable part of the settings lives in the [tool.prettyplay] section of pyproject.toml. Load it once per test; a test can additionally override specific values programmatically through PrettyConfig.
6
+
7
+ ```toml
8
+ [tool.prettyplay]
9
+ provider = "openai"
10
+ model = "gpt-5"
11
+ generation_model = "" # optional: empty -> model
12
+ classification_model = "" # optional: empty -> model
13
+ base_url = ""
14
+ cache_root = "" # empty -> <cwd>/.prettyplay/cache/
15
+ generation_attempts = 3
16
+ healing_attempts = 2
17
+ llm_request_attempts = 3 # total sends per LLM request; 1 disables request retries
18
+ send_screenshots = false
19
+ strict = false # replay-only mode; default false
20
+ polling_timeout = 6.0 # None (default) — polling off; 0 — explicit disable; >0 — settle window seconds
21
+ polling_delay = 0.5 # pause between re-executions, seconds
22
+ interactive = false # opt-in steering REPL; default false
23
+ generation_prompt = "" # user instructions for generation requests; empty -> no block
24
+ generation_approve = true # the instruction compliance gate before caching; false -> never runs (the old behavior)
25
+ classification_prompt = "" # user instructions for classification requests; empty -> no block
26
+
27
+ [tool.prettyplay.browser]
28
+ name = "chromium" # chromium | firefox | webkit | chrome | msedge
29
+ screen = "" # "" | WxH | fullscreen | Playwright device name
30
+ headless = true # false — run with a visible window
31
+ endpoint = "" # ws endpoint of a remote browser; empty -> local launch
32
+ accept_dialogs = false # true — accept (else dismiss) dialogs no in-step capture claims, at the run-unit tail
33
+ speed = 100 # pace of the run: 0–100 %, 100 — full speed (default)
34
+ ```
35
+
36
+ ## Environment overrides
37
+
38
+ Every setting has an override for CI — env variable PRETTYPLAY_<SETTING> in upper case; the browser group keeps flat env names:
39
+
40
+ | Setting | Env override |
41
+ |---|---|
42
+ | provider | PRETTYPLAY_PROVIDER |
43
+ | browser.name | PRETTYPLAY_BROWSER_NAME |
44
+ | browser.screen | PRETTYPLAY_BROWSER_SCREEN |
45
+ | browser.headless | PRETTYPLAY_BROWSER_HEADLESS |
46
+ | browser.endpoint | PRETTYPLAY_BROWSER_ENDPOINT |
47
+ | browser.accept_dialogs | PRETTYPLAY_BROWSER_ACCEPT_DIALOGS |
48
+ | browser.speed | PRETTYPLAY_BROWSER_SPEED |
49
+ | model | PRETTYPLAY_MODEL |
50
+ | generation_model | PRETTYPLAY_GENERATION_MODEL |
51
+ | classification_model | PRETTYPLAY_CLASSIFICATION_MODEL |
52
+ | base_url | PRETTYPLAY_BASE_URL |
53
+ | cache_root | PRETTYPLAY_CACHE_ROOT |
54
+ | generation_attempts | PRETTYPLAY_GENERATION_ATTEMPTS |
55
+ | healing_attempts | PRETTYPLAY_HEALING_ATTEMPTS |
56
+ | llm_request_attempts | PRETTYPLAY_LLM_REQUEST_ATTEMPTS |
57
+ | send_screenshots | PRETTYPLAY_SEND_SCREENSHOTS |
58
+ | strict | PRETTYPLAY_STRICT |
59
+ | polling_timeout | PRETTYPLAY_POLLING_TIMEOUT |
60
+ | polling_delay | PRETTYPLAY_POLLING_DELAY |
61
+ | interactive | PRETTYPLAY_INTERACTIVE |
62
+ | generation_prompt | PRETTYPLAY_GENERATION_PROMPT |
63
+ | generation_approve | PRETTYPLAY_GENERATION_APPROVE |
64
+ | classification_prompt | PRETTYPLAY_CLASSIFICATION_PROMPT |
65
+
66
+ ## Old flat keys are gone — hard break
67
+
68
+ `browser`, `headless` and `browser_endpoint` at the [tool.prettyplay] level no longer exist (pre-1.0 break). A config carrying them fails loudly at load: the error names each old key and its new home — `browser` → `[tool.prettyplay.browser] name`, `headless` → `[tool.prettyplay.browser] headless`, `browser_endpoint` → `[tool.prettyplay.browser] endpoint`. The removed legacy env name `PRETTYPLAY_BROWSER` is rejected the same way — the error names `PRETTYPLAY_BROWSER_NAME` as its new home. Migrate both the file keys and the env name before upgrading.
69
+
70
+ ## Per-test overrides — layered merge
71
+
72
+ PrettyConfig is the public name of the full settings model. A config passed to the test object carries only the explicitly set values; everything else resolves from pyproject+env:
73
+
74
+ ```python
75
+ from prettyplay import PrettyPlay, PrettyConfig, BrowserConfig
76
+
77
+ test = PrettyPlay(
78
+ cache_key="login-flow",
79
+ config=PrettyConfig(
80
+ strict=True,
81
+ polling_timeout=8.0,
82
+ interactive=True,
83
+ browser=BrowserConfig(screen="fullscreen", headless=False),
84
+ ),
85
+ )
86
+ ```
87
+
88
+ - An explicitly set field wins over pyproject+env; a field left at its default falls back to the file layer
89
+ - The merge reaches inside the nested group: explicitly set fields of a passed BrowserConfig win over the file layer; untouched group defaults never overwrite file values
90
+ - `strict` and `interactive` participate when passed explicitly — an explicit False overrides the file value too
91
+ - `polling_timeout` passed as None is indistinguishable from unset — disable polling for one test with `0.0`
92
+ - File values you did not touch survive: base_url and model set only in pyproject.toml keep working when a config is passed
93
+ - One model — one place of validation: file and programmatic values validate identically
94
+
95
+ ## Settle polling
96
+
97
+ `polling_timeout` is the total settle horizon of one step execution — the window starts with the first execution of
98
+ the step's code (cached or candidate); the step code's own auto-waits count inside it. A transient failure of a pollable
99
+ kind with time remaining re-executes the same code after `polling_delay` until success or window end — no LLM budget
100
+ is consumed, attempts are visible as settle_retry log records. Locator ambiguity and Python-level errors of the step
101
+ code never poll. Polling is opt-in: the default `None` (and `0`) keeps it off; polling applies in strict replay too —
102
+ re-executing cached code is execution, not generation.
103
+
104
+ ## Request retries of the LLM port
105
+
106
+ `llm_request_attempts` (default 3, env PRETTYPLAY_LLM_REQUEST_ATTEMPTS, per-test override) caps
107
+ the physical sends of one LLM request — generation, failure classification, group diagnosis and
108
+ the compliance verdict alike: the initial request included, retries of transient provider
109
+ failures (connection, timeout, 408/429, 5xx) happen inside the request with fixed delays and
110
+ never consume generation or healing budgets.
111
+
112
+ ```python
113
+ from prettyplay import PrettyConfig, PrettyPlay
114
+
115
+ test = PrettyPlay(
116
+ cache_key="flaky-ci",
117
+ config=PrettyConfig(llm_request_attempts=5), # tolerate a longer provider outage
118
+ )
119
+ ```
120
+
121
+ - `1` disables request retries — fully the old single-send behavior
122
+ - a value below `1` fails loudly at configuration load — never a silent ignore
123
+ - the retry delays are fixed policy (1–10 s with jitter and the Retry-After rule) — not
124
+ configurable; only the attempt count is
125
+
126
+ ## Pace
127
+
128
+ `speed` of the browser group (a percentage, 0–100 inclusive, default 100) controls how fast the
129
+ browser executes the run: 100 — full speed, exactly the default behavior; lower values slow the
130
+ run down linearly — 0 is the slowest supported pace. It is an ordinary layered setting — file,
131
+ env (PRETTYPLAY_BROWSER_SPEED), per-test override:
132
+
133
+ ```python
134
+ from prettyplay import BrowserConfig, PrettyConfig, PrettyPlay
135
+
136
+ test = PrettyPlay(
137
+ cache_key="demo",
138
+ config=PrettyConfig(browser=BrowserConfig(speed=40)),
139
+ )
140
+ ```
141
+
142
+ - One value per test, fixed for the whole run — it applies at browser start in every launch mode
143
+ (local headed, local headless, remote connect); replays and strict runs take it identically
144
+ - An out-of-range or malformed value fails at configuration load — the error names the setting,
145
+ the received value and the allowed range; never a silent ignore
146
+
147
+ ## Interactive steering
148
+
149
+ `interactive` (default false, env PRETTYPLAY_INTERACTIVE, per-test override) enables the steering REPL: when a step
150
+ terminally fails with IncurableStepError on a non-strict run, a terminal dialog opens showing the step, the failed
151
+ code, the error and the verdict; each engineer message drives one regeneration executed against the live page. Opt-in
152
+ by design — an accidentally enabled REPL must never hang CI. This is the steering dialog of a stuck step; it is
153
+ unrelated to interactive hosts (IPython, Jupyter) — see the library lifecycle docs. The REPL never opens on
154
+ product_defect, in strict replay, or when the LLM is unavailable; quit/EOF/SIGINT/unreadable stdin raises the
155
+ original terminal failure.
156
+
157
+ ## The instruction compliance gate
158
+
159
+ The user instructions of `generation_prompt` are binding for generated step code: every
160
+ successfully executed candidate passes an independent compliance check before it is
161
+ cached. The gate is the default; switch it off consciously when the extra LLM call per
162
+ successful generation matters more than the enforcement.
163
+
164
+ ```toml
165
+ [tool.prettyplay]
166
+ generation_prompt = "Prefer id attributes for locating elements"
167
+ generation_approve = true # default; false -> the gate never runs (the old behavior)
168
+ ```
169
+
170
+ Env override (booleans: true/false/1/0, case-insensitive; anything else fails loudly):
171
+
172
+ ```bash
173
+ export PRETTYPLAY_GENERATION_APPROVE=false
174
+ ```
175
+
176
+ Per-test override — an explicit False wins over the file layer:
177
+
178
+ ```python
179
+ config = PrettyConfig(generation_approve=False)
180
+ scenario = PrettyPlay(cache_key="smoke", config=config)
181
+ ```
182
+
183
+ Notes for the engineer:
184
+ - while the gate is on, every successful generation costs one extra LLM call (the verdict
185
+ request) through the effective classification model
186
+ - a `high` finding fails the attempt and the retry carries the violation text — loud
187
+ errors instead of silent ignoring
188
+ - changing `generation_prompt` does not invalidate cached steps: the step address stays
189
+ instruction-free, so purge the cache manually after changing instructions — replayed
190
+ cached code is never re-gated
191
+ - the gate never runs when `generation_prompt` is empty, regardless of the switch
192
+
193
+ ## Browsers
194
+
195
+ The browser matrix: chromium, firefox, webkit (Playwright-bundled engines) plus chrome and msedge — channels that launch the locally installed browser through the chromium engine. A channel requires the real browser installed on the machine; a missing browser fails loudly with an actionable message.
196
+
197
+ ## Screen modes
198
+
199
+ The `screen` field of the browser group is the single size setting:
200
+
201
+ | Value | Meaning |
202
+ |---|---|
203
+ | "" | Playwright default — the current behavior |
204
+ | 1280x720 | fixed viewport WxH — pinned in every launch mode |
205
+ | fullscreen | the viewport follows the window on a local headed launch — the chromium-family engines (chromium, chrome, msedge) start maximized via `--start-maximized`; firefox and webkit keep the plain window; a fixed 1920x1080 viewport under headless and remote connects (no window exists there) |
206
+ | iPhone 13 | a Playwright device name — mobile emulation via the full descriptor: viewport, user agent, touch, is_mobile, device scale factor |
207
+
208
+ - WxH and device descriptors apply in every launch mode: local headed, local headless, remote connect
209
+ - An unknown device name fails loudly with an actionable message suggesting close device names; device names resolve against the devices registry of the running Playwright — the package never hard-codes a device list
210
+ - A WxH-shaped value with non-positive numbers fails validation at load; any other string passes through as a device name
211
+
212
+ ## Dialogs
213
+
214
+ `accept_dialogs` of the browser group controls how the resolver of last resort settles unclaimed dialogs:
215
+
216
+ - `true` — every dialog that no in-step stock dialog capture claims is accepted
217
+ - `false` (default) — unclaimed dialogs are dismissed (the Playwright default outcome)
218
+ - The resolution runs at the tail of the driver-thread run unit, not at the moment the dialog fires: a dialog unclaimed by the step blocks the page until the unit ends, which can fail the remainder of the step
219
+ - A dialog claimed by a step's in-step stock capture is accepted or dismissed by the step itself — the setting does not apply to captured dialogs
220
+
221
+ ## Strict mode
222
+
223
+ `strict = true` (env PRETTYPLAY_STRICT, per-test override) switches the run to replay-only: cached code executes honestly and nothing is ever (re)generated. A cache miss fails as an incurable step; a failed cached step is at most classified — never regenerated. Settle polling applies to cached code (re-execution, not generation). Classification is the only LLM call strict mode makes; without LLM access the failure raises immediately by step type.
224
+
225
+ ## Remote execution
226
+
227
+ A non-empty browser.endpoint switches the test to connecting over the Playwright ws endpoint — a Playwright Server or a hosted browser grid. The endpoint is an address, not a secret: it is valid in the config file; CI rotation goes through the env override. An empty endpoint keeps the local launch; headless does not apply to a connect — window visibility is controlled by the endpoint server.
228
+
229
+ ## Rules
230
+
231
+ - LLM API keys are never stored in the config file — secrets come only from environment variables: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
232
+ - Invalid configuration fails loudly: ConfigurationError names the setting, the received value and the allowed values; the raw pydantic error stays chained for debugging
233
+ - The provider set: openai, anthropic; the browser name set: chromium, firefox, webkit, chrome, msedge
234
+ - A non-empty browser.endpoint must be a valid ws/wss URL
235
+ - The cache root default: <cwd>/.prettyplay/cache/ — anchored at the working directory of the run, wherever the pyproject.toml was found
236
+ - polling_timeout is None or non-negative; polling_delay is non-negative — violations fail loudly at load
237
+ - generation_prompt reaches generation and regeneration requests only; classification_prompt reaches classification requests only — neither ever invalidates the cache
238
+
239
+ ## Loading
240
+
241
+ ```python
242
+ from prettyplay.config import load_config
243
+
244
+ config = load_config(pyproject_path=None) # locates pyproject.toml upwards from the current directory
245
+ print(config.browser.name, config.polling_timeout, config.interactive)
246
+ ```
@@ -0,0 +1,6 @@
1
+ """Facade of the prettyplay.config cell: validated project settings."""
2
+
3
+ from .loader import ConfigurationError, load_config
4
+ from .models import BrowserConfig, Config, PrettyConfig
5
+
6
+ __all__ = ["BrowserConfig", "Config", "ConfigurationError", "PrettyConfig", "load_config"]