7575Raw encoding disables escape expansion for DATA after `--`; raw decoding emits undelimited payload bytes.
7676With 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
8086Use `--enc=path/to/File.1.1.dsdl`, `--dec=path/to/File.1.1.dsdl`, or `--io=path/to/File.1.1.dsdl`.
9399The following are equivalent: `{foo: [1,2,3], bar: 456}`, `[[1,2,3], 456]`.
94100Likewise, 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
98115Run a file server on the specified topic(s) using `zubax.file.Read` out of the current working directory.
102119cn 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
107141Distributed under the MIT License. Author: Pavel Kirienko `pavel@opencyphal.org`
121155import tomllib
122156from dataclasses import dataclass
123157from datetime import datetime , timezone
124- from functools import partial
158+ from fnmatch import fnmatchcase
159+ from functools import cache , partial
125160from pathlib import Path
126- from typing import Any , Callable
161+ from typing import Any , Callable , Protocol
127162import pydsdl # type: ignore[import-untyped]
128163import yaml # type: ignore[import-untyped]
129164from pycyphal2 import Arrival , Error , Instant , LivenessError , Node , Publisher , Response , Subscriber , Topic , Transport
137172DEFAULT_TIMEOUT = 10.0
138173SCOUT_PATTERN = "/>"
139174
140- Input = Callable [[], bytes ]
175+
176+ class Input (Protocol ):
177+ def __call__ (self , topics : tuple [str , ...] | None = None ) -> bytes : ...
178+
179+
141180Output = 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+
209270def 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:
437498async 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