subsequence 0.6.4__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.
Files changed (78) hide show
  1. subsequence/__init__.py +231 -0
  2. subsequence/__main__.py +24 -0
  3. subsequence/assets/web/index.html +345 -0
  4. subsequence/cadences.py +113 -0
  5. subsequence/chord_graphs/__init__.py +100 -0
  6. subsequence/chord_graphs/aeolian_minor.py +158 -0
  7. subsequence/chord_graphs/chromatic_mediant.py +113 -0
  8. subsequence/chord_graphs/diminished.py +97 -0
  9. subsequence/chord_graphs/dorian_minor.py +127 -0
  10. subsequence/chord_graphs/functional_major.py +102 -0
  11. subsequence/chord_graphs/hooktheory_major.py +88 -0
  12. subsequence/chord_graphs/lydian_major.py +130 -0
  13. subsequence/chord_graphs/mixolydian.py +98 -0
  14. subsequence/chord_graphs/phrygian_minor.py +76 -0
  15. subsequence/chord_graphs/suspended.py +109 -0
  16. subsequence/chord_graphs/turnaround_global.py +157 -0
  17. subsequence/chord_graphs/whole_tone.py +77 -0
  18. subsequence/chords.py +419 -0
  19. subsequence/composition.py +6099 -0
  20. subsequence/conductor.py +238 -0
  21. subsequence/constants/__init__.py +24 -0
  22. subsequence/constants/durations.py +37 -0
  23. subsequence/constants/instruments/__init__.py +13 -0
  24. subsequence/constants/instruments/gm_cc.py +46 -0
  25. subsequence/constants/instruments/gm_drums.py +53 -0
  26. subsequence/constants/instruments/gm_instruments.py +32 -0
  27. subsequence/constants/instruments/roland_tr8s.py +320 -0
  28. subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
  29. subsequence/constants/midi_notes.py +29 -0
  30. subsequence/constants/pulses.py +17 -0
  31. subsequence/constants/velocity.py +22 -0
  32. subsequence/definitions.py +232 -0
  33. subsequence/display.py +617 -0
  34. subsequence/easing.py +347 -0
  35. subsequence/event_emitter.py +109 -0
  36. subsequence/form_state.py +665 -0
  37. subsequence/forms.py +257 -0
  38. subsequence/groove.py +323 -0
  39. subsequence/harmonic_rhythm.py +83 -0
  40. subsequence/harmonic_state.py +352 -0
  41. subsequence/harmony.py +197 -0
  42. subsequence/held_notes.py +91 -0
  43. subsequence/helpers/__init__.py +0 -0
  44. subsequence/helpers/network.py +58 -0
  45. subsequence/helpers/wing.py +430 -0
  46. subsequence/intervals.py +436 -0
  47. subsequence/keystroke.py +249 -0
  48. subsequence/link_clock.py +128 -0
  49. subsequence/live_client.py +187 -0
  50. subsequence/live_reloader.py +298 -0
  51. subsequence/live_server.py +161 -0
  52. subsequence/melodic_state.py +483 -0
  53. subsequence/midi.py +97 -0
  54. subsequence/midi_utils.py +329 -0
  55. subsequence/mini_notation.py +164 -0
  56. subsequence/motifs.py +2356 -0
  57. subsequence/osc.py +194 -0
  58. subsequence/pattern.py +363 -0
  59. subsequence/pattern_algorithmic.py +2010 -0
  60. subsequence/pattern_builder.py +2589 -0
  61. subsequence/pattern_midi.py +1208 -0
  62. subsequence/progressions.py +1913 -0
  63. subsequence/py.typed +0 -0
  64. subsequence/roles.py +63 -0
  65. subsequence/sequence_utils.py +3123 -0
  66. subsequence/sequencer.py +2086 -0
  67. subsequence/tuning.py +453 -0
  68. subsequence/voicings.py +151 -0
  69. subsequence/web_ui.py +337 -0
  70. subsequence/weighted_graph.py +156 -0
  71. subsequence-0.6.4.dist-info/METADATA +208 -0
  72. subsequence-0.6.4.dist-info/RECORD +78 -0
  73. subsequence-0.6.4.dist-info/WHEEL +5 -0
  74. subsequence-0.6.4.dist-info/entry_points.txt +2 -0
  75. subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
  76. subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
  77. subsequence-0.6.4.dist-info/scm_version.json +8 -0
  78. subsequence-0.6.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,128 @@
1
+ """
2
+ Ableton Link clock adapter for Subsequence.
3
+
4
+ Wraps ``aalink.Link`` and adapts its asyncio-native API to Subsequence's
5
+ 24 PPQN pulse model. Requires the optional ``link`` extra::
6
+
7
+ pip install subsequence[link]
8
+
9
+ Usage::
10
+
11
+ link_clock = LinkClock(bpm=120, quantum=4.0, loop=asyncio.get_running_loop())
12
+ beat_origin = await link_clock.wait_for_bar()
13
+ # ... in the pulse loop:
14
+ await link_clock.sync(beat_origin + pulse_count / PPQN)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import math
21
+ import typing
22
+
23
+
24
+ def _require_aalink () -> typing.Any:
25
+ """Import aalink or raise a helpful RuntimeError."""
26
+ try:
27
+ import aalink # type: ignore
28
+ return aalink
29
+ except ImportError:
30
+ raise RuntimeError(
31
+ "Ableton Link support requires the 'aalink' package.\n"
32
+ "Install it with: pip install subsequence[link]"
33
+ ) from None
34
+
35
+
36
+ class LinkClock:
37
+
38
+ """
39
+ Thin wrapper around ``aalink.Link`` for Subsequence's pulse-based clock.
40
+
41
+ Parameters:
42
+ bpm: Initial tempo in BPM (proposed to the Link session).
43
+ quantum: Beat cycle length — 4.0 means one bar in 4/4 time.
44
+ loop: The running asyncio event loop (required by aalink).
45
+ """
46
+
47
+ def __init__ (self, bpm: float, quantum: float, loop: asyncio.AbstractEventLoop) -> None:
48
+
49
+ """
50
+ Join the Link session immediately, proposing *bpm* and setting the bar length to *quantum* beats.
51
+ """
52
+
53
+ aalink = _require_aalink()
54
+ self._link = aalink.Link(bpm, loop)
55
+ self._link.enabled = True
56
+ self._link.quantum = float(quantum)
57
+
58
+ # ------------------------------------------------------------------
59
+ # Properties that mirror the Link session state
60
+ # ------------------------------------------------------------------
61
+
62
+ @property
63
+ def beat (self) -> float:
64
+ """Current absolute beat position in the Link session timeline."""
65
+ return float(self._link.beat)
66
+
67
+ @property
68
+ def tempo (self) -> float:
69
+ """Current session tempo in BPM (authoritative from the Link network)."""
70
+ return float(self._link.tempo)
71
+
72
+ @property
73
+ def quantum (self) -> float:
74
+ """Beat cycle length (e.g. 4.0 for one bar in 4/4)."""
75
+ return float(self._link.quantum)
76
+
77
+ @property
78
+ def num_peers (self) -> int:
79
+ """Number of connected Link peers (not counting self)."""
80
+ return int(self._link.num_peers)
81
+
82
+ @property
83
+ def playing (self) -> bool:
84
+ """Whether the Link session transport is playing."""
85
+ return bool(self._link.playing)
86
+
87
+ # ------------------------------------------------------------------
88
+ # Sync / control
89
+ # ------------------------------------------------------------------
90
+
91
+ async def sync (self, beat: float) -> float:
92
+ """Wait until the Link session beat reaches *beat*, then return *beat*.
93
+
94
+ This is the primary timing primitive used by the sequencer loop.
95
+ Calling ``await link_clock.sync(beat_origin + pulse / PPQN)`` for each
96
+ successive pulse gives accurate, Link-synchronised timing.
97
+ """
98
+ return float(await self._link.sync(beat))
99
+
100
+ async def wait_for_bar (self) -> float:
101
+ """Wait for the next quantum boundary (bar start) and return it.
102
+
103
+ Use this to start the sequencer at a musically clean position that is
104
+ phase-aligned with all other Link participants.
105
+
106
+ Returns the beat value at which playback should begin (``beat_origin``).
107
+ """
108
+ current = self._link.beat
109
+ # Next quantum boundary strictly after the current beat
110
+ # math.floor, not int(): Link beats can be negative before transport
111
+ # zero, and int() truncates toward zero - skipping the boundary at 0.0
112
+ # and delaying the start by a full extra quantum.
113
+ next_boundary = (math.floor(current / self._link.quantum) + 1) * self._link.quantum
114
+ result = await self._link.sync(next_boundary)
115
+ return float(result)
116
+
117
+ def request_tempo (self, bpm: float) -> None:
118
+ """Propose a new tempo to the Link session.
119
+
120
+ Other peers may accept or reject the change depending on their own
121
+ session rules. Subsequence's sequencer will pick up the network-
122
+ authoritative tempo on the next pulse.
123
+ """
124
+ self._link.tempo = float(bpm)
125
+
126
+ def disable (self) -> None:
127
+ """Disconnect from the Link session."""
128
+ self._link.enabled = False
@@ -0,0 +1,187 @@
1
+ """Interactive REPL client for live coding a running Subsequence composition.
2
+
3
+ Usage::
4
+
5
+ python -m subsequence.live_client
6
+ python -m subsequence.live_client --port 5555
7
+
8
+ The client connects to a live server started by ``composition.live()`` and
9
+ provides an interactive Python prompt. Multi-line blocks are supported -
10
+ type a line ending with ``:`` and the client will wait for more input.
11
+
12
+ Press Ctrl+C to cancel the current input. Press Ctrl+D to quit.
13
+ """
14
+
15
+ import argparse
16
+ import socket
17
+ import sys
18
+ import typing
19
+
20
+
21
+ SENTINEL = b"\x04"
22
+
23
+
24
+ class LiveClient:
25
+
26
+ """TCP client that sends code to a running Subsequence live server."""
27
+
28
+ def __init__ (self) -> None:
29
+
30
+ """Initialize with no connection."""
31
+
32
+ self._sock: typing.Optional[socket.socket] = None
33
+
34
+ def connect (self, host: str = "127.0.0.1", port: int = 5555) -> None:
35
+
36
+ """Connect to the live server."""
37
+
38
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
39
+ self._sock.connect((host, port))
40
+
41
+ def send (self, code: str) -> str:
42
+
43
+ """Send code to the server and return the response."""
44
+
45
+ if self._sock is None:
46
+ raise ConnectionError("Not connected")
47
+
48
+ self._sock.sendall(code.encode("utf-8") + SENTINEL)
49
+
50
+ chunks: typing.List[bytes] = []
51
+
52
+ while True:
53
+ chunk = self._sock.recv(4096)
54
+
55
+ if not chunk:
56
+ raise ConnectionError("Server closed connection")
57
+
58
+ if SENTINEL in chunk:
59
+ before, _, _ = chunk.partition(SENTINEL)
60
+ chunks.append(before)
61
+ break
62
+
63
+ chunks.append(chunk)
64
+
65
+ return b"".join(chunks).decode("utf-8")
66
+
67
+ def close (self) -> None:
68
+
69
+ """Close the connection."""
70
+
71
+ if self._sock is not None:
72
+ self._sock.close()
73
+ self._sock = None
74
+
75
+
76
+ def _is_incomplete (code: str) -> bool:
77
+
78
+ """Return True if the code looks like an incomplete multi-line block."""
79
+
80
+ stripped = code.rstrip()
81
+
82
+ if not stripped:
83
+ return False
84
+
85
+ # Trailing colon suggests a block header (def, if, for, etc.).
86
+ if stripped.endswith(":"):
87
+ return True
88
+
89
+ # Unclosed brackets or parens.
90
+ opens = sum(1 for c in code if c in "([{")
91
+ closes = sum(1 for c in code if c in ")]}")
92
+
93
+ if opens > closes:
94
+ return True
95
+
96
+ # Trailing backslash (line continuation).
97
+ if stripped.endswith("\\"):
98
+ return True
99
+
100
+ return False
101
+
102
+
103
+ def main () -> None:
104
+
105
+ """Run the interactive REPL loop."""
106
+
107
+ parser = argparse.ArgumentParser(description="Subsequence live coding client")
108
+ parser.add_argument("--host", default="127.0.0.1", help="Server host (default: 127.0.0.1)")
109
+ parser.add_argument("--port", type=int, default=5555, help="Server port (default: 5555)")
110
+ args = parser.parse_args()
111
+
112
+ client = LiveClient()
113
+
114
+ try:
115
+ client.connect(args.host, args.port)
116
+ except ConnectionRefusedError:
117
+ print(f"Could not connect to {args.host}:{args.port}")
118
+ print("Is the composition running with composition.live() enabled?")
119
+ sys.exit(1)
120
+
121
+ print(f"Connected to Subsequence on {args.host}:{args.port}")
122
+
123
+ # Fetch and display status header. A failure here is cosmetic (the REPL
124
+ # still works), but say so instead of hiding it.
125
+ try:
126
+ info_response = client.send("composition.live_info()")
127
+ print(info_response)
128
+ except Exception as e:
129
+ print(f"(could not fetch the status header: {e})")
130
+
131
+ print()
132
+
133
+ try:
134
+
135
+ while True:
136
+
137
+ try:
138
+ line = input(">>> ")
139
+ except KeyboardInterrupt:
140
+ print()
141
+ continue
142
+
143
+ lines = [line]
144
+
145
+ # Accumulate multi-line blocks. Once a block has started, only a
146
+ # BLANK line ends it (standard REPL behaviour) — terminating when
147
+ # the joined code merely "looked complete" cut every block off
148
+ # after its first body line.
149
+ in_block = _is_incomplete(line)
150
+
151
+ while in_block or _is_incomplete("\n".join(lines)):
152
+ try:
153
+ continuation = input("... ")
154
+ except KeyboardInterrupt:
155
+ print()
156
+ lines = []
157
+ break
158
+
159
+ if in_block and continuation.strip() == "":
160
+ break
161
+
162
+ lines.append(continuation)
163
+
164
+ if not lines:
165
+ continue
166
+
167
+ code = "\n".join(lines).strip()
168
+
169
+ if not code:
170
+ continue
171
+
172
+ try:
173
+ response = client.send(code)
174
+ print(response)
175
+ except ConnectionError:
176
+ print("Connection lost.")
177
+ break
178
+
179
+ except EOFError:
180
+ print()
181
+
182
+ finally:
183
+ client.close()
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()
@@ -0,0 +1,298 @@
1
+ """Watch a Python file and re-exec it on save into a live composition.
2
+
3
+ Provides ``LiveReloader``, the engine behind ``Composition.watch(path)``.
4
+ Together they enable file-based live coding: edit a Python file in your
5
+ normal editor, save, and the running composition picks up the changes
6
+ without stopping the clock.
7
+
8
+ How it works
9
+ ────────────
10
+
11
+ ``Composition.watch(path)`` constructs a ``LiveReloader`` and calls
12
+ ``start()``.
13
+
14
+ ``start()`` performs an initial synchronous load — reads the file and
15
+ delegates to ``Composition.load_patterns()``, which compiles and execs
16
+ the source into a namespace that has ``composition`` and ``subsequence``
17
+ in scope. This is the first chance for ``@composition.pattern``
18
+ decorators in the file to register with the composition. If the initial
19
+ load fails (``SyntaxError``, missing file), the exception propagates —
20
+ the user should know immediately if their entry point is broken.
21
+
22
+ A daemon thread is then spawned that polls the file's ``st_mtime`` every
23
+ ``poll_interval`` seconds. When the mtime changes, the thread schedules
24
+ ``_reload_async()`` onto the composition's event loop via
25
+ ``asyncio.run_coroutine_threadsafe()``, so mutation happens on the event
26
+ loop thread (where the rest of the sequencer lives).
27
+
28
+ ``_reload_async()`` reads + compiles the file content, then delegates to
29
+ ``Composition._apply_source_async()`` for exec, pattern activation, and
30
+ diff-and-unregister against the running set. Errors from any phase are
31
+ logged but do not abort the watcher.
32
+
33
+ Existing patterns hot-swap in place via the decorator path: when the
34
+ same function name is re-decorated while ``_is_live=True``, the running
35
+ pattern's ``_builder_fn`` is replaced and the next rebuild uses the new
36
+ logic. The pattern's channel, mirrors, device, and cycle counter are
37
+ preserved — only the build logic changes.
38
+
39
+ Error handling
40
+ ──────────────
41
+
42
+ ``SyntaxError`` during a reload — log a warning and skip the reload
43
+ entirely. Previous state is preserved. The user fixes the file and
44
+ saves again; the next mtime tick retries.
45
+
46
+ Runtime error during ``exec()`` (e.g. ``NameError``, ``ImportError``)
47
+ — treated the same way: log a warning and skip the rest of the reload.
48
+ ``Composition._apply_source_async`` re-raises exec failures specifically
49
+ so this catch can suppress the diff-and-unregister phase, which would
50
+ otherwise tear down patterns the broken file failed to reach. Note
51
+ that decorators that already side-effect'd before the error fired
52
+ cannot be rolled back — those builders will run their new bodies on
53
+ the next reschedule.
54
+
55
+ File missing or unreadable mid-poll — log a warning, skip, retry next
56
+ tick. Editor "atomic save" (write-temp-then-rename) is handled by
57
+ catching ``OSError`` around the read.
58
+
59
+ Module-level state in the watched file
60
+ ──────────────────────────────────────
61
+
62
+ Each reload uses a fresh namespace dict. Module-level objects in the
63
+ watched file (e.g. ``state = MelodicState(...)``) are recreated on every
64
+ reload — long-lived state belongs on ``composition.data`` or in the
65
+ wrapper script (the file that calls ``composition.watch()``), not in
66
+ the live file itself.
67
+
68
+ Security note
69
+ ─────────────
70
+
71
+ This module calls ``exec()`` on arbitrary Python by design. Treat the watched
72
+ file like any other source file in your project; never point it at
73
+ untrusted content.
74
+ """
75
+
76
+ import asyncio
77
+ import logging
78
+ import os
79
+ import pathlib
80
+ import threading
81
+ import traceback
82
+ import typing
83
+
84
+
85
+ if typing.TYPE_CHECKING:
86
+ import subsequence.composition
87
+
88
+
89
+ logger = logging.getLogger(__name__)
90
+
91
+
92
+ class LiveReloader:
93
+
94
+ """Watch a Python file and re-exec it on save into a live composition.
95
+
96
+ Constructed by ``Composition.watch(path)``; users do not instantiate
97
+ this class directly. Owns a daemon thread that polls the file's
98
+ modification time and a reference back to the composition for
99
+ scheduling reloads onto its event loop.
100
+ """
101
+
102
+ def __init__ (
103
+ self,
104
+ composition: "subsequence.composition.Composition",
105
+ path: typing.Union[str, pathlib.Path],
106
+ poll_interval: float = 0.25,
107
+ skip_initial_exec: bool = False,
108
+ ) -> None:
109
+
110
+ """Initialise the reloader in a stopped state.
111
+
112
+ Parameters:
113
+ composition: The live ``Composition`` instance to reload into.
114
+ path: Path to the Python file to watch.
115
+ poll_interval: Seconds between ``st_mtime`` polls. Default
116
+ 0.25 s gives a responsive feel for editor saves without
117
+ busy-waiting.
118
+ skip_initial_exec: When ``True``, ``start()`` skips the
119
+ compile + exec phase of the initial load and only records
120
+ ``_last_mtime``. Set by ``Composition.watch()`` when it
121
+ detects a self-watch (the file calling ``watch()`` is the
122
+ file being watched), since the outer Python script execution
123
+ will already run the patterns at the module level — a second
124
+ exec via ``_load_initial`` would double-register every one.
125
+ """
126
+
127
+ self._composition = composition
128
+ self._path: pathlib.Path = pathlib.Path(path)
129
+ self._poll_interval = poll_interval
130
+ self._skip_initial_exec = skip_initial_exec
131
+
132
+ # Last known mtime; set by the initial load and updated on each
133
+ # detected change. Used by the watcher loop to skip unchanged ticks.
134
+ self._last_mtime: typing.Optional[float] = None
135
+
136
+ # Daemon thread state — created on start().
137
+ self._thread: typing.Optional[threading.Thread] = None
138
+ self._stop_event = threading.Event()
139
+
140
+ def start (self) -> None:
141
+
142
+ """Perform the initial synchronous load, then spawn the watcher thread.
143
+
144
+ Raises :exc:`SyntaxError` or :exc:`FileNotFoundError` if the file
145
+ cannot be loaded — better to fail loudly here than to leave the
146
+ user wondering why no patterns are running.
147
+
148
+ Safe to call once. A second call while the watcher is already
149
+ running is a no-op.
150
+ """
151
+
152
+ if self._thread is not None and self._thread.is_alive():
153
+ logger.debug(f"LiveReloader.start() no-op: already watching {self._path}")
154
+ return
155
+
156
+ # Initial load — synchronous, on the calling thread. Raises on failure.
157
+ self._load_initial()
158
+
159
+ self._stop_event.clear()
160
+ self._thread = threading.Thread(
161
+ target = self._watch_loop,
162
+ name = f"subsequence-live-reloader-{self._path.name}",
163
+ daemon = True,
164
+ )
165
+ self._thread.start()
166
+ logger.info(f"LiveReloader watching {self._path} (poll {self._poll_interval}s)")
167
+
168
+ def stop (self) -> None:
169
+
170
+ """Signal the watcher thread to exit; safe to call multiple times.
171
+
172
+ Joins the thread with a short timeout so shutdown is bounded.
173
+ """
174
+
175
+ self._stop_event.set()
176
+
177
+ if self._thread is not None and self._thread.is_alive():
178
+ self._thread.join(timeout = self._poll_interval * 2 + 0.5)
179
+
180
+ self._thread = None
181
+
182
+ # ── Internals ──────────────────────────────────────────────────────────
183
+
184
+ def _load_initial (self) -> None:
185
+
186
+ """Synchronous first load; raises on failure.
187
+
188
+ Reads, compiles and execs the file on the calling thread. Doesn't
189
+ go through ``Composition.load_patterns()`` because that method
190
+ schedules onto the event loop when one is running and waits via
191
+ ``future.result()`` — which would deadlock if ``watch()`` happens
192
+ to be called from inside the event loop (e.g. in tests). The
193
+ ``_load_initial`` contract is pre-play setup, so direct exec is
194
+ correct here: decorators populate ``_pending_patterns`` and the
195
+ composition's ``play()`` graduates them.
196
+
197
+ When ``self._skip_initial_exec`` is ``True`` (single-file self-watch),
198
+ the compile+exec step is skipped — the outer Python script will run
199
+ the decorators itself. We still stat for ``_last_mtime`` so the
200
+ watcher loop doesn't immediately re-trigger on the first poll.
201
+ """
202
+
203
+ if not self._skip_initial_exec:
204
+
205
+ content = self._path.read_text(encoding = "utf-8")
206
+ compiled = compile(content, str(self._path), "exec")
207
+
208
+ namespace = self._composition._build_live_namespace(source_label = str(self._path))
209
+
210
+ # Mirror _apply_source_async's bookkeeping so the FIRST save can
211
+ # already diff against what this file declares now — recording
212
+ # only this file's names, not the wrapper script's.
213
+ self._composition._declared_names = set()
214
+ exec(compiled, namespace)
215
+ self._composition._source_declared[str(self._path)] = set(self._composition._declared_names)
216
+
217
+ try:
218
+ self._last_mtime = os.stat(self._path).st_mtime
219
+ except OSError:
220
+ self._last_mtime = None
221
+
222
+ def _watch_loop (self) -> None:
223
+
224
+ """Polling loop running in the daemon thread.
225
+
226
+ Stats the file every ``poll_interval`` seconds; on detected mtime
227
+ change, schedules :meth:`_reload_async` onto the composition's
228
+ event loop via :func:`asyncio.run_coroutine_threadsafe`.
229
+ """
230
+
231
+ while not self._stop_event.is_set():
232
+
233
+ try:
234
+ mtime = os.stat(self._path).st_mtime
235
+ except OSError:
236
+ # File disappeared or became unreadable — wait it out.
237
+ # Editors that save via write-temp-then-rename can produce
238
+ # brief windows like this.
239
+ self._stop_event.wait(self._poll_interval)
240
+ continue
241
+
242
+ # != rather than >: a timestamp-preserving replacement (mv backup.py
243
+ # watched.py) can legitimately have an OLDER mtime.
244
+ if self._last_mtime is None or mtime != self._last_mtime:
245
+
246
+ loop = self._composition._sequencer._event_loop
247
+
248
+ if loop is None:
249
+ # Event loop isn't running yet (watch() called before play(),
250
+ # or play() not called). Don't advance _last_mtime — the
251
+ # next poll will pick up the same change and try again.
252
+ logger.debug("LiveReloader: no event loop yet, deferring reload")
253
+ else:
254
+ self._last_mtime = mtime
255
+ asyncio.run_coroutine_threadsafe(self._reload_async(), loop = loop)
256
+
257
+ # Use the stop event's wait() so shutdown is instantaneous instead
258
+ # of having to wait out the full poll interval.
259
+ self._stop_event.wait(self._poll_interval)
260
+
261
+ async def _reload_async (self) -> None:
262
+
263
+ """Read, compile, apply — runs on the event loop thread.
264
+
265
+ Delegates the exec + activate + diff-and-unregister phases to
266
+ ``Composition._apply_source_async``. We do the compile step here
267
+ (rather than via ``Composition.load_patterns``) so SyntaxError can
268
+ be reported with a watcher-specific log message, and so the apply
269
+ coroutine runs directly on the loop without re-scheduling through
270
+ ``run_coroutine_threadsafe``.
271
+
272
+ Errors are logged but do not abort the watcher.
273
+ """
274
+
275
+ try:
276
+ content = self._path.read_text(encoding = "utf-8")
277
+ except OSError as exc:
278
+ logger.warning(f"LiveReloader: could not read {self._path}: {exc}")
279
+ return
280
+
281
+ # Syntax check — bail early without touching state.
282
+ try:
283
+ compiled = compile(content, str(self._path), "exec")
284
+ except SyntaxError:
285
+ logger.warning(f"LiveReloader: SyntaxError in {self._path}, skipping reload:\n{traceback.format_exc()}")
286
+ return
287
+
288
+ namespace = self._composition._build_live_namespace(source_label = str(self._path))
289
+
290
+ try:
291
+ await self._composition._apply_source_async(compiled, namespace, source_key = str(self._path))
292
+ except Exception:
293
+ # Apply re-raises on exec failure; suppress here so the watcher
294
+ # keeps running. The diff-and-unregister phase inside
295
+ # _apply_source_async is skipped automatically when exec raises,
296
+ # so previous state is preserved.
297
+ logger.warning(f"LiveReloader: error executing {self._path}, skipping reload:\n{traceback.format_exc()}")
298
+ return