doppler-cli 0.2.6__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.
- doppler_cli/__init__.py +3 -0
- doppler_cli/__main__.py +180 -0
- doppler_cli/blocks/__init__.py +76 -0
- doppler_cli/blocks/fir.py +35 -0
- doppler_cli/blocks/specan.py +56 -0
- doppler_cli/blocks/tone.py +45 -0
- doppler_cli/compose.py +207 -0
- doppler_cli/dopplerfile.py +195 -0
- doppler_cli/ports.py +44 -0
- doppler_cli/ps.py +116 -0
- doppler_cli/source.py +125 -0
- doppler_cli/state.py +109 -0
- doppler_cli/tests/__init__.py +0 -0
- doppler_cli/tests/test_blocks.py +172 -0
- doppler_cli/tests/test_compose.py +148 -0
- doppler_cli/tests/test_dopplerfile.py +290 -0
- doppler_cli/tests/test_ports.py +101 -0
- doppler_cli/tests/test_state.py +213 -0
- doppler_cli-0.2.6.dist-info/METADATA +12 -0
- doppler_cli-0.2.6.dist-info/RECORD +22 -0
- doppler_cli-0.2.6.dist-info/WHEEL +4 -0
- doppler_cli-0.2.6.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for port auto-allocation.
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
- allocate() returns the requested number of ports
|
|
6
|
+
- Ports are in the valid range
|
|
7
|
+
- Ports are unique within a single allocation
|
|
8
|
+
- Pre-occupied ports (from fake state files) are skipped
|
|
9
|
+
- Requesting too many ports raises RuntimeError
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from doppler_cli import ports as ports_mod
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write_fake_state(chains_dir, chain_id: str, used_ports: list[int]):
|
|
19
|
+
chains_dir.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
state = {
|
|
21
|
+
"id": chain_id,
|
|
22
|
+
"started": "2026-01-01T00:00:00+00:00",
|
|
23
|
+
"compose": "/tmp/fake.yml",
|
|
24
|
+
"blocks": [
|
|
25
|
+
{"name": "tone", "pid": 9999, "bind_port": p, "connect_port": None}
|
|
26
|
+
for p in used_ports
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
(chains_dir / f"{chain_id}.json").write_text(json.dumps(state))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Basic allocation
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestAllocate:
|
|
38
|
+
def test_returns_correct_count(self, tmp_path, monkeypatch):
|
|
39
|
+
monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
|
|
40
|
+
result = ports_mod.allocate(3)
|
|
41
|
+
assert len(result) == 3
|
|
42
|
+
|
|
43
|
+
def test_ports_unique(self, tmp_path, monkeypatch):
|
|
44
|
+
monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
|
|
45
|
+
result = ports_mod.allocate(5)
|
|
46
|
+
assert len(set(result)) == 5
|
|
47
|
+
|
|
48
|
+
def test_ports_in_valid_range(self, tmp_path, monkeypatch):
|
|
49
|
+
monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
|
|
50
|
+
result = ports_mod.allocate(4)
|
|
51
|
+
for p in result:
|
|
52
|
+
assert ports_mod._BASE_PORT <= p <= ports_mod._MAX_PORT
|
|
53
|
+
|
|
54
|
+
def test_single_port(self, tmp_path, monkeypatch):
|
|
55
|
+
monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
|
|
56
|
+
result = ports_mod.allocate(1)
|
|
57
|
+
assert len(result) == 1
|
|
58
|
+
assert result[0] == ports_mod._BASE_PORT
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Skips occupied ports
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestAllocateSkipsOccupied:
|
|
67
|
+
def test_skips_port_in_state_file(self, tmp_path, monkeypatch):
|
|
68
|
+
monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
|
|
69
|
+
base = ports_mod._BASE_PORT
|
|
70
|
+
_write_fake_state(tmp_path, "aaa111", [base])
|
|
71
|
+
result = ports_mod.allocate(1)
|
|
72
|
+
assert base not in result
|
|
73
|
+
|
|
74
|
+
def test_skips_multiple_occupied(self, tmp_path, monkeypatch):
|
|
75
|
+
monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
|
|
76
|
+
base = ports_mod._BASE_PORT
|
|
77
|
+
occupied = [base, base + 1, base + 2]
|
|
78
|
+
_write_fake_state(tmp_path, "bbb222", occupied)
|
|
79
|
+
result = ports_mod.allocate(2)
|
|
80
|
+
for p in occupied:
|
|
81
|
+
assert p not in result
|
|
82
|
+
|
|
83
|
+
def test_gracefully_skips_corrupt_state(self, tmp_path, monkeypatch):
|
|
84
|
+
monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
|
|
85
|
+
(tmp_path / "corrupt.json").write_text("not json {{{")
|
|
86
|
+
# Should not raise — corrupt files are ignored
|
|
87
|
+
result = ports_mod.allocate(1)
|
|
88
|
+
assert len(result) == 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Exhaustion
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestAllocateExhaustion:
|
|
97
|
+
def test_too_many_raises(self, tmp_path, monkeypatch):
|
|
98
|
+
monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
|
|
99
|
+
capacity = ports_mod._MAX_PORT - ports_mod._BASE_PORT + 1
|
|
100
|
+
with pytest.raises(RuntimeError, match="No free ports"):
|
|
101
|
+
ports_mod.allocate(capacity + 1)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for chain state persistence.
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
- ChainState.save() writes a valid JSON file
|
|
6
|
+
- ChainState.load() round-trips correctly
|
|
7
|
+
- ChainState.delete() removes the file
|
|
8
|
+
- load() raises KeyError for missing chain
|
|
9
|
+
- list_chains() returns all saved chains
|
|
10
|
+
- list_chains() skips corrupt files silently
|
|
11
|
+
- pid_alive() returns False for dead PIDs
|
|
12
|
+
- stop_chain() calls the right signal and deletes state
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import signal
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from doppler_cli.state import (
|
|
22
|
+
BlockState,
|
|
23
|
+
ChainState,
|
|
24
|
+
list_chains,
|
|
25
|
+
pid_alive,
|
|
26
|
+
stop_chain,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _make_chain(chain_id: str = "abc123") -> ChainState:
|
|
31
|
+
return ChainState(
|
|
32
|
+
id=chain_id,
|
|
33
|
+
started="2026-01-01T00:00:00+00:00",
|
|
34
|
+
compose="/tmp/fake.yml",
|
|
35
|
+
blocks=[
|
|
36
|
+
BlockState(name="tone", pid=11111, bind_port=5600),
|
|
37
|
+
BlockState(name="specan", pid=11112, connect_port=5600),
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Save / load / delete
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestPersistence:
|
|
48
|
+
def test_save_creates_file(self, tmp_path, monkeypatch):
|
|
49
|
+
from doppler_cli import state as state_mod
|
|
50
|
+
|
|
51
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
52
|
+
chain = _make_chain()
|
|
53
|
+
chain.save()
|
|
54
|
+
assert (tmp_path / "abc123.json").exists()
|
|
55
|
+
|
|
56
|
+
def test_save_valid_json(self, tmp_path, monkeypatch):
|
|
57
|
+
from doppler_cli import state as state_mod
|
|
58
|
+
|
|
59
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
60
|
+
_make_chain().save()
|
|
61
|
+
data = json.loads((tmp_path / "abc123.json").read_text())
|
|
62
|
+
assert data["id"] == "abc123"
|
|
63
|
+
assert len(data["blocks"]) == 2
|
|
64
|
+
|
|
65
|
+
def test_load_round_trips(self, tmp_path, monkeypatch):
|
|
66
|
+
from doppler_cli import state as state_mod
|
|
67
|
+
|
|
68
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
69
|
+
original = _make_chain()
|
|
70
|
+
original.save()
|
|
71
|
+
loaded = ChainState.load("abc123")
|
|
72
|
+
assert loaded.id == original.id
|
|
73
|
+
assert loaded.started == original.started
|
|
74
|
+
assert loaded.blocks[0].name == "tone"
|
|
75
|
+
assert loaded.blocks[0].bind_port == 5600
|
|
76
|
+
assert loaded.blocks[1].connect_port == 5600
|
|
77
|
+
|
|
78
|
+
def test_load_missing_raises(self, tmp_path, monkeypatch):
|
|
79
|
+
from doppler_cli import state as state_mod
|
|
80
|
+
|
|
81
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
82
|
+
with pytest.raises(KeyError):
|
|
83
|
+
ChainState.load("doesnotexist")
|
|
84
|
+
|
|
85
|
+
def test_delete_removes_file(self, tmp_path, monkeypatch):
|
|
86
|
+
from doppler_cli import state as state_mod
|
|
87
|
+
|
|
88
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
89
|
+
chain = _make_chain()
|
|
90
|
+
chain.save()
|
|
91
|
+
chain.delete()
|
|
92
|
+
assert not (tmp_path / "abc123.json").exists()
|
|
93
|
+
|
|
94
|
+
def test_delete_missing_is_silent(self, tmp_path, monkeypatch):
|
|
95
|
+
from doppler_cli import state as state_mod
|
|
96
|
+
|
|
97
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
98
|
+
chain = _make_chain()
|
|
99
|
+
chain.delete() # never saved — should not raise
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# list_chains
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TestListChains:
|
|
108
|
+
def test_empty_dir(self, tmp_path, monkeypatch):
|
|
109
|
+
from doppler_cli import state as state_mod
|
|
110
|
+
|
|
111
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
112
|
+
assert list_chains() == []
|
|
113
|
+
|
|
114
|
+
def test_missing_dir(self, tmp_path, monkeypatch):
|
|
115
|
+
from doppler_cli import state as state_mod
|
|
116
|
+
|
|
117
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path / "nonexistent")
|
|
118
|
+
assert list_chains() == []
|
|
119
|
+
|
|
120
|
+
def test_returns_all_chains(self, tmp_path, monkeypatch):
|
|
121
|
+
from doppler_cli import state as state_mod
|
|
122
|
+
|
|
123
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
124
|
+
_make_chain("aaa111").save()
|
|
125
|
+
_make_chain("bbb222").save()
|
|
126
|
+
ids = {c.id for c in list_chains()}
|
|
127
|
+
assert ids == {"aaa111", "bbb222"}
|
|
128
|
+
|
|
129
|
+
def test_skips_corrupt_files(self, tmp_path, monkeypatch):
|
|
130
|
+
from doppler_cli import state as state_mod
|
|
131
|
+
|
|
132
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
133
|
+
_make_chain("good000").save()
|
|
134
|
+
(tmp_path / "bad999.json").write_text("not json {{{")
|
|
135
|
+
chains = list_chains()
|
|
136
|
+
assert len(chains) == 1
|
|
137
|
+
assert chains[0].id == "good000"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# pid_alive
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestPidAlive:
|
|
146
|
+
def test_own_pid_is_alive(self):
|
|
147
|
+
assert pid_alive(os.getpid()) is True
|
|
148
|
+
|
|
149
|
+
def test_dead_pid_returns_false(self):
|
|
150
|
+
# PID 1 exists but we won't have permission to signal it,
|
|
151
|
+
# which pid_alive treats as "not our process = alive" via
|
|
152
|
+
# PermissionError. Use a clearly invalid large PID instead.
|
|
153
|
+
assert pid_alive(999999999) is False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# stop_chain
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestStopChain:
|
|
162
|
+
def test_sigterm_sent_and_state_deleted(self, tmp_path, monkeypatch):
|
|
163
|
+
from doppler_cli import state as state_mod
|
|
164
|
+
|
|
165
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
166
|
+
|
|
167
|
+
signals_sent: list[tuple[int, int]] = []
|
|
168
|
+
|
|
169
|
+
def fake_kill(pid, sig):
|
|
170
|
+
signals_sent.append((pid, sig))
|
|
171
|
+
|
|
172
|
+
monkeypatch.setattr(os, "kill", fake_kill)
|
|
173
|
+
monkeypatch.setattr(state_mod, "pid_alive", lambda pid: True)
|
|
174
|
+
|
|
175
|
+
chain = _make_chain()
|
|
176
|
+
chain.save()
|
|
177
|
+
stop_chain(chain, kill=False)
|
|
178
|
+
|
|
179
|
+
assert (11111, signal.SIGTERM) in signals_sent
|
|
180
|
+
assert (11112, signal.SIGTERM) in signals_sent
|
|
181
|
+
assert not (tmp_path / "abc123.json").exists()
|
|
182
|
+
|
|
183
|
+
def test_sigkill_when_kill_true(self, tmp_path, monkeypatch):
|
|
184
|
+
from doppler_cli import state as state_mod
|
|
185
|
+
|
|
186
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
187
|
+
|
|
188
|
+
signals_sent: list[tuple[int, int]] = []
|
|
189
|
+
monkeypatch.setattr(
|
|
190
|
+
os, "kill", lambda pid, sig: signals_sent.append((pid, sig))
|
|
191
|
+
)
|
|
192
|
+
monkeypatch.setattr(state_mod, "pid_alive", lambda pid: True)
|
|
193
|
+
|
|
194
|
+
chain = _make_chain()
|
|
195
|
+
chain.save()
|
|
196
|
+
stop_chain(chain, kill=True)
|
|
197
|
+
|
|
198
|
+
assert all(sig == signal.SIGKILL for _, sig in signals_sent)
|
|
199
|
+
|
|
200
|
+
def test_dead_pids_skipped(self, tmp_path, monkeypatch):
|
|
201
|
+
from doppler_cli import state as state_mod
|
|
202
|
+
|
|
203
|
+
monkeypatch.setattr(state_mod, "_CHAINS_DIR", tmp_path)
|
|
204
|
+
monkeypatch.setattr(state_mod, "pid_alive", lambda pid: False)
|
|
205
|
+
|
|
206
|
+
signals_sent: list = []
|
|
207
|
+
monkeypatch.setattr(os, "kill", lambda pid, sig: signals_sent.append(pid))
|
|
208
|
+
|
|
209
|
+
chain = _make_chain()
|
|
210
|
+
chain.save()
|
|
211
|
+
stop_chain(chain)
|
|
212
|
+
|
|
213
|
+
assert signals_sent == []
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: doppler-cli
|
|
3
|
+
Version: 0.2.6
|
|
4
|
+
Summary: CLI for doppler-dsp signal processing pipelines
|
|
5
|
+
Requires-Dist: doppler-dsp>=0.2.0
|
|
6
|
+
Requires-Dist: doppler-specan>=0.2.0
|
|
7
|
+
Requires-Dist: pydantic>=2.0
|
|
8
|
+
Requires-Dist: pyyaml>=6.0
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Project-URL: Homepage, https://doppler-dsp.github.io/doppler/
|
|
12
|
+
Project-URL: Repository, https://github.com/doppler-dsp/doppler
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
doppler_cli/__init__.py,sha256=CiK-5hGYXfHw0j9uQotglcEdzkpsLXxRacguklaozmQ,87
|
|
2
|
+
doppler_cli/__main__.py,sha256=xNdB_KhIWLgHNCXtLc5mMopkIHkPOp4OxTax7JyE2Vs,5150
|
|
3
|
+
doppler_cli/blocks/__init__.py,sha256=yUKTxbmDXy92jnJ3DpTNZOMj6Y1giRWPgqLZNuSi4Gs,1924
|
|
4
|
+
doppler_cli/blocks/fir.py,sha256=HBgWqisAaF3R3yg9m0ncua3HHhJjpDQVGaZ4mi8w6ZQ,755
|
|
5
|
+
doppler_cli/blocks/specan.py,sha256=wsB1V2gecDaW-wBw6yWCn5aeg6xpLz2JCbJ3A2rumFQ,1432
|
|
6
|
+
doppler_cli/blocks/tone.py,sha256=Beqfvs15VHHiFCwKPL_lU_a1f00NJvQG1DcP2TeCepI,1079
|
|
7
|
+
doppler_cli/compose.py,sha256=Q59L9ksj3JUtXaSD5jFvuRXVWiiFnHDi3-oW0l5mdow,6399
|
|
8
|
+
doppler_cli/dopplerfile.py,sha256=EuL9l9TG8wdaZ_bQj3tA5KDQ6YgvlaLMO26av_UKmVE,5690
|
|
9
|
+
doppler_cli/ports.py,sha256=b_H5gVEzkPksayyNaUGrUsmD6T_UhfQUxvPQWYoamkE,1363
|
|
10
|
+
doppler_cli/ps.py,sha256=Qs_YC-PYP68Cb05snNr1mnFeuMWSk6SIXgabzNOtFhg,3492
|
|
11
|
+
doppler_cli/source.py,sha256=JTp4L21HM8y9uiK-DnptAIDPL4FvN58k8jZDywHfUdw,3062
|
|
12
|
+
doppler_cli/state.py,sha256=PKCQWp1strejTDRJKkGtdYNAP6s-I9XXEQoXAqkmHT0,3006
|
|
13
|
+
doppler_cli/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
doppler_cli/tests/test_blocks.py,sha256=HTwv4UP8Th3aJ46ue__SXJIEwOudnZ4Zv_0voLKrXRs,5622
|
|
15
|
+
doppler_cli/tests/test_compose.py,sha256=Bim4hnQvJ4PkutE_W9GzHwweSp7llwuzB9m3Y3yZ-JQ,6049
|
|
16
|
+
doppler_cli/tests/test_dopplerfile.py,sha256=abc8bhNfBfJPValnkc3l0BaiUp8WWWs10mjrh5sX4BU,10450
|
|
17
|
+
doppler_cli/tests/test_ports.py,sha256=62WEO7F9w8Pu32-4OIU4Ui5xq66xesZoVWr9_DNLHTA,3638
|
|
18
|
+
doppler_cli/tests/test_state.py,sha256=KDu9Z5VePJHuFTPBC6Dt5evFcrbB6MoslN3z6rUfwx0,6985
|
|
19
|
+
doppler_cli-0.2.6.dist-info/WHEEL,sha256=bEhYrD-rjlF0iRRHiAnfJ0mEjMsRwm29hhDD7yRgWCY,80
|
|
20
|
+
doppler_cli-0.2.6.dist-info/entry_points.txt,sha256=VZVDiNdRCzdToUoaQJfkcU6KgreQwiZV8VCY0m1CK2I,96
|
|
21
|
+
doppler_cli-0.2.6.dist-info/METADATA,sha256=W0Jicw5SekZ01yi5gT0-Y73YRvHMZaCI2wwopp1c2Qk,415
|
|
22
|
+
doppler_cli-0.2.6.dist-info/RECORD,,
|