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.
- prettyplay/.usages/lifecycle.md +117 -0
- prettyplay/.usages/steps.md +169 -0
- prettyplay/__init__.py +18 -0
- prettyplay/cache/.usages/addressing.md +44 -0
- prettyplay/cache/.usages/budgets.md +51 -0
- prettyplay/cache/.usages/storage.md +32 -0
- prettyplay/cache/__init__.py +8 -0
- prettyplay/cache/budgets.py +93 -0
- prettyplay/cache/models.py +55 -0
- prettyplay/cache/store.py +276 -0
- prettyplay/cache/text.py +36 -0
- prettyplay/config/.usages/configuration.md +246 -0
- prettyplay/config/__init__.py +6 -0
- prettyplay/config/loader.py +336 -0
- prettyplay/config/models.py +288 -0
- prettyplay/driver/.usages/error_kinds.md +26 -0
- prettyplay/driver/.usages/plumbing.md +52 -0
- prettyplay/driver/__init__.py +11 -0
- prettyplay/driver/errors.py +76 -0
- prettyplay/driver/page.py +175 -0
- prettyplay/driver/session.py +330 -0
- prettyplay/engine/.usages/generation.md +157 -0
- prettyplay/engine/.usages/healing.md +61 -0
- prettyplay/engine/__init__.py +19 -0
- prettyplay/engine/attempts.py +80 -0
- prettyplay/engine/classification.py +70 -0
- prettyplay/engine/compliance.py +125 -0
- prettyplay/engine/execution.py +48 -0
- prettyplay/engine/generator.py +1110 -0
- prettyplay/engine/groups/.usages/recovery.md +71 -0
- prettyplay/engine/groups/__init__.py +7 -0
- prettyplay/engine/groups/diagnosis.py +128 -0
- prettyplay/engine/groups/outcome.py +55 -0
- prettyplay/engine/groups/recovery.py +491 -0
- prettyplay/engine/healer.py +149 -0
- prettyplay/engine/polling/.usages/settle.md +55 -0
- prettyplay/engine/polling/__init__.py +6 -0
- prettyplay/engine/polling/settle.py +98 -0
- prettyplay/engine/polling/window.py +72 -0
- prettyplay/engine/renderer/.usages/memory.md +24 -0
- prettyplay/engine/renderer/.usages/rendering.md +44 -0
- prettyplay/engine/renderer/.usages/validation.md +20 -0
- prettyplay/engine/renderer/__init__.py +7 -0
- prettyplay/engine/renderer/memory.py +37 -0
- prettyplay/engine/renderer/render.py +184 -0
- prettyplay/engine/renderer/validation.py +76 -0
- prettyplay/engine/steering/.usages/steering.md +95 -0
- prettyplay/engine/steering/__init__.py +5 -0
- prettyplay/engine/steering/steering.py +765 -0
- prettyplay/engine/text.py +41 -0
- prettyplay/executor.py +654 -0
- prettyplay/failures/.usages/taxonomy.md +131 -0
- prettyplay/failures/__init__.py +25 -0
- prettyplay/failures/errors.py +359 -0
- prettyplay/groups.py +276 -0
- prettyplay/llm/.usages/classification.md +31 -0
- prettyplay/llm/.usages/providers.md +127 -0
- prettyplay/llm/.usages/transport.md +81 -0
- prettyplay/llm/__init__.py +38 -0
- prettyplay/llm/_request.py +823 -0
- prettyplay/llm/anthropic_provider.py +468 -0
- prettyplay/llm/models.py +273 -0
- prettyplay/llm/openai_provider.py +444 -0
- prettyplay/llm/provider.py +279 -0
- prettyplay/reporting/.usages/hooks.md +61 -0
- prettyplay/reporting/__init__.py +6 -0
- prettyplay/reporting/hooks.py +47 -0
- prettyplay/reporting/reporter.py +70 -0
- prettyplay/runtime.py +81 -0
- prettyplay/scenario.py +469 -0
- prettyplay-0.0.2.dist-info/METADATA +515 -0
- prettyplay-0.0.2.dist-info/RECORD +75 -0
- prettyplay-0.0.2.dist-info/WHEEL +5 -0
- prettyplay-0.0.2.dist-info/licenses/LICENSE +28 -0
- prettyplay-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Settle window
|
|
2
|
+
|
|
3
|
+
Domain: how transient page-state failures are absorbed before any costly move. Audience: engineers tuning polling_timeout and reasoning about retry behavior.
|
|
4
|
+
|
|
5
|
+
## The window
|
|
6
|
+
|
|
7
|
+
One settle window per step execution: the step executor creates it from the polling settings and threads it into
|
|
8
|
+
every execution of the step's code — cached code and generation candidates alike, replay-strict included.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from prettyplay.engine.polling import SettleWindow, settle
|
|
12
|
+
|
|
13
|
+
window = SettleWindow(timeout=config.polling_timeout, delay=config.polling_delay)
|
|
14
|
+
settle(execute=run_step_code, code=cached_step.code, page=page, window=window)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`run_step_code` comes from `prettyplay.engine` — the execution routine the caller threads in.
|
|
18
|
+
|
|
19
|
+
- The window starts at the first execution of the step code — never at the first failure; the step code's own
|
|
20
|
+
auto-waits count inside it, and the first execution may consume the whole window: no repetitions follow
|
|
21
|
+
- The window gates repetitions, never kills a running attempt: the remaining-time check happens only before a repeat
|
|
22
|
+
- A failure re-executes the same code after `polling_delay` when its kind is pollable and time remains — until
|
|
23
|
+
success or window end; success continues the step normally
|
|
24
|
+
- Everything else propagates as-is: locator ambiguity, Python-level errors, non-pollable and unknown kinds. On
|
|
25
|
+
cached code such a failure goes straight to classification; on a generation candidate it feeds the next
|
|
26
|
+
regeneration request with the fresh error
|
|
27
|
+
|
|
28
|
+
## Visibility
|
|
29
|
+
|
|
30
|
+
Each repetition writes a `settle_retry` record to the logger prettyplay at INFO — the attempt counter and the failure
|
|
31
|
+
text. Re-executions emit no hook events: hook events fire per step or per LLM attempt, never per execution retry.
|
|
32
|
+
|
|
33
|
+
## Budgets
|
|
34
|
+
|
|
35
|
+
Re-executions consume no generation or healing budgets and make no LLM requests — polling is cheaper than one
|
|
36
|
+
regeneration attempt.
|
|
37
|
+
|
|
38
|
+
## Rules
|
|
39
|
+
|
|
40
|
+
- `polling_timeout` None (default) and 0 keep polling off — the settle call degenerates to a single execution
|
|
41
|
+
- `polling_delay` 0 re-executes without a pause
|
|
42
|
+
- The settle window never re-arms: one window per step execution, shared by every execution inside it
|
|
43
|
+
|
|
44
|
+
## Count-bounded re-execution (tries)
|
|
45
|
+
|
|
46
|
+
A step declared with a retry count replaces the time bound for that step's loop with a count
|
|
47
|
+
bound: `SettleWindow(timeout=..., delay=..., tries=3)` — the code unit executes at most 3 times in
|
|
48
|
+
total (the first execution included; 1 — no re-execution). The pollable filter and the `delay`
|
|
49
|
+
pause keep applying between executions; retries appear as `settle_retry` records; exhaustion
|
|
50
|
+
propagates the failure to the ordinary path. Each `settle` call counts from zero — the cached code
|
|
51
|
+
and every generated candidate each get their own full count. No LLM budget is consumed.
|
|
52
|
+
|
|
53
|
+
## Result carriage
|
|
54
|
+
|
|
55
|
+
`settle` returns the result of the successful execution — `run_step_code` returns the step's dictionary for steps with capture declarations and None otherwise. The loop passes the successful result through untouched; validation and publication belong to the calling cycle. A repeated execution's success returns that re-execution's result.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Execution of step code under the settle window: the re-execution loop of transient page-state failures."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from ...driver import PageFacade, is_pollable_failure
|
|
8
|
+
from .window import SettleWindow
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("prettyplay")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def settle(
|
|
14
|
+
execute: Callable[[str, PageFacade], dict[str, str] | None],
|
|
15
|
+
code: str,
|
|
16
|
+
page: PageFacade,
|
|
17
|
+
window: SettleWindow,
|
|
18
|
+
) -> dict[str, str] | None:
|
|
19
|
+
"""Execute step code under the settle window and carry the successful execution's result back.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
execute: the step-code execution routine passed by the caller — returns
|
|
23
|
+
the step's result (a dictionary of declared names to observed
|
|
24
|
+
strings, or None for a declaration-free step).
|
|
25
|
+
code: the step code text.
|
|
26
|
+
page: the page facade of the current test.
|
|
27
|
+
window: the settle window of the current step execution — a declared
|
|
28
|
+
``tries`` count bounds the loop by executions (the counter is
|
|
29
|
+
local to this call), otherwise the time window applies; each
|
|
30
|
+
repetition logs ``settle_retry`` at INFO.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The result returned by the successful execution — untouched: the loop
|
|
34
|
+
never inspects, validates or alters it; validation belongs to the
|
|
35
|
+
calling cycle.
|
|
36
|
+
"""
|
|
37
|
+
window.start()
|
|
38
|
+
|
|
39
|
+
if window.count_bounded:
|
|
40
|
+
return _settle_by_count(execute, code, page, window)
|
|
41
|
+
|
|
42
|
+
attempt = 0
|
|
43
|
+
|
|
44
|
+
while True:
|
|
45
|
+
try:
|
|
46
|
+
return execute(code, page)
|
|
47
|
+
|
|
48
|
+
except Exception as failure:
|
|
49
|
+
attempt += 1
|
|
50
|
+
|
|
51
|
+
if window.enabled and is_pollable_failure(failure) and window.has_remaining():
|
|
52
|
+
logger.info("settle_retry", extra={"attempt": attempt, "error": str(failure)})
|
|
53
|
+
time.sleep(window.delay)
|
|
54
|
+
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _settle_by_count(
|
|
61
|
+
execute: Callable[[str, PageFacade], dict[str, str] | None],
|
|
62
|
+
code: str,
|
|
63
|
+
page: PageFacade,
|
|
64
|
+
window: SettleWindow,
|
|
65
|
+
) -> dict[str, str] | None:
|
|
66
|
+
"""Execute step code up to the window's declared tries count and return the successful execution's result.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
execute: the step-code execution routine passed by the caller — returns
|
|
70
|
+
the step's result (a dictionary of declared names to observed
|
|
71
|
+
strings, or None for a declaration-free step).
|
|
72
|
+
code: the step code text.
|
|
73
|
+
page: the page facade of the current test.
|
|
74
|
+
window: the count-bounded settle window — re-execute iff enabled,
|
|
75
|
+
pollable and under the declared count; exhaustion propagates
|
|
76
|
+
the failure as-is.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The result returned by the successful execution — including the result
|
|
80
|
+
of a successful re-execution; the loop never touches it beyond the
|
|
81
|
+
pass-through.
|
|
82
|
+
"""
|
|
83
|
+
executed = 0
|
|
84
|
+
|
|
85
|
+
while True:
|
|
86
|
+
try:
|
|
87
|
+
return execute(code, page)
|
|
88
|
+
|
|
89
|
+
except Exception as failure:
|
|
90
|
+
executed += 1
|
|
91
|
+
|
|
92
|
+
if window.enabled and is_pollable_failure(failure) and executed < window.tries:
|
|
93
|
+
logger.info("settle_retry", extra={"attempt": executed, "error": str(failure)})
|
|
94
|
+
time.sleep(window.delay)
|
|
95
|
+
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
raise
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The settle window of one step execution: the re-execution horizon of step code."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SettleWindow:
|
|
7
|
+
"""The settle horizon of one step execution — pure bookkeeping: gates repetitions, never kills a running attempt.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
timeout: the total window in seconds; ``None`` — polling disabled;
|
|
11
|
+
``0`` — an explicit disable, equivalent to ``None``; positive —
|
|
12
|
+
the window.
|
|
13
|
+
delay: the pause between re-executions in seconds; ``0`` —
|
|
14
|
+
re-execute without a pause.
|
|
15
|
+
tries: the total number of executions of one code unit within its
|
|
16
|
+
re-execution loop, the first execution included (``1`` — no
|
|
17
|
+
re-execution); ``None`` — the time-bounded mode of today.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, timeout: float | None, delay: float, tries: int | None = None) -> None:
|
|
21
|
+
"""Keep the settle horizon of one step execution.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
timeout: the total window in seconds; ``None`` — polling
|
|
25
|
+
disabled; ``0`` — an explicit disable, equivalent to
|
|
26
|
+
``None``.
|
|
27
|
+
delay: the pause between re-executions in seconds; ``0`` —
|
|
28
|
+
re-execute without a pause.
|
|
29
|
+
tries: the declared retry count; ``None`` — the time-bounded
|
|
30
|
+
mode; a positive integer — the count-bounded mode.
|
|
31
|
+
"""
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
self.delay = delay
|
|
34
|
+
self.tries = tries
|
|
35
|
+
self._started_at: float | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def count_bounded(self) -> bool:
|
|
39
|
+
"""Whether the window bounds this loop by execution count.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
True when a ``tries`` count is declared.
|
|
43
|
+
"""
|
|
44
|
+
return self.tries is not None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def enabled(self) -> bool:
|
|
48
|
+
"""Whether polling is active: a positive timeout is set or the window is count-bounded.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
True when the window carries a positive timeout or a declared
|
|
52
|
+
``tries`` count — a count-bounded window stays alive even with
|
|
53
|
+
the time window disabled.
|
|
54
|
+
"""
|
|
55
|
+
return (self.timeout is not None and self.timeout > 0) or self.count_bounded
|
|
56
|
+
|
|
57
|
+
def start(self) -> None:
|
|
58
|
+
"""Mark the window start at the first execution of the step code; idempotent."""
|
|
59
|
+
if self._started_at is None:
|
|
60
|
+
self._started_at = time.monotonic()
|
|
61
|
+
|
|
62
|
+
def has_remaining(self) -> bool:
|
|
63
|
+
"""Whether the window still allows a repetition.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True when time remains and the window is enabled and started.
|
|
67
|
+
Time-bounded mode alone — the count-bounded loop checks its own
|
|
68
|
+
counter; checked only before a repetition, never mid-attempt.
|
|
69
|
+
"""
|
|
70
|
+
if not self.enabled or self._started_at is None or self.timeout is None:
|
|
71
|
+
return False
|
|
72
|
+
return (time.monotonic() - self._started_at) < self.timeout
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Test memory
|
|
2
|
+
|
|
3
|
+
Domain: the per-test memory of captured observations. Audience: library internals — the executor owning the memory and the engines publishing at acceptance.
|
|
4
|
+
|
|
5
|
+
## Lifecycle
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from prettyplay.engine.renderer import StepMemory
|
|
9
|
+
|
|
10
|
+
memory = StepMemory() # empty at construction, one per test execution
|
|
11
|
+
values = memory.snapshot() # the render source — a stable copy
|
|
12
|
+
memory.publish({"name": "Book"}) # atomic, after full acceptance only
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- Rendering reads a snapshot: a step's own new captures stay invisible to its template; a same-step read observes the previous value or fails
|
|
16
|
+
- All captures of one step publish together after full acceptance (including the compliance gate); a failed or rejected step publishes nothing
|
|
17
|
+
- A successful recapture replaces the previous value; absent names keep theirs
|
|
18
|
+
- Memory survives navigation and never crosses test boundaries — concurrent tests stay isolated
|
|
19
|
+
|
|
20
|
+
## Who publishes
|
|
21
|
+
|
|
22
|
+
- The executor — after a validated successful replay of cached code
|
|
23
|
+
- The engines — at the candidate acceptance point (after the compliance gate)
|
|
24
|
+
- The group recovery — per accepted row step; a later group failure never rolls back accepted publications
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Rendering step sentences
|
|
2
|
+
|
|
3
|
+
Domain: preparing a step sentence into a plain-text instruction with capture declarations. Audience: library internals — the step executor and the recovery engine calling the render point.
|
|
4
|
+
|
|
5
|
+
## Render a step
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from prettyplay.engine.renderer import StepMemory, render_step
|
|
9
|
+
|
|
10
|
+
memory = StepMemory()
|
|
11
|
+
memory.publish({"name": "Book"}) # a previously accepted observation
|
|
12
|
+
|
|
13
|
+
prepared = render_step(
|
|
14
|
+
text="For the item named {{ name }}, read its tags into {% var tags %}",
|
|
15
|
+
step_type="action",
|
|
16
|
+
memory=memory,
|
|
17
|
+
vars=None,
|
|
18
|
+
)
|
|
19
|
+
prepared.instruction # "For the item named Book, read its tags into " — plain text, actual values
|
|
20
|
+
prepared.inputs # {} — the call's vars
|
|
21
|
+
prepared.declarations # ["tags"] — reached capture tags only
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- The render starts from a snapshot: later publications never change a taken render
|
|
25
|
+
- `{{ name }}` reads memory; `{{ vars.expected }}` reads the call input — separate namespaces, neither overwrites the other
|
|
26
|
+
- A reached `{% var name %}` renders to empty text and declares a result slot; an inactive branch declares nothing
|
|
27
|
+
- Capture declarations are invalid for assertion steps (`expect`)
|
|
28
|
+
|
|
29
|
+
## Authoring errors
|
|
30
|
+
|
|
31
|
+
Raised as the loud actionable `PrettyplayError` before any browser execution:
|
|
32
|
+
|
|
33
|
+
- a duplicate capture name within one render
|
|
34
|
+
- the reserved capture name `vars` or a non-Jinja-identifier name
|
|
35
|
+
- an unavailable ordinary reference — the message names it; author-written `| default(...)` and `is defined` behave as authored
|
|
36
|
+
- a capture declaration in an assertion step
|
|
37
|
+
|
|
38
|
+
## Literal Jinja in a sentence
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
t.expect("The page contains {% raw %}{{ name }}{% endraw %}")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Use standard `raw` blocks to output literal Jinja syntax.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Validating step results
|
|
2
|
+
|
|
3
|
+
Domain: deterministic acceptance of returned step results. Audience: library internals — the executor and the engines validating executions.
|
|
4
|
+
|
|
5
|
+
## Validate an execution result
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from prettyplay.engine.renderer import PreparedStep, StepMemory, validate_step_result
|
|
9
|
+
|
|
10
|
+
memory = StepMemory()
|
|
11
|
+
prepared = PreparedStep(instruction="Read the item name", declarations=["name"])
|
|
12
|
+
result = {"name": "Book"} # returned by the executed step after reading the page
|
|
13
|
+
captures = validate_step_result(prepared, result)
|
|
14
|
+
memory.publish(captures) # accepted cached replay; candidates must pass compliance first
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- Exact declared keys: missing or unexpected names violate; string values only; blank (empty or whitespace-only) values violate
|
|
18
|
+
- A declaration-free step accepts None and validates to empty captures — the success-without-result behavior
|
|
19
|
+
- A violation raises AssertionError with a deterministic text — it enters the existing failed-check paths: classification and bounded corrective generation where non-strict execution permits, strict failure without generation
|
|
20
|
+
- Publish only validated captures — never fabricated or repaired values
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Facade of the prettyplay.engine.renderer cell: step-sentence preparation — rendering, memory, validation."""
|
|
2
|
+
|
|
3
|
+
from .memory import StepMemory
|
|
4
|
+
from .render import PreparedStep, render_step
|
|
5
|
+
from .validation import validate_step_result
|
|
6
|
+
|
|
7
|
+
__all__: list[str] = ["PreparedStep", "StepMemory", "render_step", "validate_step_result"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The per-test-execution memory of captured observations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StepMemory:
|
|
7
|
+
"""The names namespace of rendering — captured observations of one test execution.
|
|
8
|
+
|
|
9
|
+
Owned by the step executor of one test: rendering reads snapshots, publication
|
|
10
|
+
happens only at acceptance points. Constructs empty, survives navigation and
|
|
11
|
+
never crosses test boundaries.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
"""Construct an empty memory."""
|
|
16
|
+
self._values: dict[str, str] = {}
|
|
17
|
+
|
|
18
|
+
def snapshot(self) -> dict[str, str]:
|
|
19
|
+
"""Return a stable copy of the current values — the render source.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
The copy of the current values; later publications never mutate a
|
|
23
|
+
taken snapshot.
|
|
24
|
+
"""
|
|
25
|
+
return dict(self._values)
|
|
26
|
+
|
|
27
|
+
def publish(self, captures: dict[str, str]) -> None:
|
|
28
|
+
"""Publish validated captures atomically.
|
|
29
|
+
|
|
30
|
+
Replaces the value of every present name — a successful recapture
|
|
31
|
+
replaces the previous value; names absent from ``captures`` keep theirs.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
captures: The validated dictionary of one accepted step execution.
|
|
35
|
+
"""
|
|
36
|
+
for name, value in captures.items():
|
|
37
|
+
self._values[name] = value
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""The render point: step templates become prepared instructions with capture declarations."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from jinja2 import Environment, StrictUndefined, UndefinedError, nodes
|
|
7
|
+
from jinja2.exceptions import TemplateSyntaxError
|
|
8
|
+
from jinja2.ext import Extension
|
|
9
|
+
from jinja2.parser import Parser
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
|
|
12
|
+
from ...failures import PrettyplayError
|
|
13
|
+
from .memory import StepMemory
|
|
14
|
+
|
|
15
|
+
#: The Jinja error text of an attribute miss on the inputs namespace — ``'dict object' has
|
|
16
|
+
#: no attribute 'x'``. The vars namespace is the only plain-dict root of the render context,
|
|
17
|
+
#: so a miss names the qualified ``vars.x`` form.
|
|
18
|
+
_ATTRIBUTE_MISS = re.compile(r"^'dict object' has no attribute '([^']+)'$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PreparedStep(BaseModel):
|
|
22
|
+
"""The render product of one step sentence — what the agent cycle works with after preparation.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
instruction: The prepared plain-text instruction with actual values embedded.
|
|
26
|
+
inputs: The call-local input bindings of this step; empty — none.
|
|
27
|
+
declarations: The reached capture-tag names, in execution order; empty — no memory writes.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(kw_only=True, frozen=True)
|
|
31
|
+
|
|
32
|
+
instruction: str = ""
|
|
33
|
+
inputs: dict[str, str] = {}
|
|
34
|
+
declarations: list[str] = []
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def has_declarations(self) -> bool:
|
|
38
|
+
"""Report whether the step declared any result captures.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Whether at least one capture tag was reached during rendering.
|
|
42
|
+
"""
|
|
43
|
+
return bool(self.declarations)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class VarExtension(Extension):
|
|
47
|
+
"""The ``{% var name %}`` capture-declaration tag of step templates.
|
|
48
|
+
|
|
49
|
+
A reached tag records its name into the per-render declaration list of the
|
|
50
|
+
extension instance and renders to empty text — it declares a result slot,
|
|
51
|
+
it supplies no value. One instance lives in one environment, and one
|
|
52
|
+
environment serves one render, so the declaration list is per-render.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
tags: ClassVar[set[str]] = {"var"}
|
|
56
|
+
|
|
57
|
+
def __init__(self, environment: Environment) -> None:
|
|
58
|
+
"""Construct the extension with an empty per-render declaration list.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
environment: The Jinja environment this extension is registered in.
|
|
62
|
+
"""
|
|
63
|
+
super().__init__(environment)
|
|
64
|
+
|
|
65
|
+
self.declarations: list[str] = []
|
|
66
|
+
|
|
67
|
+
def parse(self, parser: Parser) -> nodes.Output:
|
|
68
|
+
"""Parse one capture tag into an output node that records its name when reached.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
parser: The active Jinja parser positioned at the ``var`` tag token.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The output node rendering to empty text and recording the capture name.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
PrettyplayError: The token after ``var`` is not a single Jinja identifier.
|
|
78
|
+
"""
|
|
79
|
+
tag_token = parser.stream.expect("name:var")
|
|
80
|
+
name_token = next(parser.stream)
|
|
81
|
+
|
|
82
|
+
if name_token.type != "name":
|
|
83
|
+
raise PrettyplayError(
|
|
84
|
+
f"the var capture tag expects a single Jinja identifier name, got {name_token.value!r}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
call = self.call_method("_record", [nodes.Const(name_token.value)], lineno=tag_token.lineno)
|
|
88
|
+
|
|
89
|
+
return nodes.Output([call]).set_lineno(tag_token.lineno) # type: ignore[return-value]
|
|
90
|
+
|
|
91
|
+
def _record(self, name: str) -> str:
|
|
92
|
+
"""Record one reached capture name and render to empty text.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
name: The capture name reached during rendering.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The empty text the tag renders to.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
PrettyplayError: The name is the reserved ``vars`` namespace or is already recorded.
|
|
102
|
+
"""
|
|
103
|
+
if name == "vars":
|
|
104
|
+
raise PrettyplayError("the capture name 'vars' is reserved for the call inputs namespace")
|
|
105
|
+
|
|
106
|
+
if name in self.declarations:
|
|
107
|
+
raise PrettyplayError(f"the capture name {name!r} is declared twice in one step")
|
|
108
|
+
|
|
109
|
+
self.declarations.append(name)
|
|
110
|
+
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _unavailable_name(error: UndefinedError, inputs: dict[str, str]) -> str:
|
|
115
|
+
"""Name the unavailable value of one render failure.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
error: The undefined-value error raised by the render.
|
|
119
|
+
inputs: The call-local input bindings of the render.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The unavailable name — the qualified ``vars.x`` form for a miss on the
|
|
123
|
+
inputs namespace, the Jinja diagnostic text otherwise.
|
|
124
|
+
"""
|
|
125
|
+
match = _ATTRIBUTE_MISS.match(str(error))
|
|
126
|
+
|
|
127
|
+
if match and match.group(1) not in inputs:
|
|
128
|
+
return f"'vars.{match.group(1)}' is undefined"
|
|
129
|
+
|
|
130
|
+
return str(error)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render_step(text: str, step_type: str, memory: StepMemory, vars: dict[str, str] | None) -> PreparedStep:
|
|
134
|
+
"""Render one step sentence from a snapshot of memory and call inputs into a prepared step.
|
|
135
|
+
|
|
136
|
+
Compiles the sentence through a fresh Jinja environment — plain text, strict
|
|
137
|
+
undefined values, the ``{% var %}`` capture extension — and renders it with
|
|
138
|
+
the memory snapshot and the inputs namespace. All authoring errors raise
|
|
139
|
+
``PrettyplayError`` naming the problem and the offending name, before any
|
|
140
|
+
browser execution.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
text: The raw step sentence as written by the engineer — the template source.
|
|
144
|
+
step_type: The step kind — action or assertion.
|
|
145
|
+
memory: The test memory; the render reads its snapshot and never mutates it.
|
|
146
|
+
vars: The call-local string inputs; None — none.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The render product of the sentence.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
PrettyplayError: The template is malformed, a capture name is invalid or
|
|
153
|
+
duplicated, a value is unavailable, or an assertion declares captures.
|
|
154
|
+
"""
|
|
155
|
+
snapshot = memory.snapshot()
|
|
156
|
+
inputs = {} if vars is None else dict(vars)
|
|
157
|
+
|
|
158
|
+
environment = Environment(autoescape=False, undefined=StrictUndefined, extensions=[VarExtension])
|
|
159
|
+
extension = next(ext for ext in environment.extensions.values() if isinstance(ext, VarExtension))
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
template = environment.from_string(text)
|
|
163
|
+
except TemplateSyntaxError as error:
|
|
164
|
+
raise PrettyplayError(f"the step template is malformed: {error.message} (line {error.lineno})") from error
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
instruction = template.render(snapshot, vars=inputs)
|
|
168
|
+
except UndefinedError as error:
|
|
169
|
+
name = _unavailable_name(error, inputs)
|
|
170
|
+
|
|
171
|
+
raise PrettyplayError(f"the step template references an unavailable name: {name}") from error
|
|
172
|
+
except PrettyplayError:
|
|
173
|
+
raise
|
|
174
|
+
except Exception as error:
|
|
175
|
+
raise PrettyplayError(f"the step template {text!r} failed to evaluate: {error}") from error
|
|
176
|
+
|
|
177
|
+
declarations = list(extension.declarations)
|
|
178
|
+
|
|
179
|
+
if step_type == "assertion" and declarations:
|
|
180
|
+
names = ", ".join(declarations)
|
|
181
|
+
|
|
182
|
+
raise PrettyplayError(f"a step expectation cannot declare result captures: {names}")
|
|
183
|
+
|
|
184
|
+
return PreparedStep(instruction=instruction, inputs=inputs, declarations=declarations)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The deterministic acceptance gate for returned step results."""
|
|
2
|
+
|
|
3
|
+
from .render import PreparedStep
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _validate_values(result: dict[object, object]) -> None:
|
|
7
|
+
"""Validate every captured value of an accepted result dictionary.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
result: The result dictionary whose keys already match the declarations.
|
|
11
|
+
|
|
12
|
+
Raises:
|
|
13
|
+
AssertionError: A value is not a string or is blank.
|
|
14
|
+
"""
|
|
15
|
+
for name, value in result.items():
|
|
16
|
+
if not isinstance(value, str):
|
|
17
|
+
raise AssertionError(f"the result value of {name!r} must be a string, got {type(value).__name__}")
|
|
18
|
+
|
|
19
|
+
if value.strip() == "":
|
|
20
|
+
raise AssertionError(f"the result value of {name!r} is blank")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_step_result(prepared: PreparedStep, result: object) -> dict[str, str]:
|
|
24
|
+
"""Deterministically validate the returned result of one step execution.
|
|
25
|
+
|
|
26
|
+
Compares the untrusted step function's return against the declaration set of
|
|
27
|
+
the render product: a declaration-free step accepts None, a declaring step
|
|
28
|
+
must return exactly the declared names as non-blank strings. A violation
|
|
29
|
+
raises ``AssertionError`` with the deterministic violation text — the
|
|
30
|
+
failed-check channel of the calling cycle. Never fabricates or repairs.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
prepared: The render product of the executed step — the declaration set.
|
|
34
|
+
result: The untrusted step function's return — expected to be the
|
|
35
|
+
dictionary of declared names to observed strings, or None for a
|
|
36
|
+
declaration-free step.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The validated captures, ready for publication; empty for a
|
|
40
|
+
declaration-free step.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
AssertionError: The result violates the declared contract — a wrong
|
|
44
|
+
type, a missing or unexpected name, or a non-string or blank value.
|
|
45
|
+
"""
|
|
46
|
+
if not prepared.declarations:
|
|
47
|
+
if result is None:
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
raise AssertionError("unexpected result without declarations")
|
|
51
|
+
|
|
52
|
+
if result is None:
|
|
53
|
+
names = ", ".join(prepared.declarations)
|
|
54
|
+
|
|
55
|
+
raise AssertionError(f"missing result: the step declares {names}")
|
|
56
|
+
|
|
57
|
+
if not isinstance(result, dict):
|
|
58
|
+
raise AssertionError(f"result must be a dictionary, got {type(result).__name__}")
|
|
59
|
+
|
|
60
|
+
missing = sorted(set(prepared.declarations) - set(result), key=repr)
|
|
61
|
+
unexpected = sorted(set(result) - set(prepared.declarations), key=repr)
|
|
62
|
+
|
|
63
|
+
if missing or unexpected:
|
|
64
|
+
problems = []
|
|
65
|
+
|
|
66
|
+
if missing:
|
|
67
|
+
problems.append(f"missing {', '.join(repr(name) for name in missing)}")
|
|
68
|
+
|
|
69
|
+
if unexpected:
|
|
70
|
+
problems.append(f"unexpected {', '.join(repr(name) for name in unexpected)}")
|
|
71
|
+
|
|
72
|
+
raise AssertionError(f"result keys do not match the declared captures: {'; '.join(problems)}")
|
|
73
|
+
|
|
74
|
+
_validate_values(result)
|
|
75
|
+
|
|
76
|
+
return dict(result)
|