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/__init__.py +129 -0
- muse_code/connection/__init__.py +49 -0
- muse_code/connection/connection.py +905 -0
- muse_code/connection/discovery.py +55 -0
- muse_code/connection/spawn.py +1067 -0
- muse_code/connection/validation.py +149 -0
- muse_code/errors.py +199 -0
- muse_code/facade/__init__.py +89 -0
- muse_code/facade/approval.py +279 -0
- muse_code/facade/client.py +444 -0
- muse_code/facade/discarded.py +45 -0
- muse_code/facade/gap_fill.py +483 -0
- muse_code/facade/host_death.py +254 -0
- muse_code/facade/session.py +977 -0
- muse_code/facade/turn_handle.py +440 -0
- muse_code/facade/turn_submit.py +289 -0
- muse_code/fold/__init__.py +82 -0
- muse_code/fold/item_store.py +268 -0
- muse_code/fold/session_fold.py +785 -0
- muse_code/fold/state_store.py +141 -0
- muse_code/pending/__init__.py +53 -0
- muse_code/pending/pending_command_set.py +842 -0
- muse_code/py.typed +0 -0
- muse_code/replay.py +144 -0
- muse_code/sync_facade.py +493 -0
- muse_code_sdk-1.3.0.dist-info/METADATA +47 -0
- muse_code_sdk-1.3.0.dist-info/RECORD +30 -0
- muse_code_sdk-1.3.0.dist-info/WHEEL +5 -0
- muse_code_sdk-1.3.0.dist-info/licenses/LICENSE +21 -0
- muse_code_sdk-1.3.0.dist-info/top_level.txt +1 -0
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
|
muse_code/sync_facade.py
ADDED
|
@@ -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.
|