muse-code-sdk 1.3.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.
muse_code/py.typed ADDED
File without changes
muse_code/replay.py ADDED
@@ -0,0 +1,144 @@
1
+ """The transcript replay runner (spec 638 FR-638-009, Scenario 1).
2
+
3
+ Parses the #210 golden-transcript format (``{"dir","raw"}`` NDJSON, see
4
+ ``schema/msp/transcripts/README.md``), feeds the server-side notifications
5
+ to a :class:`muse_code.fold.SessionFold`, and exposes the client lines for
6
+ frame-level assertions. Usable as a library by the corpus suite, the
7
+ quickstart journey, and the cookbook harnesses; it authors no fixtures of
8
+ its own — a missing scenario is a #210 fixture request.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .fold import FoldOutcome, SessionFold
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TranscriptLine:
23
+ """One recorded wire line: its direction and the parsed frame.
24
+
25
+ Attributes:
26
+ direction: ``"client"`` (written to the host's stdin) or
27
+ ``"server"`` (read from its stdout).
28
+ frame: The ``raw`` bytes parsed as one JSON-RPC 2.0 frame. Client
29
+ lines may end with ``\\r`` on the wire (tolerated, SS1.1); the
30
+ parse is unaffected.
31
+ """
32
+
33
+ direction: str
34
+ frame: dict[str, Any]
35
+
36
+
37
+ @dataclass
38
+ class Transcript:
39
+ """A parsed golden transcript plus the outcomes of folding it.
40
+
41
+ Attributes:
42
+ scenario: The scenario id (the directory name, equal to the
43
+ manifest's ``scenario``).
44
+ lines: Every recorded line, in wire order.
45
+ fold_outcomes: One :class:`FoldOutcome` per server notification fed
46
+ to the fold by :func:`replay_into_fold`, populated by that call.
47
+ """
48
+
49
+ scenario: str
50
+ lines: list[TranscriptLine]
51
+ fold_outcomes: list[FoldOutcome] = field(default_factory=list)
52
+
53
+ @property
54
+ def client_frames(self) -> list[dict[str, Any]]:
55
+ """The frames the recorded client sent, in order."""
56
+ return [ln.frame for ln in self.lines if ln.direction == "client"]
57
+
58
+ @property
59
+ def server_frames(self) -> list[dict[str, Any]]:
60
+ """The frames the recorded host emitted, in order."""
61
+ return [ln.frame for ln in self.lines if ln.direction == "server"]
62
+
63
+ @property
64
+ def server_notifications(self) -> list[dict[str, Any]]:
65
+ """Server frames that are notifications (a ``method``, no ``id``).
66
+
67
+ Server-initiated REQUESTS (they carry an ``id``) and responses are
68
+ not fold inputs; the view plane folds notifications only.
69
+ """
70
+ return [
71
+ frame
72
+ for frame in self.server_frames
73
+ if "method" in frame and "id" not in frame
74
+ ]
75
+
76
+
77
+ def transcript_dirs(corpus_root: Path) -> list[Path]:
78
+ """Every scenario directory under the corpus root, sorted by name.
79
+
80
+ Args:
81
+ corpus_root: ``schema/msp/transcripts``.
82
+
83
+ Returns:
84
+ The scenario directories (those carrying a ``transcript.ndjson``).
85
+ """
86
+ return sorted(
87
+ (
88
+ entry
89
+ for entry in corpus_root.iterdir()
90
+ if (entry / "transcript.ndjson").is_file()
91
+ ),
92
+ key=lambda p: p.name,
93
+ )
94
+
95
+
96
+ def load_transcript(scenario_dir: Path) -> Transcript:
97
+ """Parses one scenario's ``transcript.ndjson``.
98
+
99
+ Args:
100
+ scenario_dir: The scenario directory.
101
+
102
+ Returns:
103
+ The parsed transcript, fold not yet run.
104
+
105
+ Raises:
106
+ ValueError: A line is not the ``{"dir","raw"}`` shape or its ``raw``
107
+ is not one standalone JSON object (the format contract,
108
+ ``schema/msp/transcripts/README.md``).
109
+ """
110
+ path = scenario_dir / "transcript.ndjson"
111
+ lines: list[TranscriptLine] = []
112
+ for line_number, text in enumerate(path.read_text().splitlines(), start=1):
113
+ if not text.strip():
114
+ continue
115
+ record = json.loads(text)
116
+ direction = record.get("dir")
117
+ raw = record.get("raw")
118
+ if direction not in ("client", "server") or not isinstance(raw, str):
119
+ raise ValueError(f"{path}:{line_number}: not a {{dir,raw}} record")
120
+ frame = json.loads(raw)
121
+ if not isinstance(frame, dict):
122
+ raise ValueError(f"{path}:{line_number}: raw is not a JSON object")
123
+ lines.append(TranscriptLine(direction=direction, frame=frame))
124
+ return Transcript(scenario=scenario_dir.name, lines=lines)
125
+
126
+
127
+ def replay_into_fold(transcript: Transcript) -> SessionFold:
128
+ """Feeds the transcript's server notifications through a fresh fold.
129
+
130
+ Every notification's outcome is appended to
131
+ ``transcript.fold_outcomes`` (cleared first), so a caller can assert on
132
+ what the fold did per frame — the Scenario 1 discipline checks.
133
+
134
+ Args:
135
+ transcript: A parsed transcript.
136
+
137
+ Returns:
138
+ The folded state.
139
+ """
140
+ fold = SessionFold()
141
+ transcript.fold_outcomes.clear()
142
+ for frame in transcript.server_notifications:
143
+ transcript.fold_outcomes.append(fold.apply(frame))
144
+ return fold
@@ -0,0 +1,493 @@
1
+ """The sync wrapper: a loop-runner over the asyncio facade (spec 638
2
+ FR-638-021 / T034, INV-638-07; Scenario 7).
3
+
4
+ All protocol machinery lives once, in the async facade. This surface owns a
5
+ PRIVATE event loop and delegates: every blocking verb runs its async twin to
6
+ completion on that loop, and the two turn iterators are the async iterators
7
+ pumped one element per call. It re-implements nothing — the wire frames, the
8
+ fold, and the typed errors are the async facade's own.
9
+
10
+ The one machine this module introduces is the sync boundary (spec 638 "The
11
+ close ladder and the sync boundary"): a verb called from a thread whose event
12
+ loop is RUNNING is refused with an exact error naming the async facade
13
+ (FM-638-4) — running the private loop there would nest loops or deadlock the
14
+ caller's. A second concurrent VERB from another thread serializes on the
15
+ private loop instead (Scenario 7 Edge Cases); ``close()`` is the one
16
+ exception — it refuses rather than queue behind a verb still pumping the
17
+ loop (FM-638-8), because the queue-behind can be unbounded and the shutdown
18
+ it carries is the only thing that would end it.
19
+
20
+ This is the plain-script surface. Jupyter/IPython cells already run inside an
21
+ event loop, so use :class:`~muse_code.facade.MuseClient` with top-level
22
+ ``await`` there — this wrapper refuses from async contexts (FM-638-4), as do
23
+ long-lived programs already inside ``asyncio``.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import threading
30
+ from typing import (
31
+ Any,
32
+ AsyncIterator,
33
+ Awaitable,
34
+ Callable,
35
+ Generic,
36
+ Iterator,
37
+ Literal,
38
+ TypeVar,
39
+ )
40
+
41
+ from muse_code_msp import InitializeResult, ItemDeltaParams
42
+
43
+ from .connection.spawn import ExitClassification
44
+ from .facade.approval import ApprovalFailureHandler, ApprovalHandler
45
+ from .facade.client import (
46
+ MuseClient,
47
+ MuseClientSpawnOptions,
48
+ ResumeSessionOptions,
49
+ StartSessionOptions,
50
+ )
51
+ from .facade.gap_fill import GapFillFailureHandler
52
+ from .facade.host_death import SessionDurabilityProfile
53
+ from .facade.session import PendingCommandView, Session, SessionFoldView, SessionOpening
54
+ from .facade.turn_handle import FoldedItem, Turn, TurnOutcome
55
+ from .facade.turn_submit import SendUserTurnOptions
56
+ from .pending.pending_command_set import (
57
+ PendingRetirement,
58
+ ReplayAnswer,
59
+ SnapshotJoinFacts,
60
+ )
61
+
62
+ _T = TypeVar("_T")
63
+ _I = TypeVar("_I")
64
+
65
+
66
+ def _refuse_running_loop() -> None:
67
+ # FM-638-4: never a deadlock, never a nested-loop hack. Raised BEFORE the
68
+ # private loop (or its lock) is touched, so the caller's own loop keeps
69
+ # running — this also refuses a consumer HANDLER (which runs on the
70
+ # private loop during a blocking verb) calling back into the sync surface,
71
+ # which would otherwise deadlock on the runner's own lock.
72
+ try:
73
+ asyncio.get_running_loop()
74
+ except RuntimeError:
75
+ return
76
+ raise RuntimeError(
77
+ "a SyncMuseClient verb was called from inside a running event loop; "
78
+ "this wrapper blocks its calling thread, so async code must use the "
79
+ "async facade (muse_code.MuseClient / Session / Turn) instead"
80
+ )
81
+
82
+
83
+ class _LoopRunner:
84
+ """The wrapper's private loop plus the ONE lock every member that touches
85
+ loop-owned state goes through.
86
+
87
+ Two entry shapes, one mutual exclusion: :meth:`run` pumps the loop for a
88
+ blocking verb, and :meth:`call` executes a SYNCHRONOUS delegate (stream
89
+ registration, handle minting, SS4.13 retirement verbs) that reads or
90
+ mutates structures the loop's own tasks also touch — unlocked, a thread
91
+ calling ``turn.items()`` while another thread's verb is pumping the loop
92
+ mutates the turn's stream set mid-iteration (PR #32315 review round 1).
93
+ Both re-check ``closed`` UNDER the lock: ``close()`` flips it while
94
+ holding the same lock, so a verb racing a close gets this class's own
95
+ refusal, never "Event loop is closed" off a dead loop.
96
+ """
97
+
98
+ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
99
+ self._loop = loop
100
+ self._lock = threading.Lock()
101
+ self._closed = False
102
+
103
+ def _refuse_closed(self) -> None:
104
+ if self._closed:
105
+ raise RuntimeError(
106
+ "this SyncMuseClient is closed; spawn or create a new one"
107
+ )
108
+
109
+ def run(self, awaitable: Awaitable[_T]) -> _T:
110
+ """Run one awaitable to completion on the private loop (blocking)."""
111
+ try:
112
+ _refuse_running_loop()
113
+ except BaseException:
114
+ # The refused verb's coroutine was already built; close it so the
115
+ # refusal is not followed by a "never awaited" warning.
116
+ if asyncio.iscoroutine(awaitable):
117
+ awaitable.close()
118
+ raise
119
+ with self._lock:
120
+ try:
121
+ self._refuse_closed()
122
+ except BaseException:
123
+ if asyncio.iscoroutine(awaitable):
124
+ awaitable.close()
125
+ raise
126
+ return self._loop.run_until_complete(_shield_none(awaitable))
127
+
128
+ def call(self, fn: Callable[[], _T]) -> _T:
129
+ """Run one synchronous delegate under the loop's mutual exclusion."""
130
+ _refuse_running_loop()
131
+ with self._lock:
132
+ self._refuse_closed()
133
+ return fn()
134
+
135
+ def close(self, shutdown: Callable[[], Awaitable[None]]) -> None:
136
+ """Run ``shutdown`` on the loop, then retire it.
137
+
138
+ The check-and-set and the whole teardown run under the one lock, so a
139
+ close racing a SETTLING verb (or a second ``close()``) serializes and
140
+ then no-ops or gets the refusal — never a dead loop. A close while
141
+ another thread is PUMPING the loop in ANY verb — this method cannot
142
+ tell a bounded ack wait from an unbounded ``wait_exit()`` — is
143
+ REFUSED with an exact error instead of joining a possibly-unbounded
144
+ wait: for the unbounded verbs the shutdown that would let the verb
145
+ settle needs the loop this method cannot have until the verb settles
146
+ (PR #32315 review rounds 2 and 5). The repair is to let the verb
147
+ settle (or close from the thread that drives the verbs), then close.
148
+
149
+ The teardown is a ``finally``: a shutdown that RAISES (a failing
150
+ transport close on the ``create()`` path) still cancels the watcher
151
+ tasks and closes the loop, so one fault cannot leak the loop and its
152
+ pending tasks forever (Constitution XIII; PR #32315 review round 2) —
153
+ the exception then propagates from an already-retired wrapper.
154
+ """
155
+ _refuse_running_loop()
156
+ # NEVER commit to a blocking acquire (PR #32315 review round 3, P0):
157
+ # `run()` takes the lock a few bytecodes before `run_until_complete`
158
+ # marks the loop running, so a single is_running() read can see a
159
+ # pre-pump holder as "between runs" and then block forever behind the
160
+ # very verb this close's shutdown would settle. Re-check on every
161
+ # bounded miss instead: a pre-pump holder is observed the moment it
162
+ # pumps and refused; a SETTLING holder (or a closing peer — `_closed`
163
+ # is set under the lock before its shutdown ever pumps, so a running
164
+ # loop with `_closed` set is a bounded FR-638-017 shutdown, not a
165
+ # stuck verb) is waited out and this close then no-ops.
166
+ while not self._lock.acquire(timeout=0.05):
167
+ if self._loop.is_running() and not self._closed:
168
+ raise RuntimeError(
169
+ "close() was called while another thread is blocked in a "
170
+ "SyncMuseClient verb on this client's private loop; let "
171
+ "that verb settle first (or close from the thread that "
172
+ "drives the verbs) — close() cannot interrupt a pumping "
173
+ "loop"
174
+ )
175
+ try:
176
+ if self._closed:
177
+ return
178
+ self._closed = True
179
+ try:
180
+ self._loop.run_until_complete(_shield_none(shutdown()))
181
+ finally:
182
+ # Cancel the client's own watcher tasks and let the
183
+ # cancellations settle, so a closed wrapper leaks no pending
184
+ # task onto a dead loop — shutdown failure included.
185
+ pending = asyncio.all_tasks(self._loop)
186
+ for task in pending:
187
+ task.cancel()
188
+ if pending:
189
+ self._loop.run_until_complete(
190
+ asyncio.gather(*pending, return_exceptions=True)
191
+ )
192
+ self._loop.close()
193
+ finally:
194
+ self._lock.release()
195
+
196
+
197
+ class SyncMuseClient(Generic[_I]):
198
+ """The blocking twin of :class:`~muse_code.facade.MuseClient` for plain
199
+ scripts (FR-638-021); async contexts — Jupyter cells included, since a
200
+ cell already runs inside a live loop — use ``MuseClient`` directly
201
+ (FM-638-4).
202
+
203
+ Get one from :meth:`spawn` (an owned host) or :meth:`create` (a client you
204
+ compose yourself); every verb then blocks the calling thread while the
205
+ private loop runs the async facade. Call :meth:`close` to shut the host
206
+ down and release the loop.
207
+ """
208
+
209
+ def __init__(self, client: MuseClient[_I], loop: asyncio.AbstractEventLoop) -> None:
210
+ """Wraps an async client that ALREADY lives on ``loop``.
211
+
212
+ Consumers use :meth:`spawn` or :meth:`create`; this constructor exists
213
+ for them (the async client must be constructed INSIDE the private
214
+ loop, which only those factories can arrange).
215
+ """
216
+ self._client = client
217
+ self._runner = _LoopRunner(loop)
218
+
219
+ # ---- construction --------------------------------------------------------
220
+
221
+ @staticmethod
222
+ def spawn(options: MuseClientSpawnOptions) -> "SyncMuseClient[Any]":
223
+ """Spawn an owned host and hand back a blocking client — the sync twin
224
+ of :meth:`MuseClient.spawn`.
225
+
226
+ Args:
227
+ options: The host binary and handshake identity, unchanged.
228
+
229
+ Returns:
230
+ A wired blocking client, its connection already initialized.
231
+ """
232
+ _refuse_running_loop()
233
+ loop = asyncio.new_event_loop()
234
+ try:
235
+ client = loop.run_until_complete(MuseClient.spawn(options))
236
+ except BaseException:
237
+ loop.close()
238
+ raise
239
+ return SyncMuseClient(client, loop)
240
+
241
+ @staticmethod
242
+ def create(
243
+ factory: Callable[[], Awaitable[MuseClient[_I]]],
244
+ ) -> "SyncMuseClient[_I]":
245
+ """Build a blocking client around an async client you compose yourself
246
+ — the sync twin of the :class:`MuseClient` constructor.
247
+
248
+ A factory rather than a ready client, necessarily: ``MuseClient`` (and
249
+ the ``Connection`` under it) must be constructed inside a running
250
+ event loop, and the whole point of this wrapper is that the caller has
251
+ none — so the construction runs on the wrapper's private loop.
252
+
253
+ Args:
254
+ factory: Builds the async client; awaited on the private loop.
255
+
256
+ Returns:
257
+ The wrapped blocking client.
258
+ """
259
+ _refuse_running_loop()
260
+ loop = asyncio.new_event_loop()
261
+ try:
262
+ client = loop.run_until_complete(factory())
263
+ except BaseException:
264
+ loop.close()
265
+ raise
266
+ return SyncMuseClient(client, loop)
267
+
268
+ # ---- blocking client verbs ------------------------------------------------
269
+
270
+ @property
271
+ def initialize_result(self) -> InitializeResult:
272
+ """The handshake result, when built by :meth:`spawn` (no loop needed)."""
273
+ return self._client.initialize_result
274
+
275
+ @property
276
+ def durability(self) -> SessionDurabilityProfile:
277
+ """The durability profile every session this client opens inherits."""
278
+ return self._client.durability
279
+
280
+ def start_session(
281
+ self, options: StartSessionOptions | None = None
282
+ ) -> "SyncSession[_I]":
283
+ """Open a new root session (blocking twin of ``start_session``)."""
284
+ return SyncSession(
285
+ self._runner.run(self._client.start_session(options)), self._runner
286
+ )
287
+
288
+ def resume_session(self, options: ResumeSessionOptions) -> "SyncSession[_I]":
289
+ """Load an existing session (blocking twin of ``resume_session``)."""
290
+ return SyncSession(
291
+ self._runner.run(self._client.resume_session(options)), self._runner
292
+ )
293
+
294
+ def wait_exit(self) -> ExitClassification:
295
+ """Block until the owned host exits; its SS2.11 row (twin of ``exit``)."""
296
+
297
+ async def wait() -> ExitClassification:
298
+ # The awaitable PROPERTY is read inside the loop: accessing it may
299
+ # mint a loop-bound future, which has no loop out here.
300
+ return await self._client.exit
301
+
302
+ return self._runner.run(wait())
303
+
304
+ def close(self) -> None:
305
+ """Shut the host down (SS2.1.2), then retire the private loop.
306
+
307
+ Idempotent. A close racing a SETTLING verb serializes behind it; a
308
+ close while another thread is still PUMPING the loop in ANY verb is
309
+ refused with an exact error — let that verb settle (or close from the
310
+ thread that drives the verbs) first (FM-638-8; see
311
+ ``_LoopRunner.close``).
312
+ """
313
+ self._runner.close(self._client.close)
314
+
315
+
316
+ async def _shield_none(awaitable: Awaitable[_T]) -> _T:
317
+ # ``run_until_complete`` wants a coroutine or future; the facade's
318
+ # awaitable PROPERTIES (``exit``, ``completed``) are neither, so every
319
+ # verb rides through this adapter.
320
+ return await awaitable
321
+
322
+
323
+ class SyncSession(Generic[_I]):
324
+ """One conversation, on the blocking surface — the sync twin of
325
+ :class:`~muse_code.facade.Session`.
326
+
327
+ The read views (``fold``, ``pending``) are the async session's own
328
+ objects: they are plain synchronous reads already, and a wrapped copy
329
+ would be a second surface to keep honest. The handler registrations
330
+ (``on_approval`` …) delegate unchanged — the handlers run on the private
331
+ loop while a blocking verb is pumping it. The synchronous MUTATORS
332
+ (``turn``, ``stop_retrying``, ``replay_answered``) route through the
333
+ runner's lock: they touch structures the loop's own tasks touch, and an
334
+ unlocked ``turn()`` racing a pumping verb can double-mint a handle.
335
+ """
336
+
337
+ def __init__(self, session: Session[_I], runner: _LoopRunner) -> None:
338
+ """Wraps ``session``; ``runner`` is the owning client's loop-runner."""
339
+ self._session = session
340
+ self._runner = runner
341
+
342
+ @property
343
+ def session_id(self) -> str:
344
+ """The server-named session id."""
345
+ return self._session.session_id
346
+
347
+ @property
348
+ def opening(self) -> SessionOpening | None:
349
+ """Which verb opened this session, and what it answered."""
350
+ return self._session.opening
351
+
352
+ @property
353
+ def durability(self) -> SessionDurabilityProfile:
354
+ """The profile this session discharges a host death under."""
355
+ return self._session.durability
356
+
357
+ @property
358
+ def fold(self) -> SessionFoldView:
359
+ """The fold's read view (the async session's own object).
360
+
361
+ NOT serialized against another thread's verb: the loop thread mutates
362
+ these structures whenever a verb pumps, so read them from the thread
363
+ driving the verbs, or after that verb returns — a concurrent reader
364
+ can see a mid-fold mutation (PR #32315 review round 5).
365
+ """
366
+ return self._session.fold
367
+
368
+ @property
369
+ def pending(self) -> PendingCommandView[_I]:
370
+ """The SS4.13 set's read/safe-drive view; same threading caveat as
371
+ :attr:`fold` — read from the verb-driving thread."""
372
+ return self._session.pending
373
+
374
+ def send_user_turn(self, options: SendUserTurnOptions[_I]) -> "SyncTurn":
375
+ """Submit a user turn and block for its ack (twin of
376
+ ``send_user_turn``)."""
377
+ return SyncTurn(
378
+ self._runner.run(self._session.send_user_turn(options)), self._runner
379
+ )
380
+
381
+ def turn(self, turn_id: str) -> "SyncTurn":
382
+ """The handle for a turn, created on first mention from either side."""
383
+ return SyncTurn(
384
+ self._runner.call(lambda: self._session.turn(turn_id)), self._runner
385
+ )
386
+
387
+ def on_approval(self, handler: ApprovalHandler) -> None:
388
+ """Answer approvals with ``handler`` (FR-638-019b), unchanged."""
389
+ self._session.on_approval(handler)
390
+
391
+ def on_approval_error(self, handler: ApprovalFailureHandler) -> None:
392
+ """Observe approval round trips that did not complete."""
393
+ self._session.on_approval_error(handler)
394
+
395
+ def on_gap_error(self, handler: GapFillFailureHandler) -> None:
396
+ """Observe SS4.8 fills that did not complete."""
397
+ self._session.on_gap_error(handler)
398
+
399
+ def stop_retrying(self, command_id: str) -> PendingRetirement[_I] | None:
400
+ """SS4.13 "Abandoned": stop one entry's retry loop (sync, locked)."""
401
+ return self._runner.call(lambda: self._session.stop_retrying(command_id))
402
+
403
+ def replay_answered(
404
+ self, command_id: str, answer: ReplayAnswer[_I]
405
+ ) -> PendingRetirement[_I] | Literal["held"]:
406
+ """Feed a consumer-driven replay's answer through the SS4.13 rules."""
407
+ return self._runner.call(
408
+ lambda: self._session.replay_answered(command_id, answer)
409
+ )
410
+
411
+ def resolve_snapshot_join(
412
+ self, facts: SnapshotJoinFacts
413
+ ) -> tuple[PendingRetirement[_I], ...]:
414
+ """Blocking twin of ``resolve_snapshot_join``."""
415
+ return self._runner.run(self._session.resolve_snapshot_join(facts))
416
+
417
+ def resolve_reconnect(self) -> tuple[PendingRetirement[_I], ...]:
418
+ """Blocking twin of ``resolve_reconnect``."""
419
+ return self._runner.run(self._session.resolve_reconnect())
420
+
421
+
422
+ class SyncTurn:
423
+ """One turn, on the blocking surface — the sync twin of
424
+ :class:`~muse_code.facade.Turn`."""
425
+
426
+ def __init__(self, turn: Turn, runner: _LoopRunner) -> None:
427
+ """Wraps the async turn view; ``runner`` is the owning loop-runner."""
428
+ self._turn = turn
429
+ self._runner = runner
430
+
431
+ @property
432
+ def turn_id(self) -> str:
433
+ """The wire turn id."""
434
+ return self._turn.turn_id
435
+
436
+ @property
437
+ def observed_start(self) -> bool:
438
+ """Did THIS session observe a ``turn/started``? Not a launch marker."""
439
+ return self._turn.observed_start
440
+
441
+ def completed(self) -> TurnOutcome:
442
+ """Block for the SS3.1.4 turn-wait; settles on a server-authored fact
443
+ (a method where the async twin is an awaitable property — a blocking
444
+ property read would hide an unbounded wait behind attribute access).
445
+ """
446
+
447
+ async def wait() -> TurnOutcome:
448
+ # Read inside the loop: the property mints a loop-bound future.
449
+ return await self._turn.completed
450
+
451
+ return self._runner.run(wait())
452
+
453
+ def items(self) -> Iterator[FoldedItem]:
454
+ """This turn's items, iterated synchronously: the folded backlog, then
455
+ the live tail, ending when the turn settles.
456
+
457
+ Once iteration has STARTED, breaking out (or closing the generator)
458
+ releases the underlying stream — the sync twin of the async surface's
459
+ ``aclosing`` deregistration. A never-advanced iterator holds its
460
+ registration (Python runs no ``finally`` in a generator that never
461
+ started) and is reclaimed when the turn settles — the same bound the
462
+ async surface has for a dropped, never-read stream. Registration stays
463
+ AT CALL TIME so the replay snapshot point matches the async twin
464
+ (PR #32315 review round 2)."""
465
+ return self._drain(self._runner.call(self._turn.items))
466
+
467
+ def deltas(self) -> Iterator[ItemDeltaParams]:
468
+ """This turn's ``item/delta`` frames, live only, iterated
469
+ synchronously; released on break/close (and bounded on a never-started
470
+ iterator) exactly like :meth:`items`."""
471
+ return self._drain(self._runner.call(self._turn.deltas))
472
+
473
+ def _drain(self, source: AsyncIterator[_T]) -> Iterator[_T]:
474
+ # A plain generator: `for … break` (or an explicit .close()) runs the
475
+ # finally and deregisters the stream, so a consumer that stops early
476
+ # does not leave the fan-out buffering into a stream nobody reads —
477
+ # the sync twin of `contextlib.aclosing` (PR #32315 review round 1).
478
+ try:
479
+ while True:
480
+ try:
481
+ yield self._runner.run(source.__anext__())
482
+ except StopAsyncIteration:
483
+ return
484
+ finally:
485
+ aclose = getattr(source, "aclose", None)
486
+ if callable(aclose):
487
+ try:
488
+ self._runner.run(aclose())
489
+ except RuntimeError:
490
+ # A closed wrapper (or a close racing this release) has
491
+ # already torn every stream down with the loop; there is
492
+ # nothing left to deregister.
493
+ pass
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: muse-code-sdk
3
+ Version: 1.3.0
4
+ Summary: Muse Code SDK for MSP: asyncio-first facade over the generated wire types (muse-code-msp), with a thin sync wrapper.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/meta-models/muse-code-sdk
7
+ Project-URL: Repository, https://github.com/meta-models/muse-code-sdk
8
+ Project-URL: Issues, https://github.com/meta-models/muse-code-sdk/issues
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pydantic==2.13.5
18
+ Dynamic: license-file
19
+
20
+ # `muse-code-sdk`
21
+
22
+ The MSP Python facade (spec `specs/638-muse-sdk-python`; the Python tier of
23
+ tdd SS7.3, chartered by issue #638). Mirrors `@muse-code/sdk`
24
+ (`clients/sdk-ts`): asyncio-first — async iterators for turn streams,
25
+ awaitable approvals — with a thin sync loop-runner for scripts and
26
+ notebooks.
27
+
28
+ **One runtime dependency**: `pydantic`, exact-pinned (the ADR 638 D2
29
+ supply-chain exception, #211 O-3 form). The generated wire types live in
30
+ `muse-code-msp` (`clients/msp-py`) and are never restated here.
31
+
32
+ **Status: S0 skeleton.** The facade surface lands slice by slice
33
+ (`specs/638-muse-sdk-python/plan.md` S1–S4); the first acceptance milestone
34
+ is the quickstart journey against a release-built host. Python 3.10+.
35
+
36
+ ## The loop
37
+
38
+ ```sh
39
+ cd projects/tbh
40
+ python3 -m pip install -r clients/py-dev-requirements.txt
41
+ python3 -m pip install -e clients/msp-py -e clients/sdk-py
42
+ pytest clients/sdk-py/tests
43
+ mypy --strict clients/msp-py/src clients/sdk-py/src
44
+ ```
45
+
46
+ CI runs exactly this in the non-required, path-filtered `tbh-muse-sdk-py`
47
+ workflow, on the floor Python (3.10) and a current one.