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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ doppler = doppler_cli.__main__:main
3
+ doppler-source = doppler_cli.source:main
4
+