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.
- prettyplay/.usages/lifecycle.md +50 -0
- prettyplay/.usages/steps.md +56 -0
- prettyplay/CODEMANIFEST +182 -0
- prettyplay/__init__.py +13 -0
- prettyplay/cache/.usages/addressing.md +31 -0
- prettyplay/cache/.usages/budgets.md +21 -0
- prettyplay/cache/.usages/storage.md +32 -0
- prettyplay/cache/CODEMANIFEST +152 -0
- prettyplay/cache/__init__.py +8 -0
- prettyplay/cache/budgets.py +73 -0
- prettyplay/cache/models.py +60 -0
- prettyplay/cache/store.py +260 -0
- prettyplay/cache/text.py +35 -0
- prettyplay/config/.usages/configuration.md +87 -0
- prettyplay/config/CODEMANIFEST +132 -0
- prettyplay/config/__init__.py +6 -0
- prettyplay/config/loader.py +192 -0
- prettyplay/config/models.py +92 -0
- prettyplay/driver/.usages/facade.md +66 -0
- prettyplay/driver/CODEMANIFEST +162 -0
- prettyplay/driver/__init__.py +6 -0
- prettyplay/driver/page.py +350 -0
- prettyplay/driver/session.py +288 -0
- prettyplay/engine/.usages/generation.md +45 -0
- prettyplay/engine/.usages/healing.md +31 -0
- prettyplay/engine/CODEMANIFEST +215 -0
- prettyplay/engine/__init__.py +8 -0
- prettyplay/engine/classification.py +69 -0
- prettyplay/engine/execution.py +25 -0
- prettyplay/engine/generator.py +318 -0
- prettyplay/engine/healer.py +116 -0
- prettyplay/engine/text.py +19 -0
- prettyplay/executor.py +109 -0
- prettyplay/failures/.usages/taxonomy.md +40 -0
- prettyplay/failures/CODEMANIFEST +117 -0
- prettyplay/failures/__init__.py +17 -0
- prettyplay/failures/errors.py +147 -0
- prettyplay/llm/.usages/classification.md +25 -0
- prettyplay/llm/.usages/providers.md +33 -0
- prettyplay/llm/CODEMANIFEST +125 -0
- prettyplay/llm/__init__.py +8 -0
- prettyplay/llm/_request.py +216 -0
- prettyplay/llm/anthropic_provider.py +213 -0
- prettyplay/llm/models.py +22 -0
- prettyplay/llm/openai_provider.py +187 -0
- prettyplay/llm/provider.py +115 -0
- prettyplay/reporting/.usages/hooks.md +41 -0
- prettyplay/reporting/CODEMANIFEST +79 -0
- prettyplay/reporting/__init__.py +6 -0
- prettyplay/reporting/hooks.py +37 -0
- prettyplay/reporting/reporter.py +70 -0
- prettyplay/runtime.py +107 -0
- prettyplay/scenario.py +291 -0
- prettyplay-0.0.0.dist-info/METADATA +236 -0
- prettyplay-0.0.0.dist-info/RECORD +58 -0
- prettyplay-0.0.0.dist-info/WHEEL +5 -0
- prettyplay-0.0.0.dist-info/licenses/LICENSE +28 -0
- prettyplay-0.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Run lifecycle
|
|
2
|
+
|
|
3
|
+
Domain: how a run is composed — runtimes, contexts, hooks, failures. Audience: integrators wiring the library into a runner and CI.
|
|
4
|
+
|
|
5
|
+
## Composition
|
|
6
|
+
|
|
7
|
+
One runtime per test: each PrettyTest builds its own runtime — its own configuration, browser process, LLM provider and attempt budgets. Tests never share browser state or budgets through the library; outcomes do not depend on the execution order. Constructing a test is cheap and requires no LLM credentials: the browser starts lazily on the first step. A config passed to the test overrides only the explicitly set values — everything else resolves from pyproject+env. When the process exits, every runtime stops its browser and driver synchronously before returning control to the terminal — scripts never leave browser processes behind.
|
|
8
|
+
|
|
9
|
+
## Wiring into a framework
|
|
10
|
+
|
|
11
|
+
The library is framework-agnostic: no plugins, no base classes. Construct the object in your test, call the step methods, let failures propagate — the runner counts them as ordinary test failures. A few lines of glue are enough; the suite runs by the standard runner command.
|
|
12
|
+
|
|
13
|
+
## Hooks
|
|
14
|
+
|
|
15
|
+
Implement the StepHooks callback contract and register the implementation with add_hooks before the first step — step, generation, healing, cache and verdict events reach the handler synchronously. on_step_verdict fires after on_step_failed whenever the terminal failure carries an LLM verdict.
|
|
16
|
+
|
|
17
|
+
## Failures
|
|
18
|
+
|
|
19
|
+
Four kinds reach the runner:
|
|
20
|
+
|
|
21
|
+
| Kind | Meaning | Reaction |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| ProductDefectError | a real regression — also an AssertionError: runners show a failure, not an error; the traceback is folded to the library boundary | treat as a bug — this failure is the value of the suite |
|
|
24
|
+
| IncurableStepError | the step cannot be generated or healed | follow the carried recommendation |
|
|
25
|
+
| LlmUnavailableError | the LLM is down | only generation and healing are blocked; cached steps keep running |
|
|
26
|
+
| ConfigurationError | the settings are invalid | fix the named setting — the message lists the allowed values |
|
|
27
|
+
|
|
28
|
+
ProductDefectError and IncurableStepError carry the LLM verdict — category, explanation, recommendation — in the exception message, the on_step_verdict hook event and the log. When the LLM is unavailable the verdict is skipped quietly; the failure itself never waits for it.
|
|
29
|
+
|
|
30
|
+
## Team workflow
|
|
31
|
+
|
|
32
|
+
Generate locally where the LLM is reachable, commit the cache directory, run CI fully from the cache with no LLM keys.
|
|
33
|
+
|
|
34
|
+
## Interactive sessions (IPython, Jupyter)
|
|
35
|
+
|
|
36
|
+
The Playwright session lives in a background driver thread owned by the library: the thread that executes the steps never holds a running asyncio loop, so interactive hosts that drive their own prompt through asyncio (IPython, Jupyter) keep working after every step — passed or failed.
|
|
37
|
+
|
|
38
|
+
Each test owns its browser process: it starts on the first step of the test and stops when the test closes. In scripts every runtime stops automatically at process exit through its atexit hook. In an interactive session the process keeps living between cells, so close the test object explicitly when the interactive exploration is over:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from prettyplay import PrettyTest
|
|
42
|
+
|
|
43
|
+
test = PrettyTest("login-flow")
|
|
44
|
+
test.action("open the login page")
|
|
45
|
+
test.action("enter the login and password")
|
|
46
|
+
test.assertion("the «Welcome back» message appears")
|
|
47
|
+
test.close() # stops this test's browser and driver thread
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Generation of the step cache remains a batch workflow: prefer a plain script or a pytest run over a REPL when generating many steps.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Writing steps
|
|
2
|
+
|
|
3
|
+
Domain: authoring UI tests as plain sentences. Audience: engineers writing tests and integrators wiring the library into a test framework.
|
|
4
|
+
|
|
5
|
+
## A test as a scenario
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from prettyplay import PrettyTest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_login():
|
|
12
|
+
t = PrettyTest("login-flow")
|
|
13
|
+
t.action("open the login page")
|
|
14
|
+
t.action("enter the login and password")
|
|
15
|
+
t.action("click the «Sign in» button")
|
|
16
|
+
t.assertion("the «Welcome back» message appears")
|
|
17
|
+
t.close()
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or with the context manager:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
with PrettyTest("login-flow") as t:
|
|
24
|
+
t.action("open the login page")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Step kinds
|
|
28
|
+
|
|
29
|
+
- action(text) — performs what the sentence says
|
|
30
|
+
- assertion(text) — verifies what the sentence says; a legitimately failed expectation fails the test as a product defect
|
|
31
|
+
|
|
32
|
+
## Screenshots
|
|
33
|
+
|
|
34
|
+
Two author-facing abilities on the test object:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
with PrettyTest("login-flow") as t:
|
|
38
|
+
t.action("open the login page")
|
|
39
|
+
png = t.get_screenshot() # full-page PNG bytes of the current state
|
|
40
|
+
t.save_screenshot("artifacts/home.png") # write full-page PNG to an explicit path
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- Both require an opened page: call them after the first step of the test
|
|
44
|
+
- Nothing is captured automatically on failures — attaching screenshots to reports is the author's decision
|
|
45
|
+
|
|
46
|
+
## Addressing
|
|
47
|
+
|
|
48
|
+
The constructor arguments form the cache address: cache_key (mandatory) and cache_path (optional subdirectory). Equal cache keys in the shared root reuse one cached step across tests; a different language, step type or key is a different step.
|
|
49
|
+
|
|
50
|
+
## What you see
|
|
51
|
+
|
|
52
|
+
Step sentences go to the logger prettyplay at info level — the suite output reads as a plain-language scenario. Healing, cache writes and skipped writes are reported loudly through the same logger.
|
|
53
|
+
|
|
54
|
+
## Limitations
|
|
55
|
+
|
|
56
|
+
Step sentences land in the repository cache, the logs and the LLM requests: never put secrets or personal data into a step.
|
prettyplay/CODEMANIFEST
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Imports:
|
|
2
|
+
- Types:
|
|
3
|
+
- Config AS PrettyConfig
|
|
4
|
+
- load_config
|
|
5
|
+
From: prettyplay/config
|
|
6
|
+
- Types:
|
|
7
|
+
- StepHooks
|
|
8
|
+
- StepReporter
|
|
9
|
+
Usages:
|
|
10
|
+
- hooks
|
|
11
|
+
From: prettyplay/reporting
|
|
12
|
+
- Types:
|
|
13
|
+
- ProductDefectError
|
|
14
|
+
- IncurableStepError
|
|
15
|
+
- LlmUnavailableError
|
|
16
|
+
- PrettyplayError
|
|
17
|
+
Usages:
|
|
18
|
+
- taxonomy
|
|
19
|
+
From: prettyplay/failures
|
|
20
|
+
- Types:
|
|
21
|
+
- DriverSession
|
|
22
|
+
- PageFacade
|
|
23
|
+
From: prettyplay/driver
|
|
24
|
+
- Types:
|
|
25
|
+
- StepCache
|
|
26
|
+
- StepIdentity
|
|
27
|
+
- normalize_step_text
|
|
28
|
+
- RunBudgets
|
|
29
|
+
From: prettyplay/cache
|
|
30
|
+
- Types:
|
|
31
|
+
- LlmProvider
|
|
32
|
+
- create_provider
|
|
33
|
+
From: prettyplay/llm
|
|
34
|
+
- Types:
|
|
35
|
+
- StepGenerator
|
|
36
|
+
- StepHealer
|
|
37
|
+
- run_step_code
|
|
38
|
+
Usages:
|
|
39
|
+
- generation
|
|
40
|
+
- healing
|
|
41
|
+
From: prettyplay/engine
|
|
42
|
+
|
|
43
|
+
Usages:
|
|
44
|
+
conventions: .goga/usages/conventions.md
|
|
45
|
+
|
|
46
|
+
Annotations: |
|
|
47
|
+
Use `conventions` for code writing rules and testing.
|
|
48
|
+
Use `taxonomy` from Imports for the failure kinds the step methods propagate.
|
|
49
|
+
Use `hooks` from Imports for the callback contract accepted by add_hooks.
|
|
50
|
+
Use `generation` and `healing` from Imports for the engine cycles the executor delegates to.
|
|
51
|
+
|
|
52
|
+
The facade of the library: one main object per test; the engineer writes steps as plain sentences and reads the suite as a scenario.
|
|
53
|
+
Framework-agnostic: no plugin machinery, no runner integration — the integrator wires the library in a few lines.
|
|
54
|
+
Step sentences are visible in the test output through the standard logger prettyplay; step texts land in the cache and in LLM requests — never put secrets or personal data into a step sentence.
|
|
55
|
+
`PrettyConfig` — the public name of the settings model — is re-exported by this facade (see the embedding).
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
->PrettyConfig: {}
|
|
60
|
+
|
|
61
|
+
"PrettyTest(cache_key: str, cache_path: str | None, config: PrettyConfig | None)":
|
|
62
|
+
location: scenario.py
|
|
63
|
+
annotations: |
|
|
64
|
+
The main integrator object — one instance per test. Owns the cache addressing and the isolated browser context of the test; the step cycle is delegated to `StepExecutor`.
|
|
65
|
+
|
|
66
|
+
`cache_key`: the mandatory explicit context key — part of the step address; equal keys in the shared root reuse steps across tests.
|
|
67
|
+
`cache_path`: the optional cache subdirectory — part of the address; steps never leak across subdirectories.
|
|
68
|
+
`config`: per-test overrides — the same full model; explicitly set values win, unset/empty fields resolve from pyproject+env; None — everything resolves from pyproject+env, as before.
|
|
69
|
+
|
|
70
|
+
Supports the context manager protocol: exit closes the test.
|
|
71
|
+
|
|
72
|
+
Algorithm:
|
|
73
|
+
1. Resolve the effective config: `load_config` with overrides = config
|
|
74
|
+
2. Build the own `PrettyplayRuntime` with the effective config — no process-wide singleton exists
|
|
75
|
+
3. Construct the per-test reporter: `StepReporter` with an empty hooks list; add_hooks appends to it
|
|
76
|
+
4. Construct the per-test `StepCache` from the runtime config, `cache_path` and the reporter
|
|
77
|
+
5. Construct `StepGenerator` and `StepHealer` from the runtime config, provider and budgets, the step cache and the reporter
|
|
78
|
+
6. Construct `StepExecutor` with `cache_key`, the cache, the engines, the runtime budgets and the reporter
|
|
79
|
+
7. The test page opens lazily on the first step via the runtime open_page
|
|
80
|
+
|
|
81
|
+
Requirements:
|
|
82
|
+
- Construction is cheap: the browser starts lazily on the first step; no LLM credentials are required to construct
|
|
83
|
+
- The instance holds no cross-test state: identical outcomes regardless of execution order
|
|
84
|
+
properties:
|
|
85
|
+
"cache_key -> str": |
|
|
86
|
+
The explicit context key, exposed for diagnostics.
|
|
87
|
+
methods:
|
|
88
|
+
"action(text: str)": |
|
|
89
|
+
Execute the action step `text`.
|
|
90
|
+
|
|
91
|
+
Delegates to the executor execute with the step type action, the sentence and the test page; failures propagate by kind — `ProductDefectError`, `IncurableStepError`, `LlmUnavailableError` (see `taxonomy`).
|
|
92
|
+
A `PrettyplayError` leaving this method carries its traceback folded to the library boundary: internal library frames — engine, healing, provider — do not appear in what the runner shows.
|
|
93
|
+
"assertion(text: str)": |
|
|
94
|
+
Execute the assertion step `text` — a legitimately failed expectation surfaces as the product defect failure.
|
|
95
|
+
|
|
96
|
+
Delegates to the executor execute with the step type assertion; failures propagate by kind — `ProductDefectError`, `IncurableStepError`, `LlmUnavailableError` (see `taxonomy`).
|
|
97
|
+
A `PrettyplayError` leaving this method carries its traceback folded to the library boundary: internal library frames — engine, healing, provider — do not appear in what the runner shows.
|
|
98
|
+
"get_screenshot() -> image: bytes": |
|
|
99
|
+
Return a full-page PNG image of the current state of the test page — uniform with the facade screenshot.
|
|
100
|
+
|
|
101
|
+
`image`: the full-page PNG bytes.
|
|
102
|
+
|
|
103
|
+
Requirements:
|
|
104
|
+
- Requires an opened test page: calling before the first step raises a loud actionable `PrettyplayError` telling to run a step first
|
|
105
|
+
- No screenshot is taken automatically on step failures: the decision to capture belongs to the test author
|
|
106
|
+
"save_screenshot(filepath: str)": |
|
|
107
|
+
Write a full-page PNG image of the current state of the test page to `filepath`.
|
|
108
|
+
|
|
109
|
+
`filepath`: the explicit destination path chosen by the user — any directory, any filename; no default directory is imposed.
|
|
110
|
+
|
|
111
|
+
Requirements:
|
|
112
|
+
- Requires an opened test page: calling before the first step raises a loud actionable `PrettyplayError` telling to run a step first
|
|
113
|
+
- A write failure — e.g. a missing parent directory — surfaces as a loud actionable `PrettyplayError`; nothing is created silently
|
|
114
|
+
"add_hooks(hooks: StepHooks)": |
|
|
115
|
+
Register a callback implementation (see `hooks`); applies to the steps of this test.
|
|
116
|
+
"close()": |
|
|
117
|
+
Close the test page context and stop the whole runtime of this test — the browser process, the Playwright driver and the driver thread; idempotent; the context manager exit does the same.
|
|
118
|
+
|
|
119
|
+
"StepExecutor(cache_key: str, cache: StepCache, generator: StepGenerator, healer: StepHealer, budgets: RunBudgets, reporter: StepReporter)":
|
|
120
|
+
location: executor.py
|
|
121
|
+
annotations: |
|
|
122
|
+
The owner of the step cycle: cache hit — execute; cache miss — generate and store; cached failure — heal.
|
|
123
|
+
|
|
124
|
+
`cache_key`: the context key of the owning test object.
|
|
125
|
+
`cache`: the step cache of the test.
|
|
126
|
+
`generator` and `healer`: the engines (see `generation` and `healing` from Imports).
|
|
127
|
+
`budgets`: the per-test attempt registry.
|
|
128
|
+
`reporter`: the visibility point.
|
|
129
|
+
methods:
|
|
130
|
+
"execute(step_text: str, step_type: str, page: PageFacade)": |
|
|
131
|
+
Run one step through the full cycle.
|
|
132
|
+
|
|
133
|
+
Algorithm:
|
|
134
|
+
1. Report on_step_started with the sentence and the step type
|
|
135
|
+
2. Build the step identity: `normalize_step_text`, then `StepIdentity` with the test cache key and the step type
|
|
136
|
+
3. Load the cached step: a hit executes its code with `run_step_code` against `page`
|
|
137
|
+
4. On a hit execution failure: delegate to the healer heal with the failure description and the scenario context — a healed step is already re-executed and stored by the engine
|
|
138
|
+
5. On a miss: the generator generate — the engine stores the step on success
|
|
139
|
+
6. Append the sentence to the scenario context of the test — the previous step texts feed the next generation
|
|
140
|
+
7. Report on_step_passed; on a failed step report on_step_failed with the sentence, the step type and the short error description — then, when the terminal failure carries a verdict, report on_step_verdict with the sentence and the three verdict fields; finally raise by kind — `ProductDefectError`, `IncurableStepError`, `LlmUnavailableError` (see `taxonomy`)
|
|
141
|
+
|
|
142
|
+
Requirements:
|
|
143
|
+
- The scenario context lives per test: steps of different tests never mix
|
|
144
|
+
- A cached step executes with no LLM involvement whatsoever
|
|
145
|
+
- An assertion step surfaces a legitimately failed expectation as the product defect failure
|
|
146
|
+
|
|
147
|
+
"PrettyplayRuntime(config: PrettyConfig)":
|
|
148
|
+
location: runtime.py
|
|
149
|
+
annotations: |
|
|
150
|
+
The per-test composition root: one instance per test, owning everything the steps of that test share.
|
|
151
|
+
|
|
152
|
+
`config`: the effective settings of the test.
|
|
153
|
+
|
|
154
|
+
Requirements:
|
|
155
|
+
- One instance serves exactly one test: construction starts nothing expensive — the browser, the provider and the budgets belong to this test alone
|
|
156
|
+
properties:
|
|
157
|
+
"config -> PrettyConfig": |
|
|
158
|
+
The effective settings of the test.
|
|
159
|
+
"driver -> DriverSession": |
|
|
160
|
+
The browser process of this test, created lazily.
|
|
161
|
+
"budgets -> RunBudgets": |
|
|
162
|
+
The attempt registry of the test — one budget per step within the test.
|
|
163
|
+
"provider -> LlmProvider": |
|
|
164
|
+
The LLM provider instance, created lazily on first access via `create_provider`.
|
|
165
|
+
|
|
166
|
+
Requirements:
|
|
167
|
+
- Constructing the runtime never requires LLM credentials: a missing key surfaces as the infrastructure failure on the first generation or classification request
|
|
168
|
+
methods:
|
|
169
|
+
"open_page() -> page: PageFacade": |
|
|
170
|
+
Open a fresh isolated browser context and return its page facade — one per test.
|
|
171
|
+
"close()": |
|
|
172
|
+
Stop the browser, the Playwright driver and the driver thread of this test; safe when nothing was started.
|
|
173
|
+
|
|
174
|
+
Requirements:
|
|
175
|
+
- Every instance registers its own close with atexit, so the driver stops synchronously before the process exits even when no test closes the runtime explicitly; manual calls stay valid and idempotent
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
Author: Goga
|
|
180
|
+
CreatedAt: 07/09/26
|
|
181
|
+
Description: |
|
|
182
|
+
The facade of prettyplay: the per-test scenario object with screenshot abilities, the step cycle executor with verdict reporting, and the per-test composition root.
|
prettyplay/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Facade of the prettyplay root: the composition root of the library."""
|
|
2
|
+
|
|
3
|
+
from .config import PrettyConfig
|
|
4
|
+
from .executor import StepExecutor
|
|
5
|
+
from .runtime import PrettyplayRuntime
|
|
6
|
+
from .scenario import PrettyTest
|
|
7
|
+
|
|
8
|
+
__all__ = [ # noqa: RUF022 — the facade listing order is fixed by the root cell contract
|
|
9
|
+
"PrettyTest",
|
|
10
|
+
"PrettyConfig",
|
|
11
|
+
"PrettyplayRuntime",
|
|
12
|
+
"StepExecutor",
|
|
13
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Step addressing
|
|
2
|
+
|
|
3
|
+
Domain: step identity and addressing of the cache. Audience: engineers reasoning about step reuse and inspecting the repository cache.
|
|
4
|
+
|
|
5
|
+
## Identity triple
|
|
6
|
+
|
|
7
|
+
| Component | Source | Effect on identity |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| cache_key | the main object constructor argument | a different key — a different step |
|
|
10
|
+
| step type | action vs assertion | the same sentence as action and as assertion — two steps |
|
|
11
|
+
| normalized sentence | NFC, trim, whitespace collapse, casefold | «Click Sign in» equals «click sign in »; a Russian sentence and its English translation are different steps |
|
|
12
|
+
|
|
13
|
+
A missing cache entry for the computed address is a cache miss — the step is generated, not an error.
|
|
14
|
+
|
|
15
|
+
## Normalize and address
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from prettyplay.cache import StepIdentity, normalize_step_text
|
|
19
|
+
|
|
20
|
+
normalized = normalize_step_text(" Click Sign In ")
|
|
21
|
+
identity = StepIdentity(
|
|
22
|
+
cache_key="login-flow",
|
|
23
|
+
step_type="action",
|
|
24
|
+
normalized_text=normalized,
|
|
25
|
+
)
|
|
26
|
+
# identity.filename — the deterministic digest of the triple
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Layout
|
|
30
|
+
|
|
31
|
+
The cache root defaults to <repo root>/.prettyplay/cache/ and is set by cache_root. The optional subdirectory argument is part of the address: steps never leak across subdirectories; without a subdirectory, equal cache keys are reused across tests. One .py file per step.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Attempt budgets
|
|
2
|
+
|
|
3
|
+
Domain: generation and healing attempt budgets. Audience: engineers tuning budgets and reasoning about budget exhaustion.
|
|
4
|
+
|
|
5
|
+
## Consume attempts
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from prettyplay.cache import RunBudgets
|
|
9
|
+
|
|
10
|
+
budgets = RunBudgets(generation_limit=3, healing_limit=2)
|
|
11
|
+
|
|
12
|
+
if budgets.try_generation(identity):
|
|
13
|
+
... # attempt allowed; False — budget exhausted, the caller reports incurability
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Semantics
|
|
17
|
+
|
|
18
|
+
- Budgets are per step per test: every test owns its registry and starts with full limits — a step reused across tests gets a fresh budget in each test (N tests running one step in a process spend N × attempts in total)
|
|
19
|
+
- Defaults: 3 generation attempts, 2 healing attempts — configurable in the project settings
|
|
20
|
+
- An exhausted budget is the incurable failure, never an infinite loop
|
|
21
|
+
- Budgets exist only in the memory of the running process — nothing is persisted
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Cache storage
|
|
2
|
+
|
|
3
|
+
Domain: reading and writing the step cache. Audience: engineers inspecting cache files and building tooling around the store.
|
|
4
|
+
|
|
5
|
+
## Read and write
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from prettyplay.cache import CachedStep, StepCache
|
|
9
|
+
from prettyplay.config import Config
|
|
10
|
+
from prettyplay.reporting import StepReporter
|
|
11
|
+
|
|
12
|
+
cache = StepCache(config=Config(), path="checkout", reporter=StepReporter(hooks=[]))
|
|
13
|
+
|
|
14
|
+
step = cache.load(identity) # None on a cache miss
|
|
15
|
+
if step is None:
|
|
16
|
+
# a miss means: generate the step, then store it
|
|
17
|
+
cache.save(CachedStep(identity=identity, code=step_code, created_at="2026-09-07"))
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## File format
|
|
21
|
+
|
|
22
|
+
Each file carries the metadata fields (step sentence, cache key, step type, creation date) followed by the generated step code of the fixed form. Files carry no library version and are never invalidated by a library upgrade.
|
|
23
|
+
|
|
24
|
+
## Write behavior
|
|
25
|
+
|
|
26
|
+
- save never fails the run: a read-only cache or a busy Windows target skips the write loudly
|
|
27
|
+
- writes are atomic: a unique temporary file in the target directory, then an atomic replace; the last writer wins, a partial file never becomes visible
|
|
28
|
+
- load always works, in every environment
|
|
29
|
+
|
|
30
|
+
## Team workflow
|
|
31
|
+
|
|
32
|
+
Generate locally where the LLM is reachable → commit the cache directory → CI runs the whole suite from the cache with no LLM keys at all.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Imports:
|
|
2
|
+
- Types:
|
|
3
|
+
- Config
|
|
4
|
+
From: prettyplay/config
|
|
5
|
+
- Types:
|
|
6
|
+
- StepReporter
|
|
7
|
+
Usages:
|
|
8
|
+
- hooks
|
|
9
|
+
From: prettyplay/reporting
|
|
10
|
+
|
|
11
|
+
Usages:
|
|
12
|
+
conventions: .goga/usages/conventions.md
|
|
13
|
+
|
|
14
|
+
Annotations: |
|
|
15
|
+
Use `conventions` for code writing rules and testing.
|
|
16
|
+
Use `hooks` from Imports for the payload contract of the cache events.
|
|
17
|
+
|
|
18
|
+
The cache is a repository artifact: one .py file per step, addressed deterministically; the cache is always read, writes are best-effort.
|
|
19
|
+
Step identity is intentional: the same normalized sentence in the same context is one step; a different language, a different step type or a different cache key is a different step.
|
|
20
|
+
RunBudgets lives in this cell because its accounting key is `StepIdentity`: per-test attempt accounting stays next to the addressing it is keyed by.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
"normalize_step_text(text: str) -> normalized: str":
|
|
25
|
+
location: text.py
|
|
26
|
+
annotations: |
|
|
27
|
+
Normalize a step sentence for identity and addressing.
|
|
28
|
+
|
|
29
|
+
`text`: the raw step sentence as written by the engineer.
|
|
30
|
+
`normalized`: the normalized sentence.
|
|
31
|
+
|
|
32
|
+
Algorithm:
|
|
33
|
+
1. Apply Unicode NFC normalization
|
|
34
|
+
2. Trim leading and trailing whitespace
|
|
35
|
+
3. Collapse internal whitespace runs to single spaces
|
|
36
|
+
4. Apply casefold
|
|
37
|
+
|
|
38
|
+
Requirements:
|
|
39
|
+
- Pure function: no I/O, no locale dependence
|
|
40
|
+
- «Нажать Войти» and «нажать войти » normalize to the same string; a Russian sentence and its English translation stay different
|
|
41
|
+
|
|
42
|
+
"StepIdentity(cache_key: str, step_type: str, normalized_text: str)":
|
|
43
|
+
location: models.py
|
|
44
|
+
annotations: |
|
|
45
|
+
The address of a cache step: the triple (cache_key, step_type, normalized_text) plus the deterministic file name derived from it.
|
|
46
|
+
|
|
47
|
+
`cache_key`: the explicit context key set by the integrator on the main object.
|
|
48
|
+
`step_type`: action or assertion.
|
|
49
|
+
`normalized_text`: the step sentence after `normalize_step_text`.
|
|
50
|
+
properties:
|
|
51
|
+
"cache_key -> str": |
|
|
52
|
+
The explicit context key of the step address.
|
|
53
|
+
"step_type -> str": |
|
|
54
|
+
The step kind: action or assertion.
|
|
55
|
+
"normalized_text -> str": |
|
|
56
|
+
The normalized step sentence.
|
|
57
|
+
"filename -> str": |
|
|
58
|
+
The deterministic cache file name.
|
|
59
|
+
|
|
60
|
+
Algorithm:
|
|
61
|
+
1. Build the identity string: cache_key, step_type and normalized_text joined with an unambiguous separator
|
|
62
|
+
2. Hash the string with sha256
|
|
63
|
+
3. Compose the file name from the hex digest
|
|
64
|
+
|
|
65
|
+
Requirements:
|
|
66
|
+
- The same triple always yields the same file name; any difference in the triple yields a different one
|
|
67
|
+
- The digest identifies the file unambiguously inside the cache directory
|
|
68
|
+
|
|
69
|
+
"CachedStep(identity: StepIdentity, code: str, created_at: str)":
|
|
70
|
+
location: models.py
|
|
71
|
+
annotations: |
|
|
72
|
+
One cached step in memory: the metadata and the generated code of the step, as stored in its cache file.
|
|
73
|
+
|
|
74
|
+
`identity`: the step address.
|
|
75
|
+
`code`: the generated step code of the fixed form.
|
|
76
|
+
`created_at`: the creation date.
|
|
77
|
+
|
|
78
|
+
Requirements:
|
|
79
|
+
- The cache file is a valid Python module: metadata fields first, then the step code; importing the module and reading the fields reconstructs the step
|
|
80
|
+
- The file carries no library version field and is never invalidated by a library upgrade
|
|
81
|
+
properties:
|
|
82
|
+
"identity -> StepIdentity": |
|
|
83
|
+
The step address.
|
|
84
|
+
"code -> str": |
|
|
85
|
+
The generated step code of the fixed form.
|
|
86
|
+
"created_at -> str": |
|
|
87
|
+
The creation date.
|
|
88
|
+
|
|
89
|
+
"StepCache(config: Config, path: str | None, reporter: StepReporter | None)":
|
|
90
|
+
location: store.py
|
|
91
|
+
annotations: |
|
|
92
|
+
The repository store of cache steps: addressing, atomic writes and the read-only mode.
|
|
93
|
+
|
|
94
|
+
`config`: project settings; the cache_root setting is the cache root.
|
|
95
|
+
`path`: the optional subdirectory inside the cache; part of the address — steps of different subdirectories never collide; empty — the shared root, so equal cache keys are reused across tests.
|
|
96
|
+
`reporter`: the visibility point — cache events (saved, skipped) go through it; omitted — the hook-less default reporter, so a missing visibility point never fails a save.
|
|
97
|
+
properties:
|
|
98
|
+
"root -> str": |
|
|
99
|
+
The effective cache root; the default is <repo root>/.prettyplay/cache/.
|
|
100
|
+
"writable -> bool": |
|
|
101
|
+
Whether the cache directory accepts writes.
|
|
102
|
+
methods:
|
|
103
|
+
"load(identity: StepIdentity) -> step: CachedStep | None": |
|
|
104
|
+
Load the cached step by address.
|
|
105
|
+
|
|
106
|
+
Algorithm:
|
|
107
|
+
1. Resolve the target file by the effective root, the subdirectory and the identity file name
|
|
108
|
+
2. A missing file returns None
|
|
109
|
+
3. Read the module, validate the metadata fields, reconstruct `CachedStep`
|
|
110
|
+
"save(step: CachedStep)": |
|
|
111
|
+
Store the step atomically, best-effort.
|
|
112
|
+
|
|
113
|
+
Algorithm:
|
|
114
|
+
1. Check writability: a read-only cache skips the write and reports on_cache_skipped with the reason read-only cache
|
|
115
|
+
2. Serialize the step into the module text: metadata fields, then the code
|
|
116
|
+
3. Write a temporary file with a unique name in the target directory
|
|
117
|
+
4. Replace the target file atomically via os.replace
|
|
118
|
+
5. On Windows, when the target is busy: retry the replace shortly, then skip the write for this step and report on_cache_skipped — the run does not fail
|
|
119
|
+
6. Report on_cache_saved with the file name on success
|
|
120
|
+
|
|
121
|
+
Requirements:
|
|
122
|
+
- Concurrent writers on one step never corrupt the file: last writer wins
|
|
123
|
+
- A partially written file never becomes visible: the replace is atomic
|
|
124
|
+
- The cache is always read, in every environment
|
|
125
|
+
|
|
126
|
+
"RunBudgets(generation_limit: int, healing_limit: int)":
|
|
127
|
+
location: budgets.py
|
|
128
|
+
annotations: |
|
|
129
|
+
The per-test attempt registry: how many generation and healing attempts each step has left within the test.
|
|
130
|
+
|
|
131
|
+
`generation_limit`: the generation attempt budget per step per test.
|
|
132
|
+
`healing_limit`: the healing attempt budget per step per test.
|
|
133
|
+
|
|
134
|
+
Requirements:
|
|
135
|
+
- The registry lives for the lifetime of one test, owned by the test's runtime: every test starts with full limits — a step reused across tests gets a fresh budget in each test
|
|
136
|
+
- The identity key is `StepIdentity`
|
|
137
|
+
- An exhausted budget returns False — the caller turns it into the incurable failure
|
|
138
|
+
|
|
139
|
+
Constraints:
|
|
140
|
+
- No persistence: budgets exist only in the memory of the running process
|
|
141
|
+
methods:
|
|
142
|
+
"try_generation(identity: StepIdentity) -> allowed: bool": |
|
|
143
|
+
Consume one generation attempt for the step; False — the budget is exhausted, the caller reports incurability.
|
|
144
|
+
"try_healing(identity: StepIdentity) -> allowed: bool": |
|
|
145
|
+
Consume one healing attempt for the step; False — the budget is exhausted, the caller reports incurability.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
Author: Goga
|
|
150
|
+
CreatedAt: 07/09/26
|
|
151
|
+
Description: |
|
|
152
|
+
The step cache of prettyplay: normalization, deterministic addressing, atomic repository storage and the per-test attempt budgets.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Facade of the prettyplay.cache cell: repository step cache and addressing."""
|
|
2
|
+
|
|
3
|
+
from .budgets import RunBudgets
|
|
4
|
+
from .models import CachedStep, StepIdentity
|
|
5
|
+
from .store import StepCache
|
|
6
|
+
from .text import normalize_step_text
|
|
7
|
+
|
|
8
|
+
__all__ = ["CachedStep", "RunBudgets", "StepCache", "StepIdentity", "normalize_step_text"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Per-test attempt registry: how many tries a step still has in this test.
|
|
2
|
+
|
|
3
|
+
One registry lives for the lifetime of one test, owned by the test's runtime
|
|
4
|
+
(see the runtime composition root), so every test starts with full limits —
|
|
5
|
+
a step reused across tests gets a fresh budget in each test. Generation and
|
|
6
|
+
healing draw from separate pools, and each step is accounted by its
|
|
7
|
+
deterministic address.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .models import StepIdentity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunBudgets:
|
|
14
|
+
"""The per-test registry of generation and healing attempts per step.
|
|
15
|
+
|
|
16
|
+
Both pools are keyed by ``identity.filename`` — the deterministic digest
|
|
17
|
+
of the step triple — so accounting survives without hashing the pydantic
|
|
18
|
+
model. Nothing is persisted: the registry is process memory only.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
_generation_limit: how many generation attempts a step may take per test.
|
|
22
|
+
_healing_limit: how many healing attempts a step may take per test.
|
|
23
|
+
_generation_used: attempts already spent per step in the generation pool.
|
|
24
|
+
_healing_used: attempts already spent per step in the healing pool.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, generation_limit: int, healing_limit: int) -> None:
|
|
28
|
+
"""Init the registry with separate generation and healing limits.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
generation_limit: the per-step generation attempt limit of the test.
|
|
32
|
+
healing_limit: the per-step healing attempt limit of the test.
|
|
33
|
+
"""
|
|
34
|
+
self._generation_limit = generation_limit
|
|
35
|
+
self._healing_limit = healing_limit
|
|
36
|
+
self._generation_used: dict[str, int] = {}
|
|
37
|
+
self._healing_used: dict[str, int] = {}
|
|
38
|
+
|
|
39
|
+
def try_generation(self, identity: StepIdentity) -> bool:
|
|
40
|
+
"""Spend one generation attempt of the step or refuse on exhaustion.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
identity: the address of the step asking for an attempt.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True when the attempt is granted and counted, False when the
|
|
47
|
+
per-test generation budget of the step is exhausted.
|
|
48
|
+
"""
|
|
49
|
+
used = self._generation_used.get(identity.filename, 0)
|
|
50
|
+
if used >= self._generation_limit:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
self._generation_used[identity.filename] = used + 1
|
|
54
|
+
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
def try_healing(self, identity: StepIdentity) -> bool:
|
|
58
|
+
"""Spend one healing attempt of the step or refuse on exhaustion.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
identity: the address of the step asking for an attempt.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
True when the attempt is granted and counted, False when the
|
|
65
|
+
per-test healing budget of the step is exhausted.
|
|
66
|
+
"""
|
|
67
|
+
used = self._healing_used.get(identity.filename, 0)
|
|
68
|
+
if used >= self._healing_limit:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
self._healing_used[identity.filename] = used + 1
|
|
72
|
+
|
|
73
|
+
return True
|