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,336 @@
|
|
|
1
|
+
"""Loading of the ``[tool.prettyplay]`` section of pyproject.toml with layered overrides."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from ..failures import PrettyplayError
|
|
10
|
+
from .models import BrowserConfig, Config
|
|
11
|
+
|
|
12
|
+
if sys.version_info >= (3, 11):
|
|
13
|
+
import tomllib
|
|
14
|
+
else:
|
|
15
|
+
import tomli as tomllib
|
|
16
|
+
|
|
17
|
+
#: The removed legacy env name; set — the load fails loudly before merging.
|
|
18
|
+
_LEGACY_BROWSER_ENV = "PRETTYPLAY_BROWSER"
|
|
19
|
+
|
|
20
|
+
#: Env variable name of every overridable setting; dotted names address the
|
|
21
|
+
#: browser group, whose flat PRETTYPLAY_BROWSER_* names stay historical.
|
|
22
|
+
_ENV_NAMES: dict[str, str] = {
|
|
23
|
+
setting: f"PRETTYPLAY_{setting.upper().replace('.', '_')}"
|
|
24
|
+
for setting in (
|
|
25
|
+
"provider",
|
|
26
|
+
"model",
|
|
27
|
+
"generation_model",
|
|
28
|
+
"classification_model",
|
|
29
|
+
"base_url",
|
|
30
|
+
"cache_root",
|
|
31
|
+
"generation_prompt",
|
|
32
|
+
"classification_prompt",
|
|
33
|
+
"strict",
|
|
34
|
+
"polling_timeout",
|
|
35
|
+
"polling_delay",
|
|
36
|
+
"interactive",
|
|
37
|
+
"generation_attempts",
|
|
38
|
+
"healing_attempts",
|
|
39
|
+
"llm_request_attempts",
|
|
40
|
+
"send_screenshots",
|
|
41
|
+
"generation_approve",
|
|
42
|
+
"browser.name",
|
|
43
|
+
"browser.screen",
|
|
44
|
+
"browser.headless",
|
|
45
|
+
"browser.endpoint",
|
|
46
|
+
"browser.accept_dialogs",
|
|
47
|
+
"browser.speed",
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#: The settings whose env values parse as booleans.
|
|
52
|
+
_BOOL_ENV_SETTINGS = frozenset(
|
|
53
|
+
{
|
|
54
|
+
"strict",
|
|
55
|
+
"interactive",
|
|
56
|
+
"send_screenshots",
|
|
57
|
+
"generation_approve",
|
|
58
|
+
"browser.headless",
|
|
59
|
+
"browser.accept_dialogs",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
#: The settings whose env values parse as decimal integers.
|
|
64
|
+
_INT_ENV_SETTINGS = frozenset({"generation_attempts", "healing_attempts", "llm_request_attempts", "browser.speed"})
|
|
65
|
+
|
|
66
|
+
#: The settings whose env values parse as decimal floats.
|
|
67
|
+
_FLOAT_ENV_SETTINGS = frozenset({"polling_timeout", "polling_delay"})
|
|
68
|
+
|
|
69
|
+
#: The removed flat keys of the [tool.prettyplay] level and their new group homes.
|
|
70
|
+
_FLAT_KEY_HOMES: dict[str, str] = {
|
|
71
|
+
"browser": "name",
|
|
72
|
+
"headless": "headless",
|
|
73
|
+
"browser_endpoint": "endpoint",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#: The allowed-values text of the settings the validation render names.
|
|
77
|
+
_ALLOWED_TEXT: dict[str, str] = {
|
|
78
|
+
"provider": "openai, anthropic",
|
|
79
|
+
"browser.name": "chromium, firefox, webkit, chrome, msedge",
|
|
80
|
+
"generation_attempts": "a positive integer",
|
|
81
|
+
"healing_attempts": "a positive integer",
|
|
82
|
+
"llm_request_attempts": "a positive integer",
|
|
83
|
+
"browser.headless": "a boolean",
|
|
84
|
+
"browser.accept_dialogs": "a boolean",
|
|
85
|
+
"strict": "a boolean",
|
|
86
|
+
"polling_timeout": "None or a non-negative number (finite)",
|
|
87
|
+
"polling_delay": "a non-negative number",
|
|
88
|
+
"interactive": "a boolean",
|
|
89
|
+
"send_screenshots": "a boolean",
|
|
90
|
+
"generation_approve": "a boolean",
|
|
91
|
+
"model": "a non-empty string",
|
|
92
|
+
"generation_model": "a non-empty string",
|
|
93
|
+
"classification_model": "a non-empty string",
|
|
94
|
+
"base_url": "a non-empty string",
|
|
95
|
+
"cache_root": "a non-empty string",
|
|
96
|
+
"generation_prompt": "a non-empty string",
|
|
97
|
+
"classification_prompt": "a non-empty string",
|
|
98
|
+
"browser.endpoint": "a valid ws/wss URL",
|
|
99
|
+
"browser.screen": "an empty value, WxH (must be positive integers), fullscreen or a Playwright device name",
|
|
100
|
+
"browser.speed": "an integer 0-100 inclusive",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ConfigurationError(PrettyplayError):
|
|
105
|
+
"""An invalid prettyplay configuration: the loaded settings failed validation.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
message: the rendered actionable text — one line per invalid setting
|
|
109
|
+
or per removed old flat key.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _find_pyproject() -> Path:
|
|
114
|
+
"""Return the first existing pyproject.toml upward from the cwd.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Absolute path of the found pyproject.toml.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
FileNotFoundError: when no pyproject.toml exists upward from the cwd.
|
|
121
|
+
"""
|
|
122
|
+
for directory in (Path.cwd(), *Path.cwd().parents):
|
|
123
|
+
candidate = directory / "pyproject.toml"
|
|
124
|
+
if candidate.is_file():
|
|
125
|
+
return candidate
|
|
126
|
+
|
|
127
|
+
raise FileNotFoundError("pyproject.toml not found upward from the current directory")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_env_scalar(setting: str, raw: str) -> object:
|
|
131
|
+
"""Parse one environment override by the field type of the setting.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
setting: the dotted setting name the variable overrides.
|
|
135
|
+
raw: the raw environment value.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The parsed value: a bool, a decimal int, a float or the verbatim string.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ConfigurationError: when the value does not parse — the message names
|
|
142
|
+
the setting, the received value and the accepted form.
|
|
143
|
+
"""
|
|
144
|
+
if setting in _BOOL_ENV_SETTINGS:
|
|
145
|
+
lowered = raw.lower()
|
|
146
|
+
|
|
147
|
+
if lowered in ("true", "1"):
|
|
148
|
+
return True
|
|
149
|
+
if lowered in ("false", "0"):
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
raise ConfigurationError(f"{setting}: received {raw!r} — allowed: a boolean (true/false/1/0)")
|
|
153
|
+
|
|
154
|
+
if setting in _INT_ENV_SETTINGS:
|
|
155
|
+
try:
|
|
156
|
+
return int(raw)
|
|
157
|
+
except ValueError:
|
|
158
|
+
raise ConfigurationError(f"{setting}: received {raw!r} — allowed: a decimal integer") from None
|
|
159
|
+
|
|
160
|
+
if setting in _FLOAT_ENV_SETTINGS:
|
|
161
|
+
try:
|
|
162
|
+
return float(raw)
|
|
163
|
+
except ValueError:
|
|
164
|
+
raise ConfigurationError(f"{setting}: received {raw!r} — allowed: a decimal float") from None
|
|
165
|
+
|
|
166
|
+
return raw
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _collect_env_overrides() -> dict[str, object]:
|
|
170
|
+
"""Collect and parse the set environment overrides of all supported settings.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
ConfigurationError: when a set variable does not parse by its field type.
|
|
174
|
+
"""
|
|
175
|
+
overrides: dict[str, object] = {}
|
|
176
|
+
|
|
177
|
+
for setting, env_name in _ENV_NAMES.items():
|
|
178
|
+
raw = os.environ.get(env_name)
|
|
179
|
+
if raw is not None:
|
|
180
|
+
overrides[setting] = _parse_env_scalar(setting, raw)
|
|
181
|
+
|
|
182
|
+
return overrides
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _render_validation(error: ValidationError) -> str:
|
|
186
|
+
"""Render the pydantic error as actionable per-setting lines.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
error: the pydantic validation failure of the merged settings.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
One line per invalid setting — the dotted setting name, the received
|
|
193
|
+
value and the allowed values or range — joined with newlines; no
|
|
194
|
+
pydantic internals in the user-visible text.
|
|
195
|
+
"""
|
|
196
|
+
lines: list[str] = []
|
|
197
|
+
|
|
198
|
+
for entry in error.errors():
|
|
199
|
+
field = ".".join(str(part) for part in entry["loc"])
|
|
200
|
+
received = entry.get("input")
|
|
201
|
+
|
|
202
|
+
if entry.get("type") == "extra_forbidden":
|
|
203
|
+
lines.append(f"{field}: received {received!r} — not a prettyplay setting")
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
allowed = _ALLOWED_TEXT.get(field, entry["msg"])
|
|
207
|
+
lines.append(f"{field}: received {received!r} — allowed: {allowed}")
|
|
208
|
+
|
|
209
|
+
return "\n".join(lines)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _reject_flat_keys(section: dict) -> None:
|
|
213
|
+
"""Raise when the section carries removed old flat keys.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
section: the raw ``[tool.prettyplay]`` mapping of the file.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
ConfigurationError: one line per present old flat key — the key name
|
|
220
|
+
and its new home inside the ``[tool.prettyplay.browser]`` group.
|
|
221
|
+
A ``browser`` key holding a table is the group itself, not a flat
|
|
222
|
+
key.
|
|
223
|
+
"""
|
|
224
|
+
flat_old = [
|
|
225
|
+
key for key in _FLAT_KEY_HOMES if key in section and not (key == "browser" and isinstance(section[key], dict))
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
if flat_old:
|
|
229
|
+
lines = [
|
|
230
|
+
f"{key}: removed — its new home is [tool.prettyplay.browser] {_FLAT_KEY_HOMES[key]}" for key in flat_old
|
|
231
|
+
]
|
|
232
|
+
raise ConfigurationError("\n".join(lines))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _apply_overrides(file_config: Config, overrides: Config) -> Config:
|
|
236
|
+
"""Overlay the explicitly set fields of ``overrides`` onto the file layer.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
file_config: the validated pyproject+env layer.
|
|
240
|
+
overrides: the programmatic layer; a field participates when it was
|
|
241
|
+
passed at construction, is not None and is non-empty for strings —
|
|
242
|
+
explicitly set values win, untouched model defaults, None and empty
|
|
243
|
+
strings never overwrite the file layer.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
The effective configuration: ``file_config`` with the participating
|
|
247
|
+
overrides applied; the merge reaches inside the browser group —
|
|
248
|
+
explicitly set fields of a passed :class:`BrowserConfig` win over the
|
|
249
|
+
file group, untouched group defaults never overwrite it.
|
|
250
|
+
"""
|
|
251
|
+
update: dict[str, object] = {}
|
|
252
|
+
|
|
253
|
+
for name, value in overrides:
|
|
254
|
+
if name not in overrides.model_fields_set:
|
|
255
|
+
continue
|
|
256
|
+
if value is None: # None means unset (e.g. polling_timeout) — indistinguishable from untouched
|
|
257
|
+
continue
|
|
258
|
+
if isinstance(value, str) and not value:
|
|
259
|
+
continue
|
|
260
|
+
if isinstance(value, BrowserConfig):
|
|
261
|
+
group_update = {
|
|
262
|
+
group_name: group_value
|
|
263
|
+
for group_name, group_value in value
|
|
264
|
+
if group_name in value.model_fields_set and (group_value or not isinstance(group_value, str))
|
|
265
|
+
}
|
|
266
|
+
if group_update:
|
|
267
|
+
update["browser"] = file_config.browser.model_copy(update=group_update)
|
|
268
|
+
# no explicitly set group fields — the file group survives untouched
|
|
269
|
+
else:
|
|
270
|
+
update[name] = value
|
|
271
|
+
|
|
272
|
+
return file_config.model_copy(update=update)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def load_config(pyproject_path: str | None = None, overrides: Config | None = None) -> Config:
|
|
276
|
+
"""Load validated settings layered pyproject → env → explicit programmatic values.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
pyproject_path: explicit pyproject.toml path; ``None`` auto-searches
|
|
280
|
+
upward from the current working directory.
|
|
281
|
+
overrides: the programmatic layer, typically a ``PrettyConfig`` built
|
|
282
|
+
by the integrator; ``None`` (or an instance with no explicitly set
|
|
283
|
+
fields) returns the validated file layer as is — an explicitly set
|
|
284
|
+
field (``model_fields_set``, non-None, non-empty for strings)
|
|
285
|
+
wins over the pyproject+env layer, the merge reaching inside the
|
|
286
|
+
browser group.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Validated configuration.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
FileNotFoundError: when ``pyproject_path`` is ``None`` and no
|
|
293
|
+
pyproject.toml is found upward from the cwd.
|
|
294
|
+
tomllib.TOMLDecodeError: when the file is not valid TOML.
|
|
295
|
+
ConfigurationError: when merged settings fail validation or carry a
|
|
296
|
+
removed legacy name — the original ``ValidationError`` chained as
|
|
297
|
+
the cause when there is one.
|
|
298
|
+
"""
|
|
299
|
+
path = Path(pyproject_path) if pyproject_path is not None else _find_pyproject()
|
|
300
|
+
|
|
301
|
+
with path.open("rb") as stream:
|
|
302
|
+
data = tomllib.load(stream)
|
|
303
|
+
section = data.get("tool", {}).get("prettyplay", {})
|
|
304
|
+
|
|
305
|
+
if _LEGACY_BROWSER_ENV in os.environ:
|
|
306
|
+
raise ConfigurationError(f"{_LEGACY_BROWSER_ENV} is no longer supported: use PRETTYPLAY_BROWSER_NAME")
|
|
307
|
+
|
|
308
|
+
_reject_flat_keys(section)
|
|
309
|
+
|
|
310
|
+
env = _collect_env_overrides()
|
|
311
|
+
|
|
312
|
+
merged = {**section, **{setting: value for setting, value in env.items() if "." not in setting}}
|
|
313
|
+
group_env = {setting.split(".", 1)[1]: value for setting, value in env.items() if "." in setting}
|
|
314
|
+
|
|
315
|
+
if group_env:
|
|
316
|
+
merged["browser"] = {**(merged.get("browser") or {}), **group_env}
|
|
317
|
+
|
|
318
|
+
browser_section = merged.get("browser")
|
|
319
|
+
|
|
320
|
+
if isinstance(browser_section, dict) and browser_section.get("name") == "":
|
|
321
|
+
# empty means unset only in the programmatic overlay; from the file or
|
|
322
|
+
# env layer an empty name is an invalid value, not an omission
|
|
323
|
+
raise ConfigurationError(f"browser.name: received '' — allowed: {_ALLOWED_TEXT['browser.name']}")
|
|
324
|
+
|
|
325
|
+
if not merged.get("cache_root"):
|
|
326
|
+
merged["cache_root"] = str(Path.cwd() / ".prettyplay" / "cache")
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
file_config = Config(**merged)
|
|
330
|
+
except ValidationError as error:
|
|
331
|
+
raise ConfigurationError(_render_validation(error)) from error
|
|
332
|
+
|
|
333
|
+
if overrides is None:
|
|
334
|
+
return file_config
|
|
335
|
+
|
|
336
|
+
return _apply_overrides(file_config, overrides)
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Validated project settings of prettyplay — secrets never live here, LLM API keys come only from the environment."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, PositiveInt, field_validator
|
|
9
|
+
|
|
10
|
+
#: The browser matrix the group ``name`` setting is validated against.
|
|
11
|
+
_BROWSER_MATRIX = ("chromium", "firefox", "webkit", "chrome", "msedge")
|
|
12
|
+
|
|
13
|
+
#: The inclusive pace range, in percent, the group ``speed`` setting accepts.
|
|
14
|
+
_SPEED_RANGE = range(0, 101)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BrowserConfig(BaseModel):
|
|
18
|
+
"""The nested browser group of the project settings — every field defaults empty, load_config fills it.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
name: browser of the {chromium, firefox, webkit, chrome, msedge} set;
|
|
22
|
+
chrome and msedge launch the locally installed browser through the
|
|
23
|
+
driver channel mechanism; default chromium; empty means unset in
|
|
24
|
+
the programmatic overlay — ``load_config`` rejects an empty name
|
|
25
|
+
arriving from the file or env layer.
|
|
26
|
+
screen: the single size setting; empty — the Playwright default; WxH —
|
|
27
|
+
a fixed viewport; fullscreen — the maximized window of a local
|
|
28
|
+
headed launch, a fixed 1920x1080 elsewhere; any other value — a
|
|
29
|
+
Playwright device name applied as the full descriptor. Validated
|
|
30
|
+
at format level only: a WxH-shaped value must carry positive
|
|
31
|
+
integers; every other value — including fullscreen and device
|
|
32
|
+
names — passes through unresolved, because the device registry
|
|
33
|
+
belongs to the running Playwright, never to the config.
|
|
34
|
+
headless: run the browser without a visible window of a local launch;
|
|
35
|
+
default True; ignored on a remote connect.
|
|
36
|
+
endpoint: ws endpoint of a remote browser; empty means the local
|
|
37
|
+
launch.
|
|
38
|
+
accept_dialogs: how the resolver of last resort settles a dialog
|
|
39
|
+
no in-step stock dialog capture claims — True accepts it,
|
|
40
|
+
False dismisses it (the Playwright default outcome); default
|
|
41
|
+
False. The resolution runs at the tail of the driver-thread
|
|
42
|
+
unit, not at the moment the dialog fires: a dialog unclaimed
|
|
43
|
+
by the step blocks the page until the unit ends.
|
|
44
|
+
speed: the pace of the run as a percentage; 100 — full speed, the
|
|
45
|
+
default, a behavior identical to today; lower values slow the
|
|
46
|
+
run linearly; 0 — the slowest supported pace; valid 0-100
|
|
47
|
+
inclusive — an out-of-range or non-integer value fails loudly
|
|
48
|
+
with the received value named, never a silent ignore.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
model_config = ConfigDict(kw_only=True, extra="forbid")
|
|
52
|
+
|
|
53
|
+
name: str = "chromium"
|
|
54
|
+
screen: str = ""
|
|
55
|
+
headless: bool = True
|
|
56
|
+
endpoint: str = ""
|
|
57
|
+
accept_dialogs: bool = False
|
|
58
|
+
speed: int = 100
|
|
59
|
+
|
|
60
|
+
@field_validator("name")
|
|
61
|
+
@classmethod
|
|
62
|
+
def _validate_name(cls, value: str) -> str:
|
|
63
|
+
"""Check that a non-empty name belongs to the browser matrix.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
value: the raw name setting; empty means unset in the programmatic
|
|
67
|
+
overlay and passes through — ``load_config`` rejects an empty
|
|
68
|
+
name arriving from the file or env layer.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The unchanged name when empty or inside the matrix.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: when a non-empty name is outside the matrix.
|
|
75
|
+
"""
|
|
76
|
+
if value and value not in _BROWSER_MATRIX:
|
|
77
|
+
raise ValueError("must be one of: chromium, firefox, webkit, chrome, msedge")
|
|
78
|
+
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
@field_validator("endpoint")
|
|
82
|
+
@classmethod
|
|
83
|
+
def _validate_endpoint(cls, value: str) -> str:
|
|
84
|
+
"""Check that a non-empty endpoint is a ws/wss URL.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
value: the raw endpoint setting; empty means the local launch.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The unchanged endpoint when empty or a valid ws/wss URL.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: when a non-empty endpoint is not a ws/wss URL.
|
|
94
|
+
"""
|
|
95
|
+
if not value:
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
parsed = urlparse(value)
|
|
99
|
+
|
|
100
|
+
if parsed.scheme not in {"ws", "wss"} or not parsed.netloc:
|
|
101
|
+
raise ValueError("must be a valid ws/wss URL")
|
|
102
|
+
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
@field_validator("screen")
|
|
106
|
+
@classmethod
|
|
107
|
+
def _validate_screen(cls, value: str) -> str:
|
|
108
|
+
"""Check the format of a WxH-shaped screen value; pass everything else through.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
value: the raw screen setting; empty, fullscreen and device names
|
|
112
|
+
stay unresolved by design.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The unchanged screen value.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: when a WxH-shaped value carries a non-positive
|
|
119
|
+
width or height.
|
|
120
|
+
"""
|
|
121
|
+
if not value:
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
match = re.fullmatch(r"(\d+)x(\d+)", value)
|
|
125
|
+
if match is None:
|
|
126
|
+
return value # fullscreen, device names — the driver resolves them
|
|
127
|
+
|
|
128
|
+
width, height = int(match.group(1)), int(match.group(2))
|
|
129
|
+
if width <= 0 or height <= 0:
|
|
130
|
+
raise ValueError("must be positive integers in WxH form")
|
|
131
|
+
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
@field_validator("speed", mode="before")
|
|
135
|
+
@classmethod
|
|
136
|
+
def _validate_speed(cls, value: object) -> object:
|
|
137
|
+
"""Check that the pace is an integer 0-100 inclusive.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
value: the raw speed setting; booleans are rejected explicitly
|
|
141
|
+
before the integer check — ``True`` is not a pace.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The unchanged speed when an integer inside the 0-100 range.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: when the value is a boolean, not an integer or
|
|
148
|
+
outside 0-100 — the received value is named, never a
|
|
149
|
+
silent ignore.
|
|
150
|
+
"""
|
|
151
|
+
if isinstance(value, bool) or not isinstance(value, int) or value not in _SPEED_RANGE:
|
|
152
|
+
raise ValueError(f"must be an integer 0-100 inclusive — received {value!r}")
|
|
153
|
+
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Config(BaseModel):
|
|
158
|
+
"""Validated project settings loaded from ``[tool.prettyplay]`` — fields default empty, load_config fills them.
|
|
159
|
+
|
|
160
|
+
Attributes:
|
|
161
|
+
provider: the LLM provider of the {openai, anthropic} set; default openai.
|
|
162
|
+
browser: the nested browser group — a :class:`BrowserConfig` with the
|
|
163
|
+
engine, the screen mode, the window visibility and the remote
|
|
164
|
+
endpoint.
|
|
165
|
+
model: main LLM model name.
|
|
166
|
+
generation_model: optional generation override; empty falls back to ``model``.
|
|
167
|
+
classification_model: optional classification override; empty falls back to ``model``.
|
|
168
|
+
base_url: optional custom LLM API endpoint.
|
|
169
|
+
cache_root: cache root; empty means the default resolved at load.
|
|
170
|
+
generation_prompt: user instructions for generation requests; non-empty
|
|
171
|
+
renders a separate USER INSTRUCTIONS block in generation and
|
|
172
|
+
regeneration requests; empty means no block.
|
|
173
|
+
classification_prompt: user instructions for classification requests;
|
|
174
|
+
non-empty renders a separate USER INSTRUCTIONS block in
|
|
175
|
+
classification requests only; takes no part in the step address;
|
|
176
|
+
empty means no block.
|
|
177
|
+
strict: replay-only mode; True — cached code executes honestly and
|
|
178
|
+
nothing is ever (re)generated; default False.
|
|
179
|
+
polling_timeout: the settle horizon of one step execution in seconds;
|
|
180
|
+
None — polling off (the default); 0 — an explicit disable
|
|
181
|
+
equivalent to None; a finite non-negative number — failed step
|
|
182
|
+
code is re-executed inside the window to absorb transient
|
|
183
|
+
page-state failures. Sizing: the window must exceed the longest
|
|
184
|
+
auto-wait of the step code the engineer wants to absorb — the
|
|
185
|
+
Playwright expect default is 5 s, so 6.0 covers one exhausted
|
|
186
|
+
expectation plus one re-execution; a 30 s action wait consumes any
|
|
187
|
+
sane window alone, polling targets expectation/element-state races.
|
|
188
|
+
polling_delay: the pause between settle re-executions in seconds;
|
|
189
|
+
default 0.5; 0 — repetition without a pause; must be a finite
|
|
190
|
+
non-negative number.
|
|
191
|
+
interactive: open the opt-in steering REPL when an otherwise terminal
|
|
192
|
+
step failure occurs on a non-strict interactive run; default
|
|
193
|
+
False — an accidentally enabled REPL must never hang CI.
|
|
194
|
+
generation_attempts: generation attempt budget per step per test; default 3.
|
|
195
|
+
healing_attempts: healing attempt budget per step per test; default 2.
|
|
196
|
+
llm_request_attempts: the total attempt budget of one LLM request —
|
|
197
|
+
the maximum number of physical request sends of one logical LLM
|
|
198
|
+
attempt of the port (generation, failure classification, group
|
|
199
|
+
diagnosis, compliance verdict alike), the initial request
|
|
200
|
+
included; default 3; 1 — request retries disabled, fully the
|
|
201
|
+
single-send behavior; the retry delays themselves are fixed
|
|
202
|
+
policy, never configurable; participates in the layered merge
|
|
203
|
+
like every other scalar setting.
|
|
204
|
+
send_screenshots: whether screenshots are attached to LLM requests.
|
|
205
|
+
generation_approve: whether the instruction compliance gate runs before
|
|
206
|
+
caching a generated step; True — every successfully executed
|
|
207
|
+
generation and regeneration candidate is verified against the user
|
|
208
|
+
instructions (the non-empty generation_prompt) before it is cached;
|
|
209
|
+
False — the gate never runs, fully the old behavior; default True
|
|
210
|
+
(the opt-out default).
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
model_config = ConfigDict(kw_only=True, extra="forbid")
|
|
214
|
+
|
|
215
|
+
provider: Literal["openai", "anthropic"] = "openai"
|
|
216
|
+
browser: BrowserConfig = BrowserConfig()
|
|
217
|
+
model: str = ""
|
|
218
|
+
generation_model: str = ""
|
|
219
|
+
classification_model: str = ""
|
|
220
|
+
base_url: str = ""
|
|
221
|
+
cache_root: str = ""
|
|
222
|
+
generation_prompt: str = ""
|
|
223
|
+
classification_prompt: str = ""
|
|
224
|
+
strict: bool = False
|
|
225
|
+
polling_timeout: float | None = None
|
|
226
|
+
polling_delay: float = 0.5
|
|
227
|
+
interactive: bool = False
|
|
228
|
+
generation_attempts: PositiveInt = 3
|
|
229
|
+
healing_attempts: PositiveInt = 2
|
|
230
|
+
llm_request_attempts: PositiveInt = 3
|
|
231
|
+
send_screenshots: bool = False
|
|
232
|
+
generation_approve: bool = True
|
|
233
|
+
|
|
234
|
+
@field_validator("polling_timeout")
|
|
235
|
+
@classmethod
|
|
236
|
+
def _validate_polling_timeout(cls, value: float | None) -> float | None:
|
|
237
|
+
"""Check that a set timeout is a finite non-negative number.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
value: the raw timeout setting; None means the settle window is
|
|
241
|
+
off; 0 is an explicit disable and stays legal.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
The unchanged timeout when None or a finite non-negative number.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
ValueError: when the value is negative or non-finite (inf/nan).
|
|
248
|
+
"""
|
|
249
|
+
if value is None:
|
|
250
|
+
return value
|
|
251
|
+
|
|
252
|
+
if not math.isfinite(value) or value < 0:
|
|
253
|
+
raise ValueError("must be None or a non-negative number")
|
|
254
|
+
|
|
255
|
+
return value
|
|
256
|
+
|
|
257
|
+
@field_validator("polling_delay")
|
|
258
|
+
@classmethod
|
|
259
|
+
def _validate_polling_delay(cls, value: float) -> float:
|
|
260
|
+
"""Check that the delay is a finite non-negative number.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
value: the raw delay setting; 0 — repetition without a pause.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
The unchanged delay when finite and non-negative.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
ValueError: when the value is negative or non-finite (inf/nan).
|
|
270
|
+
"""
|
|
271
|
+
if not math.isfinite(value) or value < 0:
|
|
272
|
+
raise ValueError("must be a non-negative number")
|
|
273
|
+
|
|
274
|
+
return value
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def effective_generation_model(self) -> str:
|
|
278
|
+
"""Return generation_model when non-empty, otherwise model."""
|
|
279
|
+
return self.generation_model if self.generation_model else self.model
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def effective_classification_model(self) -> str:
|
|
283
|
+
"""Return classification_model when non-empty, otherwise model."""
|
|
284
|
+
return self.classification_model if self.classification_model else self.model
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
#: The public name of ``Config``: the integrator-facing settings model.
|
|
288
|
+
PrettyConfig = Config
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Error kinds — the pollable map
|
|
2
|
+
|
|
3
|
+
Domain: which failed step-code exceptions the settle window may absorb. Audience: engineers tuning polling and reasoning about why a failure did or did not retry.
|
|
4
|
+
|
|
5
|
+
The driver ships a fixed map: `is_pollable_failure(exc)` returns True when the exception kind is transient page state — the settle window may re-execute the same code; False when the failure is deterministic or unknown — it goes straight to classification. Recognition is by exception type and message pattern; no LLM, no settings.
|
|
6
|
+
|
|
7
|
+
| Kind | Message signature | Pollable |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| timeout | `Timeout NNNms exceeded` — locator, action, expectation or navigation wait | yes |
|
|
10
|
+
| element state | `element is not visible`, `element is not enabled`, `element is outside of the viewport`, detached/stale element | yes |
|
|
11
|
+
| navigation / context | `Execution context was destroyed`, `Target closed`, navigation interrupted mid-flight | yes |
|
|
12
|
+
| failed expectation | plain `AssertionError` from a failed expect(...) chain or a plain Python assert on an immediate read — the check executed and did not hold | yes |
|
|
13
|
+
| locator ambiguity | `strict mode violation: locator resolved to N elements` | no — the elements are there; waiting will not collapse them to one |
|
|
14
|
+
| Python-level error | syntax, name and type errors of the step code itself | no — not a driver error at all |
|
|
15
|
+
| unrecognized | anything else | no — conservative default, straight to classification |
|
|
16
|
+
|
|
17
|
+
## Reading the map
|
|
18
|
+
|
|
19
|
+
- Pollable kinds typically reflect a page-state race: the previous action finished while a state transition was still in flight; re-executing the same code once the state settles is honest retrying, not masking.
|
|
20
|
+
- A failed expectation is pollable because the same code passing later means the state caught up — the check itself stays intact.
|
|
21
|
+
- Locator ambiguity is deterministic: the locator itself is at fault, the classification labels it `fixable` and regeneration produces an unambiguous locator.
|
|
22
|
+
|
|
23
|
+
## Rules
|
|
24
|
+
|
|
25
|
+
- The map is fixed in code: it never reads settings and never asks an LLM.
|
|
26
|
+
- The decision takes only the exception object — pure and deterministic.
|