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.
- prettyplay/.usages/lifecycle.md +117 -0
- prettyplay/.usages/steps.md +169 -0
- prettyplay/__init__.py +18 -0
- prettyplay/cache/.usages/addressing.md +44 -0
- prettyplay/cache/.usages/budgets.md +51 -0
- prettyplay/cache/.usages/storage.md +32 -0
- prettyplay/cache/__init__.py +8 -0
- prettyplay/cache/budgets.py +93 -0
- prettyplay/cache/models.py +55 -0
- prettyplay/cache/store.py +276 -0
- prettyplay/cache/text.py +36 -0
- prettyplay/config/.usages/configuration.md +246 -0
- prettyplay/config/__init__.py +6 -0
- prettyplay/config/loader.py +336 -0
- prettyplay/config/models.py +288 -0
- prettyplay/driver/.usages/error_kinds.md +26 -0
- prettyplay/driver/.usages/plumbing.md +52 -0
- prettyplay/driver/__init__.py +11 -0
- prettyplay/driver/errors.py +76 -0
- prettyplay/driver/page.py +175 -0
- prettyplay/driver/session.py +330 -0
- prettyplay/engine/.usages/generation.md +157 -0
- prettyplay/engine/.usages/healing.md +61 -0
- prettyplay/engine/__init__.py +19 -0
- prettyplay/engine/attempts.py +80 -0
- prettyplay/engine/classification.py +70 -0
- prettyplay/engine/compliance.py +125 -0
- prettyplay/engine/execution.py +48 -0
- prettyplay/engine/generator.py +1110 -0
- prettyplay/engine/groups/.usages/recovery.md +71 -0
- prettyplay/engine/groups/__init__.py +7 -0
- prettyplay/engine/groups/diagnosis.py +128 -0
- prettyplay/engine/groups/outcome.py +55 -0
- prettyplay/engine/groups/recovery.py +491 -0
- prettyplay/engine/healer.py +149 -0
- prettyplay/engine/polling/.usages/settle.md +55 -0
- prettyplay/engine/polling/__init__.py +6 -0
- prettyplay/engine/polling/settle.py +98 -0
- prettyplay/engine/polling/window.py +72 -0
- prettyplay/engine/renderer/.usages/memory.md +24 -0
- prettyplay/engine/renderer/.usages/rendering.md +44 -0
- prettyplay/engine/renderer/.usages/validation.md +20 -0
- prettyplay/engine/renderer/__init__.py +7 -0
- prettyplay/engine/renderer/memory.py +37 -0
- prettyplay/engine/renderer/render.py +184 -0
- prettyplay/engine/renderer/validation.py +76 -0
- prettyplay/engine/steering/.usages/steering.md +95 -0
- prettyplay/engine/steering/__init__.py +5 -0
- prettyplay/engine/steering/steering.py +765 -0
- prettyplay/engine/text.py +41 -0
- prettyplay/executor.py +654 -0
- prettyplay/failures/.usages/taxonomy.md +131 -0
- prettyplay/failures/__init__.py +25 -0
- prettyplay/failures/errors.py +359 -0
- prettyplay/groups.py +276 -0
- prettyplay/llm/.usages/classification.md +31 -0
- prettyplay/llm/.usages/providers.md +127 -0
- prettyplay/llm/.usages/transport.md +81 -0
- prettyplay/llm/__init__.py +38 -0
- prettyplay/llm/_request.py +823 -0
- prettyplay/llm/anthropic_provider.py +468 -0
- prettyplay/llm/models.py +273 -0
- prettyplay/llm/openai_provider.py +444 -0
- prettyplay/llm/provider.py +279 -0
- prettyplay/reporting/.usages/hooks.md +61 -0
- prettyplay/reporting/__init__.py +6 -0
- prettyplay/reporting/hooks.py +47 -0
- prettyplay/reporting/reporter.py +70 -0
- prettyplay/runtime.py +81 -0
- prettyplay/scenario.py +469 -0
- prettyplay-0.0.2.dist-info/METADATA +515 -0
- prettyplay-0.0.2.dist-info/RECORD +75 -0
- prettyplay-0.0.2.dist-info/WHEEL +5 -0
- prettyplay-0.0.2.dist-info/licenses/LICENSE +28 -0
- 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()
|
prettyplay/cache/text.py
ADDED
|
@@ -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"]
|