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,279 @@
1
+ """The unified LLM port of the library and the provider factory."""
2
+
3
+ from ..config import Config
4
+ from .models import ComplianceFinding, FailureClassification, GroupFailureClassification, ScenarioStep
5
+
6
+
7
+ class LLMProvider:
8
+ """The single LLM port: step code generation, failure classification, the group diagnosis and the verdict."""
9
+
10
+ def generate_step_code( # noqa: PLR0913, PLR0917 — the signature is fixed by the port contract
11
+ self,
12
+ prompt: str,
13
+ user_instructions: str,
14
+ instruction: str,
15
+ step_type: str,
16
+ previous_steps: list[ScenarioStep],
17
+ group_prompt: str | None,
18
+ inputs: dict[str, str],
19
+ declarations: list[str],
20
+ snapshot: str,
21
+ page_url: str | None,
22
+ screenshot: bytes | None,
23
+ cheat_sheet: str,
24
+ attempt_history: list[str],
25
+ recommendation: str | None,
26
+ guidance: str | None,
27
+ ) -> str:
28
+ """Generate step code of the fixed form working through the standard Playwright sync API.
29
+
30
+ Args:
31
+ prompt: the system prompt text supplied by the calling engine;
32
+ applied verbatim as the system message.
33
+ user_instructions: the project's code style instructions supplied
34
+ by the calling engine from the generation_prompt setting;
35
+ empty — the request carries no instructions block, non-empty —
36
+ rendered verbatim as a separate USER INSTRUCTIONS block of the
37
+ user content, identically in both implementations.
38
+ instruction: the prepared instruction of the step — the rendered
39
+ plain-text sentence with actual values embedded; rendered as
40
+ the STEP block; the raw template sentence never reaches the
41
+ request.
42
+ step_type: the type of the step; rendered as a STEP TYPE line
43
+ immediately before the STEP line, identically in both
44
+ implementations; takes no part in step addressing.
45
+ previous_steps: the typed scenario records of the previous steps
46
+ of the test, in execution order — each the prepared
47
+ instruction recorded at the step's execution plus its
48
+ permanent group membership; rendered by the provider
49
+ implementations as the PREVIOUS STEPS block with the group
50
+ entries marked, identically in both.
51
+ group_prompt: the group prompt of the current step's group;
52
+ None — an ordinary step, no GROUP PROMPT block; non-empty —
53
+ rendered by the provider implementations verbatim as a
54
+ separate GROUP PROMPT block immediately before the PREVIOUS
55
+ STEPS block, identically in both; takes no part in step
56
+ addressing.
57
+ inputs: the call-local input bindings of the step; non-empty —
58
+ rendered by the provider implementations as the INPUTS block
59
+ immediately after the STEP line, one ``name = value`` line
60
+ per binding, identically in both; empty — no block.
61
+ declarations: the declared result names of the step; non-empty —
62
+ rendered by the provider implementations as the RESULTS
63
+ block immediately after the INPUTS block stating the result
64
+ contract — the code returns a dictionary of exactly the
65
+ declared names to non-blank strings observed on the page;
66
+ empty — no block, the success-without-result code form.
67
+ snapshot: the accessibility snapshot of the current page.
68
+ page_url: the current URL of the page; non-empty — rendered by
69
+ the provider implementations as its own PAGE URL line
70
+ immediately after the PAGE SNAPSHOT block of the user
71
+ content, identically in both; None — no line; supplied by
72
+ the engine and steering generation paths.
73
+ screenshot: an optional PNG image of the page; passed only when
74
+ the project enables screenshots.
75
+ cheat_sheet: the compact standard Playwright sync API reference
76
+ supplied by the calling engine — rendered as the CHEAT SHEET
77
+ block after the scenario inputs of the user content,
78
+ identically in both implementations; guidance, not an
79
+ allowlist — everything standard stays allowed.
80
+ attempt_history: the rendered per-step attempt records — every
81
+ record a complete multi-line verbatim record composed by the
82
+ calling engine, the original cached code anchored as record 0
83
+ when it exists, the last record the code being fixed;
84
+ non-empty — rendered as a separate HISTORY block after the
85
+ USER INSTRUCTIONS block, every record verbatim, no
86
+ collapsing, no size limits; empty — no block; the list takes
87
+ the place of the former existing_code, error and
88
+ guidance_history inputs.
89
+ recommendation: the diagnosis of the classification that preceded
90
+ the regeneration; non-empty — rendered as a separate
91
+ RECOMMENDATION block after the HISTORY block, None — no
92
+ block.
93
+ guidance: the engineer guidance message of the interactive
94
+ steering; non-empty — rendered as a separate USER GUIDANCE
95
+ block, None — no block.
96
+
97
+ Returns:
98
+ The generated step code of the fixed form, working through the
99
+ standard Playwright sync API — imports from playwright.sync_api
100
+ and the Python standard library only, global at the top level of
101
+ the code block.
102
+
103
+ Raises:
104
+ NotImplementedError: the port itself carries no implementation.
105
+ """
106
+ raise NotImplementedError("LLMProvider is a port; use create_provider() to select an implementation")
107
+
108
+ def classify_step_failure( # noqa: PLR0913, PLR0917 — the signature is fixed by the port contract
109
+ self,
110
+ prompt: str,
111
+ user_instructions: str,
112
+ step_text: str,
113
+ code: str,
114
+ error: str,
115
+ snapshot: str,
116
+ screenshot: bytes | None,
117
+ ) -> FailureClassification:
118
+ """Classify a failed cached step — the former classify_failure, renamed nominally.
119
+
120
+ Args:
121
+ prompt: the system prompt text supplied by the calling engine;
122
+ applied verbatim as the system message.
123
+ user_instructions: the project's classification guidance supplied
124
+ by the calling engine from the classification_prompt setting;
125
+ empty — the request carries no instructions block, non-empty —
126
+ rendered verbatim as a separate USER INSTRUCTIONS block placed
127
+ last of the user content, identically in both implementations.
128
+ step_text: the sentence of the failed step — the prepared
129
+ instruction, never the raw template sentence; a non-template
130
+ step passes its sentence unchanged.
131
+ code: the existing step code that failed.
132
+ error: the human-readable description of the failure.
133
+ snapshot: the accessibility snapshot of the current page.
134
+ screenshot: an optional PNG image of the page; passed only when
135
+ the project enables screenshots.
136
+
137
+ Returns:
138
+ The classification verdict.
139
+
140
+ Raises:
141
+ NotImplementedError: the port itself carries no implementation.
142
+ """
143
+ raise NotImplementedError("LLMProvider is a port; use create_provider() to select an implementation")
144
+
145
+ def classify_group_failure( # noqa: PLR0913, PLR0917 — the signature is fixed by the port contract
146
+ self,
147
+ prompt: str,
148
+ user_instructions: str,
149
+ group_prompt: str,
150
+ group_steps: list[str],
151
+ previous_steps: list[ScenarioStep],
152
+ step_text: str,
153
+ attempt_history: list[str],
154
+ snapshot: str,
155
+ screenshot: bytes | None,
156
+ ) -> GroupFailureClassification:
157
+ """Diagnose a failed group step with the whole interaction in view.
158
+
159
+ Args:
160
+ prompt: the group diagnosis system prompt supplied by the calling
161
+ engine; applied verbatim as the system message.
162
+ user_instructions: the project's classification guidance supplied
163
+ by the calling engine from the classification_prompt setting;
164
+ the diagnosis request carries them exactly as a classification
165
+ request does — empty, no instructions block; non-empty,
166
+ rendered verbatim as a separate USER INSTRUCTIONS block placed
167
+ last of the user content, identically in both implementations.
168
+ group_prompt: the group prompt of the diagnosed group, verbatim.
169
+ group_steps: the composed verbatim traces of the group's steps in
170
+ execution order — each the prepared instruction, the outcome
171
+ and the URL before -> after transition, supplied by the
172
+ calling engine.
173
+ previous_steps: the typed scenario records of the previous steps
174
+ of the test, in execution order — each the prepared
175
+ instruction recorded at the step's execution plus its
176
+ permanent group membership; rendered by the provider
177
+ implementations as the PREVIOUS STEPS block immediately before
178
+ the GROUP STEPS block with the group entries marked,
179
+ identically in both; empty — no block.
180
+ step_text: the prepared instruction of the failed step — never
181
+ the raw template sentence; a non-template step passes its
182
+ sentence unchanged.
183
+ attempt_history: the rendered verbatim records of the failed
184
+ step's attempt history; non-empty — rendered as a separate
185
+ HISTORY block, every record verbatim, no collapsing, no size
186
+ limits; empty — no block.
187
+ snapshot: the accessibility snapshot of the current page.
188
+ screenshot: an optional PNG image of the page; passed only when
189
+ the project enables screenshots.
190
+
191
+ Returns:
192
+ The diagnosis verdict — parsed strictly through
193
+ :func:`~prettyplay.llm.parse_group_failure_classification`; a
194
+ degraded answer is the conservative incurable, never a granted
195
+ regeneration.
196
+
197
+ Raises:
198
+ NotImplementedError: the port itself carries no implementation.
199
+ """
200
+ raise NotImplementedError("LLMProvider is a port; use create_provider() to select an implementation")
201
+
202
+ def check_instruction_compliance( # noqa: PLR0913, PLR0917 — the signature is fixed by the port contract
203
+ self,
204
+ prompt: str,
205
+ user_instructions: str,
206
+ instruction: str,
207
+ step_type: str,
208
+ inputs: dict[str, str],
209
+ declarations: list[str],
210
+ code: str,
211
+ attempt_history: list[str],
212
+ ) -> list[ComplianceFinding]:
213
+ """Check the successfully executed candidate code in two dimensions.
214
+
215
+ Args:
216
+ prompt: the gate system prompt text supplied by the calling
217
+ engine; applied verbatim as the system message.
218
+ user_instructions: the project's generation instructions
219
+ supplied by the calling engine from the generation_prompt
220
+ setting; the calling engine guarantees non-empty — the gate
221
+ never runs on empty instructions.
222
+ instruction: the prepared plain-text instruction of the generated
223
+ step — rendered as the STEP block; the raw template sentence
224
+ never reaches the request.
225
+ step_type: the type of the step; rendered as a STEP TYPE line
226
+ immediately before the STEP line, identically in both
227
+ implementations.
228
+ inputs: the call-local input bindings of the step; non-empty —
229
+ rendered by the provider implementations as the INPUTS block
230
+ immediately after the STEP block, one ``name = value`` line
231
+ per binding, identically in both; empty — no block.
232
+ declarations: the reached capture names of the step; non-empty —
233
+ rendered by the provider implementations as the RESULTS block
234
+ immediately after the INPUTS block stating the exact-key,
235
+ observed non-blank string return contract, identically in
236
+ both; empty — no block, the success-without-result form.
237
+ code: the successfully executed candidate code.
238
+ attempt_history: the rendered per-step attempt records — the
239
+ ground truth of what was already tried; non-empty — rendered
240
+ as a separate ATTEMPT HISTORY block, every record verbatim,
241
+ no collapsing, no size limits; empty — no block.
242
+
243
+ Returns:
244
+ The parsed findings; an empty list means compliant — one verdict
245
+ request per executed candidate; the block order INSTRUCTIONS,
246
+ STEP, the optional INPUTS and RESULTS sections, ATTEMPT HISTORY,
247
+ CODE.
248
+
249
+ Raises:
250
+ NotImplementedError: the port itself carries no implementation.
251
+ """
252
+ raise NotImplementedError("LLMProvider is a port; use create_provider() to select an implementation")
253
+
254
+
255
+ def create_provider(config: Config) -> LLMProvider:
256
+ """Select and construct the LLM provider from configuration — the implementations are interchangeable.
257
+
258
+ Args:
259
+ config: project settings; the provider setting selects the SDK
260
+ implementation.
261
+
262
+ Returns:
263
+ The selected provider implementation.
264
+
265
+ Raises:
266
+ ValueError: the provider setting names no supported provider.
267
+ """
268
+ # deferred: the implementations subclass the port defined in this module,
269
+ # so a top-level import here would be circular
270
+ from .anthropic_provider import AnthropicProvider # noqa: PLC0415
271
+ from .openai_provider import OpenAIProvider # noqa: PLC0415
272
+
273
+ if config.provider == "openai":
274
+ return OpenAIProvider(config)
275
+
276
+ if config.provider == "anthropic":
277
+ return AnthropicProvider(config)
278
+
279
+ raise ValueError(f"unsupported provider {config.provider!r}: expected one of openai, anthropic")
@@ -0,0 +1,61 @@
1
+ # Step and healing hooks
2
+
3
+ Domain: event callbacks of prettyplay. Audience: integrators building custom reporting, metrics or CI reactions on top of step execution.
4
+
5
+ StepHooks is a thin callback contract. The library calls the matching method synchronously while a step executes. The base implementation of every method is a no-op — override only the events you need. Hook implementations are registered on the main library object at the start of a test.
6
+
7
+ ## Events
8
+
9
+ | Method | When | Payload |
10
+ |---|---|---|
11
+ | on_step_started | a step started executing | step_text, step_type (action or assertion) |
12
+ | on_step_passed | the step finished successfully | step_text, step_type |
13
+ | on_step_failed | the step failed | step_text, step_type, error — the **full rendered failure message** (see below) |
14
+ | on_step_verdict | the terminal failure carried a verdict (fires after on_step_failed) | step_text, category (rot, product_defect, fixable, incurable), explanation, recommendation |
15
+ | on_step_finished | the step ended — always the last step event, regardless of outcome | step_text, step_type, outcome (passed or failed) |
16
+ | on_group_started | a group block started (once per entered group, zero-step groups included) | group_prompt |
17
+ | on_group_passed | the group block completed without an exception — a recovered group reports passed; a zero-step group reports passed | group_prompt |
18
+ | on_group_failed | the group block exited through an exception — a failed step, a refused recovery or an author exception alike; the exception still propagates | group_prompt |
19
+ | on_group_finished | the group block ended — always the last group event, regardless of outcome, after on_group_passed/on_group_failed | group_prompt |
20
+ | on_generation_started | a generation attempt started | step_text, attempt (1-based, one per LLM request) |
21
+ | on_healing_started | healing of a failed cached step started | step_text, category (rot, product_defect, fixable, incurable — a group recovery row reports the diagnosis label recoverable) |
22
+ | on_healed | the step healed, cache updated | step_text, explanation (why it failed, what changed — interactive healings report here too) |
23
+ | on_cache_saved | step code written to the cache | step_text, filename |
24
+ | on_cache_skipped | cache write skipped | step_text, reason (e.g. read-only cache) |
25
+
26
+ ## The error payload of on_step_failed
27
+
28
+ `error` carries the full structured render of the terminal failure — the same multi-line text the raised exception carries and the log record writes: the first line with the class name of the terminal failure and the authored reason, the `---` separated step/error section, the conditional received/cause/Call log details section and the unpadded verdict block. Display it verbatim in reports; do not parse it — structured data arrives through on_step_verdict fields. In strict mode on_generation_started and on_healing_started never fire: classification is the only LLM call.
29
+
30
+ ## Attempt semantics
31
+
32
+ Hook events fire per step or per LLM attempt — never per execution retry. Settle re-executions of the same step code (transient failure absorption) emit no hook events: they are visible only as settle_retry log records. on_step_finished closes every step exactly once, passed or failed; on_group_finished closes every entered group exactly once the same way — the group verdict is the block outcome, never the traces: a step healed by the group recovery leaves its verbatim failed trace record behind while the group reports passed.
33
+
34
+ ## Example
35
+
36
+ ```python
37
+ from prettyplay.reporting import StepHooks
38
+
39
+
40
+ class FailureMonitor(StepHooks):
41
+ def on_step_failed(self, step_text: str, step_type: str, error: str) -> None:
42
+ print(f"failed [{step_type}]: {step_text}\n{error}") # the full render, verbatim
43
+
44
+ def on_step_verdict(self, step_text: str, category: str, explanation: str, recommendation: str) -> None:
45
+ print(f"verdict [{category}]: {step_text} — {explanation} → {recommendation}")
46
+
47
+ def on_step_finished(self, step_text: str, step_type: str, outcome: str) -> None:
48
+ print(f"finished [{outcome}]: {step_text}")
49
+
50
+ def on_healed(self, step_text: str, explanation: str) -> None:
51
+ print(f"healed: {step_text} — {explanation}")
52
+ ```
53
+
54
+ ## Rules
55
+
56
+ - Handlers run synchronously inside step execution: keep them fast
57
+ - A raising handler is logged as a warning and skipped — the test run never fails because of a hook
58
+ - on_step_verdict fires only when the verdict exists: LLM unavailability skips the verdict quietly (WARNING in the log)
59
+ - The verdict payload is built from the verdict object of the failure — never parsed from the rendered `error` text
60
+ - Payload values are plain strings; the attempt counter of on_generation_started is an int
61
+ - Step texts land in logs and hooks: never put secrets or personal data into a step sentence
@@ -0,0 +1,6 @@
1
+ """Facade of the prettyplay.reporting cell: the visibility of step execution."""
2
+
3
+ from .hooks import StepHooks
4
+ from .reporter import StepReporter
5
+
6
+ __all__ = ["StepHooks", "StepReporter"]
@@ -0,0 +1,47 @@
1
+ """Thin callback contract for integrator-side reactions to step execution events."""
2
+
3
+
4
+ class StepHooks:
5
+ """Callback contract of the fourteen prettyplay events; every base method is a no-op."""
6
+
7
+ def on_step_started(self, step_text: str, step_type: str) -> None:
8
+ """A step started executing; ``step_type`` is action or assertion."""
9
+
10
+ def on_step_passed(self, step_text: str, step_type: str) -> None:
11
+ """The step finished successfully."""
12
+
13
+ def on_step_failed(self, step_text: str, step_type: str, error: str) -> None:
14
+ """The step failed; ``error`` is the rendered failure message, same as the exception text."""
15
+
16
+ def on_step_verdict(self, step_text: str, category: str, explanation: str, recommendation: str) -> None:
17
+ """The terminal failure of the step carried a verdict; fires after on_step_failed."""
18
+
19
+ def on_step_finished(self, step_text: str, step_type: str, outcome: str) -> None:
20
+ """The step ended — the closing event of every step, fired exactly once regardless of outcome."""
21
+
22
+ def on_group_started(self, group_prompt: str) -> None:
23
+ """A group block started; ``group_prompt`` is the group prompt, verbatim."""
24
+
25
+ def on_group_passed(self, group_prompt: str) -> None:
26
+ """The group block completed without an exception — a recovered group reports passed here."""
27
+
28
+ def on_group_failed(self, group_prompt: str) -> None:
29
+ """The group block exited through an exception — a failed step, a refused recovery or an author error."""
30
+
31
+ def on_group_finished(self, group_prompt: str) -> None:
32
+ """The group block ended — the closing event of every group, fired exactly once regardless of outcome."""
33
+
34
+ def on_generation_started(self, step_text: str, attempt: int) -> None:
35
+ """A code generation attempt started; ``attempt`` is the 1-based attempt number."""
36
+
37
+ def on_healing_started(self, step_text: str, category: str) -> None:
38
+ """Healing of a failed cached step started; ``category`` is rot, product_defect, fixable or incurable."""
39
+
40
+ def on_healed(self, step_text: str, explanation: str) -> None:
41
+ """The step was healed and the cache updated; ``explanation`` says why and what changed."""
42
+
43
+ def on_cache_saved(self, step_text: str, filename: str) -> None:
44
+ """Step code was written to the cache file ``filename``."""
45
+
46
+ def on_cache_skipped(self, step_text: str, reason: str) -> None:
47
+ """The cache write was skipped; ``reason`` names the cause, e.g. a read-only cache."""
@@ -0,0 +1,70 @@
1
+ """The single visibility point of prettyplay: the ``prettyplay`` logger plus hook fan-out."""
2
+
3
+ import logging
4
+
5
+ from .hooks import StepHooks
6
+
7
+ #: Events written at WARNING; every other event is step/generation/healing lifecycle INFO.
8
+ _WARNING_EVENTS = frozenset({"on_cache_skipped"})
9
+
10
+ #: Attributes Logger.makeRecord forbids overwriting: collision keys get a ``ctx_`` prefix
11
+ #: in the log record only — hooks always receive the original payload kwargs.
12
+ _LOG_RECORD_RESERVED = frozenset(
13
+ {
14
+ "name",
15
+ "msg",
16
+ "message",
17
+ "args",
18
+ "levelname",
19
+ "levelno",
20
+ "pathname",
21
+ "filename",
22
+ "module",
23
+ "exc_info",
24
+ "funcName",
25
+ "lineno",
26
+ "created",
27
+ "msecs",
28
+ "relativeCreated",
29
+ "thread",
30
+ "threadName",
31
+ "process",
32
+ "processName",
33
+ "stack_info",
34
+ "asctime",
35
+ "taskName",
36
+ }
37
+ )
38
+
39
+
40
+ class StepReporter:
41
+ """Dispatches every step, generation, healing and cache event to the logger and hooks."""
42
+
43
+ def __init__(self, hooks: list[StepHooks]) -> None:
44
+ """Keep the hooks list by reference in the public ``hooks`` attribute.
45
+
46
+ Args:
47
+ hooks: callback implementations registered by the integrator; may be empty.
48
+ ``add_hooks`` of the main object appends into this same list.
49
+ """
50
+ self.hooks = hooks
51
+ self._logger = logging.getLogger("prettyplay")
52
+
53
+ def emit(self, event: str, payload: dict[str, str | int]) -> None:
54
+ """Dispatch one event to the logger and to every hook, in registration order.
55
+
56
+ Args:
57
+ event: the event name — equals a ``StepHooks`` method name exactly.
58
+ payload: the payload fields of the event; values are strings, the
59
+ attempt counter is an int.
60
+ """
61
+ level = logging.WARNING if event in _WARNING_EVENTS else logging.INFO
62
+ extra = {f"ctx_{key}" if key in _LOG_RECORD_RESERVED else key: value for key, value in payload.items()}
63
+
64
+ self._logger.log(level, event, extra=extra)
65
+
66
+ for hook in self.hooks:
67
+ try:
68
+ getattr(hook, event)(**payload)
69
+ except Exception:
70
+ self._logger.warning("hook call failed", extra={"event": event, "hook": type(hook).__name__})
prettyplay/runtime.py ADDED
@@ -0,0 +1,81 @@
1
+ """Per-test composition root of the library: the objects one test owns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+
7
+ from .cache import RunBudgets
8
+ from .config import Config
9
+ from .driver import DriverSession, PageFacade
10
+ from .llm import LLMProvider, create_provider
11
+
12
+
13
+ class PrettyplayRuntime:
14
+ """The composition root: the per-test objects one test composes — nothing shared between tests.
15
+
16
+ Attributes:
17
+ _config: validated project settings of the test.
18
+ _budgets: the per-test attempt registry owned by this runtime.
19
+ _driver: the browser driver of the test; ``None`` until first access.
20
+ _provider: the LLM provider of the test; ``None`` until first access.
21
+ """
22
+
23
+ def __init__(self, config: Config) -> None:
24
+ """Compose the eager per-test objects from the config.
25
+
26
+ Args:
27
+ config: validated project settings of the test.
28
+ """
29
+ self._config = config
30
+ self._budgets = RunBudgets(config.generation_attempts, config.healing_attempts)
31
+ self._driver: DriverSession | None = None
32
+ self._provider: LLMProvider | None = None
33
+ atexit.register(self.close)
34
+
35
+ @property
36
+ def config(self) -> Config:
37
+ """The validated settings of the test."""
38
+ return self._config
39
+
40
+ @property
41
+ def budgets(self) -> RunBudgets:
42
+ """The per-test attempt registry owned by this one runtime."""
43
+ return self._budgets
44
+
45
+ @property
46
+ def driver(self) -> DriverSession:
47
+ """The browser driver of the test, constructed lazily exactly once.
48
+
49
+ Returns:
50
+ The browser driver — a construction after a ``close`` re-arms
51
+ the ``atexit`` hook the close dropped.
52
+ """
53
+ if self._driver is None:
54
+ self._driver = DriverSession(self._config)
55
+ atexit.register(self.close)
56
+
57
+ return self._driver
58
+
59
+ @property
60
+ def provider(self) -> LLMProvider:
61
+ """The LLM provider of the test, constructed lazily exactly once."""
62
+ if self._provider is None:
63
+ self._provider = create_provider(self._config)
64
+
65
+ return self._provider
66
+
67
+ def open_page(self) -> PageFacade:
68
+ """Open a fresh isolated page of the test driver.
69
+
70
+ Returns:
71
+ The facade of the new page of a fresh isolated context.
72
+ """
73
+ return self.driver.open_context()
74
+
75
+ def close(self) -> None:
76
+ """Stop the browser driver of the test; idempotent; drops the ``atexit`` hook."""
77
+ atexit.unregister(self.close)
78
+
79
+ if self._driver is not None:
80
+ self._driver.close()
81
+ self._driver = None