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