prettyplay 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. prettyplay/.usages/lifecycle.md +117 -0
  2. prettyplay/.usages/steps.md +169 -0
  3. prettyplay/__init__.py +18 -0
  4. prettyplay/cache/.usages/addressing.md +44 -0
  5. prettyplay/cache/.usages/budgets.md +51 -0
  6. prettyplay/cache/.usages/storage.md +32 -0
  7. prettyplay/cache/__init__.py +8 -0
  8. prettyplay/cache/budgets.py +93 -0
  9. prettyplay/cache/models.py +55 -0
  10. prettyplay/cache/store.py +276 -0
  11. prettyplay/cache/text.py +36 -0
  12. prettyplay/config/.usages/configuration.md +246 -0
  13. prettyplay/config/__init__.py +6 -0
  14. prettyplay/config/loader.py +336 -0
  15. prettyplay/config/models.py +288 -0
  16. prettyplay/driver/.usages/error_kinds.md +26 -0
  17. prettyplay/driver/.usages/plumbing.md +52 -0
  18. prettyplay/driver/__init__.py +11 -0
  19. prettyplay/driver/errors.py +76 -0
  20. prettyplay/driver/page.py +175 -0
  21. prettyplay/driver/session.py +330 -0
  22. prettyplay/engine/.usages/generation.md +157 -0
  23. prettyplay/engine/.usages/healing.md +61 -0
  24. prettyplay/engine/__init__.py +19 -0
  25. prettyplay/engine/attempts.py +80 -0
  26. prettyplay/engine/classification.py +70 -0
  27. prettyplay/engine/compliance.py +125 -0
  28. prettyplay/engine/execution.py +48 -0
  29. prettyplay/engine/generator.py +1110 -0
  30. prettyplay/engine/groups/.usages/recovery.md +71 -0
  31. prettyplay/engine/groups/__init__.py +7 -0
  32. prettyplay/engine/groups/diagnosis.py +128 -0
  33. prettyplay/engine/groups/outcome.py +55 -0
  34. prettyplay/engine/groups/recovery.py +491 -0
  35. prettyplay/engine/healer.py +149 -0
  36. prettyplay/engine/polling/.usages/settle.md +55 -0
  37. prettyplay/engine/polling/__init__.py +6 -0
  38. prettyplay/engine/polling/settle.py +98 -0
  39. prettyplay/engine/polling/window.py +72 -0
  40. prettyplay/engine/renderer/.usages/memory.md +24 -0
  41. prettyplay/engine/renderer/.usages/rendering.md +44 -0
  42. prettyplay/engine/renderer/.usages/validation.md +20 -0
  43. prettyplay/engine/renderer/__init__.py +7 -0
  44. prettyplay/engine/renderer/memory.py +37 -0
  45. prettyplay/engine/renderer/render.py +184 -0
  46. prettyplay/engine/renderer/validation.py +76 -0
  47. prettyplay/engine/steering/.usages/steering.md +95 -0
  48. prettyplay/engine/steering/__init__.py +5 -0
  49. prettyplay/engine/steering/steering.py +765 -0
  50. prettyplay/engine/text.py +41 -0
  51. prettyplay/executor.py +654 -0
  52. prettyplay/failures/.usages/taxonomy.md +131 -0
  53. prettyplay/failures/__init__.py +25 -0
  54. prettyplay/failures/errors.py +359 -0
  55. prettyplay/groups.py +276 -0
  56. prettyplay/llm/.usages/classification.md +31 -0
  57. prettyplay/llm/.usages/providers.md +127 -0
  58. prettyplay/llm/.usages/transport.md +81 -0
  59. prettyplay/llm/__init__.py +38 -0
  60. prettyplay/llm/_request.py +823 -0
  61. prettyplay/llm/anthropic_provider.py +468 -0
  62. prettyplay/llm/models.py +273 -0
  63. prettyplay/llm/openai_provider.py +444 -0
  64. prettyplay/llm/provider.py +279 -0
  65. prettyplay/reporting/.usages/hooks.md +61 -0
  66. prettyplay/reporting/__init__.py +6 -0
  67. prettyplay/reporting/hooks.py +47 -0
  68. prettyplay/reporting/reporter.py +70 -0
  69. prettyplay/runtime.py +81 -0
  70. prettyplay/scenario.py +469 -0
  71. prettyplay-0.0.2.dist-info/METADATA +515 -0
  72. prettyplay-0.0.2.dist-info/RECORD +75 -0
  73. prettyplay-0.0.2.dist-info/WHEEL +5 -0
  74. prettyplay-0.0.2.dist-info/licenses/LICENSE +28 -0
  75. prettyplay-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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,6 @@
1
+ """Facade of the prettyplay.engine.polling cell: the settle window and the re-execution loop."""
2
+
3
+ from .settle import settle
4
+ from .window import SettleWindow
5
+
6
+ __all__ = ["SettleWindow", "settle"]
@@ -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)