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,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")
|