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,3 @@
1
+ """doppler-cli — signal processing pipeline orchestration."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,180 @@
1
+ """
2
+ doppler — signal processing pipeline CLI.
3
+
4
+ Usage
5
+ -----
6
+ doppler ps
7
+ doppler stop <ID>
8
+ doppler kill <ID>
9
+ doppler inspect <ID>
10
+ doppler logs <ID> [--block NAME]
11
+
12
+ doppler compose init <BLOCK...>
13
+ doppler compose up <FILE>
14
+ doppler compose down <ID>
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ # Import blocks to populate the registry
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
+
28
+
29
+ def main() -> None:
30
+ parser = argparse.ArgumentParser(
31
+ prog="doppler",
32
+ description="doppler signal processing pipeline CLI",
33
+ formatter_class=argparse.RawDescriptionHelpFormatter,
34
+ )
35
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
36
+
37
+ # --- ps ---
38
+ sub.add_parser("ps", help="List running chains")
39
+
40
+ # --- stop ---
41
+ p_stop = sub.add_parser("stop", help="Gracefully stop a chain")
42
+ p_stop.add_argument("id", metavar="ID")
43
+
44
+ # --- kill ---
45
+ p_kill = sub.add_parser("kill", help="Forcefully kill a chain")
46
+ p_kill.add_argument("id", metavar="ID")
47
+
48
+ # --- inspect ---
49
+ p_inspect = sub.add_parser("inspect", help="Show resolved config and PIDs")
50
+ p_inspect.add_argument("id", metavar="ID")
51
+
52
+ # --- logs ---
53
+ p_logs = sub.add_parser("logs", help="Stream logs from a chain")
54
+ p_logs.add_argument("id", metavar="ID")
55
+ p_logs.add_argument(
56
+ "--block",
57
+ default=None,
58
+ metavar="NAME",
59
+ help="Show logs for a specific block only",
60
+ )
61
+
62
+ # --- compose ---
63
+ p_compose = sub.add_parser("compose", help="Manage compose chains")
64
+ compose_sub = p_compose.add_subparsers(dest="compose_cmd", metavar="SUBCOMMAND")
65
+
66
+ p_init = compose_sub.add_parser(
67
+ "init",
68
+ help="Scaffold a compose file with default config",
69
+ )
70
+ p_init.add_argument(
71
+ "blocks",
72
+ nargs="+",
73
+ metavar="BLOCK",
74
+ help="Ordered block names, e.g. tone fir specan",
75
+ )
76
+ p_init.add_argument(
77
+ "--name",
78
+ default=None,
79
+ metavar="NAME",
80
+ help=(
81
+ "Human-readable chain name (default: random hex ID). "
82
+ "Used as the filename stem and chain ID."
83
+ ),
84
+ )
85
+ p_init.add_argument(
86
+ "--out",
87
+ default=None,
88
+ metavar="FILE",
89
+ help="Write compose file to FILE (default: ~/.doppler/chains/<ID>.yml)",
90
+ )
91
+
92
+ p_up = compose_sub.add_parser("up", help="Start a chain from a compose file")
93
+ p_up.add_argument(
94
+ "file",
95
+ metavar="FILE",
96
+ nargs="?",
97
+ default=None,
98
+ help="Compose file to start (default: most recently created)",
99
+ )
100
+
101
+ p_down = compose_sub.add_parser("down", help="Stop a running chain")
102
+ p_down.add_argument("id", metavar="ID")
103
+
104
+ args = parser.parse_args()
105
+
106
+ # Dispatch
107
+ if args.command == "ps":
108
+ from doppler_cli.ps import cmd_ps # noqa: PLC0415
109
+
110
+ cmd_ps()
111
+
112
+ elif args.command == "stop":
113
+ from doppler_cli.ps import cmd_stop # noqa: PLC0415
114
+
115
+ cmd_stop(args.id)
116
+
117
+ elif args.command == "kill":
118
+ from doppler_cli.ps import cmd_kill # noqa: PLC0415
119
+
120
+ cmd_kill(args.id)
121
+
122
+ elif args.command == "inspect":
123
+ from doppler_cli.ps import cmd_inspect # noqa: PLC0415
124
+
125
+ cmd_inspect(args.id)
126
+
127
+ elif args.command == "logs":
128
+ from doppler_cli.ps import cmd_logs # noqa: PLC0415
129
+
130
+ cmd_logs(args.id, args.block)
131
+
132
+ elif args.command == "compose":
133
+ if args.compose_cmd == "init":
134
+ from doppler_cli.compose import init # noqa: PLC0415
135
+
136
+ out = Path(args.out) if args.out else None
137
+ path = init(args.blocks, out=out, name=args.name)
138
+ print(f"wrote {path}")
139
+
140
+ elif args.compose_cmd == "up":
141
+ from doppler_cli.compose import up # noqa: PLC0415
142
+ from doppler_cli.state import _CHAINS_DIR # noqa: PLC0415
143
+
144
+ if args.file:
145
+ p = Path(args.file)
146
+ # Bare name (no path separators) → resolve to chains dir
147
+ if not p.parts[1:] and not p.suffix:
148
+ p = _CHAINS_DIR / f"{args.file}.yml"
149
+ compose_file = p
150
+ else:
151
+ ymls = sorted(
152
+ _CHAINS_DIR.glob("*.yml"),
153
+ key=lambda p: p.stat().st_mtime,
154
+ reverse=True,
155
+ )
156
+ if not ymls:
157
+ print("no compose files found in ~/.doppler/chains/")
158
+ sys.exit(1)
159
+ compose_file = ymls[0]
160
+ print(f"using {compose_file}")
161
+ state = up(compose_file)
162
+ print(f"started chain {state.id} ({len(state.blocks)} blocks)")
163
+
164
+ elif args.compose_cmd == "down":
165
+ from doppler_cli.compose import down # noqa: PLC0415
166
+
167
+ down(args.id)
168
+ print(f"stopped chain {args.id}")
169
+
170
+ else:
171
+ p_compose.print_help()
172
+ sys.exit(1)
173
+
174
+ else:
175
+ parser.print_help()
176
+ sys.exit(1)
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()
@@ -0,0 +1,76 @@
1
+ """Block base class and registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import ClassVar, Type
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class BlockConfig(BaseModel):
12
+ """Base config for all blocks."""
13
+
14
+ pass
15
+
16
+
17
+ class Block(ABC):
18
+ """Base class for all doppler pipeline blocks.
19
+
20
+ Subclasses declare a ``name`` and a ``Config`` pydantic model.
21
+ The CLI uses the Config schema to scaffold compose file defaults
22
+ and to validate user-supplied overrides.
23
+
24
+ A block has zero or one input port and zero or one output port:
25
+
26
+ - source: no input, one output (binds PUSH)
27
+ - chain: one input (connects PULL), one output (binds PUSH)
28
+ - sink: one input (connects PULL), no output
29
+ """
30
+
31
+ name: ClassVar[str]
32
+ Config: ClassVar[Type[BlockConfig]]
33
+
34
+ #: "source" | "chain" | "sink"
35
+ role: ClassVar[str]
36
+
37
+ @abstractmethod
38
+ def command(
39
+ self,
40
+ config: BlockConfig,
41
+ input_addr: str | None,
42
+ output_addr: str | None,
43
+ ) -> list[str]:
44
+ """Return the argv list to spawn this block as a subprocess."""
45
+ ...
46
+
47
+
48
+ # Registry: name → Block subclass
49
+ _REGISTRY: dict[str, Type[Block]] = {}
50
+
51
+
52
+ def register(cls: Type[Block]) -> Type[Block]:
53
+ """Decorator to register a Block subclass by name."""
54
+ _REGISTRY[cls.name] = cls
55
+ return cls
56
+
57
+
58
+ def get(name: str) -> Type[Block]:
59
+ if name in _REGISTRY:
60
+ return _REGISTRY[name]
61
+ # Fall back to dopplerfile discovery
62
+ from doppler_cli import dopplerfile # noqa: PLC0415
63
+
64
+ cls = dopplerfile.discover(name)
65
+ if cls is not None:
66
+ return cls
67
+ available = ", ".join(sorted(_REGISTRY))
68
+ raise KeyError(
69
+ f"Unknown block {name!r}. Built-ins: {available}\n"
70
+ f" Or add a dopplerfile: ~/.doppler/blocks/{name}.yml "
71
+ f"or ./{name}.yml"
72
+ )
73
+
74
+
75
+ def all_blocks() -> dict[str, Type[Block]]:
76
+ return dict(_REGISTRY)
@@ -0,0 +1,35 @@
1
+ """FIR filter chain block."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from doppler_cli.blocks import Block, BlockConfig, register
6
+
7
+
8
+ class FirConfig(BlockConfig):
9
+ taps: list[float] = []
10
+
11
+
12
+ @register
13
+ class FirBlock(Block):
14
+ name = "fir"
15
+ Config = FirConfig
16
+ role = "chain"
17
+
18
+ def command(
19
+ self,
20
+ config: FirConfig,
21
+ input_addr: str | None,
22
+ output_addr: str | None,
23
+ ) -> list[str]:
24
+ assert input_addr is not None
25
+ assert output_addr is not None
26
+ cmd = [
27
+ "doppler-fir",
28
+ "--connect",
29
+ input_addr,
30
+ "--bind",
31
+ output_addr,
32
+ ]
33
+ if config.taps:
34
+ cmd += ["--taps", *[str(t) for t in config.taps]]
35
+ return cmd
@@ -0,0 +1,56 @@
1
+ """Specan sink block — spectrum analyzer display."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from doppler_cli.blocks import Block, BlockConfig, register
8
+
9
+
10
+ class SpecanConfig(BlockConfig):
11
+ mode: Literal["terminal", "web"] = "web"
12
+ center: float = 0.0
13
+ span: float | None = None
14
+ rbw: float | None = None
15
+ level: float | None = None
16
+ web_port: int = 8080
17
+ web_host: str = "127.0.0.1"
18
+
19
+
20
+ @register
21
+ class SpecanBlock(Block):
22
+ name = "specan"
23
+ Config = SpecanConfig
24
+ role = "sink"
25
+
26
+ def command(
27
+ self,
28
+ config: SpecanConfig,
29
+ input_addr: str | None,
30
+ output_addr: str | None,
31
+ ) -> list[str]:
32
+ assert input_addr is not None
33
+ cmd = [
34
+ "doppler-specan",
35
+ "--source",
36
+ "pull",
37
+ "--address",
38
+ input_addr,
39
+ ]
40
+ if config.center:
41
+ cmd += ["--center", str(config.center)]
42
+ if config.span is not None:
43
+ cmd += ["--span", str(config.span)]
44
+ if config.rbw is not None:
45
+ cmd += ["--rbw", str(config.rbw)]
46
+ if config.level is not None:
47
+ cmd += ["--level", str(config.level)]
48
+ if config.mode == "web":
49
+ cmd += [
50
+ "--web",
51
+ "--port",
52
+ str(config.web_port),
53
+ "--host",
54
+ config.web_host,
55
+ ]
56
+ return cmd
@@ -0,0 +1,45 @@
1
+ """Tone source block — synthetic complex tone + AWGN."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from doppler_cli.blocks import Block, BlockConfig, register
6
+
7
+
8
+ class ToneConfig(BlockConfig):
9
+ sample_rate: float = 2.048e6
10
+ center_freq: float = 0.0
11
+ tone_freq: float = 100e3
12
+ tone_power: float = -20.0
13
+ noise_floor: float = -90.0
14
+
15
+
16
+ @register
17
+ class ToneBlock(Block):
18
+ name = "tone"
19
+ Config = ToneConfig
20
+ role = "source"
21
+
22
+ def command(
23
+ self,
24
+ config: ToneConfig,
25
+ input_addr: str | None,
26
+ output_addr: str | None,
27
+ ) -> list[str]:
28
+ assert output_addr is not None
29
+ return [
30
+ "doppler-source",
31
+ "--type",
32
+ "tone",
33
+ "--bind",
34
+ output_addr,
35
+ "--fs",
36
+ str(config.sample_rate),
37
+ "--center",
38
+ str(config.center_freq),
39
+ "--tone-freq",
40
+ str(config.tone_freq),
41
+ "--tone-power",
42
+ str(config.tone_power),
43
+ "--noise-floor",
44
+ str(config.noise_floor),
45
+ ]
doppler_cli/compose.py ADDED
@@ -0,0 +1,207 @@
1
+ """doppler compose — init, up, down."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ import subprocess
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from doppler_cli import blocks as block_registry
13
+ from doppler_cli.ports import allocate
14
+ from doppler_cli.state import BlockState, ChainState, stop_chain
15
+
16
+ _CHAINS_DIR = Path.home() / ".doppler" / "chains"
17
+
18
+
19
+ # ------------------------------------------------------------------
20
+ # compose init
21
+ # ------------------------------------------------------------------
22
+
23
+
24
+ def init(
25
+ block_names: list[str],
26
+ out: Path | None = None,
27
+ name: str | None = None,
28
+ ) -> Path:
29
+ """Scaffold a compose file with defaults for the given block names.
30
+
31
+ The first block must be a source, the last a sink. Ports are
32
+ auto-assigned and written into the file so it is fully explicit.
33
+
34
+ Parameters
35
+ ----------
36
+ block_names:
37
+ Ordered list of block names, e.g. ``["tone", "fir", "specan"]``.
38
+ out:
39
+ Path to write the compose file. Defaults to
40
+ ``~/.doppler/chains/<ID>.yml``.
41
+ name:
42
+ Human-readable chain name / ID. Used as the filename stem and
43
+ the ``id`` field in the compose file. Defaults to a random
44
+ 6-hex-digit ID.
45
+
46
+ Returns
47
+ -------
48
+ Path to the written compose file.
49
+ """
50
+ if len(block_names) < 2:
51
+ raise ValueError("Need at least a source and a sink.")
52
+
53
+ # Resolve block classes and build default configs
54
+ block_classes = [block_registry.get(n) for n in block_names]
55
+ configs = [cls.Config() for cls in block_classes]
56
+
57
+ # Validate roles
58
+ if block_classes[0].role != "source":
59
+ raise ValueError(
60
+ f"First block must be a source, got {block_names[0]!r} "
61
+ f"(role={block_classes[0].role!r})"
62
+ )
63
+ if block_classes[-1].role != "sink":
64
+ raise ValueError(
65
+ f"Last block must be a sink, got {block_names[-1]!r} "
66
+ f"(role={block_classes[-1].role!r})"
67
+ )
68
+
69
+ # Number of inter-block connections = number of non-sink blocks
70
+ n_ports = len(block_names) - 1
71
+ ports = allocate(n_ports)
72
+
73
+ chain_id = name or secrets.token_hex(3) # e.g. "a3f7c2"
74
+
75
+ # Build YAML document
76
+ doc: dict = {"id": chain_id}
77
+
78
+ source_cfg = configs[0].model_dump()
79
+ source_cfg["port"] = ports[0]
80
+ doc["source"] = {"type": block_names[0], **source_cfg}
81
+
82
+ chain_blocks = []
83
+ for i, (name, cfg) in enumerate(zip(block_names[1:-1], configs[1:-1])):
84
+ entry = cfg.model_dump()
85
+ entry["port"] = ports[i + 1]
86
+ chain_blocks.append({name: entry})
87
+ if chain_blocks:
88
+ doc["chain"] = chain_blocks
89
+
90
+ sink_cfg = configs[-1].model_dump()
91
+ doc["sink"] = {"type": block_names[-1], **sink_cfg}
92
+
93
+ # Write file
94
+ if out is None:
95
+ _CHAINS_DIR.mkdir(parents=True, exist_ok=True)
96
+ out = _CHAINS_DIR / f"{chain_id}.yml"
97
+
98
+ out.write_text(yaml.dump(doc, default_flow_style=False, sort_keys=False))
99
+ return out
100
+
101
+
102
+ # ------------------------------------------------------------------
103
+ # compose up
104
+ # ------------------------------------------------------------------
105
+
106
+
107
+ def up(compose_file: Path) -> ChainState:
108
+ """Spawn all blocks described in *compose_file*.
109
+
110
+ Returns the ChainState (also persisted to ~/.doppler/chains/).
111
+ """
112
+ doc = yaml.safe_load(compose_file.read_text())
113
+ chain_id: str = doc.get("id") or secrets.token_hex(3)
114
+
115
+ source_doc = doc["source"]
116
+ source_type = source_doc.pop("type")
117
+ source_port: int = source_doc.pop("port")
118
+ source_addr = f"tcp://127.0.0.1:{source_port}"
119
+
120
+ chain_docs: list[dict] = doc.get("chain", [])
121
+ sink_doc = doc["sink"]
122
+ sink_type = sink_doc.pop("type")
123
+
124
+ block_states: list[BlockState] = []
125
+ _CHAINS_DIR.mkdir(parents=True, exist_ok=True)
126
+
127
+ def _spawn(name: str, cmd: list[str]) -> tuple[subprocess.Popen, str]:
128
+ log_path = _CHAINS_DIR / f"{chain_id}-{name}.log"
129
+ log_fh = open(log_path, "w") # noqa: SIM115
130
+ proc = subprocess.Popen( # noqa: S603
131
+ cmd, stdout=log_fh, stderr=log_fh
132
+ )
133
+ return proc, str(log_path)
134
+
135
+ # --- spawn source ---
136
+ src_cls = block_registry.get(source_type)
137
+ src_cfg = src_cls.Config(**source_doc)
138
+ src_cmd = src_cls().command(src_cfg, None, source_addr)
139
+ src_proc, src_log = _spawn(source_type, src_cmd)
140
+ block_states.append(
141
+ BlockState(
142
+ name=source_type,
143
+ pid=src_proc.pid,
144
+ bind_port=source_port,
145
+ log_file=src_log,
146
+ )
147
+ )
148
+
149
+ # --- spawn chain blocks ---
150
+ prev_addr = source_addr
151
+ for entry in chain_docs:
152
+ (name, cfg_dict) = next(iter(entry.items()))
153
+ out_port: int = cfg_dict.pop("port")
154
+ out_addr = f"tcp://127.0.0.1:{out_port}"
155
+ in_port = int(prev_addr.rsplit(":", 1)[-1])
156
+
157
+ blk_cls = block_registry.get(name)
158
+ blk_cfg = blk_cls.Config(**cfg_dict)
159
+ blk_cmd = blk_cls().command(blk_cfg, prev_addr, out_addr)
160
+ blk_proc, blk_log = _spawn(name, blk_cmd)
161
+ block_states.append(
162
+ BlockState(
163
+ name=name,
164
+ pid=blk_proc.pid,
165
+ connect_port=in_port,
166
+ bind_port=out_port,
167
+ log_file=blk_log,
168
+ )
169
+ )
170
+ prev_addr = out_addr
171
+
172
+ # --- spawn sink ---
173
+ sink_in_port = int(prev_addr.rsplit(":", 1)[-1])
174
+ snk_cls = block_registry.get(sink_type)
175
+ snk_cfg = snk_cls.Config(**sink_doc)
176
+ snk_cmd = snk_cls().command(snk_cfg, prev_addr, None)
177
+ snk_proc, snk_log = _spawn(sink_type, snk_cmd)
178
+ block_states.append(
179
+ BlockState(
180
+ name=sink_type,
181
+ pid=snk_proc.pid,
182
+ log_file=snk_log,
183
+ connect_port=sink_in_port,
184
+ )
185
+ )
186
+
187
+ state = ChainState(
188
+ id=chain_id,
189
+ started=datetime.now(timezone.utc).isoformat(),
190
+ compose=str(compose_file),
191
+ blocks=block_states,
192
+ )
193
+ state.save()
194
+ return state
195
+
196
+
197
+ # ------------------------------------------------------------------
198
+ # compose down
199
+ # ------------------------------------------------------------------
200
+
201
+
202
+ def down(chain_id: str, kill: bool = False) -> None:
203
+ """Stop all blocks in the chain identified by *chain_id*."""
204
+ from doppler_cli.state import ChainState # noqa: PLC0415
205
+
206
+ chain = ChainState.load(chain_id)
207
+ stop_chain(chain, kill=kill)