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,195 @@
1
+ """
2
+ doppler dopplerfile — YAML-defined custom block loader.
3
+
4
+ A dopplerfile lets you register a pipeline block without writing
5
+ Python. Drop a small YAML file next to your script and
6
+ ``doppler compose init`` picks it up automatically:
7
+
8
+ # chirp.yml
9
+ name: chirp
10
+ role: source
11
+ executable: ./chirp.py
12
+ config:
13
+ sample_rate: 2048000.0
14
+ sweep_rate: 50000.0
15
+ tone_power: -20.0
16
+ noise_floor: -90.0
17
+
18
+ Arg mapping
19
+ -----------
20
+ By default every config field is passed as ``--{field-with-dashes}
21
+ value`` and the socket addresses are injected as:
22
+
23
+ source / chain → ``--bind {output_addr}``
24
+ chain / sink → ``--connect {input_addr}``
25
+
26
+ For scripts that use different flag names, add an explicit ``args``
27
+ section using ``{placeholder}`` substitution:
28
+
29
+ args:
30
+ output: "{output_addr}"
31
+ rate: "{sample_rate}"
32
+
33
+ Available placeholders are ``{output_addr}``, ``{input_addr}``, and
34
+ any config field name.
35
+
36
+ Discovery order for ``doppler compose init <name>``:
37
+ 1. Built-in Python registry
38
+ 2. ~/.doppler/blocks/<name>.yml
39
+ 3. ./<name>.yml (current working directory)
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ from pathlib import Path
46
+ from typing import Any, Type
47
+
48
+ import yaml
49
+ from pydantic import create_model
50
+
51
+ from doppler_cli.blocks import Block, BlockConfig
52
+
53
+ _BLOCKS_DIR = Path.home() / ".doppler" / "blocks"
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Config synthesis
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def _make_config(defaults: dict[str, Any]) -> Type[BlockConfig]:
62
+ """Dynamically build a pydantic BlockConfig from a defaults dict."""
63
+ fields: dict[str, Any] = {}
64
+ for k, v in defaults.items():
65
+ fields[k] = (type(v), v)
66
+ return create_model("DopplerfileConfig", __base__=BlockConfig, **fields)
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Block synthesis
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def _make_block(
75
+ name: str,
76
+ role: str,
77
+ executable: str,
78
+ config_defaults: dict[str, Any],
79
+ args_template: dict[str, str] | None,
80
+ dependencies: list[str] | None = None,
81
+ ) -> Type[Block]:
82
+ """Return a live Block subclass described by a dopplerfile document."""
83
+
84
+ ConfigClass = _make_config(config_defaults)
85
+
86
+ def _command(
87
+ self,
88
+ config: BlockConfig,
89
+ input_addr: str | None,
90
+ output_addr: str | None,
91
+ ) -> list[str]:
92
+ exe = self.__class__._exe
93
+ tmpl = self.__class__._args_template
94
+ cmd: list[str] = [exe]
95
+
96
+ if tmpl is not None:
97
+ # Explicit template: substitute placeholders
98
+ ctx: dict[str, Any] = dict(config.model_dump())
99
+ ctx["output_addr"] = output_addr or ""
100
+ ctx["input_addr"] = input_addr or ""
101
+ for flag, value_tmpl in tmpl.items():
102
+ cmd += [f"--{flag}", value_tmpl.format(**ctx)]
103
+ else:
104
+ # Auto-map: inject socket addresses, then all config fields
105
+ if output_addr is not None:
106
+ cmd += ["--bind", output_addr]
107
+ if input_addr is not None:
108
+ cmd += ["--connect", input_addr]
109
+ for field, value in config.model_dump().items():
110
+ flag = "--" + field.replace("_", "-")
111
+ if isinstance(value, (list, dict)):
112
+ cmd += [flag, json.dumps(value)]
113
+ elif isinstance(value, bool):
114
+ if value:
115
+ cmd.append(flag)
116
+ else:
117
+ cmd += [flag, str(value)]
118
+
119
+ # Wrap with uv run --with <dep> ... for dependency isolation
120
+ deps = self.__class__._dependencies
121
+ if deps:
122
+ with_flags: list[str] = []
123
+ for dep in deps:
124
+ with_flags += ["--with", dep]
125
+ cmd = ["uv", "run"] + with_flags + cmd
126
+
127
+ return cmd
128
+
129
+ cls = type(
130
+ f"DopplerfileBlock_{name}",
131
+ (Block,),
132
+ {
133
+ "name": name,
134
+ "role": role,
135
+ "Config": ConfigClass,
136
+ "_exe": executable,
137
+ "_args_template": args_template,
138
+ "_dependencies": dependencies or [],
139
+ "command": _command,
140
+ },
141
+ )
142
+ return cls # type: ignore[return-value]
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Public API
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ def load(path: Path) -> Type[Block]:
151
+ """Parse *path* as a dopplerfile and return a Block subclass.
152
+
153
+ Parameters
154
+ ----------
155
+ path:
156
+ Path to a YAML dopplerfile.
157
+
158
+ Returns
159
+ -------
160
+ A Block subclass ready to be used by the compose engine.
161
+
162
+ Raises
163
+ ------
164
+ KeyError
165
+ If the dopplerfile is missing a required field.
166
+ """
167
+ doc = yaml.safe_load(path.read_text())
168
+ return _make_block(
169
+ name=doc["name"],
170
+ role=doc["role"],
171
+ executable=doc["executable"],
172
+ config_defaults=doc.get("config", {}),
173
+ args_template=doc.get("args"),
174
+ dependencies=doc.get("dependencies"),
175
+ )
176
+
177
+
178
+ def discover(name: str) -> Type[Block] | None:
179
+ """Search for a dopplerfile that defines *name*.
180
+
181
+ Search order:
182
+
183
+ 1. ``~/.doppler/blocks/<name>.yml``
184
+ 2. ``./<name>.yml`` (current working directory)
185
+
186
+ Returns ``None`` if no dopplerfile is found.
187
+ """
188
+ candidates = [
189
+ _BLOCKS_DIR / f"{name}.yml",
190
+ Path.cwd() / f"{name}.yml",
191
+ ]
192
+ for path in candidates:
193
+ if path.exists():
194
+ return load(path)
195
+ return None
doppler_cli/ports.py ADDED
@@ -0,0 +1,44 @@
1
+ """Auto port allocation for doppler pipeline blocks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ _BASE_PORT = 5600
9
+ _MAX_PORT = 5700
10
+ _CHAINS_DIR = Path.home() / ".doppler" / "chains"
11
+
12
+
13
+ def _in_use() -> set[int]:
14
+ """Return all ports referenced in active chain state files."""
15
+ ports: set[int] = set()
16
+ if not _CHAINS_DIR.exists():
17
+ return ports
18
+ for f in _CHAINS_DIR.glob("*.json"):
19
+ try:
20
+ state = json.loads(f.read_text())
21
+ except (json.JSONDecodeError, OSError):
22
+ continue
23
+ for block in state.get("blocks", []):
24
+ for key in ("bind_port", "connect_port"):
25
+ if (p := block.get(key)) is not None:
26
+ ports.add(int(p))
27
+ return ports
28
+
29
+
30
+ def allocate(n: int) -> list[int]:
31
+ """Return *n* consecutive free ports starting from _BASE_PORT."""
32
+ used = _in_use()
33
+ allocated: list[int] = []
34
+ candidate = _BASE_PORT
35
+ while len(allocated) < n:
36
+ if candidate > _MAX_PORT:
37
+ raise RuntimeError(
38
+ f"No free ports in range {_BASE_PORT}–{_MAX_PORT}. "
39
+ "Clean up old chains with `doppler ps` and `doppler stop`."
40
+ )
41
+ if candidate not in used and candidate not in allocated:
42
+ allocated.append(candidate)
43
+ candidate += 1
44
+ return allocated
doppler_cli/ps.py ADDED
@@ -0,0 +1,116 @@
1
+ """doppler ps / stop / kill / logs / inspect commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from doppler_cli.state import ChainState, list_chains, pid_alive, stop_chain
14
+
15
+ console = Console()
16
+
17
+
18
+ def cmd_ps() -> None:
19
+ """List running chains."""
20
+ chains = list_chains()
21
+ if not chains:
22
+ console.print("[dim]No chains running.[/dim]")
23
+ return
24
+
25
+ table = Table(show_header=True, header_style="bold")
26
+ table.add_column("ID", style="cyan")
27
+ table.add_column("Started")
28
+ table.add_column("Blocks")
29
+ table.add_column("Status")
30
+
31
+ for chain in chains:
32
+ alive = [b for b in chain.blocks if pid_alive(b.pid)]
33
+ status = (
34
+ "[green]running[/green]"
35
+ if len(alive) == len(chain.blocks)
36
+ else f"[yellow]{len(alive)}/{len(chain.blocks)} alive[/yellow]"
37
+ )
38
+ block_names = ", ".join(b.name for b in chain.blocks)
39
+ table.add_row(chain.id, chain.started[:19], block_names, status)
40
+
41
+ console.print(table)
42
+
43
+
44
+ def cmd_stop(chain_id: str) -> None:
45
+ """Gracefully stop a chain (SIGTERM)."""
46
+ chain = ChainState.load(chain_id)
47
+ stop_chain(chain, kill=False)
48
+ console.print(f"Stopped chain [cyan]{chain_id}[/cyan].")
49
+
50
+
51
+ def cmd_kill(chain_id: str) -> None:
52
+ """Forcefully kill a chain (SIGKILL)."""
53
+ chain = ChainState.load(chain_id)
54
+ stop_chain(chain, kill=True)
55
+ console.print(f"Killed chain [cyan]{chain_id}[/cyan].")
56
+
57
+
58
+ def cmd_inspect(chain_id: str) -> None:
59
+ """Print resolved config and PIDs for a chain."""
60
+ chain = ChainState.load(chain_id)
61
+ console.print_json(
62
+ json.dumps(
63
+ {
64
+ "id": chain.id,
65
+ "started": chain.started,
66
+ "compose": chain.compose,
67
+ "blocks": [
68
+ {
69
+ "name": b.name,
70
+ "pid": b.pid,
71
+ "alive": pid_alive(b.pid),
72
+ "bind_port": b.bind_port,
73
+ "connect_port": b.connect_port,
74
+ }
75
+ for b in chain.blocks
76
+ ],
77
+ }
78
+ )
79
+ )
80
+
81
+
82
+ def cmd_logs(chain_id: str, block_name: str | None = None) -> None:
83
+ """Stream logs for a chain or a specific block.
84
+
85
+ Since each block is an independent subprocess, we tail the journal
86
+ (systemd) or fall back to printing PIDs for manual inspection.
87
+ """
88
+ chain = ChainState.load(chain_id)
89
+ targets = (
90
+ [b for b in chain.blocks if b.name == block_name]
91
+ if block_name
92
+ else chain.blocks
93
+ )
94
+ if not targets:
95
+ console.print(
96
+ f"[red]Block {block_name!r} not found in chain {chain_id!r}.[/red]"
97
+ )
98
+ sys.exit(1)
99
+
100
+ log_files = [b.log_file for b in targets if b.log_file]
101
+ if not log_files:
102
+ pids = [str(b.pid) for b in targets]
103
+ console.print(
104
+ "[yellow]No log files for this chain.[/yellow] "
105
+ f"(Chain may have been started before log support was added.) "
106
+ f"Block PIDs: {', '.join(pids)}"
107
+ )
108
+ return
109
+
110
+ available = [f for f in log_files if Path(f).exists()]
111
+ for f in log_files:
112
+ if f not in available:
113
+ console.print(f"[yellow]Log file not found:[/yellow] {f}")
114
+
115
+ if available:
116
+ subprocess.run(["tail", "-f", *available], check=False) # noqa: S603
doppler_cli/source.py ADDED
@@ -0,0 +1,125 @@
1
+ """
2
+ doppler-source — streaming IQ source entry point.
3
+
4
+ Generates synthetic IQ samples and pushes them over a ZMQ PUSH socket
5
+ so they can be consumed by downstream blocks in a doppler pipeline.
6
+
7
+ Usage
8
+ -----
9
+ doppler-source --type tone --bind tcp://127.0.0.1:5600 [options]
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import signal
16
+ from datetime import datetime, timezone
17
+
18
+ BLOCK_SIZE = 4096 # samples per push frame
19
+
20
+
21
+ def _log(msg: str) -> None:
22
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
23
+ print(f"[{ts}] {msg}", flush=True)
24
+
25
+
26
+ def main() -> None:
27
+ parser = argparse.ArgumentParser(
28
+ prog="doppler-source",
29
+ description="doppler streaming IQ source",
30
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
31
+ )
32
+ parser.add_argument(
33
+ "--type",
34
+ required=True,
35
+ choices=["tone"],
36
+ help="Source type",
37
+ )
38
+ parser.add_argument(
39
+ "--bind",
40
+ required=True,
41
+ metavar="ADDR",
42
+ help="ZMQ PUSH bind address, e.g. tcp://127.0.0.1:5600",
43
+ )
44
+ parser.add_argument(
45
+ "--fs",
46
+ type=float,
47
+ default=2.048e6,
48
+ metavar="Hz",
49
+ help="Sample rate",
50
+ )
51
+ parser.add_argument(
52
+ "--center",
53
+ type=float,
54
+ default=0.0,
55
+ metavar="Hz",
56
+ help="Center frequency (metadata only)",
57
+ )
58
+ parser.add_argument(
59
+ "--tone-freq",
60
+ type=float,
61
+ default=100e3,
62
+ metavar="Hz",
63
+ help="Tone offset from DC",
64
+ )
65
+ parser.add_argument(
66
+ "--tone-power",
67
+ type=float,
68
+ default=-20.0,
69
+ metavar="DBM",
70
+ help="Tone power in dBm",
71
+ )
72
+ parser.add_argument(
73
+ "--noise-floor",
74
+ type=float,
75
+ default=-90.0,
76
+ metavar="DBM",
77
+ help="AWGN noise floor in dBm",
78
+ )
79
+
80
+ args = parser.parse_args()
81
+
82
+ from doppler.stream import CF64, Push # noqa: PLC0415
83
+ from doppler_specan.source import DemoSource # noqa: PLC0415
84
+
85
+ _log(
86
+ f"doppler-source started — type=tone bind={args.bind}"
87
+ f" fs={args.fs:.0f} tone_freq={args.tone_freq:.0f}Hz"
88
+ f" tone_power={args.tone_power}dBm noise_floor={args.noise_floor}dBm"
89
+ )
90
+
91
+ source = DemoSource(
92
+ sample_rate=args.fs,
93
+ center_freq=args.center,
94
+ tone_freq=args.tone_freq,
95
+ tone_power=args.tone_power,
96
+ noise_floor=args.noise_floor,
97
+ )
98
+
99
+ # Graceful shutdown on SIGTERM
100
+ _running = True
101
+
102
+ def _stop(signum, frame):
103
+ nonlocal _running
104
+ _running = False
105
+
106
+ signal.signal(signal.SIGTERM, _stop)
107
+
108
+ try:
109
+ with Push(args.bind, CF64) as push:
110
+ while _running:
111
+ iq, fs, cf = source.read(BLOCK_SIZE)
112
+ push.send(
113
+ iq.astype("complex128"),
114
+ sample_rate=fs,
115
+ center_freq=cf,
116
+ )
117
+ except KeyboardInterrupt:
118
+ pass
119
+ finally:
120
+ source.close()
121
+ _log("doppler-source stopped")
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
doppler_cli/state.py ADDED
@@ -0,0 +1,109 @@
1
+ """Chain state persistence in ~/.doppler/chains/."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import signal
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ _CHAINS_DIR = Path.home() / ".doppler" / "chains"
12
+
13
+
14
+ @dataclass
15
+ class BlockState:
16
+ name: str
17
+ pid: int
18
+ bind_port: int | None = None # output port this block binds
19
+ connect_port: int | None = None # input port this block connects to
20
+ log_file: str | None = None # stderr/stdout log path
21
+
22
+
23
+ @dataclass
24
+ class ChainState:
25
+ id: str
26
+ started: str
27
+ compose: str
28
+ blocks: list[BlockState] = field(default_factory=list)
29
+
30
+ def save(self) -> None:
31
+ _CHAINS_DIR.mkdir(parents=True, exist_ok=True)
32
+ path = _CHAINS_DIR / f"{self.id}.json"
33
+ path.write_text(json.dumps(self._to_dict(), indent=2))
34
+
35
+ def delete(self) -> None:
36
+ path = _CHAINS_DIR / f"{self.id}.json"
37
+ path.unlink(missing_ok=True)
38
+
39
+ def _to_dict(self) -> dict:
40
+ return {
41
+ "id": self.id,
42
+ "started": self.started,
43
+ "compose": self.compose,
44
+ "blocks": [
45
+ {
46
+ "name": b.name,
47
+ "pid": b.pid,
48
+ "bind_port": b.bind_port,
49
+ "connect_port": b.connect_port,
50
+ "log_file": b.log_file,
51
+ }
52
+ for b in self.blocks
53
+ ],
54
+ }
55
+
56
+ @classmethod
57
+ def load(cls, chain_id: str) -> "ChainState":
58
+ path = _CHAINS_DIR / f"{chain_id}.json"
59
+ if not path.exists():
60
+ raise KeyError(f"No chain with id {chain_id!r}")
61
+ data = json.loads(path.read_text())
62
+ blocks = [
63
+ BlockState(
64
+ name=b["name"],
65
+ pid=b["pid"],
66
+ bind_port=b.get("bind_port"),
67
+ connect_port=b.get("connect_port"),
68
+ log_file=b.get("log_file"),
69
+ )
70
+ for b in data.get("blocks", [])
71
+ ]
72
+ return cls(
73
+ id=data["id"],
74
+ started=data["started"],
75
+ compose=data["compose"],
76
+ blocks=blocks,
77
+ )
78
+
79
+
80
+ def list_chains() -> list[ChainState]:
81
+ if not _CHAINS_DIR.exists():
82
+ return []
83
+ chains = []
84
+ for f in sorted(_CHAINS_DIR.glob("*.json")):
85
+ try:
86
+ chains.append(ChainState.load(f.stem))
87
+ except (KeyError, json.JSONDecodeError, OSError):
88
+ continue
89
+ return chains
90
+
91
+
92
+ def pid_alive(pid: int) -> bool:
93
+ try:
94
+ os.kill(pid, 0)
95
+ return True
96
+ except (ProcessLookupError, PermissionError):
97
+ return False
98
+
99
+
100
+ def stop_chain(chain: ChainState, kill: bool = False) -> None:
101
+ """Send SIGTERM (or SIGKILL) to all block processes."""
102
+ sig = signal.SIGKILL if kill else signal.SIGTERM
103
+ for block in chain.blocks:
104
+ if pid_alive(block.pid):
105
+ try:
106
+ os.kill(block.pid, sig)
107
+ except ProcessLookupError:
108
+ pass
109
+ chain.delete()
File without changes