prettyplay 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. prettyplay/.usages/lifecycle.md +50 -0
  2. prettyplay/.usages/steps.md +56 -0
  3. prettyplay/CODEMANIFEST +182 -0
  4. prettyplay/__init__.py +13 -0
  5. prettyplay/cache/.usages/addressing.md +31 -0
  6. prettyplay/cache/.usages/budgets.md +21 -0
  7. prettyplay/cache/.usages/storage.md +32 -0
  8. prettyplay/cache/CODEMANIFEST +152 -0
  9. prettyplay/cache/__init__.py +8 -0
  10. prettyplay/cache/budgets.py +73 -0
  11. prettyplay/cache/models.py +60 -0
  12. prettyplay/cache/store.py +260 -0
  13. prettyplay/cache/text.py +35 -0
  14. prettyplay/config/.usages/configuration.md +87 -0
  15. prettyplay/config/CODEMANIFEST +132 -0
  16. prettyplay/config/__init__.py +6 -0
  17. prettyplay/config/loader.py +192 -0
  18. prettyplay/config/models.py +92 -0
  19. prettyplay/driver/.usages/facade.md +66 -0
  20. prettyplay/driver/CODEMANIFEST +162 -0
  21. prettyplay/driver/__init__.py +6 -0
  22. prettyplay/driver/page.py +350 -0
  23. prettyplay/driver/session.py +288 -0
  24. prettyplay/engine/.usages/generation.md +45 -0
  25. prettyplay/engine/.usages/healing.md +31 -0
  26. prettyplay/engine/CODEMANIFEST +215 -0
  27. prettyplay/engine/__init__.py +8 -0
  28. prettyplay/engine/classification.py +69 -0
  29. prettyplay/engine/execution.py +25 -0
  30. prettyplay/engine/generator.py +318 -0
  31. prettyplay/engine/healer.py +116 -0
  32. prettyplay/engine/text.py +19 -0
  33. prettyplay/executor.py +109 -0
  34. prettyplay/failures/.usages/taxonomy.md +40 -0
  35. prettyplay/failures/CODEMANIFEST +117 -0
  36. prettyplay/failures/__init__.py +17 -0
  37. prettyplay/failures/errors.py +147 -0
  38. prettyplay/llm/.usages/classification.md +25 -0
  39. prettyplay/llm/.usages/providers.md +33 -0
  40. prettyplay/llm/CODEMANIFEST +125 -0
  41. prettyplay/llm/__init__.py +8 -0
  42. prettyplay/llm/_request.py +216 -0
  43. prettyplay/llm/anthropic_provider.py +213 -0
  44. prettyplay/llm/models.py +22 -0
  45. prettyplay/llm/openai_provider.py +187 -0
  46. prettyplay/llm/provider.py +115 -0
  47. prettyplay/reporting/.usages/hooks.md +41 -0
  48. prettyplay/reporting/CODEMANIFEST +79 -0
  49. prettyplay/reporting/__init__.py +6 -0
  50. prettyplay/reporting/hooks.py +37 -0
  51. prettyplay/reporting/reporter.py +70 -0
  52. prettyplay/runtime.py +107 -0
  53. prettyplay/scenario.py +291 -0
  54. prettyplay-0.0.0.dist-info/METADATA +236 -0
  55. prettyplay-0.0.0.dist-info/RECORD +58 -0
  56. prettyplay-0.0.0.dist-info/WHEEL +5 -0
  57. prettyplay-0.0.0.dist-info/licenses/LICENSE +28 -0
  58. prettyplay-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,60 @@
1
+ """In-memory models of the step cache: step addressing and a cached unit.
2
+
3
+ ``StepIdentity`` is the address of a step: the triple (cache key, step type,
4
+ normalized sentence) plus the filename derived from it. ``CachedStep`` is one
5
+ unit of the cache in memory: the identity, the generated code, and the date
6
+ of its creation.
7
+ """
8
+
9
+ import hashlib
10
+
11
+ from pydantic import BaseModel, ConfigDict
12
+
13
+ #: Unit Separator: makes the identity concatenation unambiguous.
14
+ _IDENTITY_SEPARATOR = "\x1f"
15
+
16
+
17
+ class StepIdentity(BaseModel):
18
+ """The address of a step in the repository cache.
19
+
20
+ Attributes:
21
+ cache_key: the key of the test the step belongs to.
22
+ step_type: the kind of the step sentence ({action, assertion}).
23
+ normalized_text: the normalized step sentence.
24
+ """
25
+
26
+ model_config = ConfigDict(kw_only=True)
27
+
28
+ cache_key: str
29
+ step_type: str
30
+ normalized_text: str
31
+
32
+ @property
33
+ def filename(self) -> str:
34
+ """Return the cache filename deterministically derived from the triple.
35
+
36
+ The three parts are joined with the Unit Separator (so no pair of
37
+ values can collide with another pair), hashed with sha256, and given
38
+ the ``.py`` extension. The name does not have to be a Python
39
+ identifier: the cache loader parses the file text and never imports
40
+ the module by name.
41
+ """
42
+ identity_string = _IDENTITY_SEPARATOR.join((self.cache_key, self.step_type, self.normalized_text))
43
+ digest = hashlib.sha256(identity_string.encode("utf-8")).hexdigest()
44
+ return f"{digest}.py"
45
+
46
+
47
+ class CachedStep(BaseModel):
48
+ """One unit of the step cache: a working step bound to its identity.
49
+
50
+ Attributes:
51
+ identity: the address of the step.
52
+ code: the step code of the fixed form ``def step(page) -> None:``.
53
+ created_at: the date the code was generated (ISO format).
54
+ """
55
+
56
+ model_config = ConfigDict(kw_only=True)
57
+
58
+ identity: StepIdentity
59
+ code: str
60
+ created_at: str
@@ -0,0 +1,260 @@
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
+ #: Marker of the step code tail; the leading newline stays out of the loaded code.
18
+ _STEP_MARKER = "\ndef step("
19
+
20
+ #: Replace attempts and backoff for a busy target (Windows keeps the file open).
21
+ _REPLACE_ATTEMPTS = 3
22
+ _REPLACE_BACKOFF_SECONDS = 0.1
23
+
24
+
25
+ class StepCache:
26
+ """The repository store of cache steps: addressing, atomic writes and the read-only mode.
27
+
28
+ The cache is always read: any structural error of a cache file is a
29
+ protective miss (``None``), never a failed run — the step is simply
30
+ regenerated. Writes are best-effort: a read-only cache or a busy target
31
+ skips the write loudly through the reporter and the run continues.
32
+
33
+ Attributes:
34
+ _root: the cache root from the settings (absolute after ``load_config``).
35
+ _subdir: the optional subdirectory; part of the address, so steps of
36
+ different subdirectories never collide.
37
+ _reporter: the visibility point for the cache events.
38
+ _writable: the lazily probed writability flag; ``None`` until first use.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ config: Config,
44
+ path: str | None = None,
45
+ reporter: StepReporter | None = None,
46
+ ) -> None:
47
+ """Keep the settings, the optional subdirectory and the reporter.
48
+
49
+ Args:
50
+ config: project settings; the ``cache_root`` setting is the cache root.
51
+ path: the optional subdirectory inside the cache; ``None`` — shared root.
52
+ reporter: the visibility point — cache events go through it; a
53
+ missing reporter falls back to the hook-less default reporter,
54
+ so construction and saves never crash on it.
55
+
56
+ Raises:
57
+ ValueError: ``path`` is absolute or escapes the cache root.
58
+ """
59
+ self._root = Path(config.cache_root)
60
+ self._subdir = self._validated_subdir(path)
61
+ self._reporter = reporter if reporter is not None else StepReporter(hooks=[])
62
+ self._writable: bool | None = None
63
+
64
+ @property
65
+ def root(self) -> str:
66
+ """Return the effective cache root."""
67
+ return str(self._root)
68
+
69
+ @property
70
+ def writable(self) -> bool:
71
+ """Return whether the cache directory accepts writes.
72
+
73
+ Probed lazily once: the target directory is created (parents included)
74
+ and checked with ``os.access``; any ``OSError`` counts as read-only.
75
+ """
76
+ if self._writable is None:
77
+ try:
78
+ target_dir = self._target_dir()
79
+ target_dir.mkdir(parents=True, exist_ok=True)
80
+ self._writable = os.access(target_dir, os.W_OK)
81
+ except OSError:
82
+ self._writable = False
83
+
84
+ return self._writable
85
+
86
+ def load(self, identity: StepIdentity) -> CachedStep | None:
87
+ """Load the cached step by address; any structural damage is a miss, not a failure.
88
+
89
+ Args:
90
+ identity: the address of the step.
91
+
92
+ Returns:
93
+ The cached step, or ``None`` when the file is missing, damaged, or
94
+ its metadata does not match the address.
95
+ """
96
+ target = self._target_dir() / identity.filename
97
+ if not target.exists():
98
+ return None
99
+
100
+ try:
101
+ text = target.read_text(encoding="utf-8")
102
+ fields = _parse_header(text)
103
+
104
+ marker = text.find(_STEP_MARKER)
105
+ if marker == -1:
106
+ raise KeyError("def step(")
107
+ code = text[marker + 1 :]
108
+
109
+ if fields["CACHE_KEY"] != identity.cache_key or fields["STEP_TYPE"] != identity.step_type:
110
+ return None
111
+ if fields["STEP_TEXT"] != identity.normalized_text:
112
+ return None
113
+
114
+ return CachedStep(identity=identity, code=code, created_at=fields["CREATED_AT"])
115
+ except (ValueError, SyntaxError, KeyError, IndexError, OSError):
116
+ return None # cache corruption never cripples the run
117
+
118
+ def save(self, step: CachedStep) -> None:
119
+ """Store the step atomically, best-effort: no write error ever fails the run.
120
+
121
+ Args:
122
+ step: the working step to store.
123
+ """
124
+ if not self.writable:
125
+ self._emit_skipped(step, "read-only cache")
126
+ return
127
+
128
+ body = _serialize(step)
129
+ try:
130
+ handle, tmp_name = tempfile.mkstemp(dir=self._target_dir(), prefix=".tmp-", suffix=".py")
131
+ except OSError:
132
+ self._emit_skipped(step, "cache target busy")
133
+ return
134
+
135
+ try:
136
+ with os.fdopen(handle, "w", encoding="utf-8") as tmp_file:
137
+ tmp_file.write(body)
138
+ tmp_file.flush()
139
+ os.fsync(tmp_file.fileno())
140
+ except (OSError, ValueError): # ValueError: unencodable text (e.g. surrogates)
141
+ _remove_quietly(tmp_name)
142
+ self._emit_skipped(step, "cache target busy")
143
+ return
144
+
145
+ for _ in range(_REPLACE_ATTEMPTS):
146
+ try:
147
+ os.replace(tmp_name, self._target_dir() / step.identity.filename)
148
+ break
149
+ except PermissionError:
150
+ time.sleep(_REPLACE_BACKOFF_SECONDS) # Windows: target busy
151
+ except (OSError, ValueError): # ValueError: unencodable address (e.g. surrogates)
152
+ _remove_quietly(tmp_name)
153
+ self._emit_skipped(step, "cache target busy")
154
+ return
155
+ else:
156
+ _remove_quietly(tmp_name)
157
+ self._emit_skipped(step, "cache target busy")
158
+ return
159
+
160
+ self._reporter.emit(
161
+ "on_cache_saved",
162
+ {"step_text": step.identity.normalized_text, "filename": step.identity.filename},
163
+ )
164
+
165
+ def _target_dir(self) -> Path:
166
+ """Return the directory holding the steps of this address."""
167
+ return self._root / self._subdir if self._subdir else self._root
168
+
169
+ @staticmethod
170
+ def _validated_subdir(path: str | None) -> Path | None:
171
+ """Return the subdirectory path, rejecting addresses outside the cache root.
172
+
173
+ Args:
174
+ path: the integrator-supplied subdirectory; ``None`` — shared root.
175
+
176
+ Returns:
177
+ The validated relative subdirectory, or ``None`` for the shared root.
178
+
179
+ Raises:
180
+ ValueError: the path is absolute or resolves outside the cache root.
181
+ """
182
+ if path is None or path == "":
183
+ return None
184
+
185
+ subdir = Path(path)
186
+
187
+ if subdir.is_absolute():
188
+ raise ValueError(f"cache path must be a subdirectory, got absolute {path!r}")
189
+ if ".." in subdir.parts:
190
+ raise ValueError(f"cache path must stay inside the cache root, got {path!r}")
191
+
192
+ return subdir
193
+
194
+ def _emit_skipped(self, step: CachedStep, reason: str) -> None:
195
+ """Report a skipped write through the visibility point."""
196
+ self._reporter.emit(
197
+ "on_cache_skipped",
198
+ {"step_text": step.identity.normalized_text, "reason": reason},
199
+ )
200
+
201
+
202
+ def _parse_header(text: str) -> dict[str, str]:
203
+ """Parse the header constants of a cache file into a field map.
204
+
205
+ Args:
206
+ text: the whole cache file text.
207
+
208
+ Returns:
209
+ The map of the header constants found in the module.
210
+
211
+ Raises:
212
+ SyntaxError: the file is not a parseable Python module.
213
+ ValueError: a header value is not a literal.
214
+ KeyError: any of the header constants is missing.
215
+ """
216
+ module = ast.parse(text)
217
+
218
+ fields: dict[str, str] = {}
219
+ for node in module.body:
220
+ if not isinstance(node, ast.Assign) or len(node.targets) != 1:
221
+ continue
222
+
223
+ target = node.targets[0]
224
+
225
+ if isinstance(target, ast.Name) and target.id in _HEADER_FIELDS:
226
+ fields[target.id] = ast.literal_eval(node.value)
227
+
228
+ missing = [name for name in _HEADER_FIELDS if name not in fields]
229
+
230
+ if missing:
231
+ raise KeyError(", ".join(missing))
232
+
233
+ return fields
234
+
235
+
236
+ def _serialize(step: CachedStep) -> str:
237
+ """Serialize a step into the module text: metadata literals first, then the code.
238
+
239
+ Args:
240
+ step: the step to serialize.
241
+
242
+ Returns:
243
+ The text of a valid Python module carrying the step.
244
+ """
245
+ header = "".join(
246
+ f"{name} = {value!r}\n"
247
+ for name, value in (
248
+ ("STEP_TEXT", step.identity.normalized_text),
249
+ ("CACHE_KEY", step.identity.cache_key),
250
+ ("STEP_TYPE", step.identity.step_type),
251
+ ("CREATED_AT", step.created_at),
252
+ )
253
+ )
254
+ return header + "\n" + step.code + "\n"
255
+
256
+
257
+ def _remove_quietly(path: str) -> None:
258
+ """Remove a temporary file, ignoring a failure: the write is already skipped."""
259
+ with contextlib.suppress(OSError):
260
+ Path(path).unlink()
@@ -0,0 +1,35 @@
1
+ """Normalization of step sentences for cache identity and addressing.
2
+
3
+ The same normalized sentence in the same context is one step: normalization
4
+ erases incidental differences (unicode composition, surrounding and internal
5
+ whitespace runs, letter case) while keeping distinct sentences distinct.
6
+ """
7
+
8
+ import re
9
+ import unicodedata
10
+
11
+ #: Pattern of any whitespace run, collapsed to a single space by :func:`normalize_step_text`.
12
+ _WHITESPACE_RUN = re.compile(r"\s+")
13
+
14
+
15
+ def normalize_step_text(text: str) -> str:
16
+ """Normalize a step sentence for identity and addressing.
17
+
18
+ Pipeline: Unicode NFC normalization, trimming of leading and trailing
19
+ whitespace, collapsing of internal whitespace runs to single spaces,
20
+ casefold. «Нажать Войти» and «нажать войти » normalize to the same
21
+ string; a Russian sentence and its English translation stay different.
22
+
23
+ Args:
24
+ text: the raw step sentence as written by the engineer.
25
+
26
+ Returns:
27
+ The normalized sentence.
28
+ """
29
+ normalized = unicodedata.normalize("NFC", text)
30
+
31
+ normalized = normalized.strip()
32
+
33
+ normalized = _WHITESPACE_RUN.sub(" ", normalized)
34
+
35
+ return normalized.casefold()
@@ -0,0 +1,87 @@
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
+ browser = "chromium"
11
+ headless = true # false — run with a visible window
12
+ model = "gpt-5"
13
+ generation_model = "" # optional: empty -> model
14
+ classification_model = "" # optional: empty -> model
15
+ base_url = ""
16
+ cache_root = "" # empty -> <repo>/.prettyplay/cache/
17
+ generation_attempts = 3
18
+ healing_attempts = 2
19
+ send_screenshots = false
20
+ generation_prompt = "" # user instructions for generation requests; empty -> no instructions block
21
+ browser_endpoint = "" # ws endpoint of a remote browser; empty -> local launch
22
+ ```
23
+
24
+ ## Environment overrides
25
+
26
+ Every setting has an override for CI — env variable PRETTYPLAY_<SETTING> in upper case:
27
+
28
+ | Setting | Env override |
29
+ |---|---|
30
+ | provider | PRETTYPLAY_PROVIDER |
31
+ | browser | PRETTYPLAY_BROWSER_NAME |
32
+ | headless | PRETTYPLAY_BROWSER_HEADLESS |
33
+ | model | PRETTYPLAY_MODEL |
34
+ | generation_model | PRETTYPLAY_GENERATION_MODEL |
35
+ | classification_model | PRETTYPLAY_CLASSIFICATION_MODEL |
36
+ | base_url | PRETTYPLAY_BASE_URL |
37
+ | cache_root | PRETTYPLAY_CACHE_ROOT |
38
+ | generation_attempts | PRETTYPLAY_GENERATION_ATTEMPTS |
39
+ | healing_attempts | PRETTYPLAY_HEALING_ATTEMPTS |
40
+ | send_screenshots | PRETTYPLAY_SEND_SCREENSHOTS |
41
+ | generation_prompt | PRETTYPLAY_GENERATION_PROMPT |
42
+ | browser_endpoint | PRETTYPLAY_BROWSER_ENDPOINT |
43
+
44
+ ## Per-test overrides — layered merge
45
+
46
+ 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:
47
+
48
+ ```python
49
+ from prettyplay import PrettyTest, PrettyConfig
50
+
51
+ test = PrettyTest(
52
+ cache_key="login-flow",
53
+ config=PrettyConfig(
54
+ browser="firefox",
55
+ browser_endpoint="ws://ci-grid:3000/playwright/firefox",
56
+ ),
57
+ )
58
+ ```
59
+
60
+ - An explicitly set field wins over pyproject+env; a field left at its default (an empty string) falls back to the file layer
61
+ - File values you did not touch survive: base_url and model set only in pyproject.toml keep working when a config is passed
62
+ - One model — one place of validation: file and programmatic values validate identically
63
+
64
+ ## Browsers
65
+
66
+ 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.
67
+
68
+ ## Remote execution
69
+
70
+ 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.
71
+
72
+ ## Rules
73
+
74
+ - 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
75
+ - Invalid configuration fails loudly: ConfigurationError names the setting, the received value and the allowed values; the raw pydantic error stays chained for debugging
76
+ - The provider set: openai, anthropic
77
+ - A non-empty browser_endpoint must be a valid ws/wss URL
78
+ - The cache root default: <repo root>/.prettyplay/cache/ — resolved from the located pyproject.toml
79
+
80
+ ## Loading
81
+
82
+ ```python
83
+ from prettyplay.config import load_config
84
+
85
+ config = load_config(pyproject_path=None) # locates pyproject.toml upwards from the current directory
86
+ print(config.browser, config.headless, config.generation_attempts)
87
+ ```
@@ -0,0 +1,132 @@
1
+ Imports:
2
+ - Types:
3
+ - PrettyplayError
4
+ Usages:
5
+ - taxonomy
6
+ From: prettyplay/failures
7
+
8
+ Usages:
9
+ conventions: .goga/usages/conventions.md
10
+ pydantic: .goga/usages/cooks/pydantic.md
11
+
12
+ Annotations: |
13
+ Use `conventions` for code writing rules and testing.
14
+ Use `pydantic` for data models and TOML loading.
15
+ Use `taxonomy` from Imports for the failure base the configuration error joins.
16
+
17
+ All data models — pydantic v2, kw_only=True, empty defaults (None only for explicit absence).
18
+ Naming: PascalCase for classes; snake_case for functions, methods, properties.
19
+ Type hints mandatory; no *args/**kwargs; generics parameterized.
20
+ A validation failure never surfaces as a raw pydantic error: the loader wraps it into the loud actionable `ConfigurationError`.
21
+ Layered resolution: a value passed programmatically wins over the pyproject+env layer only when explicitly set; empty string means unset for string fields. One model — one place of validation — file and programmatic values validate identically. `Config` is publicly known as PrettyConfig.
22
+
23
+ ---
24
+
25
+ "PrettyplayError::ConfigurationError(message: str)":
26
+ location: loader.py
27
+ annotations: |
28
+ An invalid prettyplay configuration: the loaded [tool.prettyplay] section failed validation.
29
+
30
+ `message`: the rendered actionable text — one line per invalid setting: the setting name, the received value, the allowed values or range.
31
+
32
+ Requirements:
33
+ - Raised by `load_config` with the original pydantic ValidationError chained
34
+ - Catchable with the single library except clause: derives from `PrettyplayError` (see `taxonomy` from Imports)
35
+ - Never carries a verdict: a configuration failure is not a step failure
36
+ properties:
37
+ "message -> str": |
38
+ The rendered actionable validation text.
39
+
40
+ "Config(provider: str, browser: str, model: str, generation_model: str, classification_model: str, base_url: str, cache_root: str, generation_prompt: str, browser_endpoint: str, generation_attempts: int, healing_attempts: int, send_screenshots: bool, headless: bool)":
41
+ location: models.py
42
+ annotations: |
43
+ Validated project settings — the single source of the immutable configuration part.
44
+
45
+ `provider`: the LLM provider of the {openai, anthropic} set; default openai.
46
+ `browser`: browser of the {chromium, firefox, webkit, chrome, msedge} set; chrome and msedge launch the locally installed browser through the driver channel mechanism; default chromium.
47
+ `model`: main LLM model name.
48
+ `generation_model`: optional generation override; empty — fallback to `model`.
49
+ `classification_model`: optional classification override; empty — fallback to `model`.
50
+ `base_url`: optional custom LLM API endpoint.
51
+ `cache_root`: cache root; empty — default <repo root>/.prettyplay/cache/ resolved by `load_config`.
52
+ `generation_prompt`: user instructions for generation requests; non-empty — a separate USER INSTRUCTIONS block in generation and regeneration requests; empty — no block; default empty.
53
+ `browser_endpoint`: ws endpoint of a remote browser; empty — local launch; default empty.
54
+ `generation_attempts`: generation attempt budget per step per test; default 3.
55
+ `healing_attempts`: healing attempt budget per step per test; default 2.
56
+ `send_screenshots`: optional screenshot input to the LLM; default False.
57
+ `headless`: run the browser without a visible window of a local launch; default True.
58
+
59
+ Requirements:
60
+ - kw_only construction; every field has an empty default
61
+ - provider validated against {openai, anthropic}; browser against the five-value set; invalid value — loud actionable error
62
+ - attempts are positive integers
63
+ - a non-empty browser_endpoint is a valid ws/wss URL — otherwise a loud actionable error
64
+
65
+ Constraints:
66
+ - No secret values in fields: LLM API keys are never stored in the config; keys come only from environment variables
67
+ properties:
68
+ "provider -> str": |
69
+ The LLM provider setting: openai or anthropic.
70
+ "browser -> str": |
71
+ The browser setting of the {chromium, firefox, webkit, chrome, msedge} set.
72
+ "model -> str": |
73
+ The main LLM model name.
74
+ "generation_model -> str": |
75
+ The optional generation model override.
76
+ "classification_model -> str": |
77
+ The optional classification model override.
78
+ "base_url -> str": |
79
+ The optional custom LLM API endpoint.
80
+ "cache_root -> str": |
81
+ The cache root; empty means the default resolved at load.
82
+ "generation_prompt -> str": |
83
+ The user instructions for generation requests; empty means no instructions block.
84
+ "browser_endpoint -> str": |
85
+ The ws endpoint of a remote browser; empty means the local launch.
86
+ "generation_attempts -> int": |
87
+ The generation attempt budget per step per test.
88
+ "healing_attempts -> int": |
89
+ The healing attempt budget per step per test.
90
+ "send_screenshots -> bool": |
91
+ Whether screenshots are attached to LLM requests.
92
+ "headless -> bool": |
93
+ Whether the browser runs without a visible window of a local launch; ignored on a remote connect.
94
+ "effective_generation_model -> str": |
95
+ generation_model when non-empty, otherwise model.
96
+ "effective_classification_model -> str": |
97
+ classification_model when non-empty, otherwise model.
98
+
99
+ "load_config(pyproject_path: str | None, overrides: Config | None) -> config: Config":
100
+ location: loader.py
101
+ annotations: |
102
+ Load project configuration from pyproject.toml with environment overrides and explicit per-test values.
103
+
104
+ `pyproject_path`: optional explicit path to pyproject.toml; empty — the first pyproject.toml found upwards from the current directory.
105
+ `overrides`: the programmatically passed values — the same full model; None — no programmatic layer, the file layer resolves everything.
106
+ `config`: fully resolved and validated `Config`.
107
+
108
+ Algorithm:
109
+ 1. Resolve the pyproject.toml path: given `pyproject_path` or the first match found upwards from the current directory
110
+ 2. Parse TOML: stdlib tomllib on Python 3.11+, tomli on 3.10 (see `pydantic`)
111
+ 3. Extract the tool.prettyplay section; a missing section is an empty section
112
+ 4. Apply environment overrides: each setting is overridden by PRETTYPLAY_<SETTING_UPPERCASE> when the variable is set; the browser override is PRETTYPLAY_BROWSER_NAME, the headless override is PRETTYPLAY_BROWSER_HEADLESS
113
+ 5. Construct `Config`; on a validation failure render the actionable text — one line per invalid setting: the setting name, the received value, the allowed values — and raise `ConfigurationError` with the original ValidationError chained
114
+ 6. `overrides` is None — return the file layer as is
115
+ 7. Overlay the explicitly set fields of `overrides` onto the file layer (the model with empty defaults, model_copy — see `pydantic`): a field participates when it was passed at construction and is non-empty for strings; untouched model defaults never overwrite file values
116
+ 8. Return the effective `Config`
117
+
118
+ Requirements:
119
+ - An env override exists for every setting of `Config` (including PRETTYPLAY_GENERATION_PROMPT and PRETTYPLAY_BROWSER_ENDPOINT)
120
+ - A raw pydantic.ValidationError never leaves the loader
121
+ - The empty cache_root setting is resolved to the absolute default <repo root>/.prettyplay/cache/ at load
122
+
123
+ Constraints:
124
+ - Never read or store LLM API keys from any file; keys come only from environment variables
125
+ - Python 3.10 compatibility via the tomli fallback (see `pydantic`)
126
+
127
+ ---
128
+
129
+ Author: Goga
130
+ CreatedAt: 07/09/26
131
+ Description: |
132
+ Project settings of prettyplay: the validated [tool.prettyplay] schema with the browser channels, the headless mode, the generation instructions and the remote browser endpoint, and the loader with environment overrides, explicit per-test merging and the actionable configuration error.
@@ -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 Config, PrettyConfig
5
+
6
+ __all__ = ["Config", "ConfigurationError", "PrettyConfig", "load_config"]