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
doppler_cli/__init__.py
ADDED
doppler_cli/__main__.py
ADDED
|
@@ -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)
|