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,172 @@
1
+ """
2
+ Tests for the block registry and individual block configs/commands.
3
+
4
+ Covers:
5
+ - Registry: register, get, unknown block error
6
+ - ToneBlock: defaults, config override, command argv
7
+ - FirBlock: defaults, taps in command, no-taps omission
8
+ - SpecanBlock: terminal and web mode commands
9
+ """
10
+
11
+ import pytest
12
+
13
+ import doppler_cli.blocks.fir # noqa: F401 — populate registry
14
+ import doppler_cli.blocks.specan # noqa: F401
15
+ import doppler_cli.blocks.tone # noqa: F401
16
+ from doppler_cli.blocks import get, all_blocks
17
+ from doppler_cli.blocks.tone import ToneBlock, ToneConfig
18
+ from doppler_cli.blocks.fir import FirBlock, FirConfig
19
+ from doppler_cli.blocks.specan import SpecanBlock, SpecanConfig
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Registry
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ class TestRegistry:
28
+ def test_known_blocks_present(self):
29
+ names = set(all_blocks())
30
+ assert {"tone", "fir", "specan"} <= names
31
+
32
+ def test_get_returns_correct_class(self):
33
+ assert get("tone") is ToneBlock
34
+ assert get("fir") is FirBlock
35
+ assert get("specan") is SpecanBlock
36
+
37
+ def test_get_unknown_raises(self):
38
+ with pytest.raises(KeyError, match="Unknown block"):
39
+ get("nonexistent_block")
40
+
41
+ def test_roles(self):
42
+ assert ToneBlock.role == "source"
43
+ assert FirBlock.role == "chain"
44
+ assert SpecanBlock.role == "sink"
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # ToneBlock
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class TestToneBlock:
53
+ def test_default_config(self):
54
+ cfg = ToneConfig()
55
+ assert cfg.sample_rate == 2.048e6
56
+ assert cfg.tone_freq == 100e3
57
+ assert cfg.tone_power == -20.0
58
+ assert cfg.noise_floor == -90.0
59
+
60
+ def test_config_override(self):
61
+ cfg = ToneConfig(tone_freq=50e3, tone_power=-30.0)
62
+ assert cfg.tone_freq == 50e3
63
+ assert cfg.tone_power == -30.0
64
+
65
+ def test_command_contains_bind(self):
66
+ cfg = ToneConfig()
67
+ cmd = ToneBlock().command(cfg, None, "tcp://127.0.0.1:5600")
68
+ assert "--bind" in cmd
69
+ assert "tcp://127.0.0.1:5600" in cmd
70
+
71
+ def test_command_contains_params(self):
72
+ cfg = ToneConfig(sample_rate=1e6, tone_freq=200e3)
73
+ cmd = ToneBlock().command(cfg, None, "tcp://127.0.0.1:5600")
74
+ assert "--fs" in cmd
75
+ assert "1000000.0" in cmd
76
+ assert "--tone-freq" in cmd
77
+ assert "200000.0" in cmd
78
+
79
+ def test_command_no_input_addr(self):
80
+ cfg = ToneConfig()
81
+ cmd = ToneBlock().command(cfg, None, "tcp://127.0.0.1:5600")
82
+ assert "--connect" not in cmd
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # FirBlock
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ class TestFirBlock:
91
+ def test_default_config_empty_taps(self):
92
+ cfg = FirConfig()
93
+ assert cfg.taps == []
94
+
95
+ def test_command_contains_connect_and_bind(self):
96
+ cfg = FirConfig()
97
+ cmd = FirBlock().command(
98
+ cfg,
99
+ "tcp://127.0.0.1:5600",
100
+ "tcp://127.0.0.1:5601",
101
+ )
102
+ assert "--connect" in cmd
103
+ assert "tcp://127.0.0.1:5600" in cmd
104
+ assert "--bind" in cmd
105
+ assert "tcp://127.0.0.1:5601" in cmd
106
+
107
+ def test_command_with_taps(self):
108
+ cfg = FirConfig(taps=[0.25, 0.5, 0.25])
109
+ cmd = FirBlock().command(
110
+ cfg,
111
+ "tcp://127.0.0.1:5600",
112
+ "tcp://127.0.0.1:5601",
113
+ )
114
+ assert "--taps" in cmd
115
+ assert "0.25" in cmd
116
+ assert "0.5" in cmd
117
+
118
+ def test_command_no_taps_flag_when_empty(self):
119
+ cfg = FirConfig(taps=[])
120
+ cmd = FirBlock().command(
121
+ cfg,
122
+ "tcp://127.0.0.1:5600",
123
+ "tcp://127.0.0.1:5601",
124
+ )
125
+ assert "--taps" not in cmd
126
+
127
+ def test_requires_both_addrs(self):
128
+ cfg = FirConfig()
129
+ with pytest.raises(AssertionError):
130
+ FirBlock().command(cfg, None, "tcp://127.0.0.1:5601")
131
+ with pytest.raises(AssertionError):
132
+ FirBlock().command(cfg, "tcp://127.0.0.1:5600", None)
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # SpecanBlock
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ class TestSpecanBlock:
141
+ def test_default_config(self):
142
+ cfg = SpecanConfig()
143
+ assert cfg.mode == "web"
144
+ assert cfg.web_port == 8080
145
+
146
+ def test_terminal_command(self):
147
+ cfg = SpecanConfig(mode="terminal")
148
+ cmd = SpecanBlock().command(cfg, "tcp://127.0.0.1:5601", None)
149
+ assert "--source" in cmd
150
+ assert "pull" in cmd
151
+ assert "--address" in cmd
152
+ assert "tcp://127.0.0.1:5601" in cmd
153
+ assert "--web" not in cmd
154
+
155
+ def test_web_command_includes_web_flag(self):
156
+ cfg = SpecanConfig(mode="web", web_port=9090)
157
+ cmd = SpecanBlock().command(cfg, "tcp://127.0.0.1:5601", None)
158
+ assert "--web" in cmd
159
+ assert "--port" in cmd
160
+ assert "9090" in cmd
161
+
162
+ def test_optional_display_params(self):
163
+ cfg = SpecanConfig(span=200e3, rbw=500.0, level=-40.0)
164
+ cmd = SpecanBlock().command(cfg, "tcp://127.0.0.1:5601", None)
165
+ assert "--span" in cmd
166
+ assert "--rbw" in cmd
167
+ assert "--level" in cmd
168
+
169
+ def test_requires_input_addr(self):
170
+ cfg = SpecanConfig()
171
+ with pytest.raises(AssertionError):
172
+ SpecanBlock().command(cfg, None, None)
@@ -0,0 +1,148 @@
1
+ """
2
+ Tests for doppler compose init.
3
+
4
+ Covers:
5
+ - init() writes a valid YAML file
6
+ - Generated file has correct id, source, chain, sink structure
7
+ - Ports are assigned and present in the file
8
+ - Custom output path is respected
9
+ - Single-port chain (source → sink, no middle blocks)
10
+ - Unknown block name raises KeyError
11
+ - Wrong roles raise ValueError
12
+ - Too-few blocks (< 2) raises ValueError
13
+ """
14
+
15
+ import yaml
16
+ import pytest
17
+
18
+ import doppler_cli.blocks.fir # noqa: F401 — populate registry
19
+ import doppler_cli.blocks.specan # noqa: F401
20
+ import doppler_cli.blocks.tone # noqa: F401
21
+ from doppler_cli import compose as compose_mod
22
+ from doppler_cli import ports as ports_mod
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Helpers
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ def _init(tmp_path, monkeypatch, blocks):
31
+ """Run compose.init with a temp chains dir and return parsed YAML."""
32
+ monkeypatch.setattr(compose_mod, "_CHAINS_DIR", tmp_path)
33
+ monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
34
+ path = compose_mod.init(blocks)
35
+ return path, yaml.safe_load(path.read_text())
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Structure
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ class TestComposeInit:
44
+ def test_file_is_written(self, tmp_path, monkeypatch):
45
+ path, _ = _init(tmp_path, monkeypatch, ["tone", "specan"])
46
+ assert path.exists()
47
+
48
+ def test_has_id(self, tmp_path, monkeypatch):
49
+ _, doc = _init(tmp_path, monkeypatch, ["tone", "specan"])
50
+ assert "id" in doc
51
+ assert len(doc["id"]) == 6 # 3 hex bytes → 6 chars
52
+
53
+ def test_named_chain_id(self, tmp_path, monkeypatch):
54
+ monkeypatch.setattr(compose_mod, "_CHAINS_DIR", tmp_path)
55
+ monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
56
+ path = compose_mod.init(["tone", "specan"], name="filter-test")
57
+ doc = yaml.safe_load(path.read_text())
58
+ assert doc["id"] == "filter-test"
59
+ assert path.name == "filter-test.yml"
60
+
61
+ def test_named_chain_filename(self, tmp_path, monkeypatch):
62
+ monkeypatch.setattr(compose_mod, "_CHAINS_DIR", tmp_path)
63
+ monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
64
+ path = compose_mod.init(["tone", "specan"], name="my-chain")
65
+ assert path == tmp_path / "my-chain.yml"
66
+
67
+ def test_source_type(self, tmp_path, monkeypatch):
68
+ _, doc = _init(tmp_path, monkeypatch, ["tone", "specan"])
69
+ assert doc["source"]["type"] == "tone"
70
+
71
+ def test_sink_type(self, tmp_path, monkeypatch):
72
+ _, doc = _init(tmp_path, monkeypatch, ["tone", "specan"])
73
+ assert doc["sink"]["type"] == "specan"
74
+
75
+ def test_source_has_port(self, tmp_path, monkeypatch):
76
+ _, doc = _init(tmp_path, monkeypatch, ["tone", "specan"])
77
+ assert "port" in doc["source"]
78
+ assert doc["source"]["port"] == ports_mod._BASE_PORT
79
+
80
+ def test_no_chain_key_for_two_blocks(self, tmp_path, monkeypatch):
81
+ _, doc = _init(tmp_path, monkeypatch, ["tone", "specan"])
82
+ assert "chain" not in doc or doc.get("chain") == []
83
+
84
+ def test_chain_block_present(self, tmp_path, monkeypatch):
85
+ _, doc = _init(tmp_path, monkeypatch, ["tone", "fir", "specan"])
86
+ assert "chain" in doc
87
+ assert len(doc["chain"]) == 1
88
+ assert "fir" in doc["chain"][0]
89
+
90
+ def test_chain_block_has_port(self, tmp_path, monkeypatch):
91
+ _, doc = _init(tmp_path, monkeypatch, ["tone", "fir", "specan"])
92
+ fir_entry = doc["chain"][0]["fir"]
93
+ assert "port" in fir_entry
94
+
95
+ def test_source_defaults_present(self, tmp_path, monkeypatch):
96
+ _, doc = _init(tmp_path, monkeypatch, ["tone", "specan"])
97
+ src = doc["source"]
98
+ assert "sample_rate" in src
99
+ assert "tone_freq" in src
100
+
101
+ def test_sink_defaults_present(self, tmp_path, monkeypatch):
102
+ _, doc = _init(tmp_path, monkeypatch, ["tone", "specan"])
103
+ assert "mode" in doc["sink"]
104
+
105
+ def test_custom_out_path(self, tmp_path, monkeypatch):
106
+ monkeypatch.setattr(compose_mod, "_CHAINS_DIR", tmp_path)
107
+ monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
108
+ out = tmp_path / "my_chain.yml"
109
+ path = compose_mod.init(["tone", "specan"], out=out)
110
+ assert path == out
111
+ assert out.exists()
112
+
113
+ def test_ports_are_unique(self, tmp_path, monkeypatch):
114
+ _, doc = _init(tmp_path, monkeypatch, ["tone", "fir", "specan"])
115
+ src_port = doc["source"]["port"]
116
+ fir_port = doc["chain"][0]["fir"]["port"]
117
+ assert src_port != fir_port
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Error cases
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ class TestComposeInitErrors:
126
+ def test_too_few_blocks(self, tmp_path, monkeypatch):
127
+ monkeypatch.setattr(compose_mod, "_CHAINS_DIR", tmp_path)
128
+ monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
129
+ with pytest.raises(ValueError, match="at least"):
130
+ compose_mod.init(["tone"])
131
+
132
+ def test_unknown_block(self, tmp_path, monkeypatch):
133
+ monkeypatch.setattr(compose_mod, "_CHAINS_DIR", tmp_path)
134
+ monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
135
+ with pytest.raises(KeyError, match="Unknown block"):
136
+ compose_mod.init(["tone", "mystery_block"])
137
+
138
+ def test_wrong_first_role(self, tmp_path, monkeypatch):
139
+ monkeypatch.setattr(compose_mod, "_CHAINS_DIR", tmp_path)
140
+ monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
141
+ with pytest.raises(ValueError, match="source"):
142
+ compose_mod.init(["specan", "tone"])
143
+
144
+ def test_wrong_last_role(self, tmp_path, monkeypatch):
145
+ monkeypatch.setattr(compose_mod, "_CHAINS_DIR", tmp_path)
146
+ monkeypatch.setattr(ports_mod, "_CHAINS_DIR", tmp_path)
147
+ with pytest.raises(ValueError, match="sink"):
148
+ compose_mod.init(["tone", "fir"])
@@ -0,0 +1,290 @@
1
+ """
2
+ Tests for doppler dopplerfile — YAML-defined block loading.
3
+
4
+ Covers:
5
+ - load() parses name / role / executable / config / args
6
+ - auto-map: --bind for source, --connect for sink
7
+ - auto-map: config fields become --flag-name value
8
+ - auto-map: lists JSON-encoded, bool flags are bare
9
+ - explicit args template overrides auto-map
10
+ - discover() finds ~/.doppler/blocks/<name>.yml
11
+ - discover() finds ./<name>.yml (CWD)
12
+ - discover() returns None when not found
13
+ - blocks.get() falls back to dopplerfile discovery
14
+ - missing required field raises KeyError
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ import pytest
22
+ import yaml
23
+
24
+ import doppler_cli.blocks.fir # noqa: F401
25
+ import doppler_cli.blocks.specan # noqa: F401
26
+ import doppler_cli.blocks.tone # noqa: F401
27
+ from doppler_cli import blocks as block_registry
28
+ from doppler_cli import dopplerfile as df
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Helpers
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ def _write_dopplerfile(tmp_path: Path, doc: dict) -> Path:
37
+ p = tmp_path / f"{doc['name']}.yml"
38
+ p.write_text(yaml.dump(doc))
39
+ return p
40
+
41
+
42
+ SIMPLE_DOC = {
43
+ "name": "chirp",
44
+ "role": "source",
45
+ "executable": "./chirp.py",
46
+ "config": {
47
+ "sample_rate": 2048000.0,
48
+ "sweep_rate": 50000.0,
49
+ "tone_power": -20.0,
50
+ },
51
+ }
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # load()
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ class TestLoad:
60
+ def test_name(self, tmp_path):
61
+ path = _write_dopplerfile(tmp_path, SIMPLE_DOC)
62
+ cls = df.load(path)
63
+ assert cls.name == "chirp"
64
+
65
+ def test_role(self, tmp_path):
66
+ path = _write_dopplerfile(tmp_path, SIMPLE_DOC)
67
+ cls = df.load(path)
68
+ assert cls.role == "source"
69
+
70
+ def test_config_defaults(self, tmp_path):
71
+ path = _write_dopplerfile(tmp_path, SIMPLE_DOC)
72
+ cls = df.load(path)
73
+ cfg = cls.Config()
74
+ assert cfg.sample_rate == 2048000.0
75
+ assert cfg.sweep_rate == 50000.0
76
+
77
+ def test_missing_name_raises(self, tmp_path):
78
+ bad = {"role": "source", "executable": "./x.py"}
79
+ p = tmp_path / "bad.yml"
80
+ p.write_text(yaml.dump(bad))
81
+ with pytest.raises(KeyError):
82
+ df.load(p)
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Auto-map command building
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ class TestAutoMap:
91
+ def _block(self, tmp_path, doc=None):
92
+ path = _write_dopplerfile(tmp_path, doc or SIMPLE_DOC)
93
+ return df.load(path)()
94
+
95
+ def test_executable_first(self, tmp_path):
96
+ blk = self._block(tmp_path)
97
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
98
+ assert cmd[0] == "./chirp.py"
99
+
100
+ def test_source_gets_bind(self, tmp_path):
101
+ blk = self._block(tmp_path)
102
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
103
+ assert "--bind" in cmd
104
+ assert "tcp://127.0.0.1:5600" in cmd
105
+
106
+ def test_source_no_connect(self, tmp_path):
107
+ blk = self._block(tmp_path)
108
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
109
+ assert "--connect" not in cmd
110
+
111
+ def test_sink_gets_connect(self, tmp_path):
112
+ doc = {**SIMPLE_DOC, "role": "sink"}
113
+ blk = self._block(tmp_path, doc)
114
+ cmd = blk.command(blk.__class__.Config(), "tcp://127.0.0.1:5600", None)
115
+ assert "--connect" in cmd
116
+ assert "--bind" not in cmd
117
+
118
+ def test_chain_gets_both(self, tmp_path):
119
+ doc = {**SIMPLE_DOC, "role": "chain"}
120
+ blk = self._block(tmp_path, doc)
121
+ cmd = blk.command(
122
+ blk.__class__.Config(),
123
+ "tcp://127.0.0.1:5600",
124
+ "tcp://127.0.0.1:5601",
125
+ )
126
+ assert "--connect" in cmd
127
+ assert "--bind" in cmd
128
+
129
+ def test_config_field_mapping(self, tmp_path):
130
+ blk = self._block(tmp_path)
131
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
132
+ assert "--sample-rate" in cmd
133
+ assert "--sweep-rate" in cmd
134
+ assert "--tone-power" in cmd
135
+
136
+ def test_list_field_json_encoded(self, tmp_path):
137
+ doc = {**SIMPLE_DOC, "config": {"taps": []}}
138
+ blk = self._block(tmp_path, doc)
139
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
140
+ idx = cmd.index("--taps")
141
+ assert cmd[idx + 1] == "[]"
142
+
143
+ def test_bool_true_is_bare_flag(self, tmp_path):
144
+ doc = {**SIMPLE_DOC, "config": {"verbose": True}}
145
+ blk = self._block(tmp_path, doc)
146
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
147
+ assert "--verbose" in cmd
148
+ # bare flag — no separate value token follows it
149
+ idx = cmd.index("--verbose")
150
+ assert idx == len(cmd) - 1 or not cmd[idx + 1].startswith("True")
151
+
152
+ def test_bool_false_omitted(self, tmp_path):
153
+ doc = {**SIMPLE_DOC, "config": {"verbose": False}}
154
+ blk = self._block(tmp_path, doc)
155
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
156
+ assert "--verbose" not in cmd
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Explicit args template
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ class TestArgsTemplate:
165
+ def test_explicit_args_used(self, tmp_path):
166
+ doc = {
167
+ **SIMPLE_DOC,
168
+ "args": {
169
+ "output": "{output_addr}",
170
+ "rate": "{sample_rate}",
171
+ },
172
+ }
173
+ path = _write_dopplerfile(tmp_path, doc)
174
+ blk = df.load(path)()
175
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
176
+ assert "--output" in cmd
177
+ assert "tcp://127.0.0.1:5600" in cmd
178
+ assert "--rate" in cmd
179
+
180
+ def test_auto_map_not_applied_with_template(self, tmp_path):
181
+ doc = {
182
+ **SIMPLE_DOC,
183
+ "args": {"output": "{output_addr}"},
184
+ }
185
+ path = _write_dopplerfile(tmp_path, doc)
186
+ blk = df.load(path)()
187
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
188
+ # --bind is the auto-map flag; should NOT appear when using template
189
+ assert "--bind" not in cmd
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # discover()
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ class TestDiscover:
198
+ def test_finds_global_blocks_dir(self, tmp_path, monkeypatch):
199
+ monkeypatch.setattr(df, "_BLOCKS_DIR", tmp_path)
200
+ _write_dopplerfile(tmp_path, SIMPLE_DOC)
201
+ cls = df.discover("chirp")
202
+ assert cls is not None
203
+ assert cls.name == "chirp"
204
+
205
+ def test_finds_cwd(self, tmp_path, monkeypatch):
206
+ monkeypatch.chdir(tmp_path)
207
+ _write_dopplerfile(tmp_path, SIMPLE_DOC)
208
+ monkeypatch.setattr(df, "_BLOCKS_DIR", tmp_path / "nonexistent")
209
+ cls = df.discover("chirp")
210
+ assert cls is not None
211
+
212
+ def test_returns_none_when_not_found(self, tmp_path, monkeypatch):
213
+ monkeypatch.setattr(df, "_BLOCKS_DIR", tmp_path)
214
+ monkeypatch.chdir(tmp_path)
215
+ assert df.discover("no-such-block") is None
216
+
217
+ def test_global_takes_priority_over_cwd(self, tmp_path, monkeypatch):
218
+ global_dir = tmp_path / "global"
219
+ cwd_dir = tmp_path / "cwd"
220
+ global_dir.mkdir()
221
+ cwd_dir.mkdir()
222
+
223
+ # Global has sweep_rate=1.0, CWD has sweep_rate=2.0
224
+ _write_dopplerfile(global_dir, {**SIMPLE_DOC, "config": {"sweep_rate": 1.0}})
225
+ _write_dopplerfile(cwd_dir, {**SIMPLE_DOC, "config": {"sweep_rate": 2.0}})
226
+
227
+ monkeypatch.setattr(df, "_BLOCKS_DIR", global_dir)
228
+ monkeypatch.chdir(cwd_dir)
229
+
230
+ cls = df.discover("chirp")
231
+ assert cls.Config().sweep_rate == 1.0
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Dependency isolation
236
+ # ---------------------------------------------------------------------------
237
+
238
+
239
+ class TestDependencies:
240
+ def _block_with_deps(self, tmp_path, deps):
241
+ doc = {**SIMPLE_DOC, "dependencies": deps}
242
+ path = _write_dopplerfile(tmp_path, doc)
243
+ return df.load(path)()
244
+
245
+ def test_no_deps_no_uv_wrap(self, tmp_path):
246
+ path = _write_dopplerfile(tmp_path, SIMPLE_DOC)
247
+ blk = df.load(path)()
248
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
249
+ assert cmd[0] != "uv"
250
+
251
+ def test_deps_wrap_with_uv_run(self, tmp_path):
252
+ blk = self._block_with_deps(tmp_path, ["numpy", "scipy"])
253
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
254
+ assert cmd[:2] == ["uv", "run"]
255
+
256
+ def test_deps_each_get_with_flag(self, tmp_path):
257
+ blk = self._block_with_deps(tmp_path, ["numpy", "scipy"])
258
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
259
+ assert "--with" in cmd
260
+ assert cmd[cmd.index("--with") + 1] == "numpy"
261
+ assert cmd[cmd.index("--with", cmd.index("--with") + 2) + 1] == "scipy"
262
+
263
+ def test_deps_executable_still_present(self, tmp_path):
264
+ blk = self._block_with_deps(tmp_path, ["numpy"])
265
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
266
+ assert "./chirp.py" in cmd
267
+
268
+ def test_deps_bind_addr_still_present(self, tmp_path):
269
+ blk = self._block_with_deps(tmp_path, ["numpy"])
270
+ cmd = blk.command(blk.__class__.Config(), None, "tcp://127.0.0.1:5600")
271
+ assert "tcp://127.0.0.1:5600" in cmd
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # blocks.get() fallback
276
+ # ---------------------------------------------------------------------------
277
+
278
+
279
+ class TestRegistryFallback:
280
+ def test_get_falls_back_to_dopplerfile(self, tmp_path, monkeypatch):
281
+ monkeypatch.setattr(df, "_BLOCKS_DIR", tmp_path)
282
+ _write_dopplerfile(tmp_path, SIMPLE_DOC)
283
+ cls = block_registry.get("chirp")
284
+ assert cls.name == "chirp"
285
+
286
+ def test_get_raises_for_unknown(self, tmp_path, monkeypatch):
287
+ monkeypatch.setattr(df, "_BLOCKS_DIR", tmp_path)
288
+ monkeypatch.chdir(tmp_path)
289
+ with pytest.raises(KeyError, match="no-such-block"):
290
+ block_registry.get("no-such-block")