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,765 @@
|
|
|
1
|
+
"""Interactive steering of a terminally stuck step: the engineer-in-the-loop REPL."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
import tempfile
|
|
6
|
+
from datetime import date
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ...cache import CachedStep, StepCache, StepIdentity
|
|
10
|
+
from ...config import Config
|
|
11
|
+
from ...driver import PageFacade
|
|
12
|
+
from ...failures import ComplianceVerdictError, IncurableStepError, LLMUnavailableError
|
|
13
|
+
from ...llm import ComplianceFinding, LLMProvider, ScenarioStep
|
|
14
|
+
from ...reporting import StepReporter
|
|
15
|
+
from ..attempts import (
|
|
16
|
+
OUTCOME_COMPLIANCE_BLOCKED,
|
|
17
|
+
OUTCOME_EXECUTION_FAILED,
|
|
18
|
+
OUTCOME_FAILED_CHECK,
|
|
19
|
+
OUTCOME_REJECTED,
|
|
20
|
+
StepAttempt,
|
|
21
|
+
)
|
|
22
|
+
from ..compliance import check_step_compliance
|
|
23
|
+
from ..execution import bind_step_inputs, run_step_code
|
|
24
|
+
from ..renderer import PreparedStep, StepMemory, validate_step_result
|
|
25
|
+
from ..text import format_step_error
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("prettyplay")
|
|
28
|
+
|
|
29
|
+
#: System prompt of every guided regeneration request of the steering dialog.
|
|
30
|
+
#: Frozen mirror of ``.goga/usages/prompts/step_generation.md`` (the section after
|
|
31
|
+
#: the ``---`` separator) — the single source of the prompt; the constant changes
|
|
32
|
+
#: only together with the file. A local copy of the engine constant, not an
|
|
33
|
+
#: import: frozen mirrors stay cell-owned, no runtime read of ``.goga/`` ever
|
|
34
|
+
#: happens.
|
|
35
|
+
SYSTEM_PROMPT = """You generate executable Python code for one step of a web UI test.
|
|
36
|
+
|
|
37
|
+
Input you receive:
|
|
38
|
+
- STEP TYPE: action or assertion — the kind of the step
|
|
39
|
+
- STEP: the prepared instruction of the step — the plain-text sentence with actual values embedded
|
|
40
|
+
- INPUTS: the call input bindings, one name = value line each — present when the step carries inputs
|
|
41
|
+
- RESULTS: the declared result names — present when the step declares captures; the code must return a dictionary of exactly these names to non-blank strings observed on the page
|
|
42
|
+
- PREVIOUS STEPS: the sentences of the previous steps of the test, in order
|
|
43
|
+
- PAGE SNAPSHOT: the accessibility snapshot of the current page
|
|
44
|
+
- PAGE URL: the current URL of the page, when present
|
|
45
|
+
- SCREENSHOT: an image of the page, when attached
|
|
46
|
+
- CHEAT SHEET: a compact reference of useful Playwright sync API idioms — guidance, not an allowlist; everything standard stays allowed
|
|
47
|
+
- USER INSTRUCTIONS: the project's binding code style guidance, when configured
|
|
48
|
+
- HISTORY: the verbatim record of every attempt of this step so far, when present — the original cached code first when it exists; each record carries the attempt outcome, the URL before -> after line, the complete candidate code and the complete error
|
|
49
|
+
- RECOMMENDATION: the diagnosis of the classification that preceded this regeneration, when present
|
|
50
|
+
- USER GUIDANCE: the engineer guidance message of the interactive steering, when present
|
|
51
|
+
|
|
52
|
+
Output exactly one Python code block with one function of the fixed form:
|
|
53
|
+
|
|
54
|
+
- without RESULTS: def step(page) -> None: ...
|
|
55
|
+
- with RESULTS: def step(page) -> dict[str, str] | None: ... — return the dictionary of exactly the declared names to values actually read from the current page
|
|
56
|
+
|
|
57
|
+
Rules:
|
|
58
|
+
- The function receives exactly one argument: the page — the genuine Playwright sync Page; the whole step runs inside the driver worker thread
|
|
59
|
+
- The global `step_inputs` is supplied fresh on every execution: captured values are available by name and call-local INPUTS under `step_inputs["vars"]`. Read changing values from it instead of hardcoding the current STEP or INPUTS values into cached code
|
|
60
|
+
- Import from playwright.sync_api and the Python standard library only — no third-party
|
|
61
|
+
libraries; imports are global only: at the top level of the code block, before `def
|
|
62
|
+
step`, never inside the function body
|
|
63
|
+
- Work through the standard Playwright sync API: locator factories, actions, waits, expect chains, plain asserts on immediate reads — everything standard is allowed; the CHEAT SHEET is guidance, never a boundary
|
|
64
|
+
- Assertions: for an assertion sentence end with a check — a waiting expect(...) chain for dynamic content, or an immediate read with a plain Python assert (assert locator.count() > 1)
|
|
65
|
+
- When the request carries a RESULTS block: read every declared value from the current page — never fabricate, never reuse values from HISTORY; return all declared names in one dictionary; every value is a non-blank string; a missing observation fails the step rather than fabricating a value
|
|
66
|
+
- No fixed delays, no sleeps, no wait_for_timeout — locators and expect chains auto-wait
|
|
67
|
+
- The runtime owns the page lifecycle: never call page.close() or context.close()
|
|
68
|
+
- No stateful actions that outlive the step on the page shared by the whole test: page.route, page.clock, add_init_script, tracing, HAR, CDP — excluded from generated code; a cached step would poison every later step far from the cause
|
|
69
|
+
- Dialogs: capture with the stock means — with page.expect_event("dialog") as info: — perform the triggering action inside the block, read info.value.type, info.value.message, info.value.default_value, then info.value.accept() or info.value.dismiss()
|
|
70
|
+
- Popups and new tabs: capture with with page.expect_popup() as popup_info: — trigger the opening action inside the block, work through popup_info.value; page.bring_to_front() raises a page above the others
|
|
71
|
+
- Content inside an iframe goes through page.frame_locator(selector) — locate elements within the returned scope; nested frames chain
|
|
72
|
+
- Scrolling: locator.scroll_into_view_if_needed() and page.mouse.wheel(dx, dy) are the standard means
|
|
73
|
+
- The page state may already include the effects of prior attempts or manual intervention — the HISTORY records and their URL before -> after lines show what already happened. Regeneration after failed attempts never rides the leftover state of those attempts: an action step repeats its action through the page even when the page looks done. A first attempt — no HISTORY — works on the page the previous steps produced: the PAGE URL line and the snapshot show where they landed
|
|
74
|
+
- An action step performs its action and ends there — no trailing check that confirms the action's own completion; the following steps of the test carry their own expectations
|
|
75
|
+
- An assertion step only observes the current page — never navigate, click, type or fill; passive observation such as scroll_into_view_if_needed() stays allowed
|
|
76
|
+
- RECOMMENDATION and USER GUIDANCE carry the diagnosis and the engineer's intent — follow them when they conflict with your first instinct
|
|
77
|
+
- USER INSTRUCTIONS are binding for everything below the safety core of these Rules: follow them when configured; silently ignoring an instruction is a violation
|
|
78
|
+
- The safety core of these Rules always outranks the instructions: the fixed function form, the import rule, the lifecycle rule, the stateful-action exclusions, no fixed delays. An instruction conflicting with a Rule or demanding a stateful action is unfollowable: never implement it silently — raise in the step code with the message "instruction conflicts with rule Y" naming the conflict, so the failure surfaces loudly
|
|
79
|
+
- Prefer-type instructions are conditional by their own wording: follow them when the page offers the option — best-effort with a graceful fallback is compliance
|
|
80
|
+
- The step must complete exactly what STEP says — nothing more, nothing less
|
|
81
|
+
- Output only the code block, no explanations"""
|
|
82
|
+
|
|
83
|
+
#: The compact standard Playwright sync API reference of every guided request;
|
|
84
|
+
#: guidance, not an allowlist — everything standard stays allowed.
|
|
85
|
+
#: Frozen mirror of ``.goga/usages/prompts/step_cheatsheet.md`` — the whole file, verbatim
|
|
86
|
+
#: (the practice has no ``---`` separator, so the whole-file rule is the only mirror
|
|
87
|
+
#: rule with no extraction logic to drift); the constant changes only together with
|
|
88
|
+
#: the file. A local copy of the engine constant, not an import — the same
|
|
89
|
+
#: frozen-mirror rule; no runtime read of ``.goga/`` ever happens.
|
|
90
|
+
CHEAT_SHEET = """# Playwright cheat sheet
|
|
91
|
+
|
|
92
|
+
The compact standard Playwright sync API reference carried by every step-code generation and regeneration request
|
|
93
|
+
of prettyplay — referenced by the engine and steering cells as the `cheat_sheet` practice; rendered by the provider
|
|
94
|
+
implementations as the leading CHEAT SHEET block of the user content. Guidance, not an allowlist: everything
|
|
95
|
+
standard stays allowed — the error-driven regeneration loop is the second line of defense against hallucinated
|
|
96
|
+
calls. Target audience: the generation model — a model that knows Playwright weakly writes a correct step from
|
|
97
|
+
this reference alone.
|
|
98
|
+
|
|
99
|
+
## Locator factories
|
|
100
|
+
|
|
101
|
+
page.get_by_role("button", name="Sign in")
|
|
102
|
+
page.get_by_label("Username")
|
|
103
|
+
page.get_by_text("Welcome back")
|
|
104
|
+
page.get_by_placeholder("Search")
|
|
105
|
+
page.get_by_alt_text("Logo")
|
|
106
|
+
page.get_by_title("Close")
|
|
107
|
+
page.get_by_test_id("submit")
|
|
108
|
+
page.locator("css selector | //xpath | [data-qa=row]")
|
|
109
|
+
|
|
110
|
+
## Narrowing
|
|
111
|
+
|
|
112
|
+
locator.first / locator.last / locator.nth(2)
|
|
113
|
+
locator.filter(has_text="Product X")
|
|
114
|
+
locator.and_(other) / locator.or_(other)
|
|
115
|
+
|
|
116
|
+
## Actions
|
|
117
|
+
|
|
118
|
+
.click() / .dblclick() / .click(button="right")
|
|
119
|
+
.fill("text") / .clear() / .press("Enter") / .press("Control+A")
|
|
120
|
+
.check() / .uncheck() / .hover() / .select_option("v")
|
|
121
|
+
.drag_to(target) / .set_input_files("path.png")
|
|
122
|
+
|
|
123
|
+
## Navigation and waits
|
|
124
|
+
|
|
125
|
+
page.goto(url) / page.go_back() / page.go_forward() / page.reload()
|
|
126
|
+
page.wait_for_url("**/dashboard") / page.wait_for_load_state("networkidle")
|
|
127
|
+
page.url # the current URL — an immediate read beside the waiting forms
|
|
128
|
+
|
|
129
|
+
## Waiting assertions — expect chains
|
|
130
|
+
|
|
131
|
+
from playwright.sync_api import expect
|
|
132
|
+
|
|
133
|
+
expect(locator).to_be_visible() / to_be_hidden()
|
|
134
|
+
expect(locator).to_be_enabled() / to_be_checked()
|
|
135
|
+
expect(locator).to_have_text("...") / to_contain_text("...")
|
|
136
|
+
expect(locator).to_have_value("v") / to_have_attribute("href", "/docs")
|
|
137
|
+
expect(page).to_have_url("**/dashboard") / to_have_title("Dashboard")
|
|
138
|
+
|
|
139
|
+
## Count forms — "the page shows a list of X"
|
|
140
|
+
|
|
141
|
+
items = page.get_by_role("listitem")
|
|
142
|
+
assert items.count() > 1
|
|
143
|
+
|
|
144
|
+
An exact count: `expect(items).to_have_count(3)`. One check per meaning — a visibility expect
|
|
145
|
+
plus a count assert on the same locator verifies one fact twice.
|
|
146
|
+
|
|
147
|
+
## Immediate reads with plain asserts
|
|
148
|
+
|
|
149
|
+
assert locator.count() >= 1
|
|
150
|
+
assert "Dashboard" in page.title()
|
|
151
|
+
assert "/dashboard" in page.url
|
|
152
|
+
|
|
153
|
+
## Dialogs
|
|
154
|
+
|
|
155
|
+
with page.expect_event("dialog") as info:
|
|
156
|
+
page.get_by_role("button", name="Delete").click()
|
|
157
|
+
dialog = info.value
|
|
158
|
+
dialog.type / dialog.message / dialog.default_value
|
|
159
|
+
dialog.accept() / dialog.dismiss() / dialog.accept("the answer")
|
|
160
|
+
|
|
161
|
+
## Popups and new tabs
|
|
162
|
+
|
|
163
|
+
with page.expect_popup() as popup_info:
|
|
164
|
+
page.get_by_role("link", name="Open docs").click()
|
|
165
|
+
popup = popup_info.value
|
|
166
|
+
popup.bring_to_front()
|
|
167
|
+
|
|
168
|
+
## Frames
|
|
169
|
+
|
|
170
|
+
frame = page.frame_locator("#checkout")
|
|
171
|
+
frame.get_by_role("button", name="Pay").click()
|
|
172
|
+
|
|
173
|
+
## Scrolling
|
|
174
|
+
|
|
175
|
+
locator.scroll_into_view_if_needed()
|
|
176
|
+
page.mouse.wheel(0, 600)
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
#: The local commands of the dialog — context services without an LLM request.
|
|
180
|
+
_LOCAL_COMMANDS = frozenset({"snapshot", "screenshot", "error", "code"})
|
|
181
|
+
|
|
182
|
+
#: The banner label column — every banner label aligns to the widest one,
|
|
183
|
+
#: ``commands:``. The banner's own presentation padding; the render text
|
|
184
|
+
#: inside stays unpadded.
|
|
185
|
+
_BANNER_LABEL_WIDTH = len("commands:")
|
|
186
|
+
|
|
187
|
+
#: Prefix of the temporary screenshot file of a dialog.
|
|
188
|
+
_SCREENSHOT_PREFIX = "prettyplay-steering-"
|
|
189
|
+
|
|
190
|
+
#: The explanation of the ``on_healed`` event of an interactively healed step.
|
|
191
|
+
_HEALED_EXPLANATION = "healed interactively by engineer guidance"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class StepSteering:
|
|
195
|
+
"""Steers a terminally stuck step back to green through engineer-approved turns; no budget is ever consumed.
|
|
196
|
+
|
|
197
|
+
Attributes:
|
|
198
|
+
_config: project settings; the generation instructions and the
|
|
199
|
+
screenshot flag feed the guided requests.
|
|
200
|
+
_provider: the LLM port implementation of the guided requests.
|
|
201
|
+
_cache: the store the healed step is written back to.
|
|
202
|
+
_reporter: the visibility point of the healed step.
|
|
203
|
+
_screenshot_path: the path of the one temporary screenshot file of
|
|
204
|
+
the current dialog; ``None`` — the dialog took no screenshot
|
|
205
|
+
yet.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
config: Config,
|
|
211
|
+
provider: LLMProvider,
|
|
212
|
+
cache: StepCache,
|
|
213
|
+
reporter: StepReporter | None,
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Keep the collaborators of the steering dialog.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
config: project settings; ``generation_prompt`` renders into every
|
|
219
|
+
guided request, ``send_screenshots`` attaches page images.
|
|
220
|
+
provider: the LLM port implementation of the guided requests.
|
|
221
|
+
cache: the store the healed step is written back to.
|
|
222
|
+
reporter: the visibility point of the healed step; ``None`` — the
|
|
223
|
+
hook-less default reporter, so an omitted reporter never
|
|
224
|
+
breaks the healed-step report.
|
|
225
|
+
"""
|
|
226
|
+
self._config = config
|
|
227
|
+
self._provider = provider
|
|
228
|
+
self._cache = cache
|
|
229
|
+
self._reporter = reporter if reporter is not None else StepReporter([])
|
|
230
|
+
self._screenshot_path: str | None = None
|
|
231
|
+
|
|
232
|
+
def steer( # noqa: PLR0913, PLR0917 — the signature is fixed by the steering contract
|
|
233
|
+
self,
|
|
234
|
+
failure: IncurableStepError,
|
|
235
|
+
identity: StepIdentity,
|
|
236
|
+
prepared: PreparedStep,
|
|
237
|
+
step_type: str,
|
|
238
|
+
previous_steps: list[ScenarioStep],
|
|
239
|
+
group_prompt: str | None,
|
|
240
|
+
page: PageFacade,
|
|
241
|
+
attempt_history: list[StepAttempt],
|
|
242
|
+
memory: StepMemory,
|
|
243
|
+
) -> CachedStep | None:
|
|
244
|
+
"""Run the steering dialog over a terminal failure.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
failure: the terminal failure about to propagate — the source of
|
|
248
|
+
the failed code, the underlying error and the verdict.
|
|
249
|
+
identity: the address of the stuck step — the healed step is
|
|
250
|
+
written back under it.
|
|
251
|
+
prepared: the render product of the stuck step — threaded from
|
|
252
|
+
the executor; the banner and every guided request carry the
|
|
253
|
+
prepared instruction with its INPUTS and RESULTS blocks; the
|
|
254
|
+
declared names gate the result of an approved turn.
|
|
255
|
+
step_type: action or assertion — carried into every guided
|
|
256
|
+
request and the gate verdict.
|
|
257
|
+
previous_steps: the typed scenario records of the previous steps
|
|
258
|
+
of the test — each the prepared instruction plus its
|
|
259
|
+
permanent group membership; carried into every guided
|
|
260
|
+
request unchanged, the provider renders the marked entries.
|
|
261
|
+
group_prompt: the group prompt of the stuck step's group;
|
|
262
|
+
``None`` — an ordinary step, no group framing; non-empty —
|
|
263
|
+
every guided request of this dialog carries the group framing
|
|
264
|
+
(the GROUP PROMPT block before the scenario context, rendered
|
|
265
|
+
by the provider).
|
|
266
|
+
page: the live page facade of the test.
|
|
267
|
+
attempt_history: the shared per-step attempt history grown by
|
|
268
|
+
the engine loops and anchored by record 0 — rendered into
|
|
269
|
+
every guided request; the dialog appends every completed
|
|
270
|
+
turn to it.
|
|
271
|
+
memory: the test memory — the publication point of the accepted
|
|
272
|
+
turn; the validated captures of a green turn publish with
|
|
273
|
+
its write-back, after the compliance gate.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
The healed cached step on a successful approved turn; ``None`` —
|
|
277
|
+
the dialog was declined or died: the caller propagates the
|
|
278
|
+
original failure. Every completed turn appends one record into
|
|
279
|
+
the shared history; a green turn validates its result and passes
|
|
280
|
+
the compliance gate before the publication and the write-back —
|
|
281
|
+
a high finding never reaches the cache.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
KeyboardInterrupt: a SIGINT outside the prompts and the approval
|
|
285
|
+
gate escapes directly — never swallowed into a turn or a
|
|
286
|
+
heal.
|
|
287
|
+
"""
|
|
288
|
+
self._screenshot_path = None # a fresh dialog owns no screenshot file yet
|
|
289
|
+
self._render_banner(failure, prepared.instruction, page)
|
|
290
|
+
logger.info("steering_opened", extra={"step_text": prepared.instruction})
|
|
291
|
+
|
|
292
|
+
while True:
|
|
293
|
+
message = self._await_guidance(failure, prepared.instruction, page)
|
|
294
|
+
if message is None: # quit, EOF, SIGINT or an unreadable stdin — declined
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
logger.info("steering_guidance", extra={"guidance": message})
|
|
298
|
+
print("regenerating with USER GUIDANCE")
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
code = self._guided_request(
|
|
302
|
+
prepared, step_type, previous_steps, group_prompt, page, message, attempt_history
|
|
303
|
+
)
|
|
304
|
+
except LLMUnavailableError as outcome:
|
|
305
|
+
print(f"provider unavailable: {outcome}")
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
approved = self._confirm_run(code)
|
|
310
|
+
except (EOFError, KeyboardInterrupt, OSError): # a dead approval prompt declines the dialog
|
|
311
|
+
logger.info("steering_declined", extra={"step_text": prepared.instruction})
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
if not approved: # the code never runs — one read, the same URL on both sides
|
|
315
|
+
url = self._guarded_url(page) or ""
|
|
316
|
+
attempt_history.append(_record(code, "", OUTCOME_REJECTED, url, url))
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
url_before = self._guarded_url(page) or ""
|
|
320
|
+
try:
|
|
321
|
+
with bind_step_inputs(memory.snapshot(), prepared.inputs):
|
|
322
|
+
result = run_step_code(code, page) # bare — the settle window never re-arms inside the dialog
|
|
323
|
+
except Exception as outcome: # a red turn returns to the prompt, never escapes
|
|
324
|
+
print(f"turn failed: {outcome}")
|
|
325
|
+
url_after = self._guarded_url(page) or ""
|
|
326
|
+
attempt_history.append(
|
|
327
|
+
_record(code, format_step_error(outcome), _outcome_label(outcome), url_before, url_after)
|
|
328
|
+
)
|
|
329
|
+
continue
|
|
330
|
+
url_after = self._guarded_url(page) or ""
|
|
331
|
+
|
|
332
|
+
# the deterministic result gate: an approved green turn is accepted only by a
|
|
333
|
+
# validated result — a violation is a red turn of the identical failed-check channel
|
|
334
|
+
captures, violation_text = _validated_captures(prepared, result)
|
|
335
|
+
|
|
336
|
+
if violation_text is not None:
|
|
337
|
+
print(f"turn failed: {violation_text}")
|
|
338
|
+
attempt_history.append(_record(code, violation_text, OUTCOME_FAILED_CHECK, url_before, url_after))
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
# the gate sits outside the execution try/except: its hard failures end the
|
|
342
|
+
# dialog, they never degrade into a red turn of the executed candidate; the
|
|
343
|
+
# history it judges holds every prior turn — the candidate rides the CODE block
|
|
344
|
+
try:
|
|
345
|
+
findings = check_step_compliance(
|
|
346
|
+
self._config,
|
|
347
|
+
self._provider,
|
|
348
|
+
prepared,
|
|
349
|
+
step_type,
|
|
350
|
+
code,
|
|
351
|
+
attempt_history,
|
|
352
|
+
)
|
|
353
|
+
except (LLMUnavailableError, ComplianceVerdictError) as gate_failure:
|
|
354
|
+
print(f"compliance gate failed: {gate_failure}")
|
|
355
|
+
logger.warning(
|
|
356
|
+
"compliance gate failed",
|
|
357
|
+
extra={"step_text": prepared.instruction, "gate_failure": str(gate_failure)},
|
|
358
|
+
)
|
|
359
|
+
return None # the green candidate stays unchecked — the original failure propagates
|
|
360
|
+
|
|
361
|
+
high = _high_finding(findings)
|
|
362
|
+
if high is not None: # the violation never reaches the cache — steer the fix
|
|
363
|
+
violation = f"{high.dimension} violation: {high.instruction} — {high.explanation}"
|
|
364
|
+
print(f"compliance violation — not written back: {violation}")
|
|
365
|
+
attempt_history.append(_record(code, violation, OUTCOME_COMPLIANCE_BLOCKED, url_before, url_after))
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
_medium_warning(prepared.instruction, findings) # medium and low findings are visible, never blocking
|
|
369
|
+
|
|
370
|
+
memory.publish(captures) # publication rides the acceptance — after validation and the gate
|
|
371
|
+
return self._write_back(prepared.instruction, identity, code)
|
|
372
|
+
|
|
373
|
+
def _render_banner(self, failure: IncurableStepError, instruction: str, page: PageFacade) -> None:
|
|
374
|
+
"""Render the context banner of the dialog.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
failure: the terminal failure the dialog opens over — the source
|
|
378
|
+
of the failed code and the terminal render.
|
|
379
|
+
instruction: the prepared instruction of the stuck step — the
|
|
380
|
+
banner header names it; the raw template sentence never
|
|
381
|
+
reaches the dialog surface.
|
|
382
|
+
page: the live page facade of the test.
|
|
383
|
+
"""
|
|
384
|
+
print(f'── step "{instruction}" — about to raise IncurableStepError ──')
|
|
385
|
+
print(_banner_line("code:", failure.code))
|
|
386
|
+
print(_banner_line("error:", str(failure)))
|
|
387
|
+
|
|
388
|
+
url = self._guarded_url(page)
|
|
389
|
+
|
|
390
|
+
if url is not None:
|
|
391
|
+
print(_banner_line("url:", url))
|
|
392
|
+
|
|
393
|
+
path = self._screenshot_file(page)
|
|
394
|
+
|
|
395
|
+
if path is not None:
|
|
396
|
+
print(_banner_line("shot:", path))
|
|
397
|
+
|
|
398
|
+
print(_banner_line("commands:", "snapshot | screenshot | error | code | quit"))
|
|
399
|
+
|
|
400
|
+
def _confirm_run(self, code: str) -> bool:
|
|
401
|
+
"""Show the complete generated code and read the strict approval answer.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
code: the complete generated step code of this turn.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
``True`` — a bare ``y`` approved the execution; ``False`` — any
|
|
408
|
+
other answer: the candidate reaches the live page only through
|
|
409
|
+
this gate.
|
|
410
|
+
|
|
411
|
+
Raises:
|
|
412
|
+
EOFError: the approval prompt hit the end of the input.
|
|
413
|
+
KeyboardInterrupt: a SIGINT arrived at the approval prompt.
|
|
414
|
+
OSError: the approval prompt cannot read stdin at all.
|
|
415
|
+
"""
|
|
416
|
+
print("generated code:")
|
|
417
|
+
print(code)
|
|
418
|
+
|
|
419
|
+
return input("run? [y/N] ").strip() == "y"
|
|
420
|
+
|
|
421
|
+
def _guarded_url(self, page: PageFacade) -> str | None:
|
|
422
|
+
"""Read the current URL; a failed interaction degrades to None.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
page: the live page facade of the test.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
The current URL of the page; ``None`` when the read failed —
|
|
429
|
+
its failure text was printed.
|
|
430
|
+
"""
|
|
431
|
+
try:
|
|
432
|
+
return page.url
|
|
433
|
+
except Exception as failure: # a dead page never kills the dialog
|
|
434
|
+
print(f"url unavailable: {failure}")
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
def _await_guidance(self, failure: IncurableStepError, instruction: str, page: PageFacade) -> str | None:
|
|
438
|
+
"""Read guidance lines until one is a real guidance message.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
failure: the terminal failure the dialog opens over.
|
|
442
|
+
instruction: the prepared instruction of the stuck step — the
|
|
443
|
+
decline log names it.
|
|
444
|
+
page: the live page facade of the test.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
The raw guidance message; ``None`` — quit, EOF, SIGINT or an
|
|
448
|
+
unreadable stdin ended the dialog declined. Blank lines
|
|
449
|
+
re-prompt; local commands serve the context.
|
|
450
|
+
"""
|
|
451
|
+
while True:
|
|
452
|
+
message = self._read_guidance(instruction)
|
|
453
|
+
if message is None:
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
command = message.strip()
|
|
457
|
+
if not command: # blank line — re-prompt, no LLM request, no history
|
|
458
|
+
continue
|
|
459
|
+
if command in _LOCAL_COMMANDS:
|
|
460
|
+
self._serve_local_command(command, failure, page)
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
return message
|
|
464
|
+
|
|
465
|
+
def _read_guidance(self, instruction: str) -> str | None:
|
|
466
|
+
"""Read one guidance line from the prompt.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
instruction: the prepared instruction of the stuck step — the
|
|
470
|
+
decline log names it.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
The raw guidance message; ``None`` — quit, EOF, SIGINT or an
|
|
474
|
+
unreadable stdin ended the dialog declined.
|
|
475
|
+
"""
|
|
476
|
+
try:
|
|
477
|
+
message = input("guidance> ")
|
|
478
|
+
except (EOFError, KeyboardInterrupt, OSError): # an unreadable stdin (captured CI) declines like EOF
|
|
479
|
+
logger.info("steering_declined", extra={"step_text": instruction})
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
if message.strip() == "quit":
|
|
483
|
+
logger.info("steering_declined", extra={"step_text": instruction})
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
return message
|
|
487
|
+
|
|
488
|
+
def _serve_local_command(self, command: str, failure: IncurableStepError, page: PageFacade) -> None:
|
|
489
|
+
"""Serve one local context command without an LLM request.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
command: the command name — one of snapshot, screenshot, error,
|
|
493
|
+
code.
|
|
494
|
+
failure: the terminal failure the dialog opens over.
|
|
495
|
+
page: the live page facade of the test.
|
|
496
|
+
"""
|
|
497
|
+
if command == "snapshot":
|
|
498
|
+
print("snapshot:")
|
|
499
|
+
print(self._guarded_snapshot(page))
|
|
500
|
+
return
|
|
501
|
+
if command == "screenshot":
|
|
502
|
+
path = self._screenshot_file(page)
|
|
503
|
+
if path is not None:
|
|
504
|
+
print(path)
|
|
505
|
+
return
|
|
506
|
+
if command == "error":
|
|
507
|
+
print(f"error: {failure.error}")
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
print("code:") # the code command — the last of the fixed set
|
|
511
|
+
print(failure.code)
|
|
512
|
+
|
|
513
|
+
def _guided_request( # noqa: PLR0913, PLR0917 — the fixed request inputs of the port signature
|
|
514
|
+
self,
|
|
515
|
+
prepared: PreparedStep,
|
|
516
|
+
step_type: str,
|
|
517
|
+
previous_steps: list[ScenarioStep],
|
|
518
|
+
group_prompt: str | None,
|
|
519
|
+
page: PageFacade,
|
|
520
|
+
guidance: str,
|
|
521
|
+
attempt_history: list[StepAttempt],
|
|
522
|
+
) -> str:
|
|
523
|
+
"""Run one guided regeneration request against the provider.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
prepared: the render product of the stuck step — the prepared
|
|
527
|
+
instruction rides the request with its INPUTS and RESULTS
|
|
528
|
+
blocks; the raw template sentence never reaches the request.
|
|
529
|
+
step_type: action or assertion — carried into the request.
|
|
530
|
+
previous_steps: the typed scenario records of the previous steps
|
|
531
|
+
of the test — carried unchanged.
|
|
532
|
+
group_prompt: the group prompt of the stuck step's group;
|
|
533
|
+
``None`` — an ordinary step, no group framing.
|
|
534
|
+
page: the live page facade of the test.
|
|
535
|
+
guidance: the engineer guidance message of this turn.
|
|
536
|
+
attempt_history: the shared per-step attempt history — rendered
|
|
537
|
+
record by record into the HISTORY block of the request;
|
|
538
|
+
record 0 anchors the original failure.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
The generated step code of the fixed form — the fresh page state
|
|
542
|
+
plus the render product and the step type compose the request;
|
|
543
|
+
the guidance message rides the USER GUIDANCE block.
|
|
544
|
+
|
|
545
|
+
Raises:
|
|
546
|
+
LLMUnavailableError: the provider request failed; the caller ends
|
|
547
|
+
the dialog.
|
|
548
|
+
"""
|
|
549
|
+
screenshot = self._guarded_screenshot_bytes(page) if self._config.send_screenshots else None
|
|
550
|
+
|
|
551
|
+
return self._provider.generate_step_code(
|
|
552
|
+
prompt=SYSTEM_PROMPT,
|
|
553
|
+
user_instructions=self._config.generation_prompt,
|
|
554
|
+
instruction=prepared.instruction,
|
|
555
|
+
step_type=step_type,
|
|
556
|
+
previous_steps=previous_steps,
|
|
557
|
+
group_prompt=group_prompt,
|
|
558
|
+
inputs=prepared.inputs,
|
|
559
|
+
declarations=prepared.declarations,
|
|
560
|
+
snapshot=self._guarded_snapshot(page),
|
|
561
|
+
page_url=self._guarded_url(page),
|
|
562
|
+
screenshot=screenshot,
|
|
563
|
+
cheat_sheet=CHEAT_SHEET,
|
|
564
|
+
attempt_history=[record.render() for record in attempt_history],
|
|
565
|
+
recommendation=None, # the verdict diagnosis is banner-only — the live guidance replaces it
|
|
566
|
+
guidance=guidance,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def _write_back(self, instruction: str, identity: StepIdentity, code: str) -> CachedStep:
|
|
570
|
+
"""Write the proven guided step back to the cache and report it healed.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
instruction: the prepared instruction of the stuck step — the
|
|
574
|
+
healed event names it.
|
|
575
|
+
identity: the address of the stuck step.
|
|
576
|
+
code: the step code that just worked on the live page.
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
The healed cached step, already stored and reported.
|
|
580
|
+
"""
|
|
581
|
+
step = CachedStep(
|
|
582
|
+
identity=identity,
|
|
583
|
+
code=code,
|
|
584
|
+
created_at=date.today().isoformat(), # noqa: DTZ011 — calendar date of the healed step
|
|
585
|
+
)
|
|
586
|
+
stored = self._cache.save(step)
|
|
587
|
+
|
|
588
|
+
self._reporter.emit("on_healed", {"step_text": instruction, "explanation": _HEALED_EXPLANATION})
|
|
589
|
+
|
|
590
|
+
if stored:
|
|
591
|
+
print("step green — healed step written to the cache")
|
|
592
|
+
else: # the skip reason rides the on_cache_skipped event — the console line never claims a write that failed
|
|
593
|
+
print("step green — cache write skipped (best-effort cache)")
|
|
594
|
+
|
|
595
|
+
return step
|
|
596
|
+
|
|
597
|
+
def _guarded_snapshot(self, page: PageFacade) -> str:
|
|
598
|
+
"""Read the accessibility snapshot; a failed interaction degrades to empty.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
page: the live page facade of the test.
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
The full accessibility snapshot; empty when the interaction
|
|
605
|
+
failed — its failure text was printed.
|
|
606
|
+
"""
|
|
607
|
+
try:
|
|
608
|
+
return page.aria_snapshot()
|
|
609
|
+
except Exception as failure: # a dead page never kills the dialog
|
|
610
|
+
print(f"snapshot unavailable: {failure}")
|
|
611
|
+
return ""
|
|
612
|
+
|
|
613
|
+
def _screenshot_file(self, page: PageFacade) -> str | None:
|
|
614
|
+
"""Write a full PNG to the one temporary file of the dialog; return its path.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
page: the live page facade of the test.
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
The path of the written file — one file per dialog, overwritten
|
|
621
|
+
by every later screenshot; ``None`` when the interaction failed.
|
|
622
|
+
"""
|
|
623
|
+
path = self._screenshot_path
|
|
624
|
+
fresh = path is None # the first screenshot of the dialog allocates the one file
|
|
625
|
+
|
|
626
|
+
try:
|
|
627
|
+
png = page.screenshot()
|
|
628
|
+
|
|
629
|
+
if fresh:
|
|
630
|
+
with tempfile.NamedTemporaryFile(prefix=_SCREENSHOT_PREFIX, suffix=".png", delete=False) as target:
|
|
631
|
+
path = target.name
|
|
632
|
+
target.write(png)
|
|
633
|
+
else: # every later screenshot of the dialog overwrites the one file
|
|
634
|
+
Path(path).write_bytes(png)
|
|
635
|
+
except Exception as failure: # a dead page never kills the dialog
|
|
636
|
+
if fresh and path is not None:
|
|
637
|
+
with contextlib.suppress(OSError):
|
|
638
|
+
Path(path).unlink() # a partial write leaves nothing worth inspecting
|
|
639
|
+
print(f"screenshot unavailable: {failure}")
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
self._screenshot_path = path
|
|
643
|
+
return path
|
|
644
|
+
|
|
645
|
+
def _guarded_screenshot_bytes(self, page: PageFacade) -> bytes | None:
|
|
646
|
+
"""Read the screenshot bytes of a guided request; a failed interaction degrades to None.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
page: the live page facade of the test.
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
The full-page PNG bytes; ``None`` when the interaction failed —
|
|
653
|
+
its failure text was printed.
|
|
654
|
+
"""
|
|
655
|
+
try:
|
|
656
|
+
return page.screenshot()
|
|
657
|
+
except Exception as failure: # a dead page never kills the dialog
|
|
658
|
+
print(f"screenshot unavailable: {failure}")
|
|
659
|
+
return None
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _high_finding(findings: list[ComplianceFinding]) -> ComplianceFinding | None:
|
|
663
|
+
"""Return the first high finding of a gate verdict — either dimension blocks.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
findings: the findings of the verdict, in the verdict's own ordering.
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
The first finding of priority high, or ``None`` when the verdict
|
|
670
|
+
holds none — the guidance prompt reopens and the next candidate is
|
|
671
|
+
gated anew.
|
|
672
|
+
"""
|
|
673
|
+
return next((finding for finding in findings if finding.priority == "high"), None)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _medium_warning(instruction: str, findings: list[ComplianceFinding]) -> None:
|
|
677
|
+
"""Log the medium and low findings a green turn passed with.
|
|
678
|
+
|
|
679
|
+
A verdict without findings warns nothing — the clean pass stays quiet.
|
|
680
|
+
|
|
681
|
+
Args:
|
|
682
|
+
instruction: the prepared instruction of the gated step.
|
|
683
|
+
findings: the non-blocking findings of both dimensions of the verdict.
|
|
684
|
+
"""
|
|
685
|
+
if not findings:
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
logger.warning(
|
|
689
|
+
"compliance findings passed",
|
|
690
|
+
extra={
|
|
691
|
+
"step_text": instruction,
|
|
692
|
+
"findings": [
|
|
693
|
+
f"{finding.priority} {finding.dimension}: {finding.instruction} — {finding.explanation}"
|
|
694
|
+
for finding in findings
|
|
695
|
+
],
|
|
696
|
+
},
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _outcome_label(outcome: BaseException) -> str:
|
|
701
|
+
"""Pick the outcome label of a red turn by its exception kind.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
outcome: the exception the executed candidate raised.
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
The failed-check label for an ``AssertionError``, the
|
|
708
|
+
execution-failed label for every other failure.
|
|
709
|
+
"""
|
|
710
|
+
return OUTCOME_FAILED_CHECK if isinstance(outcome, AssertionError) else OUTCOME_EXECUTION_FAILED
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _record(code: str, error: str, outcome: str, url_before: str, url_after: str) -> StepAttempt:
|
|
714
|
+
"""Compose one verbatim attempt record — the shape every turn-append site shares.
|
|
715
|
+
|
|
716
|
+
Args:
|
|
717
|
+
code: the complete candidate code of the turn.
|
|
718
|
+
error: the complete failure text of the turn; empty on no error.
|
|
719
|
+
outcome: the outcome label constant of the turn.
|
|
720
|
+
url_before: the page URL read immediately before the turn's execution.
|
|
721
|
+
url_after: the page URL read immediately after the turn's execution.
|
|
722
|
+
|
|
723
|
+
Returns:
|
|
724
|
+
The immutable record appended to the shared per-step attempt history.
|
|
725
|
+
"""
|
|
726
|
+
return StepAttempt(code=code, error=error, outcome=outcome, url_before=url_before, url_after=url_after)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _validated_captures(prepared: PreparedStep, result: object) -> tuple[dict[str, str], str | None]:
|
|
730
|
+
"""Validate the returned result of an approved green turn against the render product.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
prepared: the render product of the stuck step — the declaration set
|
|
734
|
+
the returned result must satisfy.
|
|
735
|
+
result: the step function's return, passed through by the run
|
|
736
|
+
primitive.
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
The validated captures with no violation; an empty captures with the
|
|
740
|
+
formatted deterministic violation text when the result contract
|
|
741
|
+
failed — the violation joins the failed-check channel of the dialog.
|
|
742
|
+
"""
|
|
743
|
+
try:
|
|
744
|
+
return validate_step_result(prepared, result), None
|
|
745
|
+
except AssertionError as violation:
|
|
746
|
+
return {}, format_step_error(violation)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _banner_line(label: str, text: str) -> str:
|
|
750
|
+
"""Align one banner label with its value text.
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
label: the banner label including its colon.
|
|
754
|
+
text: the value text of the line; its continuation lines indent to
|
|
755
|
+
the value column.
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
The aligned banner line with its continuation lines — the label pads
|
|
759
|
+
to the label column, multi-line values indent to the value column.
|
|
760
|
+
"""
|
|
761
|
+
value_column = " " * (_BANNER_LABEL_WIDTH + 1)
|
|
762
|
+
head, *tail = text.splitlines() or [""] # an empty value renders its label alone
|
|
763
|
+
lines = [f"{label.ljust(_BANNER_LABEL_WIDTH)} {head}", *(value_column + line for line in tail)]
|
|
764
|
+
|
|
765
|
+
return "\n".join(line.rstrip() for line in lines)
|