diff --git a/README.md b/README.md index 1aeae35..392a533 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,49 @@ keyword registry: - `keys.describe` — payload `{"name": "..."}` → flat metadata dict. Exact lookup; no wildcards. +## Client library + +`Client` is the programmatic front for reading and writing keywords — the +import-and-use counterpart to the CLI. Where the CLI opens a connection per +command, a `Client` holds one for its lifetime, so a script can touch many +keywords cheaply. It addresses keywords by the same qualified +`..` and reuses the CLI's `cli_config.yaml`. + +```python +from libby import Client + +with Client.from_config() as client: # transport/url from cli_config.yaml + pos = client.get("hsfei.focpupsel.positionvalue") # -> 7.15 + full = client.show("hsfei.focpupsel.positionvalue") # -> {"ok": True, "value": 7.15, "units": "mm", ...} + client.set("hsfei.pickoff.softmax", 120) # returns the applied value +``` + +Construct explicitly when you don't want config-file resolution: + +```python +client = Client.rabbitmq(rabbitmq_url="amqp://user:pass@host") +client = Client.zmq(address_book={"hsfei_pickoff": "tcp://host:5555"}) +``` + +- `get(name)` → the value; `show(name)` → the full response dict (value, units, + flags); `set(name, value)` → the value the daemon applied. +- Failures raise rather than return sentinels: `KeywordError` when the daemon + rejects a get/set (its message is on `.error`), `LibbyTimeout` when a request + isn't answered, both subclasses of `LibbyError`. `set` accepts `timeout_s=`; + otherwise it honors the keyword's `timeout_s` metadata, like the CLI. + +```python +from libby import KeywordError + +try: + client.get("hsfei.yjpiaagim.positionvaluex") +except KeywordError as ex: + print(ex.error) # "Control loops are not closed" +``` + +Exact names only for now; `%` wildcard reads, `list`, and `describe` are planned +follow-ons — use the CLI for those today. + ## CLI `libby` is the command-line front for keyword peers. Verbs: diff --git a/libby/__init__.py b/libby/__init__.py index 7686ff9..5f4aedb 100644 --- a/libby/__init__.py +++ b/libby/__init__.py @@ -12,9 +12,18 @@ match_pattern, ) from .keyword_registry import KeywordRegistry +from .client import Client +from .errors import ( + LibbyError, + ConfigError, + KeywordNameError, + LibbyTimeout, + KeywordError, +) __all__ = [ "Libby", + "Client", "Protocol", "MessageBuilder", "KeyRegistry", @@ -26,4 +35,9 @@ "TriggerKeyword", "KeywordRegistry", "match_pattern", + "LibbyError", + "ConfigError", + "KeywordNameError", + "LibbyTimeout", + "KeywordError", ] diff --git a/libby/cli/libby_cli.py b/libby/cli/libby_cli.py index 1225fbd..1a0939e 100644 --- a/libby/cli/libby_cli.py +++ b/libby/cli/libby_cli.py @@ -6,78 +6,39 @@ import signal import sys import time -from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -import yaml - +from libby.config_resolve import ( + DEFAULT_BIND, + DEFAULT_CONFIG_PATH, + DEFAULT_RABBITMQ_URL, + load_cli_config, + resolve_address_book, + resolve_rabbitmq_url, + resolve_transport, +) +from libby.errors import KeywordError, LibbyError from libby.libby import Libby +from libby.naming import coerce_value, parse_keyword, peer_id +from libby.response import unwrap DEFAULT_SELF_ID = "cli" -DEFAULT_BIND = "tcp://127.0.0.1:56001" -DEFAULT_RABBITMQ_URL = "amqp://localhost" -DEFAULT_TRANSPORT = "rabbitmq" -DEFAULT_CONFIG_PATH = Path.home() / ".libby" / "cli_config.yaml" DEFAULT_TIMEOUT_S = 3.0 -def _load_config(path: Optional[str]) -> Dict[str, Any]: - """Load cli_config.yaml. Missing file → empty dict.""" - p = Path(path) if path else DEFAULT_CONFIG_PATH - if not p.exists(): - return {} - try: - with open(p, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) or {} - except Exception as ex: - raise SystemExit(f"libby: failed to load {p}: {ex}") - if not isinstance(data, dict): - raise SystemExit(f"libby: {p} must parse to a dict, got {type(data).__name__}") - return data - - -def _resolve_transport(namespace: argparse.Namespace, config: Dict[str, Any]) -> str: - transport = namespace.transport or config.get("transport") or DEFAULT_TRANSPORT - if transport not in ("zmq", "rabbitmq"): - raise SystemExit(f"libby: invalid transport: {transport}") - return transport - - -def _resolve_rabbitmq_url(namespace: argparse.Namespace, config: Dict[str, Any]) -> str: - return namespace.rabbitmq_url or config.get("rabbitmq_url") or DEFAULT_RABBITMQ_URL - - -def _resolve_address_book(namespace: argparse.Namespace, config: Dict[str, Any]) -> Dict[str, str]: - book: Dict[str, str] = dict(config.get("peers") or {}) - for kv in (namespace.addr or []): - peer, address = _parse_addr_kv(kv) - book[peer] = address - return book - - -def _parse_addr_kv(kv: str) -> Tuple[str, str]: - if "=" not in kv: - raise argparse.ArgumentTypeError("Expected 'peer_id=tcp://host:port'") - peer, address = kv.split("=", 1) - peer, address = peer.strip(), address.strip() - if not peer or not address: - raise argparse.ArgumentTypeError("Expected 'peer_id=tcp://host:port'") - return peer, address - - def _mk_libby(namespace: argparse.Namespace, config: Dict[str, Any]) -> Libby: - transport = _resolve_transport(namespace, config) + transport = resolve_transport(namespace.transport, config) self_id = namespace.self_id or DEFAULT_SELF_ID if transport == "rabbitmq": return Libby.rabbitmq( self_id=self_id, - rabbitmq_url=_resolve_rabbitmq_url(namespace, config), + rabbitmq_url=resolve_rabbitmq_url(namespace.rabbitmq_url, config), keys=[], ) return Libby.zmq( self_id=self_id, bind=namespace.bind or DEFAULT_BIND, - address_book=_resolve_address_book(namespace, config), + address_book=resolve_address_book(config, namespace.addr), keys=[], callback=None, discover=True, @@ -86,57 +47,6 @@ def _mk_libby(namespace: argparse.Namespace, config: Dict[str, Any]) -> Libby: ) -def _parse_keyword(arg: str, *, allow_pattern: bool = False) -> Tuple[str, str, str]: - """Parse '..' into (group, scope, name). - - With ``allow_pattern=True``, ``%`` is allowed in the name segment. - Group and scope must always be explicit. - """ - parts = arg.split(".", 2) - if len(parts) < 3 or not all(parts): - raise SystemExit(f"libby: keyword must be .., got: {arg}") - group, scope, name = parts - if "%" in group or "%" in scope: - raise SystemExit( - f"libby: wildcards (%) are not allowed in or : {arg}" - ) - if "%" in name and not allow_pattern: - raise SystemExit( - f"libby: this verb requires an exact keyword name (no %): {arg}" - ) - return group, scope, name - - -def _peer_id(group: str, scope: str) -> str: - return f"{group}_{scope}" - - -def _coerce_value(value: str) -> Any: - """Coerce a modify value string. - - Empty / 'null' → None; 'true'/'false' → bool; parseable as int → int; - parseable as float → float; otherwise the original string. - """ - if value == "": - return None - low = value.strip().lower() - if low == "null": - return None - if low == "true": - return True - if low == "false": - return False - try: - return int(value) - except ValueError: - pass - try: - return float(value) - except ValueError: - pass - return value - - def _emit_one(qualified: str, resp: Dict[str, Any], *, as_json: bool) -> int: """Print one keyword response. Return exit code (0 ok, 2 error).""" if not isinstance(resp, dict): @@ -246,25 +156,33 @@ def _modify_timeout(lib: Libby, peer: str, name: str, user_timeout: Optional[flo return DEFAULT_TIMEOUT_S +def _peel(envelope: Any) -> Dict[str, Any]: + """Non-raising unwrap for the renderer: failures become an + ``{"ok": False, "error": ...}`` dict so a table can show them inline.""" + try: + return unwrap("", envelope) + except KeywordError as ex: + return {"ok": False, "error": ex.error} + except LibbyError as ex: + return {"ok": False, "error": str(ex)} + + def _rpc_show_one(lib: Libby, peer: str, name: str, timeout: float) -> Dict[str, Any]: - result = lib.rpc(peer, name, {}, ttl_ms=int(timeout * 1000)) - return result.get("resp", result) if isinstance(result, dict) else {} + return _peel(lib.rpc(peer, name, {}, ttl_ms=int(timeout * 1000))) def _rpc_keys_list(lib: Libby, peer: str, pattern: str, timeout: float) -> Dict[str, Any]: - result = lib.rpc(peer, "keys.list", {"pattern": pattern}, ttl_ms=int(timeout * 1000)) - return result.get("resp", result) if isinstance(result, dict) else {} + return _peel(lib.rpc(peer, "keys.list", {"pattern": pattern}, ttl_ms=int(timeout * 1000))) def _rpc_keys_describe(lib: Libby, peer: str, name: str, timeout: float) -> Dict[str, Any]: - result = lib.rpc(peer, "keys.describe", {"name": name}, ttl_ms=int(timeout * 1000)) - return result.get("resp", result) if isinstance(result, dict) else {} + return _peel(lib.rpc(peer, "keys.describe", {"name": name}, ttl_ms=int(timeout * 1000))) def cmd_show(namespace: argparse.Namespace) -> int: - config = _load_config(namespace.config) - group, scope, name = _parse_keyword(namespace.keyword, allow_pattern=True) - peer = _peer_id(group, scope) + config = load_cli_config(namespace.config) + group, scope, name = parse_keyword(namespace.keyword, allow_pattern=True) + peer = peer_id(group, scope) timeout = namespace.timeout if namespace.timeout is not None else DEFAULT_TIMEOUT_S qualified_arg = f"{group}.{scope}.{name}" @@ -303,9 +221,9 @@ def cmd_show(namespace: argparse.Namespace) -> int: def cmd_list(namespace: argparse.Namespace) -> int: - config = _load_config(namespace.config) - group, scope, pattern = _parse_keyword(namespace.pattern, allow_pattern=True) - peer = _peer_id(group, scope) + config = load_cli_config(namespace.config) + group, scope, pattern = parse_keyword(namespace.pattern, allow_pattern=True) + peer = peer_id(group, scope) timeout = namespace.timeout if namespace.timeout is not None else DEFAULT_TIMEOUT_S lib: Optional[Libby] = None @@ -336,9 +254,9 @@ def cmd_list(namespace: argparse.Namespace) -> int: def cmd_describe(namespace: argparse.Namespace) -> int: - config = _load_config(namespace.config) - group, scope, name = _parse_keyword(namespace.keyword, allow_pattern=False) - peer = _peer_id(group, scope) + config = load_cli_config(namespace.config) + group, scope, name = parse_keyword(namespace.keyword, allow_pattern=False) + peer = peer_id(group, scope) qualified = f"{group}.{scope}.{name}" timeout = namespace.timeout if namespace.timeout is not None else DEFAULT_TIMEOUT_S @@ -364,7 +282,7 @@ def cmd_describe(namespace: argparse.Namespace) -> int: def cmd_modify(namespace: argparse.Namespace) -> int: - config = _load_config(namespace.config) + config = load_cli_config(namespace.config) # Two forms accepted: # modify ..= @@ -381,17 +299,16 @@ def cmd_modify(namespace: argparse.Namespace) -> int: keyword_str = namespace.keyword value_str = namespace.value - group, scope, name = _parse_keyword(keyword_str) - peer = _peer_id(group, scope) + group, scope, name = parse_keyword(keyword_str) + peer = peer_id(group, scope) qualified = f"{group}.{scope}.{name}" - value = _coerce_value(value_str) + value = coerce_value(value_str) lib: Optional[Libby] = None try: lib = _mk_libby(namespace, config) timeout = _modify_timeout(lib, peer, name, namespace.timeout) - result = lib.rpc(peer, name, {"value": value}, ttl_ms=int(timeout * 1000)) - resp = result.get("resp", result) if isinstance(result, dict) else {} + resp = _peel(lib.rpc(peer, name, {"value": value}, ttl_ms=int(timeout * 1000))) return _emit_one(qualified, resp, as_json=namespace.json) except Exception as ex: return _emit_error(qualified, str(ex), as_json=namespace.json) @@ -414,7 +331,7 @@ def _parse_json(text: Optional[str]) -> Dict[str, Any]: def cmd_req(namespace: argparse.Namespace) -> int: """Raw RPC for debugging — works on either transport.""" - config = _load_config(namespace.config) + config = load_cli_config(namespace.config) payload = _parse_json(namespace.data) timeout = namespace.timeout if namespace.timeout is not None else DEFAULT_TIMEOUT_S @@ -442,8 +359,8 @@ def cmd_req(namespace: argparse.Namespace) -> int: def cmd_sub(namespace: argparse.Namespace) -> int: """Subscribe to topics. ZMQ-only.""" - config = _load_config(namespace.config) - if _resolve_transport(namespace, config) != "zmq": + config = load_cli_config(namespace.config) + if resolve_transport(namespace.transport, config) != "zmq": print("libby sub: only ZMQ transport is supported", file=sys.stderr) return 2 if not namespace.topics: @@ -567,7 +484,12 @@ def add_common(p): def main(argv: Optional[List[str]] = None) -> int: namespace = build_parser().parse_args(argv) - return namespace.func(namespace) + try: + return namespace.func(namespace) + except LibbyError as ex: + # Core raises LibbyError; the CLI is the boundary that exits + print(f"libby: {ex}", file=sys.stderr) + return 2 if __name__ == "__main__": diff --git a/libby/client.py b/libby/client.py new file mode 100644 index 0000000..e0379bc --- /dev/null +++ b/libby/client.py @@ -0,0 +1,126 @@ +"""Programmatic client for getting and setting keywords on libby daemons. + +``Client`` CLI spins up a connection and holds it for its lifetime, so a script can touch many +keywords cheaply. It resolves a qualified ``..`` to a peer +and key, calls ``Libby.rpc``, and turns the reply into a value or a +``LibbyError`` via :func:`libby.response.unwrap`. +""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +from .config_resolve import ( + DEFAULT_BIND, + load_cli_config, + resolve_address_book, + resolve_rabbitmq_url, + resolve_transport, +) +from .errors import LibbyError +from .libby import Libby +from .naming import parse_keyword, peer_id +from .response import unwrap + +DEFAULT_SELF_ID = "libby-client" +DEFAULT_TIMEOUT_S = 3.0 + + +class Client: + """Long-lived, in-process handle for getting and setting libby keywords.""" + + def __init__(self, libby: Libby) -> None: + self._libby = libby + + @classmethod + def rabbitmq( + cls, + *, + self_id: str = DEFAULT_SELF_ID, + rabbitmq_url: str = "amqp://localhost", + ) -> "Client": + """Connect over RabbitMQ.""" + return cls(Libby.rabbitmq(self_id=self_id, rabbitmq_url=rabbitmq_url, keys=[])) + + @classmethod + def zmq( + cls, + *, + self_id: str = DEFAULT_SELF_ID, + bind: str = DEFAULT_BIND, + address_book: Optional[Dict[str, str]] = None, + ) -> "Client": + """Connect over ZMQ, given an address book of peer endpoints.""" + return cls(Libby.zmq( + self_id=self_id, + bind=bind, + address_book=address_book or {}, + keys=[], + discover=True, + discover_interval_s=2.0, + hello_on_start=True, + )) + + @classmethod + def from_config( + cls, + path: Optional[str] = None, + *, + self_id: str = DEFAULT_SELF_ID, + ) -> "Client": + """Build a Client from cli_config.yaml, resolving transport as the CLI does.""" + config = load_cli_config(path) + if resolve_transport(None, config) == "rabbitmq": + return cls.rabbitmq(self_id=self_id, + rabbitmq_url=resolve_rabbitmq_url(None, config)) + return cls.zmq(self_id=self_id, address_book=resolve_address_book(config)) + + def show(self, name: str, *, timeout_s: float = DEFAULT_TIMEOUT_S) -> Dict[str, Any]: + """Read a keyword's full response (value, units, flags).""" + group, scope, keyword = parse_keyword(name) + envelope = self._libby.rpc(peer_id(group, scope), keyword, {}, + ttl_ms=int(timeout_s * 1000)) + return unwrap(name, envelope) + + def get(self, name: str, *, timeout_s: float = DEFAULT_TIMEOUT_S) -> Any: + """Read a keyword's value.""" + return self.show(name, timeout_s=timeout_s).get("value") + + def set(self, name: str, value: Any, *, timeout_s: Optional[float] = None) -> Any: + """Write a keyword and return the value the daemon applied.""" + group, scope, keyword = parse_keyword(name) + peer = peer_id(group, scope) + ttl_s = self._set_timeout(name, peer, keyword, timeout_s) + envelope = self._libby.rpc(peer, keyword, {"value": value}, + ttl_ms=int(ttl_s * 1000)) + return unwrap(name, envelope).get("value") + + def _set_timeout( + self, + name: str, + peer: str, + keyword: str, + override: Optional[float], + ) -> float: + """Resolve a write timeout: override → keyword's timeout_s → default.""" + if override is not None: + return override + try: + envelope = self._libby.rpc(peer, "keys.describe", {"name": keyword}, + ttl_ms=int(DEFAULT_TIMEOUT_S * 1000)) + described = unwrap(name, envelope).get("timeout_s") + if described is not None: + return float(described) + except LibbyError: + # Best-effort: a missing/unreadable describe just falls back + pass + return DEFAULT_TIMEOUT_S + + def close(self) -> None: + """Disconnect the underlying transport.""" + self._libby.stop() + + def __enter__(self) -> "Client": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() diff --git a/libby/config.py b/libby/config.py index ea89a32..5e03426 100644 --- a/libby/config.py +++ b/libby/config.py @@ -15,23 +15,36 @@ def _load_yaml(p: pathlib.Path) -> Dict[str, Any]: raise RuntimeError("YAML requested but PyYAML not installed. `pip install pyyaml`") return yaml.safe_load(p.read_text()) or {} -def load_config(path: str | os.PathLike[str]) -> Dict[str, Any]: - """ - Load config from .json or .yml/.yaml. - If the extension is missing/unknown, attempt JSON → YAML. +def load_config(path: str | os.PathLike[str], *, optional: bool = False) -> Dict[str, Any]: + """Load a JSON/YAML config file into a dict. + + The single file→dict loader for libby; daemon and client config readers wrap + this with their own semantics. A missing file raises ``FileNotFoundError`` + unless ``optional``, in which case ``{}`` is returned. The extension picks + the parser (.json vs .yml/.yaml); an unknown extension auto-detects JSON→YAML. """ p = pathlib.Path(path) if not p.exists(): + if optional: + return {} raise FileNotFoundError(f"Config file not found: {p}") + data = _parse(p) + if not isinstance(data, dict): + raise ValueError( + f"Config file {p} did not parse to a dict, got {type(data).__name__}" + ) + return data + + +def _parse(p: pathlib.Path) -> Any: ext = p.suffix.lower() if ext == ".json": return _load_json(p) or {} if ext in (".yml", ".yaml"): return _load_yaml(p) or {} - # Auto-detect - for fn in (_load_json, _load_yaml): + for parser in (_load_json, _load_yaml): try: - return fn(p) or {} + return parser(p) or {} except Exception: pass raise ValueError(f"Could not parse config file as JSON or YAML: {p}") diff --git a/libby/config_resolve.py b/libby/config_resolve.py new file mode 100644 index 0000000..0a0492a --- /dev/null +++ b/libby/config_resolve.py @@ -0,0 +1,66 @@ +"""Resolve libby connection settings from cli_config.yaml plus overrides. + +Shared by the libby CLI and the lib's ``Client.from_config`` so the precedence +rules (override → config file → built-in default) live in one place. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from . import config +from .errors import ConfigError + +DEFAULT_BIND = "tcp://127.0.0.1:56001" +DEFAULT_RABBITMQ_URL = "amqp://localhost" +DEFAULT_TRANSPORT = "rabbitmq" +DEFAULT_CONFIG_PATH = Path.home() / ".libby" / "cli_config.yaml" + + +def load_cli_config(path: Optional[str] = None) -> Dict[str, Any]: + """Load the client's cli_config.yaml. Missing file → empty dict. + + The client config is optional, so it wraps ``libby.config.load_config`` with + ``optional=True`` and re-raises parse/shape failures as ``ConfigError``. + """ + target = Path(path) if path else DEFAULT_CONFIG_PATH + try: + return config.load_config(target, optional=True) + except Exception as ex: + raise ConfigError(f"failed to load {target}: {ex}") from ex + + +def resolve_transport(override: Optional[str], config: Dict[str, Any]) -> str: + """Pick the transport: override → config → default; validated.""" + transport = override or config.get("transport") or DEFAULT_TRANSPORT + if transport not in ("zmq", "rabbitmq"): + raise ConfigError(f"invalid transport: {transport}") + return transport + + +def resolve_rabbitmq_url(override: Optional[str], config: Dict[str, Any]) -> str: + """Pick the RabbitMQ URL: override → config → default.""" + return override or config.get("rabbitmq_url") or DEFAULT_RABBITMQ_URL + + +def parse_addr_kv(entry: str) -> Tuple[str, str]: + """Parse a 'peer_id=tcp://host:port' address-book entry.""" + if "=" not in entry: + raise ConfigError("Expected 'peer_id=tcp://host:port'") + peer, address = entry.split("=", 1) + peer, address = peer.strip(), address.strip() + if not peer or not address: + raise ConfigError("Expected 'peer_id=tcp://host:port'") + return peer, address + + +def resolve_address_book( + config: Dict[str, Any], + extra_addrs: Optional[List[str]] = None, +) -> Dict[str, str]: + """Merge the config 'peers' map with extra 'peer=addr' override strings.""" + book: Dict[str, str] = dict(config.get("peers") or {}) + for entry in (extra_addrs or []): + peer, address = parse_addr_kv(entry) + book[peer] = address + return book diff --git a/libby/daemon.py b/libby/daemon.py index 7d7d5da..96facda 100644 --- a/libby/daemon.py +++ b/libby/daemon.py @@ -4,6 +4,7 @@ import collections.abc as cabc import signal, sys, threading, time from typing import Any, Callable, Dict, List, Optional +from .config import load_config from .libby import Libby Payload = Dict[str, Any] @@ -78,24 +79,8 @@ def set_if(k: str): @classmethod def from_config_file(cls, path: str) -> "LibbyDaemon": - import os, json - try: - import yaml # type: ignore - except Exception: - yaml = None - - with open(path, "r", encoding="utf-8") as f: - text = f.read() - - if path.endswith((".yml", ".yaml")) and yaml is not None: - cfg = yaml.safe_load(text) or {} - else: - cfg = json.loads(text or "{}") - - if not isinstance(cfg, dict): - raise ValueError(f"Config file {path} did not parse to a dict.") - - return cls.from_config(cfg) + # Daemon config is required, load_config raises if the file is missing + return cls.from_config(load_config(path)) # optional hooks def on_start(self, libby: Libby) -> None: ... diff --git a/libby/errors.py b/libby/errors.py new file mode 100644 index 0000000..be80c3f --- /dev/null +++ b/libby/errors.py @@ -0,0 +1,31 @@ +"""Exceptions raised by libby core and lib code. + +Core and library code raises these errors. Entry points (the +CLI or a daemon) catch them and decide whether to exit the process or bubble up. +""" +from __future__ import annotations + + +class LibbyError(Exception): + """Base class for all libby errors.""" + + +class ConfigError(LibbyError): + """A config file or connection setting is invalid.""" + + +class KeywordNameError(LibbyError): + """A qualified keyword name is malformed.""" + + +class LibbyTimeout(LibbyError): + """An RPC request was not delivered, or got no response, within its TTL.""" + + +class KeywordError(LibbyError): + """A daemon rejected a keyword get/set (responded with ``ok=False``).""" + + def __init__(self, name: str, error: str) -> None: + self.name = name + self.error = error + super().__init__(f"{name}: {error}") diff --git a/libby/naming.py b/libby/naming.py new file mode 100644 index 0000000..8a5ad65 --- /dev/null +++ b/libby/naming.py @@ -0,0 +1,58 @@ +"""Keyword-name parsing and value coercion shared by the libby CLI and lib.""" +from __future__ import annotations + +from typing import Any, Tuple + +from .errors import KeywordNameError + + +def parse_keyword(arg: str, *, allow_pattern: bool = False) -> Tuple[str, str, str]: + """Parse '..' into (group, scope, name). + + With ``allow_pattern=True``, ``%`` is allowed in the name segment. + Group and scope must always be explicit. + """ + parts = arg.split(".", 2) + if len(parts) < 3 or not all(parts): + raise KeywordNameError(f"keyword must be .., got: {arg}") + group, scope, name = parts + if "%" in group or "%" in scope: + raise KeywordNameError( + f"wildcards (%) are not allowed in or : {arg}" + ) + if "%" in name and not allow_pattern: + raise KeywordNameError( + f"this verb requires an exact keyword name (no %): {arg}" + ) + return group, scope, name + + +def peer_id(group: str, scope: str) -> str: + """Map a keyword's group/scope to the daemon peer id.""" + return f"{group}_{scope}" + + +def coerce_value(value: str) -> Any: + """Coerce a modify value string. + + Empty / 'null' → None; 'true'/'false' → bool; parseable as int → int; + parseable as float → float; otherwise the original string. + """ + if value == "": + return None + low = value.strip().lower() + if low == "null": + return None + if low == "true": + return True + if low == "false": + return False + try: + return int(value) + except ValueError: + pass + try: + return float(value) + except ValueError: + pass + return value diff --git a/libby/response.py b/libby/response.py new file mode 100644 index 0000000..8d08838 --- /dev/null +++ b/libby/response.py @@ -0,0 +1,48 @@ +"""Turn a bamboo RPC envelope into a keyword-response dict. + +``Libby.rpc`` returns a transport envelope, not the keyword response itself: + +- ``{"status": "delivered", "resp": }`` +- ``{"status": "timeout", ...}`` +- ``{"status": "too_large", "mtu": ..., "size": ...}`` + +The keyword ```` is ``{"ok": True, "value": ..., "units"?: ...}`` or +``{"ok": False, "error": "..."}``. ``unwrap`` raises on any failure; a caller +that prefers a dict (e.g. the CLI's table renderer) wraps it in its own +non-raising adapter. +""" +from __future__ import annotations + +from typing import Any, Dict + +from .errors import KeywordError, LibbyError, LibbyTimeout + + +def _prefix(name: str, message: str) -> str: + return f"{name}: {message}" if name else message + + +def unwrap(name: str, envelope: Any) -> Dict[str, Any]: + """Reduce an rpc envelope to its keyword-response dict, or raise. + + Raises ``LibbyTimeout`` (not delivered / no response), ``KeywordError`` + (daemon answered ``ok=False``), or ``LibbyError`` (malformed / oversized). + """ + if not isinstance(envelope, dict): + raise LibbyError(_prefix(name, f"unexpected response: {envelope!r}")) + status = envelope.get("status") + if status == "timeout": + raise LibbyTimeout(_prefix(name, "request timed out")) + if status == "too_large": + raise LibbyError(_prefix( + name, f"payload too large ({envelope.get('size')} > {envelope.get('mtu')})" + )) + # Delivered, or a non-enveloped transport that returned the response directly + resp = envelope.get("resp", envelope) if "resp" in envelope else envelope + if resp is None: + raise LibbyTimeout(_prefix(name, "no response from peer")) + if not isinstance(resp, dict): + raise LibbyError(_prefix(name, f"unexpected response: {resp!r}")) + if not resp.get("ok"): + raise KeywordError(name, resp.get("error", "unknown error")) + return resp