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,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.