walkthru 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.
- walkthru/__init__.py +114 -0
- walkthru/adapters/__init__.py +10 -0
- walkthru/adapters/export/__init__.py +16 -0
- walkthru/adapters/export/json_target.py +40 -0
- walkthru/adapters/export/webvtt.py +38 -0
- walkthru/core/__init__.py +116 -0
- walkthru/core/engine.py +196 -0
- walkthru/core/events.py +154 -0
- walkthru/core/schema.py +334 -0
- walkthru/core/timeline.py +177 -0
- walkthru/ecosystem/__init__.py +12 -0
- walkthru/ecosystem/reelee/__init__.py +37 -0
- walkthru/ecosystem/reelee/render_target.py +433 -0
- walkthru/ports/__init__.py +98 -0
- walkthru-0.0.2.dist-info/METADATA +74 -0
- walkthru-0.0.2.dist-info/RECORD +18 -0
- walkthru-0.0.2.dist-info/WHEEL +4 -0
- walkthru-0.0.2.dist-info/licenses/LICENSE +21 -0
walkthru/__init__.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""walkthru — editable, re-renderable demo/tour artifacts from a command sequence.
|
|
2
|
+
|
|
3
|
+
walkthru owns the *representation* (the Demo Document) and a tiny pure playback/capture
|
|
4
|
+
engine, ``play(demoDoc, executor, observers)``. It never renders the final video — it hands a
|
|
5
|
+
validated artifact to a renderer (the ``reelee`` ecosystem, Remotion, moviepy/ffmpeg). Owning
|
|
6
|
+
representation, not pixels, is the load-bearing boundary of the design.
|
|
7
|
+
|
|
8
|
+
Two modes share one data model and one engine: *generative* (an author supplies the document;
|
|
9
|
+
walkthru plays it while recording) and *capture* (a human drives; walkthru records the video
|
|
10
|
+
and the underlying command stream into the same document).
|
|
11
|
+
|
|
12
|
+
See ``PLAN.md``, ``DECISIONS.md``, and the repository's enhancement issues for the design and
|
|
13
|
+
its running development journal. This package currently holds the Python side (schema SSOT +
|
|
14
|
+
core + render hand-off); the live capture/play engine ships separately as ``acture-walkthru``.
|
|
15
|
+
|
|
16
|
+
Quickstart::
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from walkthru import DemoDocument, Section, CommandStep, Command, Timing, play
|
|
20
|
+
|
|
21
|
+
doc = DemoDocument(
|
|
22
|
+
id="demo",
|
|
23
|
+
sections=[Section(id="s1", steps=[
|
|
24
|
+
CommandStep(id="step-1", command=Command(id="app.open"), timing=Timing(duration_ms=500)),
|
|
25
|
+
])],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
async def executor(command):
|
|
29
|
+
print("run", command.id)
|
|
30
|
+
return {"ok": True}
|
|
31
|
+
|
|
32
|
+
asyncio.run(play(doc, executor))
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from walkthru.core import ( # noqa: F401
|
|
36
|
+
Anchor,
|
|
37
|
+
AssetRef,
|
|
38
|
+
Beat,
|
|
39
|
+
CalloutCue,
|
|
40
|
+
Command,
|
|
41
|
+
CommandInvocation,
|
|
42
|
+
CommandStep,
|
|
43
|
+
Cue,
|
|
44
|
+
CursorCue,
|
|
45
|
+
DemoDocument,
|
|
46
|
+
Event,
|
|
47
|
+
HighlightCue,
|
|
48
|
+
HotspotCue,
|
|
49
|
+
Locator,
|
|
50
|
+
Meta,
|
|
51
|
+
NarrationSegment,
|
|
52
|
+
Observer,
|
|
53
|
+
Outcome,
|
|
54
|
+
Rect,
|
|
55
|
+
ResolvedCamera,
|
|
56
|
+
ResolvedCue,
|
|
57
|
+
ResolvedNarration,
|
|
58
|
+
ResolvedStep,
|
|
59
|
+
Section,
|
|
60
|
+
SpotlightCue,
|
|
61
|
+
Step,
|
|
62
|
+
Target,
|
|
63
|
+
Timeline,
|
|
64
|
+
Timing,
|
|
65
|
+
Tracks,
|
|
66
|
+
demo_document_json_schema,
|
|
67
|
+
iter_events,
|
|
68
|
+
iter_resolved_steps,
|
|
69
|
+
play,
|
|
70
|
+
record,
|
|
71
|
+
resolve_timeline,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
__version__ = "0.0.1"
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"__version__",
|
|
78
|
+
"play",
|
|
79
|
+
"record",
|
|
80
|
+
"iter_events",
|
|
81
|
+
"resolve_timeline",
|
|
82
|
+
"iter_resolved_steps",
|
|
83
|
+
"Timeline",
|
|
84
|
+
"ResolvedStep",
|
|
85
|
+
"ResolvedCue",
|
|
86
|
+
"ResolvedNarration",
|
|
87
|
+
"ResolvedCamera",
|
|
88
|
+
"demo_document_json_schema",
|
|
89
|
+
"DemoDocument",
|
|
90
|
+
"Meta",
|
|
91
|
+
"Section",
|
|
92
|
+
"Step",
|
|
93
|
+
"CommandStep",
|
|
94
|
+
"Beat",
|
|
95
|
+
"Command",
|
|
96
|
+
"Timing",
|
|
97
|
+
"Anchor",
|
|
98
|
+
"Target",
|
|
99
|
+
"Locator",
|
|
100
|
+
"Rect",
|
|
101
|
+
"Tracks",
|
|
102
|
+
"Cue",
|
|
103
|
+
"HighlightCue",
|
|
104
|
+
"SpotlightCue",
|
|
105
|
+
"HotspotCue",
|
|
106
|
+
"CalloutCue",
|
|
107
|
+
"CursorCue",
|
|
108
|
+
"NarrationSegment",
|
|
109
|
+
"AssetRef",
|
|
110
|
+
"Event",
|
|
111
|
+
"Observer",
|
|
112
|
+
"Outcome",
|
|
113
|
+
"CommandInvocation",
|
|
114
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Optional, isolated port implementations — the firewall's outer ring.
|
|
2
|
+
|
|
3
|
+
Each adapter implements one or more :mod:`walkthru.ports` against a concrete tool (Playwright,
|
|
4
|
+
OBS/ffmpeg, WhisperX, Piper, driver.js, ...). Adapters depend on ports, **never the reverse**, and
|
|
5
|
+
the core never imports anything from here. Each adapter's heavy dependency is an *optional* extra,
|
|
6
|
+
so installing walkthru's core pulls in no vendor SDK.
|
|
7
|
+
|
|
8
|
+
No adapters are implemented yet — the web-first generative path (Playwright player/recorder/
|
|
9
|
+
locator + driver.js cues) is MVP Stage 3. See ``PLAN.md`` §3.4 and issue #5.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Dependency-free export targets — the renderer hand-off and caption sidecars.
|
|
2
|
+
|
|
3
|
+
These adapters turn a validated Demo Document into the artifacts a renderer or player consumes.
|
|
4
|
+
They depend only on the core (schema + :mod:`timeline <walkthru.core.timeline>`), never the reverse,
|
|
5
|
+
and pull in no vendor dependency — so they sit behind the ports firewall yet need no optional extra.
|
|
6
|
+
|
|
7
|
+
* :class:`JsonArtifactTarget` / :func:`to_json` — the frozen JSON projection of the Demo Document,
|
|
8
|
+
the brief's *primary* renderer contract (the renderer owns pixels; we own representation).
|
|
9
|
+
* :func:`narration_to_webvtt` — WebVTT captions derived from the narration track, "nearly free"
|
|
10
|
+
from the resolved timeline.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from walkthru.adapters.export.json_target import JsonArtifactTarget, to_json
|
|
14
|
+
from walkthru.adapters.export.webvtt import narration_to_webvtt
|
|
15
|
+
|
|
16
|
+
__all__ = ["JsonArtifactTarget", "to_json", "narration_to_webvtt"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The frozen JSON projection — walkthru's primary renderer hand-off.
|
|
2
|
+
|
|
3
|
+
The boundary the whole design rests on: walkthru owns the *representation* (the Demo Document) and
|
|
4
|
+
hands a renderer a validated JSON artifact; the renderer owns the pixels and may ignore anything it
|
|
5
|
+
does not understand. :class:`JsonArtifactTarget` is the reference :class:`~walkthru.ports.RenderTarget`
|
|
6
|
+
that emits exactly that artifact.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Union
|
|
13
|
+
|
|
14
|
+
from walkthru.core.schema import AssetRef, DemoDocument
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_json(document: DemoDocument, *, indent: int | None = 2) -> str:
|
|
18
|
+
"""The frozen JSON projection (camelCase keys), validated by construction.
|
|
19
|
+
|
|
20
|
+
Because ``document`` is a validated :class:`~walkthru.core.schema.DemoDocument`, the emitted
|
|
21
|
+
JSON conforms to the published schema; the TS side / a renderer can consume it directly.
|
|
22
|
+
"""
|
|
23
|
+
return document.model_dump_json(by_alias=True, indent=indent)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JsonArtifactTarget:
|
|
27
|
+
"""A :class:`~walkthru.ports.RenderTarget` that writes the Demo Document's JSON projection.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
out_dir: directory to write ``<document.id>.json`` into (created on demand).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, out_dir: Union[str, Path] = "."):
|
|
34
|
+
self._out_dir = Path(out_dir)
|
|
35
|
+
|
|
36
|
+
async def export(self, artifact: DemoDocument) -> AssetRef:
|
|
37
|
+
path = self._out_dir / f"{artifact.id}.json"
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path.write_text(to_json(artifact) + "\n", encoding="utf-8")
|
|
40
|
+
return AssetRef(uri=str(path), mime="application/json")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""WebVTT captions derived from the narration track.
|
|
2
|
+
|
|
3
|
+
Narration is timed, editable text anchored to steps; once the timeline is composed to absolute
|
|
4
|
+
time, captions fall out almost for free (Report 01 §D.2). This exporter is the web-native default;
|
|
5
|
+
SRT is deliberately not implemented until a real need appears (YAGNI).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from walkthru.core.schema import DemoDocument
|
|
11
|
+
from walkthru.core.timeline import resolve_timeline
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _timestamp(ms: int) -> str:
|
|
15
|
+
"""Format milliseconds as a WebVTT timestamp ``HH:MM:SS.mmm``."""
|
|
16
|
+
hours, rem = divmod(ms, 3_600_000)
|
|
17
|
+
minutes, rem = divmod(rem, 60_000)
|
|
18
|
+
seconds, millis = divmod(rem, 1000)
|
|
19
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def narration_to_webvtt(document: DemoDocument) -> str:
|
|
23
|
+
"""Render the narration track as a WebVTT caption document.
|
|
24
|
+
|
|
25
|
+
Cue start/end come from the resolved (absolute) timeline; cues are emitted in time order.
|
|
26
|
+
Returns a complete ``.vtt`` document (trailing newline included).
|
|
27
|
+
"""
|
|
28
|
+
timeline = resolve_timeline(document)
|
|
29
|
+
segments = sorted(timeline.narration, key=lambda r: (r.start_ms, r.end_ms))
|
|
30
|
+
|
|
31
|
+
lines = ["WEBVTT", ""]
|
|
32
|
+
for index, segment in enumerate(segments, start=1):
|
|
33
|
+
lines.append(str(index))
|
|
34
|
+
lines.append(f"{_timestamp(segment.start_ms)} --> {_timestamp(segment.end_ms)}")
|
|
35
|
+
lines.append(segment.text)
|
|
36
|
+
lines.append("")
|
|
37
|
+
|
|
38
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""walkthru's pure core: the Demo Document schema, the lifecycle events, and the engine.
|
|
2
|
+
|
|
3
|
+
Nothing in this package imports from :mod:`walkthru.adapters` or :mod:`walkthru.ecosystem`, and it
|
|
4
|
+
has no vendor dependencies — only Pydantic (for the schema SSOT). Import the engine and schema from
|
|
5
|
+
here or from the top-level :mod:`walkthru` package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from walkthru.core.engine import iter_events, play, record
|
|
9
|
+
from walkthru.core.timeline import (
|
|
10
|
+
ResolvedCamera,
|
|
11
|
+
ResolvedCue,
|
|
12
|
+
ResolvedNarration,
|
|
13
|
+
ResolvedStep,
|
|
14
|
+
Timeline,
|
|
15
|
+
iter_resolved_steps,
|
|
16
|
+
resolve_timeline,
|
|
17
|
+
)
|
|
18
|
+
from walkthru.core.events import (
|
|
19
|
+
AfterCommand,
|
|
20
|
+
BeatEvent,
|
|
21
|
+
BeforeCommand,
|
|
22
|
+
CommandError,
|
|
23
|
+
CommandInvocation,
|
|
24
|
+
CueBegin,
|
|
25
|
+
CueEnd,
|
|
26
|
+
DemoEnd,
|
|
27
|
+
DemoStart,
|
|
28
|
+
Event,
|
|
29
|
+
Narration,
|
|
30
|
+
Observer,
|
|
31
|
+
Outcome,
|
|
32
|
+
SectionEnter,
|
|
33
|
+
SectionExit,
|
|
34
|
+
StepEnter,
|
|
35
|
+
StepExit,
|
|
36
|
+
)
|
|
37
|
+
from walkthru.core.schema import (
|
|
38
|
+
Anchor,
|
|
39
|
+
AssetRef,
|
|
40
|
+
Beat,
|
|
41
|
+
CalloutCue,
|
|
42
|
+
Command,
|
|
43
|
+
CommandStep,
|
|
44
|
+
Cue,
|
|
45
|
+
CursorCue,
|
|
46
|
+
DemoDocument,
|
|
47
|
+
HighlightCue,
|
|
48
|
+
HotspotCue,
|
|
49
|
+
Locator,
|
|
50
|
+
Meta,
|
|
51
|
+
NarrationSegment,
|
|
52
|
+
Rect,
|
|
53
|
+
Section,
|
|
54
|
+
SpotlightCue,
|
|
55
|
+
Step,
|
|
56
|
+
Target,
|
|
57
|
+
Timing,
|
|
58
|
+
Tracks,
|
|
59
|
+
demo_document_json_schema,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
# engine
|
|
64
|
+
"play",
|
|
65
|
+
"record",
|
|
66
|
+
"iter_events",
|
|
67
|
+
# timeline
|
|
68
|
+
"resolve_timeline",
|
|
69
|
+
"iter_resolved_steps",
|
|
70
|
+
"Timeline",
|
|
71
|
+
"ResolvedStep",
|
|
72
|
+
"ResolvedCue",
|
|
73
|
+
"ResolvedNarration",
|
|
74
|
+
"ResolvedCamera",
|
|
75
|
+
# schema
|
|
76
|
+
"DemoDocument",
|
|
77
|
+
"Meta",
|
|
78
|
+
"Section",
|
|
79
|
+
"Step",
|
|
80
|
+
"CommandStep",
|
|
81
|
+
"Beat",
|
|
82
|
+
"Command",
|
|
83
|
+
"Timing",
|
|
84
|
+
"Anchor",
|
|
85
|
+
"Target",
|
|
86
|
+
"Locator",
|
|
87
|
+
"Rect",
|
|
88
|
+
"Tracks",
|
|
89
|
+
"Cue",
|
|
90
|
+
"HighlightCue",
|
|
91
|
+
"SpotlightCue",
|
|
92
|
+
"HotspotCue",
|
|
93
|
+
"CalloutCue",
|
|
94
|
+
"CursorCue",
|
|
95
|
+
"NarrationSegment",
|
|
96
|
+
"AssetRef",
|
|
97
|
+
"demo_document_json_schema",
|
|
98
|
+
# events
|
|
99
|
+
"Event",
|
|
100
|
+
"Observer",
|
|
101
|
+
"Outcome",
|
|
102
|
+
"CommandInvocation",
|
|
103
|
+
"DemoStart",
|
|
104
|
+
"DemoEnd",
|
|
105
|
+
"SectionEnter",
|
|
106
|
+
"SectionExit",
|
|
107
|
+
"StepEnter",
|
|
108
|
+
"StepExit",
|
|
109
|
+
"BeforeCommand",
|
|
110
|
+
"AfterCommand",
|
|
111
|
+
"CommandError",
|
|
112
|
+
"CueBegin",
|
|
113
|
+
"CueEnd",
|
|
114
|
+
"Narration",
|
|
115
|
+
"BeatEvent",
|
|
116
|
+
]
|
walkthru/core/engine.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""The pure engine: walk a Demo Document, emit the lifecycle event stream.
|
|
2
|
+
|
|
3
|
+
One small core, one lifecycle protocol, two modes with the *driver inverted* (Report 02 §B.2):
|
|
4
|
+
|
|
5
|
+
* :func:`play` — **generative**: the executor drives. Walk the document, call ``executor`` for
|
|
6
|
+
each command, and emit the lifecycle :mod:`events <walkthru.core.events>`. Observers record,
|
|
7
|
+
draw, narrate.
|
|
8
|
+
* :func:`record` — **capture**: the human drives. Consume a stream of already-executed commands
|
|
9
|
+
and assemble the *same* :class:`~walkthru.core.schema.DemoDocument`, emitting the *same*
|
|
10
|
+
lifecycle so every observer behaves identically.
|
|
11
|
+
|
|
12
|
+
The engine performs exactly one injected effect — calling ``executor`` — and never records,
|
|
13
|
+
renders, or speaks. All other effects are injected observers/ports. ``executor`` and observers may
|
|
14
|
+
be sync or async; the engine awaits whatever is awaitable.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import inspect
|
|
20
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
|
|
21
|
+
from typing import Any, Union
|
|
22
|
+
|
|
23
|
+
from walkthru.core.events import (
|
|
24
|
+
AfterCommand,
|
|
25
|
+
BeatEvent,
|
|
26
|
+
BeforeCommand,
|
|
27
|
+
CommandError,
|
|
28
|
+
CommandInvocation,
|
|
29
|
+
CueBegin,
|
|
30
|
+
CueEnd,
|
|
31
|
+
DemoEnd,
|
|
32
|
+
DemoStart,
|
|
33
|
+
Event,
|
|
34
|
+
Narration,
|
|
35
|
+
Observer,
|
|
36
|
+
Outcome,
|
|
37
|
+
SectionEnter,
|
|
38
|
+
SectionExit,
|
|
39
|
+
StepEnter,
|
|
40
|
+
StepExit,
|
|
41
|
+
)
|
|
42
|
+
from walkthru.core.schema import (
|
|
43
|
+
Command,
|
|
44
|
+
CommandStep,
|
|
45
|
+
Cue,
|
|
46
|
+
DemoDocument,
|
|
47
|
+
NarrationSegment,
|
|
48
|
+
Section,
|
|
49
|
+
Step,
|
|
50
|
+
Timing,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: The executor is any callable taking a :class:`~walkthru.core.schema.Command`; sync or async.
|
|
54
|
+
Executor = Callable[[Command], Union[Awaitable[Any], Any]]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _maybe_await(value: Any) -> Any:
|
|
58
|
+
"""Await ``value`` if it is awaitable, else return it as-is."""
|
|
59
|
+
if inspect.isawaitable(value):
|
|
60
|
+
return await value
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cues_for(document: DemoDocument, step_id: str) -> Iterable[Cue]:
|
|
65
|
+
"""The cues anchored to ``step_id`` (anchor is the SSOT for cue-to-step association)."""
|
|
66
|
+
for cue in document.tracks.cues:
|
|
67
|
+
if cue.anchor.step_id == step_id:
|
|
68
|
+
yield cue
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _narration_for(document: DemoDocument, step_id: str) -> Iterable[NarrationSegment]:
|
|
72
|
+
"""The narration segments anchored to ``step_id``."""
|
|
73
|
+
for segment in document.tracks.narration:
|
|
74
|
+
if segment.anchor.step_id == step_id:
|
|
75
|
+
yield segment
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def iter_events(
|
|
79
|
+
document: DemoDocument, executor: Executor
|
|
80
|
+
) -> AsyncIterator[Event]:
|
|
81
|
+
"""Walk ``document`` and yield the lifecycle event stream (generative mode).
|
|
82
|
+
|
|
83
|
+
This is the canonical realization of the lifecycle protocol. The only injected effect is
|
|
84
|
+
``executor``, awaited between :class:`~walkthru.core.events.BeforeCommand` and
|
|
85
|
+
:class:`~walkthru.core.events.AfterCommand`. A command that raises yields a
|
|
86
|
+
:class:`~walkthru.core.events.CommandError` and the walk continues (the renderer/observer
|
|
87
|
+
decides what a failed step means).
|
|
88
|
+
"""
|
|
89
|
+
yield DemoStart(document)
|
|
90
|
+
errors: list[CommandError] = []
|
|
91
|
+
steps_run = 0
|
|
92
|
+
|
|
93
|
+
for section in document.sections:
|
|
94
|
+
yield SectionEnter(section)
|
|
95
|
+
for step in section.steps:
|
|
96
|
+
yield StepEnter(step)
|
|
97
|
+
|
|
98
|
+
for segment in _narration_for(document, step.id):
|
|
99
|
+
yield Narration(segment)
|
|
100
|
+
|
|
101
|
+
if isinstance(step, CommandStep):
|
|
102
|
+
yield BeforeCommand(step.command, step)
|
|
103
|
+
try:
|
|
104
|
+
result = await _maybe_await(executor(step.command))
|
|
105
|
+
except Exception as error: # noqa: BLE001 — surfaced as a CommandError event
|
|
106
|
+
err = CommandError(step.command, error, step)
|
|
107
|
+
errors.append(err)
|
|
108
|
+
yield err
|
|
109
|
+
else:
|
|
110
|
+
yield AfterCommand(step.command, result, step)
|
|
111
|
+
steps_run += 1
|
|
112
|
+
else:
|
|
113
|
+
yield BeatEvent(step)
|
|
114
|
+
|
|
115
|
+
for cue in _cues_for(document, step.id):
|
|
116
|
+
yield CueBegin(cue)
|
|
117
|
+
yield CueEnd(cue)
|
|
118
|
+
|
|
119
|
+
yield StepExit(step)
|
|
120
|
+
yield SectionExit(section)
|
|
121
|
+
|
|
122
|
+
yield DemoEnd(Outcome(ok=not errors, steps_run=steps_run, errors=tuple(errors)))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _emit(event: Event, observers: Iterable[Observer]) -> None:
|
|
126
|
+
"""Fan one event out to every observer, awaiting async observers."""
|
|
127
|
+
for observer in observers:
|
|
128
|
+
await _maybe_await(observer(event))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def play(
|
|
132
|
+
document: DemoDocument,
|
|
133
|
+
executor: Executor,
|
|
134
|
+
*,
|
|
135
|
+
observers: Iterable[Observer] = (),
|
|
136
|
+
) -> Outcome:
|
|
137
|
+
"""Play ``document`` generatively, driving ``executor`` and notifying ``observers``.
|
|
138
|
+
|
|
139
|
+
Returns the run :class:`~walkthru.core.events.Outcome`. This is a thin driver over
|
|
140
|
+
:func:`iter_events`: it forwards each event to every observer and captures the final outcome.
|
|
141
|
+
"""
|
|
142
|
+
observers = tuple(observers)
|
|
143
|
+
outcome = Outcome(ok=True, steps_run=0)
|
|
144
|
+
async for event in iter_events(document, executor):
|
|
145
|
+
if isinstance(event, DemoEnd):
|
|
146
|
+
outcome = event.outcome
|
|
147
|
+
await _emit(event, observers)
|
|
148
|
+
return outcome
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def _aiter(items: Union[Iterable[Any], AsyncIterator[Any]]) -> AsyncIterator[Any]:
|
|
152
|
+
"""Normalize a sync or async iterable to an async iterator."""
|
|
153
|
+
if hasattr(items, "__aiter__"):
|
|
154
|
+
async for item in items: # type: ignore[union-attr]
|
|
155
|
+
yield item
|
|
156
|
+
else:
|
|
157
|
+
for item in items: # type: ignore[union-attr]
|
|
158
|
+
yield item
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def record(
|
|
162
|
+
invocations: Union[Iterable[CommandInvocation], AsyncIterator[CommandInvocation]],
|
|
163
|
+
*,
|
|
164
|
+
observers: Iterable[Observer] = (),
|
|
165
|
+
document_id: str = "capture",
|
|
166
|
+
section_id: str = "captured",
|
|
167
|
+
section_title: str | None = None,
|
|
168
|
+
default_duration_ms: int = 1000,
|
|
169
|
+
) -> DemoDocument:
|
|
170
|
+
"""Capture mode: assemble a Demo Document from a stream of executed commands.
|
|
171
|
+
|
|
172
|
+
Each :class:`~walkthru.core.events.CommandInvocation` becomes a
|
|
173
|
+
:class:`~walkthru.core.schema.CommandStep`, and the same lifecycle events are emitted so
|
|
174
|
+
observers (a logger, a video recorder, a transcriber) behave exactly as in generative mode.
|
|
175
|
+
The returned document plays back through :func:`play` to reproduce the captured commands — the
|
|
176
|
+
two modes are inverses over one data model.
|
|
177
|
+
"""
|
|
178
|
+
observers = tuple(observers)
|
|
179
|
+
steps: list[Step] = []
|
|
180
|
+
|
|
181
|
+
async for invocation in _aiter(invocations):
|
|
182
|
+
step = CommandStep(
|
|
183
|
+
id=f"step-{len(steps) + 1}",
|
|
184
|
+
command=invocation.command,
|
|
185
|
+
timing=Timing(duration_ms=invocation.duration_ms or default_duration_ms),
|
|
186
|
+
)
|
|
187
|
+
steps.append(step)
|
|
188
|
+
await _emit(StepEnter(step), observers)
|
|
189
|
+
await _emit(BeforeCommand(step.command, step), observers)
|
|
190
|
+
await _emit(AfterCommand(step.command, invocation.result, step), observers)
|
|
191
|
+
await _emit(StepExit(step), observers)
|
|
192
|
+
|
|
193
|
+
return DemoDocument(
|
|
194
|
+
id=document_id,
|
|
195
|
+
sections=[Section(id=section_id, title=section_title, steps=steps)],
|
|
196
|
+
)
|
walkthru/core/events.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The lifecycle protocol, as a typed event stream.
|
|
2
|
+
|
|
3
|
+
The brief describes the engine's lifecycle as a set of named observer hooks (``onStepEnter``,
|
|
4
|
+
``beforeCommand``, ...). walkthru realizes that protocol as a stream of **typed events** consumed
|
|
5
|
+
by observer **callables** — the functional equivalent (each event type corresponds 1:1 to a hook
|
|
6
|
+
name), chosen for composability and to match Thor's functional conventions. See ``DECISIONS.md``
|
|
7
|
+
§D9.
|
|
8
|
+
|
|
9
|
+
An :data:`Observer` is any callable taking one :data:`Event`; it may be sync or async. The engine
|
|
10
|
+
(:mod:`walkthru.core.engine`) emits events; observers are pure subscribers — a recorder, an
|
|
11
|
+
overlay renderer, a narrator, a pacer, and a logger are all just observers.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Union
|
|
19
|
+
|
|
20
|
+
from walkthru.core.schema import (
|
|
21
|
+
Beat,
|
|
22
|
+
Command,
|
|
23
|
+
CommandStep,
|
|
24
|
+
Cue,
|
|
25
|
+
DemoDocument,
|
|
26
|
+
NarrationSegment,
|
|
27
|
+
Section,
|
|
28
|
+
Step,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Outcome:
|
|
34
|
+
"""The result of a play/record run."""
|
|
35
|
+
|
|
36
|
+
ok: bool
|
|
37
|
+
steps_run: int
|
|
38
|
+
errors: tuple["CommandError", ...] = field(default_factory=tuple)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- lifecycle events (one type per hook in the protocol) ---
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class DemoStart:
|
|
46
|
+
"""Emitted once, before any section."""
|
|
47
|
+
|
|
48
|
+
document: DemoDocument
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class DemoEnd:
|
|
53
|
+
"""Emitted once, after the last section; carries the run :class:`Outcome`."""
|
|
54
|
+
|
|
55
|
+
outcome: Outcome
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class SectionEnter:
|
|
60
|
+
section: Section
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class SectionExit:
|
|
65
|
+
section: Section
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class StepEnter:
|
|
70
|
+
step: Step
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class StepExit:
|
|
75
|
+
step: Step
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class BeforeCommand:
|
|
80
|
+
"""Emitted just before the executor runs the command."""
|
|
81
|
+
|
|
82
|
+
command: Command
|
|
83
|
+
step: CommandStep
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class AfterCommand:
|
|
88
|
+
"""Emitted after the executor returns successfully."""
|
|
89
|
+
|
|
90
|
+
command: Command
|
|
91
|
+
result: Any
|
|
92
|
+
step: CommandStep
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class CommandError:
|
|
97
|
+
"""Emitted when the executor raises; the run continues unless an observer stops it."""
|
|
98
|
+
|
|
99
|
+
command: Command
|
|
100
|
+
error: BaseException
|
|
101
|
+
step: CommandStep
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class CueBegin:
|
|
106
|
+
cue: Cue
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class CueEnd:
|
|
111
|
+
cue: Cue
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Narration:
|
|
116
|
+
segment: NarrationSegment
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class BeatEvent:
|
|
121
|
+
beat: Beat
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
Event = Union[
|
|
125
|
+
DemoStart,
|
|
126
|
+
DemoEnd,
|
|
127
|
+
SectionEnter,
|
|
128
|
+
SectionExit,
|
|
129
|
+
StepEnter,
|
|
130
|
+
StepExit,
|
|
131
|
+
BeforeCommand,
|
|
132
|
+
AfterCommand,
|
|
133
|
+
CommandError,
|
|
134
|
+
CueBegin,
|
|
135
|
+
CueEnd,
|
|
136
|
+
Narration,
|
|
137
|
+
BeatEvent,
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
#: An observer is any callable that consumes an event; it may return ``None`` or an awaitable.
|
|
141
|
+
Observer = Callable[[Event], Union[Awaitable[None], None]]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class CommandInvocation:
|
|
146
|
+
"""A command that has already executed — the unit of the capture-mode input stream.
|
|
147
|
+
|
|
148
|
+
In capture mode the human drives the app, each dispatch is observed *after the fact*, and the
|
|
149
|
+
engine records it. ``result``/``durationMs`` are what the live dispatch returned.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
command: Command
|
|
153
|
+
result: Any = None
|
|
154
|
+
duration_ms: int | None = None
|