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.
- subsequence/__init__.py +231 -0
- subsequence/__main__.py +24 -0
- subsequence/assets/web/index.html +345 -0
- subsequence/cadences.py +113 -0
- subsequence/chord_graphs/__init__.py +100 -0
- subsequence/chord_graphs/aeolian_minor.py +158 -0
- subsequence/chord_graphs/chromatic_mediant.py +113 -0
- subsequence/chord_graphs/diminished.py +97 -0
- subsequence/chord_graphs/dorian_minor.py +127 -0
- subsequence/chord_graphs/functional_major.py +102 -0
- subsequence/chord_graphs/hooktheory_major.py +88 -0
- subsequence/chord_graphs/lydian_major.py +130 -0
- subsequence/chord_graphs/mixolydian.py +98 -0
- subsequence/chord_graphs/phrygian_minor.py +76 -0
- subsequence/chord_graphs/suspended.py +109 -0
- subsequence/chord_graphs/turnaround_global.py +157 -0
- subsequence/chord_graphs/whole_tone.py +77 -0
- subsequence/chords.py +419 -0
- subsequence/composition.py +6099 -0
- subsequence/conductor.py +238 -0
- subsequence/constants/__init__.py +24 -0
- subsequence/constants/durations.py +37 -0
- subsequence/constants/instruments/__init__.py +13 -0
- subsequence/constants/instruments/gm_cc.py +46 -0
- subsequence/constants/instruments/gm_drums.py +53 -0
- subsequence/constants/instruments/gm_instruments.py +32 -0
- subsequence/constants/instruments/roland_tr8s.py +320 -0
- subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
- subsequence/constants/midi_notes.py +29 -0
- subsequence/constants/pulses.py +17 -0
- subsequence/constants/velocity.py +22 -0
- subsequence/definitions.py +232 -0
- subsequence/display.py +617 -0
- subsequence/easing.py +347 -0
- subsequence/event_emitter.py +109 -0
- subsequence/form_state.py +665 -0
- subsequence/forms.py +257 -0
- subsequence/groove.py +323 -0
- subsequence/harmonic_rhythm.py +83 -0
- subsequence/harmonic_state.py +352 -0
- subsequence/harmony.py +197 -0
- subsequence/held_notes.py +91 -0
- subsequence/helpers/__init__.py +0 -0
- subsequence/helpers/network.py +58 -0
- subsequence/helpers/wing.py +430 -0
- subsequence/intervals.py +436 -0
- subsequence/keystroke.py +249 -0
- subsequence/link_clock.py +128 -0
- subsequence/live_client.py +187 -0
- subsequence/live_reloader.py +298 -0
- subsequence/live_server.py +161 -0
- subsequence/melodic_state.py +483 -0
- subsequence/midi.py +97 -0
- subsequence/midi_utils.py +329 -0
- subsequence/mini_notation.py +164 -0
- subsequence/motifs.py +2356 -0
- subsequence/osc.py +194 -0
- subsequence/pattern.py +363 -0
- subsequence/pattern_algorithmic.py +2010 -0
- subsequence/pattern_builder.py +2589 -0
- subsequence/pattern_midi.py +1208 -0
- subsequence/progressions.py +1913 -0
- subsequence/py.typed +0 -0
- subsequence/roles.py +63 -0
- subsequence/sequence_utils.py +3123 -0
- subsequence/sequencer.py +2086 -0
- subsequence/tuning.py +453 -0
- subsequence/voicings.py +151 -0
- subsequence/web_ui.py +337 -0
- subsequence/weighted_graph.py +156 -0
- subsequence-0.6.4.dist-info/METADATA +208 -0
- subsequence-0.6.4.dist-info/RECORD +78 -0
- subsequence-0.6.4.dist-info/WHEEL +5 -0
- subsequence-0.6.4.dist-info/entry_points.txt +2 -0
- subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
- subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
- subsequence-0.6.4.dist-info/scm_version.json +8 -0
- 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
|