truedemo 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
truedemo/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """truedemo — a declarative toolkit for producing narrated, captioned screen-recordings.
2
+
3
+ truedemo turns a small YAML *spec* plus a data provider into:
4
+
5
+ * per-scene narration audio (TTS),
6
+ * real or captured still frames (screen recordings via Playwright, charts from data),
7
+ * a single captioned ``.mp4`` with burned-in subtitles, and
8
+ * a verification report that checks the non-negotiables (runtime window, banned
9
+ phrases, missing assets, mock-mode leakage).
10
+
11
+ It is deliberately dependency-light: ``ffmpeg`` and ``rsvg-convert`` for rendering,
12
+ ``playwright`` (optional) for capture, ``piper-tts`` (optional) for speech, and
13
+ ``pyyaml`` (optional — JSON specs work without it).
14
+
15
+ The worked example lives in ``../examples`` and is host-coupled by design; the
16
+ library itself contains no product knowledge.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from ._version import __version__ # noqa: F401 (re-exported)
22
+ from .errors import SpecError, ToolError, TruedemoError # noqa: F401
23
+
24
+ __all__ = ["TruedemoError", "SpecError", "ToolError", "__version__"]
truedemo/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m truedemo` entry point."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
truedemo/_loop.py ADDED
@@ -0,0 +1,192 @@
1
+ """Doing the engine's blocking work without stopping the server that asked for it.
2
+
3
+ The engine is synchronous, deliberately. A capture is Playwright's *sync* API, a
4
+ render is ``subprocess.run`` over ffmpeg, a container probe is a blocking read,
5
+ and a session is a long-lived object graph held in memory. Rewriting any of it to
6
+ be awaitable, so that the MCP layer could call it, would mean a second
7
+ implementation of everything — and the second one is the one nobody tests.
8
+
9
+ An MCP tool, though, is invoked by FastMCP **on its event loop**. Two things
10
+ cannot happen there, and both of them did:
11
+
12
+ * Playwright's sync API refuses outright —
13
+ ``It looks like you are using Playwright Sync API inside the asyncio loop.`` —
14
+ so every browser tool failed always, with a message naming Playwright rather
15
+ than the seam.
16
+ * Anything merely blocking stopped the whole server for its duration: every other
17
+ client, every keepalive, for as long as an ffmpeg render takes.
18
+
19
+ So there are two jobs here, and they are different jobs.
20
+
21
+ **A session-bound worker thread.** Playwright's sync objects are bound to the
22
+ thread that created them: the thread that opened a page has to be the thread that
23
+ later clicks it. A pool would work by luck and break under load, so each session
24
+ owns one thread, started lazily on first use.
25
+
26
+ **A plain off-loop hop.** Work with no thread affinity — a render, a docker probe,
27
+ a report read — only needs to not happen on the loop.
28
+
29
+ Both fast-path through when there is no loop, which is how the CLI, the pipeline's
30
+ own stages and the tests keep calling the engine directly with no thread at all.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import contextvars
37
+ import queue
38
+ import threading
39
+ from concurrent.futures import Future
40
+ from typing import Any, Callable, TypeVar
41
+
42
+ T = TypeVar("T")
43
+
44
+ #: How long to wait for a worker thread to finish when a session closes. A render
45
+ #: already in flight can outlast this; the caller is then told, rather than hung.
46
+ JOIN_TIMEOUT = 10.0
47
+
48
+
49
+ class Worker:
50
+ """One thread for one session, and whatever blocking work belongs to it.
51
+
52
+ Jobs arrive on a :class:`queue.Queue`, which is thread-safe from either side,
53
+ so a caller may be an event loop or an ordinary stack. Results come back as
54
+ :class:`concurrent.futures.Future`, likewise safe to wait on from anywhere —
55
+ deliberately *not* an asyncio future, because one worker thread has to be able
56
+ to serve a client that runs no loop at all.
57
+ """
58
+
59
+ def __init__(self, name: str = "truedemo-worker") -> None:
60
+ self._name = name
61
+ self._thread: threading.Thread | None = None
62
+ self._jobs: "queue.Queue[Any] | None" = None
63
+ self._lock = threading.Lock()
64
+
65
+ @property
66
+ def name(self) -> str:
67
+ """The thread's name, which is how a hung worker is identified."""
68
+ return self._name
69
+
70
+ @property
71
+ def alive(self) -> bool:
72
+ t = self._thread
73
+ return t is not None and t.is_alive()
74
+
75
+ def submit(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> "Future[T]":
76
+ """Queue ``fn(*args, **kwargs)`` and return a future for its result.
77
+
78
+ The callable runs on the worker thread and nowhere else, which is what
79
+ makes a page opened by one call clickable by the next.
80
+ """
81
+ fut: "Future[T]" = Future()
82
+ with self._lock:
83
+ if not self.alive:
84
+ self._jobs = queue.Queue()
85
+ self._thread = threading.Thread(
86
+ target=self._serve, name=self._name, daemon=True)
87
+ self._thread.start()
88
+ jobs = self._jobs
89
+ assert jobs is not None
90
+ jobs.put((lambda: fn(*args, **kwargs), fut))
91
+ return fut
92
+
93
+ def _serve(self) -> None:
94
+ """The worker's whole life: one job, then the next, until the sentinel."""
95
+ jobs = self._jobs
96
+ assert jobs is not None
97
+ while True:
98
+ item = jobs.get()
99
+ if item is None:
100
+ return
101
+ fn, fut = item
102
+ if not fut.set_running_or_notify_cancel():
103
+ continue
104
+ try:
105
+ value = fn()
106
+ except BaseException as exc: # noqa: BLE001 - carried to the caller
107
+ fut.set_exception(exc)
108
+ else:
109
+ fut.set_result(value)
110
+
111
+ def stop(self) -> bool:
112
+ """Ask the thread to finish, and wait for it. Safe to call twice."""
113
+ with self._lock:
114
+ thread, jobs = self._thread, self._jobs
115
+ self._thread, self._jobs = None, None
116
+ if thread is None or not thread.is_alive():
117
+ return False
118
+ if jobs is not None:
119
+ jobs.put(None)
120
+ if threading.current_thread() is thread:
121
+ # Called *from* the worker — a session closing itself. Joining here
122
+ # would be the thread waiting for its own death; the sentinel is
123
+ # already queued, so it exits as soon as this job returns.
124
+ return True
125
+ thread.join(timeout=JOIN_TIMEOUT)
126
+ return not thread.is_alive()
127
+
128
+
129
+ # --------------------------------------------------------------------------- #
130
+ # crossing the boundary
131
+ # --------------------------------------------------------------------------- #
132
+ # There is no way to escape the loop *from* the loop: a synchronous function that
133
+ # blocks the loop thread has stopped the very thing that would run the work it is
134
+ # waiting for. So the direction of the fix is forced, and it is worth saying out
135
+ # loud, because the alternative looks reasonable and deadlocks under load.
136
+ #
137
+ # * A tool the protocol can reach is declared ``async`` and hops *out* with
138
+ # :func:`offload`. That is the only door an MCP client has.
139
+ # * A function that drives a session's browser runs on that session's own thread
140
+ # with :func:`session`, and does not care whether its caller was a loop, an
141
+ # anyio worker or a plain stack.
142
+ # * Code with no loop at all — the CLI, the pipeline's stages, the tests — calls
143
+ # the engine directly, and every one of these fast-paths through to a plain
144
+ # call.
145
+ # --------------------------------------------------------------------------- #
146
+ def in_loop() -> bool:
147
+ """Whether this call is already inside a running event loop."""
148
+ try:
149
+ asyncio.get_running_loop()
150
+ except RuntimeError:
151
+ return False
152
+ return True
153
+
154
+
155
+ async def offload(fn: Callable[..., T], /, *args: Any, **kwargs: Any) -> T:
156
+ """Await a blocking ``fn(*args, **kwargs)``, run on a thread that is not the loop.
157
+
158
+ What every registered MCP tool body should be. It has no thread affinity of its
159
+ own — a render, a container probe, a report read — and only needs to not happen
160
+ on the loop.
161
+
162
+ Deliberately built only on :mod:`asyncio` and :mod:`contextvars`, with no
163
+ third-party thread pool. An earlier version delegated to ``anyio.to_thread``,
164
+ which is *not* one of truedemo's declared dependencies — it merely arrives
165
+ alongside the optional ``mcp`` extra. So the seam held exactly as long as every
166
+ caller also installed the MCP server, and any caller with the engine alone (a
167
+ lean test run, a host mounting ``truedemo://`` by hand) got
168
+ ``RuntimeError: ... anyio is not installed`` from a function documented as a
169
+ plain hop off the loop. A seam this central should not have a dependency the
170
+ engine does not declare, and it does not need a thread *pool*: the work here is
171
+ long and single, so one fresh thread per hop is the honest shape.
172
+ """
173
+ ctx = contextvars.copy_context()
174
+ return await asyncio.to_thread(lambda: ctx.run(fn, *args, **kwargs))
175
+
176
+
177
+ def _worker_of(obj: Any) -> Worker | None:
178
+ """The worker an object owns, if it has been given one."""
179
+ return getattr(obj, "worker", None)
180
+
181
+
182
+ def session(obj: Any, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
183
+ """Run ``fn`` for ``obj`` on the thread that owns its page, and wait.
184
+
185
+ Thread affinity is not about the event loop: Playwright's page has to be
186
+ clicked by the thread that made it, whoever is asking. So this goes through the
187
+ worker whenever there is one, loop or no loop.
188
+ """
189
+ worker = _worker_of(obj)
190
+ if worker is None:
191
+ return fn(*args, **kwargs)
192
+ return worker.submit(fn, *args, **kwargs).result()
truedemo/_version.py ADDED
@@ -0,0 +1,9 @@
1
+ """The single source of truth for the truedemo version.
2
+
3
+ Every consumer — the package metadata (``pyproject.toml`` reads this attribute
4
+ dynamically), ``truedemo.__version__``, the CLI ``--version``, the provenance and
5
+ job manifests — imports the same string from here. Bump it in exactly one place
6
+ or the distribution and the reported version drift apart.
7
+ """
8
+
9
+ __version__ = "2.0.0"