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
prettyplay/scenario.py ADDED
@@ -0,0 +1,469 @@
1
+ """The integrator-facing object: one PrettyPlay per test owns the addressing and the page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import types
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from types import TracebackType
10
+ from typing import TypeVar
11
+
12
+ from playwright.sync_api import Page
13
+
14
+ from .cache import StepCache
15
+ from .config import PrettyConfig, load_config
16
+ from .driver import PageFacade
17
+ from .engine import StepGenerator, StepHealer
18
+ from .engine.groups import GroupRecovery
19
+ from .engine.steering import StepSteering
20
+ from .executor import StepExecutor
21
+ from .failures import PrettyplayError
22
+ from .groups import StepGroup, _validate_delay, _validate_tries, _validate_vars
23
+ from .reporting import StepHooks, StepReporter
24
+ from .runtime import PrettyplayRuntime
25
+
26
+ _T = TypeVar("_T")
27
+
28
+ #: The inclusive pace range, in percent, a group's ``speed`` parameter accepts.
29
+ _SPEED_RANGE = range(0, 101)
30
+
31
+ #: The header of the bulky page-state section Playwright appends to a failed
32
+ #: expectation message — column zero; the section runs to the message end.
33
+ _ARIA_SNAPSHOT_HEADER = re.compile(r"^Aria snapshot:[ \t]*$", re.MULTILINE)
34
+
35
+
36
+ def _trim_aria_snapshot(error: BaseException) -> None:
37
+ """Trim the bulky aria-snapshot section from a chained exception's message.
38
+
39
+ Args:
40
+ error: the chained exception; a single-string message carrying the
41
+ page-state section loses it, every other argument shape stays
42
+ untouched.
43
+ """
44
+ if len(error.args) != 1 or not isinstance(error.args[0], str):
45
+ return
46
+
47
+ text = error.args[0]
48
+ header = _ARIA_SNAPSHOT_HEADER.search(text)
49
+
50
+ if header is not None:
51
+ error.args = (text[: header.start()].rstrip(),)
52
+
53
+
54
+ def _raise_folded(error: PrettyplayError) -> types.NoReturn:
55
+ """Re-raise a library failure with its traceback folded to this boundary.
56
+
57
+ Args:
58
+ error: the library failure leaving ``step``/``expect``.
59
+
60
+ Raises:
61
+ Always: the given error with the folded traceback attached — the
62
+ same object, never a copy; only the facade boundary frame stays,
63
+ the chained tracebacks fold away too.
64
+ """
65
+ tb = error.__traceback__
66
+ _fold_chain_tracebacks(error)
67
+
68
+ folded = types.TracebackType(
69
+ tb_next=None,
70
+ tb_frame=tb.tb_frame,
71
+ tb_lasti=tb.tb_lasti,
72
+ tb_lineno=tb.tb_lineno,
73
+ )
74
+
75
+ raise error.with_traceback(folded)
76
+
77
+
78
+ def _fold_chain_tracebacks(error: BaseException) -> None:
79
+ """Drop the tracebacks of the chained exceptions, keeping the chains themselves.
80
+
81
+ Args:
82
+ error: the exception whose ``__context__``/``__cause__`` chains are
83
+ folded — every chained single-string message also loses its
84
+ bulky aria-snapshot section, so the page state never floods
85
+ the runner output.
86
+ """
87
+ pending = [error]
88
+ seen: set[int] = set()
89
+
90
+ while pending:
91
+ current = pending.pop()
92
+
93
+ if id(current) in seen:
94
+ continue
95
+
96
+ seen.add(id(current))
97
+ _trim_aria_snapshot(current)
98
+
99
+ for link in (current.__context__, current.__cause__):
100
+ if link is not None:
101
+ link.__traceback__ = None
102
+ pending.append(link)
103
+
104
+
105
+ def _validate_group_speed(speed: int | None) -> None:
106
+ """Validate the declared group pace; ``None`` — no between-step pauses.
107
+
108
+ Args:
109
+ speed: the group pace — an integer 0-100 inclusive, bools never
110
+ coerced.
111
+
112
+ Raises:
113
+ PrettyplayError: the value is a bool, not an integer or outside
114
+ 0-100 — the loud failure names the parameter, the received
115
+ value and the allowed form.
116
+ """
117
+ if speed is None:
118
+ return
119
+
120
+ if isinstance(speed, bool) or not isinstance(speed, int) or speed not in _SPEED_RANGE:
121
+ raise PrettyplayError(f"invalid speed {speed!r} — an integer 0-100 inclusive, keyword-only")
122
+
123
+
124
+ class PrettyPlay:
125
+ """The main object of the integrator: one instance per UI test.
126
+
127
+ Attributes:
128
+ _runtime: the composition root owned by this one test — no
129
+ process-wide state: two tests hold two runtimes, two attempt
130
+ registries and two browser sessions.
131
+ _reporter: the visibility point of this test.
132
+ _cache: the step cache of this test.
133
+ _generator: the generation engine of this test.
134
+ _healer: the healing engine of this test.
135
+ _steering: the interactive steering of this test; shares the
136
+ provider, the cache and the reporter of the healer — one
137
+ visibility point and one write-back store per test.
138
+ _recovery: the group recovery engine of this test; shares the
139
+ provider, the cache, the budgets and the reporter of the
140
+ generator — the recovery row regenerates through it.
141
+ _executor: the owner of the step cycle of this test.
142
+ _page: the isolated page of this test; ``None`` until the first step.
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ cache_key: str,
148
+ cache_path: str | None = None,
149
+ *,
150
+ hooks: list[StepHooks] | None = None,
151
+ config: PrettyConfig | None = None,
152
+ ) -> None:
153
+ """Compose the per-test objects over the runtime this test owns; the page and the SDK client stay lazy.
154
+
155
+ Args:
156
+ cache_key: the context key of the test; the addressing part of
157
+ every cached step of this scenario.
158
+ cache_path: the optional subdirectory inside the cache; part of
159
+ the address, so steps of different subdirectories never
160
+ collide.
161
+ hooks: the optional integrator callbacks of this test,
162
+ keyword-only; the reporter is seeded with them at
163
+ construction, so every event of every step reaches them.
164
+ ``None`` — an empty hooks list; :meth:`add_hooks` appends
165
+ later.
166
+ config: the programmatic settings layer; explicitly set values
167
+ win over the pyproject+env file layer, unset and empty
168
+ fields resolve from it. ``None`` resolves everything from
169
+ the file layer, as before.
170
+ """
171
+ effective = load_config(None, config)
172
+ self._runtime = PrettyplayRuntime(effective)
173
+ self._reporter = StepReporter(hooks=hooks or [])
174
+ self._cache = StepCache(self._runtime.config, cache_path, self._reporter)
175
+
176
+ self._generator = StepGenerator(
177
+ self._runtime.config,
178
+ self._runtime.provider,
179
+ self._cache,
180
+ self._runtime.budgets,
181
+ self._reporter,
182
+ )
183
+ self._healer = StepHealer(
184
+ self._runtime.config,
185
+ self._runtime.provider,
186
+ self._generator,
187
+ self._cache,
188
+ self._runtime.budgets,
189
+ self._reporter,
190
+ )
191
+ self._steering = StepSteering(
192
+ self._runtime.config,
193
+ self._runtime.provider,
194
+ self._cache,
195
+ self._reporter,
196
+ )
197
+ self._recovery = GroupRecovery(
198
+ self._runtime.config,
199
+ self._runtime.provider,
200
+ self._generator,
201
+ self._cache,
202
+ self._runtime.budgets,
203
+ self._reporter,
204
+ )
205
+ self._executor = StepExecutor(
206
+ cache_key,
207
+ self._cache,
208
+ self._generator,
209
+ self._healer,
210
+ self._steering,
211
+ self._recovery,
212
+ self._runtime.budgets,
213
+ self._reporter,
214
+ config=self._runtime.config,
215
+ provider=self._runtime.provider, # cheap object construction; the SDK client stays lazy
216
+ )
217
+ self._page: PageFacade | None = None
218
+
219
+ @property
220
+ def cache_key(self) -> str:
221
+ """The context key of this test, for diagnostics."""
222
+ return self._executor.cache_key
223
+
224
+ def _ensure_page(self) -> PageFacade:
225
+ """Open the isolated page of this test lazily, exactly once.
226
+
227
+ Returns:
228
+ The page facade of this test.
229
+ """
230
+ if self._page is None:
231
+ self._page = self._runtime.open_page()
232
+ return self._page
233
+
234
+ def step(
235
+ self,
236
+ text: str,
237
+ *,
238
+ tries: int | None = None,
239
+ delay: float | None = None,
240
+ vars: dict[str, str] | None = None,
241
+ ) -> None:
242
+ """Execute one action step sentence through the step cycle.
243
+
244
+ Args:
245
+ text: the sentence of the action as written by the engineer.
246
+ tries: keyword-only — the total number of executions of the
247
+ step's code, the first execution included; a positive
248
+ integer. ``None`` — the time-bounded settle mode of the
249
+ polling settings. An invalid value raises the loud
250
+ actionable failure at the call, before any page or LLM
251
+ involvement.
252
+ delay: keyword-only — the quiet pre-step pause in seconds,
253
+ non-negative, fractional allowed; ``None`` — no pause.
254
+ vars: keyword-only — the call-local string inputs of the step;
255
+ every value must be a string, otherwise the loud actionable
256
+ failure names the parameter, the received value and the
257
+ allowed form. ``None`` — no inputs; a separate namespace
258
+ from memory — ``{{ vars.name }}`` reads these values,
259
+ ``{{ name }}`` reads memory; call inputs never establish
260
+ memory.
261
+
262
+ Raises:
263
+ PrettyplayError: ``tries``, ``delay`` or ``vars`` is invalid —
264
+ the loud failure names the parameter, the received value
265
+ and the allowed form; every library failure leaving this
266
+ method carries its traceback folded to this boundary.
267
+ ProductDefectError: the step expectation is genuinely broken in the product.
268
+ IncurableStepError: the step never generated successfully, or the verdict
269
+ says regeneration cannot help.
270
+ LLMUnavailableError: the provider service failed; no retry.
271
+ ComplianceVerdictError: the compliance gate could not obtain a
272
+ usable verdict; the executed candidate is never cached.
273
+ """
274
+ try:
275
+ _validate_tries(tries)
276
+ _validate_delay(delay)
277
+ _validate_vars(vars)
278
+ self._executor.execute(text, "action", self._ensure_page(), tries=tries, delay=delay, vars=vars)
279
+ except PrettyplayError as error:
280
+ _raise_folded(error)
281
+
282
+ def expect(
283
+ self,
284
+ text: str,
285
+ *,
286
+ tries: int | None = None,
287
+ delay: float | None = None,
288
+ vars: dict[str, str] | None = None,
289
+ ) -> None:
290
+ """Execute one assertion step sentence through the step cycle.
291
+
292
+ Args:
293
+ text: the sentence of the assertion as written by the engineer.
294
+ tries: keyword-only — the total number of executions of the
295
+ step's code, the first execution included; a positive
296
+ integer. ``None`` — the time-bounded settle mode of the
297
+ polling settings. An invalid value raises the loud
298
+ actionable failure at the call, before any page or LLM
299
+ involvement.
300
+ delay: keyword-only — the quiet pre-step pause in seconds,
301
+ non-negative, fractional allowed; ``None`` — no pause.
302
+ vars: keyword-only — the call-local string inputs of the step,
303
+ validated exactly as :meth:`step`; ``None`` — no inputs.
304
+
305
+ Raises:
306
+ PrettyplayError: ``tries``, ``delay`` or ``vars`` is invalid;
307
+ the traceback folding is exactly as :meth:`step`.
308
+ ProductDefectError: the step expectation is genuinely broken in the product.
309
+ IncurableStepError: the step never generated successfully, or the verdict
310
+ says regeneration cannot help.
311
+ LLMUnavailableError: the provider service failed; no retry.
312
+ ComplianceVerdictError: the compliance gate could not obtain a
313
+ usable verdict; the executed candidate is never cached.
314
+ """
315
+ try:
316
+ _validate_tries(tries)
317
+ _validate_delay(delay)
318
+ _validate_vars(vars)
319
+ self._executor.execute(text, "assertion", self._ensure_page(), tries=tries, delay=delay, vars=vars)
320
+ except PrettyplayError as error:
321
+ _raise_folded(error)
322
+
323
+ def group(self, prompt: str, *, speed: int | None = None, delay: float | None = None) -> StepGroup:
324
+ """Open the group authoring block — one coherent mini-scenario with a shared goal.
325
+
326
+ Args:
327
+ prompt: the group prompt, verbatim — the shared goal of the
328
+ block, reaching the generation and diagnosis requests;
329
+ empty raises the loud actionable failure at entry.
330
+ speed: keyword-only — the group pace, an integer 0-100; the
331
+ pauses between the group's steps follow the same
332
+ percent-to-ms mapping as the browser speed setting.
333
+ ``None`` — no between-step pauses.
334
+ delay: keyword-only — the quiet pause before the group's first
335
+ step, seconds, non-negative; ``None`` — no pause.
336
+
337
+ Returns:
338
+ The authoring group object — yield it from a ``with`` block: it
339
+ carries the ordinary step surface and frames the block with the
340
+ group lifecycle events; group membership changes no step's cache
341
+ address.
342
+
343
+ Raises:
344
+ PrettyplayError: ``prompt`` is empty, ``speed`` is malformed or
345
+ outside 0-100, or ``delay`` is negative or malformed.
346
+ """
347
+ try:
348
+ if not prompt:
349
+ raise PrettyplayError(
350
+ "the group prompt must not be empty — a group block needs its shared goal sentence"
351
+ )
352
+
353
+ _validate_group_speed(speed)
354
+ _validate_delay(delay)
355
+
356
+ block = StepGroup(prompt, speed, delay, self._executor, self._reporter)
357
+ block._open_page = self._ensure_page # the lazy opener of this test; the constructor stays five-parameter
358
+
359
+ return block
360
+ except PrettyplayError as error:
361
+ _raise_folded(error)
362
+
363
+ def run_on_page(self, action: Callable[[Page], _T]) -> _T:
364
+ """Execute the author action wholly inside the driver worker thread.
365
+
366
+ Args:
367
+ action: the callable to execute; receives the genuine sync Page,
368
+ runs sequentially with every step — the stateful actions
369
+ excluded from generated code are the author's explicit tools
370
+ here.
371
+
372
+ Returns:
373
+ The outcome of ``action`` as-is — plain data only; Playwright
374
+ objects never cross back to the calling thread.
375
+
376
+ Raises:
377
+ PrettyplayError: no test page exists yet — run a step first.
378
+ Exception: whatever ``action`` raises propagates to the caller
379
+ as-is; a call back into the test object from inside the
380
+ action is rejected with a loud error instead of a deadlock.
381
+ """
382
+ if self._page is None:
383
+ raise PrettyplayError("no test page yet: run a step first — the page opens lazily on the first step")
384
+
385
+ return self._page.run(action)
386
+
387
+ def get_screenshot(self) -> bytes:
388
+ """Return a full-page PNG screenshot of the current test page.
389
+
390
+ Returns:
391
+ The PNG image bytes of the page — the decision to capture
392
+ belongs to the author: nothing is captured automatically on
393
+ step failures.
394
+
395
+ Raises:
396
+ PrettyplayError: no test page exists yet — run a step first.
397
+ """
398
+ if self._page is None:
399
+ raise PrettyplayError("no test page yet: run a step first — the page opens lazily on the first step")
400
+
401
+ return self._page.screenshot()
402
+
403
+ def save_screenshot(self, filepath: str) -> None:
404
+ """Save a full-page PNG screenshot of the current test page to a file.
405
+
406
+ Args:
407
+ filepath: the destination path of the PNG file.
408
+
409
+ Raises:
410
+ PrettyplayError: no test page exists yet, or the file cannot be
411
+ written — parent directories are never created silently, a
412
+ missing directory is a loud failure; the original
413
+ ``OSError`` is chained.
414
+ """
415
+ if self._page is None:
416
+ raise PrettyplayError("no test page yet: run a step first — the page opens lazily on the first step")
417
+
418
+ image = self._page.screenshot()
419
+
420
+ try:
421
+ Path(filepath).write_bytes(image)
422
+ except OSError as error:
423
+ raise PrettyplayError(f"cannot write the screenshot to {filepath}: {error}") from error
424
+
425
+ def add_hooks(self, hooks: StepHooks) -> None:
426
+ """Register integrator hooks for the events of this test.
427
+
428
+ Args:
429
+ hooks: the hooks object; register before the first step to see
430
+ every event of the scenario.
431
+ """
432
+ self._reporter.hooks.append(hooks)
433
+
434
+ def close(self) -> None:
435
+ """Close the page, then unconditionally the whole runtime of this test; idempotent."""
436
+ page = self._page
437
+ self._page = None
438
+
439
+ try:
440
+ if page is not None:
441
+ page.close()
442
+ finally:
443
+ self._runtime.close()
444
+
445
+ def __enter__(self) -> PrettyPlay:
446
+ """Enter the scenario block of one test.
447
+
448
+ Returns:
449
+ This test object.
450
+ """
451
+ return self
452
+
453
+ def __exit__(
454
+ self,
455
+ exc_type: type[BaseException] | None,
456
+ exc_value: BaseException | None,
457
+ traceback: TracebackType | None,
458
+ ) -> None:
459
+ """Leave the scenario block: close the test and never suppress.
460
+
461
+ Args:
462
+ exc_type: the type of the block exception, if any.
463
+ exc_value: the block exception, if any.
464
+ traceback: the traceback of the block exception, if any.
465
+
466
+ Returns:
467
+ Nothing — a falsy return keeps the exception propagating.
468
+ """
469
+ self.close()