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