Skip to content

Commit f676dab

Browse files
Add io configs
1 parent f453a32 commit f676dab

2 files changed

Lines changed: 284 additions & 17 deletions

File tree

cynic.py

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@
7575
Raw encoding disables escape expansion for DATA after `--`; raw decoding emits undelimited payload bytes.
7676
With an RPC DSDL, `req` encodes the request and decodes the response; `pub` and `sub` use the request part.
7777
78+
Per-topic `--io` defaults can be configured in the TOML `io` table; this is convenient because topics usually don't
79+
change their data types.
80+
Each topic name key here is a glob pattern so that a single entry can match multiple topics.
81+
The keys are matched against fully resolved topic names (after namespace prefixing) so that this works with
82+
pattern subscribers as well.
83+
7884
### DSDL
7985
8086
Use `--enc=path/to/File.1.1.dsdl`, `--dec=path/to/File.1.1.dsdl`, or `--io=path/to/File.1.1.dsdl`.
@@ -93,6 +99,17 @@
9399
The following are equivalent: `{foo: [1,2,3], bar: 456}`, `[[1,2,3], 456]`.
94100
Likewise, for single-field objects: `{foo: 456}`, `[456]`, `456`.
95101
102+
`cynic.toml` example:
103+
104+
```toml
105+
# cynic.toml
106+
# ...skip...
107+
dsdl_root = ["/path/to/types"]
108+
[io]
109+
"command.i8" = "/path/to/types/primitive/Integer8.1.0.dsdl"
110+
"sensor.*" = "/path/to/types/sensor/Sample.1.0.dsdl"
111+
```
112+
96113
## Serve files
97114
98115
Run a file server on the specified topic(s) using `zubax.file.Read` out of the current working directory.
@@ -102,6 +119,23 @@
102119
cn fs my/file/topic /other/topic
103120
```
104121
122+
## Examples of cynic.toml
123+
124+
A few practical config examples to help one get started are collected here.
125+
126+
```toml
127+
can = "slcan0"
128+
dsdl_root = [
129+
"/home/pavel/zubax/zubax_dsdl/zubax/",
130+
]
131+
[io]
132+
"feedback" = "zubax/zubax_dsdl/zubax/fluxgrip/Feedback.1.1.dsdl"
133+
"/com.zubax.fluxgrip/*/zubax.manage" = "zubax/zubax_dsdl/zubax/fluxgrip/Manage.0.1.dsdl"
134+
```
135+
136+
With the above config, one can go like `cn sub feedback` or
137+
`cn req com.zubax.fluxgrip/0ef4cccfc40c9393/zubax.manage -- 'get_info: {}'`, very concise.
138+
105139
---
106140
107141
Distributed under the MIT License. Author: Pavel Kirienko `pavel@opencyphal.org`
@@ -121,9 +155,10 @@
121155
import tomllib
122156
from dataclasses import dataclass
123157
from datetime import datetime, timezone
124-
from functools import partial
158+
from fnmatch import fnmatchcase
159+
from functools import cache, partial
125160
from pathlib import Path
126-
from typing import Any, Callable
161+
from typing import Any, Callable, Protocol
127162
import pydsdl # type: ignore[import-untyped]
128163
import yaml # type: ignore[import-untyped]
129164
from pycyphal2 import Arrival, Error, Instant, LivenessError, Node, Publisher, Response, Subscriber, Topic, Transport
@@ -137,7 +172,11 @@
137172
DEFAULT_TIMEOUT = 10.0
138173
SCOUT_PATTERN = "/>"
139174

140-
Input = Callable[[], bytes]
175+
176+
class Input(Protocol):
177+
def __call__(self, topics: tuple[str, ...] | None = None) -> bytes: ...
178+
179+
141180
Output = Callable[[Arrival | Response | Exception, Topic | None], None]
142181

143182

@@ -206,6 +245,28 @@ def unescape(text: str) -> bytes:
206245
return text.encode("latin-1").decode("unicode_escape").encode("latin-1")
207246

208247

248+
def match_topic_io(defaults: dict[str, str], topic: str) -> str | None:
249+
"""Select a configured I/O default for a concrete wire topic name."""
250+
if topic in defaults:
251+
return defaults[topic]
252+
matches = [(pattern, selector) for pattern, selector in defaults.items() if fnmatchcase(topic, pattern)]
253+
selectors = {selector for _, selector in matches}
254+
if len(selectors) > 1:
255+
details = ", ".join(f"{pattern!r}={selector!r}" for pattern, selector in matches)
256+
raise ValueError(f"ambiguous I/O defaults for topic {topic!r}: {details}")
257+
return next(iter(selectors), None)
258+
259+
260+
def match_input_io(defaults: dict[str, str], topics: tuple[str, ...]) -> str | None:
261+
"""Select the one configured input default shared by every topic in an invocation."""
262+
matches = [(topic, match_topic_io(defaults, topic)) for topic in topics]
263+
selectors = {selector for _, selector in matches}
264+
if len(selectors) > 1:
265+
details = ", ".join(f"{topic!r}={selector!r}" for topic, selector in matches)
266+
raise ValueError(f"topics select different input defaults: {details}; use -i or --io")
267+
return next(iter(selectors), None)
268+
269+
209270
def load_dsdl(file: Path, roots: list[Path]) -> pydsdl.CompositeType:
210271
file, roots = file.resolve(), [x.resolve() for x in roots]
211272
if not file.is_file():
@@ -402,7 +463,7 @@ async def cmd_pub(node: Node, config: PubConfig) -> int:
402463
pubs = [node.advertise(x) for x in config.topics]
403464
try:
404465
deadline = Instant.now() + config.timeout
405-
message = config.input()
466+
message = config.input(tuple(pub.topic.name for pub in pubs))
406467
await asyncio.gather(*(pub(deadline, message, reliable=True) for pub in pubs))
407468
logger.info("Published on %s", config.topics)
408469
except Exception as ex:
@@ -437,7 +498,7 @@ async def request(pub: Publisher, config: ReqConfig, message: bytes) -> int:
437498
async def cmd_req(node: Node, config: ReqConfig) -> int:
438499
pubs = [node.advertise(x) for x in config.topics]
439500
try:
440-
message = config.input()
501+
message = config.input(tuple(pub.topic.name for pub in pubs))
441502
counts = await asyncio.gather(*(request(pub, config, message) for pub in pubs))
442503
except Exception as ex:
443504
config.output(ex, None)
@@ -660,15 +721,15 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
660721
sub.add_parser("ls", parents=[options], help="discover topics, print each once as it appears; exits when idle")
661722

662723
argv = sys.argv[1:]
663-
source: Input = sys.stdin.buffer.read
664-
input_ = source
724+
source: Callable[[], bytes] = sys.stdin.buffer.read
725+
expand_escapes = False
665726
if data_supplied := "--" in argv:
666727
separator = argv.index("--")
667728
if len(argv) != separator + 2:
668729
parser.error("expected exactly one DATA argument after --")
669730
data = os.fsencode(argv[-1])
670731
source = lambda: data
671-
input_ = lambda: unescape(data.decode("latin-1"))
732+
expand_escapes = True
672733
argv = argv[:separator]
673734
args = parser.parse_args(argv)
674735
if args.command is None:
@@ -707,6 +768,11 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
707768
isinstance(values["dsdl_root"], list) and all(isinstance(x, str) for x in values["dsdl_root"])
708769
):
709770
parser.error(f"{path!r}: 'dsdl_root' has an invalid type")
771+
if "io" in values and not (
772+
isinstance(values["io"], dict)
773+
and all(isinstance(pattern, str) and isinstance(selector, str) for pattern, selector in values["io"].items())
774+
):
775+
parser.error(f"{path!r}: 'io' has an invalid type")
710776

711777
env_bitrate: int | None = None
712778
if value := os.environ.get("CYNIC_BITRATE"):
@@ -727,6 +793,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
727793
timeout = float(cli.get("timeout", values.get("timeout", DEFAULT_TIMEOUT)))
728794
verbose = cli.get("verbose", values.get("verbose", 0))
729795
command = cli["command"]
796+
io_defaults: dict[str, str] = values.get("io", {})
730797
enc, dec, io = cli.get("enc"), cli.get("dec"), cli.get("io")
731798
for name, selector, commands in (
732799
("enc", enc, ("pub", "req")),
@@ -746,8 +813,13 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
746813
roots: list[Path] | None = None
747814
schemas: dict[Path, pydsdl.CompositeType] = {}
748815

816+
directional_schemas: dict[tuple[str, bool], pydsdl.CompositeType] = {}
817+
749818
def resolve_schema(selector: str, output_direction: bool) -> pydsdl.CompositeType:
750819
nonlocal roots
820+
key = (selector, output_direction)
821+
if key in directional_schemas:
822+
return directional_schemas[key]
751823
try:
752824
file = Path(selector).resolve()
753825
if roots is None:
@@ -768,22 +840,58 @@ def resolve_schema(selector: str, output_direction: bool) -> pydsdl.CompositeTyp
768840
schemas[file] = load_dsdl(file, roots)
769841
schema = schemas[file]
770842
except (OSError, RuntimeError, ValueError, pydsdl.Error) as ex:
771-
parser.error(f"cannot load DSDL {selector!r}: {ex}")
843+
raise ValueError(f"cannot load DSDL {selector!r}: {ex}") from ex
772844
if isinstance(schema, pydsdl.ServiceType):
773-
return schema.response_type if command == "req" and output_direction else schema.request_type
845+
schema = schema.response_type if command == "req" and output_direction else schema.request_type
846+
directional_schemas[key] = schema
774847
return schema
775848

776-
if enc == "raw":
777-
input_ = source
778-
elif enc is not None:
779-
input_schema = resolve_schema(enc, False)
780-
input_ = lambda: pydsdl.serialize(input_schema, yaml.safe_load(source()), relaxed=True)
849+
# Explicit CLI selectors retain eager validation. Configured selectors are resolved only if a matching wire
850+
# topic is actually used, which keeps unrelated entries from breaking a command.
851+
for selector, output_direction in ((enc, False), (dec, True)):
852+
if selector is not None and selector != "raw":
853+
try:
854+
resolve_schema(selector, output_direction)
855+
except ValueError as ex:
856+
parser.error(str(ex))
857+
858+
def input_(topics: tuple[str, ...] | None = None) -> bytes:
859+
selector = enc
860+
if selector is None and command in ("pub", "req") and topics is not None:
861+
selector = match_input_io(io_defaults, topics)
862+
data = source()
863+
if selector == "raw":
864+
return data
865+
if selector is not None:
866+
schema = resolve_schema(selector, False)
867+
return bytes(pydsdl.serialize(schema, yaml.safe_load(data), relaxed=True))
868+
return unescape(data.decode("latin-1")) if expand_escapes else data
869+
870+
@cache
871+
def configured_output(topic: str) -> Output:
872+
selector = match_topic_io(io_defaults, topic)
873+
if selector == "raw":
874+
return raw_output
875+
if selector is not None:
876+
return partial(dsdl_output, resolve_schema(selector, True))
877+
return json_output
878+
879+
def dynamic_output(value: Arrival | Response | Exception, topic: Topic | None) -> None:
880+
wire_topic = (
881+
value.breadcrumb.topic.name if isinstance(value, Arrival) else topic.name if topic is not None else None
882+
)
883+
(configured_output(wire_topic) if wire_topic is not None else json_output)(value, topic)
781884

782-
output: Output = json_output
885+
output: Output
783886
if dec == "raw":
784887
output = raw_output
785888
elif dec is not None:
786889
output = partial(dsdl_output, resolve_schema(dec, True))
890+
elif command in ("sub", "req") and io_defaults:
891+
output = dynamic_output
892+
else:
893+
output = json_output
894+
787895
common = dict(
788896
can=can,
789897
bitrate=1_000_000 if bitrate is None else bitrate,

0 commit comments

Comments
 (0)