diff --git a/README.md b/README.md index c4508cb..a451048 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,49 @@ To install any optional dependencies, such as development dependencies, use: ```bash pip install -e .[dev] ``` + +## Keywords + +A **keyword** is a typed named value served over libby, with a uniform payload convention: + +- `{}` → show (return current value) +- `{"value": V}` → modify (apply, then return it) + +Types: `BoolKeyword`, `IntKeyword`, `FloatKeyword`, `StringKeyword`, +`TriggerKeyword`. Access mode is inferred — pass a `getter` for +read-only, a `setter` for write-only, both for read-write. Optional +extras: `units`, `description`, `nullable`, `validator`. + +```python +from libby import Libby, BoolKeyword, FloatKeyword, TriggerKeyword + +libby = Libby.rabbitmq(self_id="my-peer", rabbitmq_url="amqp://localhost") + +state = {"position": 0.0} +libby.register_keywords([ + BoolKeyword("online", getter=lambda: True), + FloatKeyword("position", + getter=lambda: state["position"], + setter=lambda v: state.update(position=v), + units="mm"), + TriggerKeyword("halt", action=lambda: print("halted")), +]) +``` + +Clients call the keyword by name: + +```python +client = Libby.rabbitmq(self_id="client", rabbitmq_url="amqp://localhost") + +client.rpc("my-peer", "position", {}) # show +client.rpc("my-peer", "position", {"value": 12.5}) # modify +client.rpc("my-peer", "halt", {"value": 1}) # fire +``` + +Two meta-services are auto-registered on every peer that uses the +keyword registry: + +- `keys.list` — payload `{"pattern": "..."}` (default `"%"`) → names, + sorted. `%` wildcards within a single name. +- `keys.describe` — payload `{"name": "..."}` → flat metadata dict. + Exact lookup; no wildcards. diff --git a/libby/__init__.py b/libby/__init__.py index 8562ce6..6e30b3e 100644 --- a/libby/__init__.py +++ b/libby/__init__.py @@ -1,7 +1,27 @@ from bamboo.protocol import Protocol from bamboo.builder import MessageBuilder from bamboo.keys import KeyRegistry -from .zmq_transport import ZmqTransport from .libby import Libby +from .keyword import ( + Keyword, + BoolKeyword, + IntKeyword, + FloatKeyword, + StringKeyword, + TriggerKeyword, + match_pattern, +) -__all__ = ["Libby", "ZmqTransport", "Protocol", "MessageBuilder", "KeyRegistry"] +__all__ = [ + "Libby", + "Protocol", + "MessageBuilder", + "KeyRegistry", + "Keyword", + "BoolKeyword", + "IntKeyword", + "FloatKeyword", + "StringKeyword", + "TriggerKeyword", + "match_pattern", +] diff --git a/libby/keyword.py b/libby/keyword.py new file mode 100644 index 0000000..756ddb8 --- /dev/null +++ b/libby/keyword.py @@ -0,0 +1,210 @@ +"""Keyword classes for Libby.""" +from __future__ import annotations + +import re +from typing import Any, Callable, Iterable, List, Optional + +Getter = Callable[[], Any] +Setter = Callable[[Any], None] +Validator = Callable[[Any], Optional[str]] +Action = Callable[[], None] + + +def match_pattern(pattern: str, names: Iterable[str]) -> List[str]: + """Return names matching ``pattern``, sorted. ``%`` is a wildcard; + it matches any run of characters excluding ``.``.""" + parts = pattern.split("%") + rx = re.compile("^" + "[^.]*".join(re.escape(p) for p in parts) + "$") + return sorted(n for n in names if rx.match(n)) + + +class Keyword: + """Named value exposed as a libby service.""" + + type_name: str = "any" + + def __init__( + self, + name: str, + *, + getter: Optional[Getter] = None, + setter: Optional[Setter] = None, + units: Optional[str] = None, + description: str = "", + nullable: bool = False, + validator: Optional[Validator] = None, + ) -> None: + if getter is None and setter is None: + raise ValueError( + f"keyword '{name}' must supply at least one of getter/setter" + ) + self.name = name + self._getter = getter + self._setter = setter + self.units = units + self.description = description + self.nullable = nullable + self._validator = validator + + @property + def readonly(self) -> bool: + return self._setter is None + + @property + def writeonly(self) -> bool: + return self._getter is None + + def _type_check(self, v: Any) -> Any: + """Type-check or cast ``v``. Override per subclass.""" + return v + + def _response(self, value: Any) -> dict: + out: dict[str, Any] = {"ok": True, "value": value} + if self.units is not None: + out["units"] = self.units + return out + + def describe(self) -> dict: + out: dict[str, Any] = { + "name": self.name, + "type": self.type_name, + "readonly": self.readonly, + "writeonly": self.writeonly, + "nullable": self.nullable, + } + if self.units is not None: + out["units"] = self.units + if self.description: + out["description"] = self.description + return out + + def handle(self, payload: dict) -> dict: + if "value" in payload: + return self._modify(payload["value"]) + return self._show() + + def _show(self) -> dict: + if self.writeonly: + return {"ok": False, "error": "keyword is write-only"} + try: + value = self._getter() # type: ignore[misc] + except Exception as e: + return {"ok": False, "error": str(e)} + return self._response(value) + + def _modify(self, raw: Any) -> dict: + if self.readonly: + return {"ok": False, "error": "keyword is read-only"} + if raw is None: + if not self.nullable: + return {"ok": False, "error": f"value must be {self.type_name}"} + value: Any = None + else: + try: + value = self._type_check(raw) + except (TypeError, ValueError) as e: + return {"ok": False, "error": str(e)} + if self._validator is not None: + verr = self._validator(value) + if verr: + return {"ok": False, "error": verr} + try: + self._setter(value) # type: ignore[misc] + except Exception as e: + return {"ok": False, "error": str(e)} + return self._response(value) + + +class BoolKeyword(Keyword): + """Keyword whose value is a Python ``bool``.""" + + type_name = "bool" + + def _type_check(self, v: Any) -> bool: + if not isinstance(v, bool): + raise TypeError("value must be a bool") + return v + + +class IntKeyword(Keyword): + """Keyword whose value is a Python ``int``; ``bool`` is rejected.""" + + type_name = "int" + + def _type_check(self, v: Any) -> int: + # bool is a subclass of int in Python; reject explicitly + if isinstance(v, bool) or not isinstance(v, int): + raise TypeError("value must be an int") + return v + + +class FloatKeyword(Keyword): + """Keyword whose value is a Python ``float``; accepts ``int``, rejects ``bool``.""" + + type_name = "float" + + def _type_check(self, v: Any) -> float: + if isinstance(v, bool) or not isinstance(v, (int, float)): + raise TypeError("value must be a number") + return float(v) + + +class StringKeyword(Keyword): + """Keyword whose value is a Python ``str``.""" + + type_name = "string" + + def _type_check(self, v: Any) -> str: + if not isinstance(v, str): + raise TypeError("value must be a string") + return v + + +class TriggerKeyword(Keyword): + """Write-only action keyword. Any modify fires ``action``; show returns ``false``.""" + + type_name = "trigger" + + def __init__( + self, + name: str, + *, + action: Action, + description: str = "", + ) -> None: + self.name = name + self._action = action + self._getter = None + self._setter = None + self.units = None + self.description = description + self.nullable = False + self._validator = None + + @property + def readonly(self) -> bool: + return False + + @property + def writeonly(self) -> bool: + return True + + def handle(self, payload: dict) -> dict: + if "value" not in payload: + return {"ok": True, "value": False} + try: + self._action() + except Exception as e: + return {"ok": False, "error": str(e)} + return {"ok": True, "value": True} + + +__all__ = [ + "Keyword", + "BoolKeyword", + "IntKeyword", + "FloatKeyword", + "StringKeyword", + "TriggerKeyword", + "match_pattern", +] diff --git a/libby/libby.py b/libby/libby.py index f55a194..7bb8f93 100644 --- a/libby/libby.py +++ b/libby/libby.py @@ -1,11 +1,13 @@ from __future__ import annotations -from typing import Callable, Dict, List, Optional, Any +from typing import Any, Callable, Dict, Iterable, List, Optional import time from bamboo.keys import KeyRegistry from bamboo.protocol import Protocol from bamboo.discovery import Discovery +from .keyword import Keyword, match_pattern + class Libby: def __init__( self, @@ -42,6 +44,10 @@ def __init__( pass self._disco.start() + self._keywords: Dict[str, Keyword] = {} + self.serve_keys(["keys.list"], self._keys_list) + self.serve_keys(["keys.describe"], self._keys_describe) + @classmethod def zmq( cls, @@ -159,6 +165,45 @@ def serve_keys(self, keys: List[str], callback: Callable[[dict, dict], Optional[ for k in keys: self.proto.serve(k, callback) + def register_keyword(self, keyword: Keyword) -> None: + if keyword.name in self._keywords: + raise ValueError(f"keyword '{keyword.name}' already registered") + self._keywords[keyword.name] = keyword + self.serve_keys([keyword.name], self._keyword_dispatcher) + + def register_keywords(self, keywords: Iterable[Keyword]) -> None: + keywords = list(keywords) + for keyword in keywords: + if keyword.name in self._keywords: + raise ValueError(f"keyword '{keyword.name}' already registered") + self._keywords[keyword.name] = keyword + if keywords: + self.serve_keys([k.name for k in keywords], self._keyword_dispatcher) + + def _keyword_dispatcher(self, payload: dict, ctx: dict) -> dict: + keyword = self._keywords.get(ctx.get("key", "")) + if keyword is None: + return {"ok": False, "error": f"unknown keyword '{ctx.get('key', '')}'"} + try: + return keyword.handle(payload) + except Exception as e: + return {"ok": False, "error": str(e)} + + def _keys_list(self, payload: dict, _ctx: dict) -> dict: + pattern = payload.get("pattern", "%") + if not isinstance(pattern, str): + return {"ok": False, "error": "pattern must be a string"} + return {"ok": True, "matches": match_pattern(pattern, self._keywords)} + + def _keys_describe(self, payload: dict, _ctx: dict) -> dict: + name = payload.get("name") + if not isinstance(name, str) or not name: + return {"ok": False, "error": "name must be a non-empty string"} + keyword = self._keywords.get(name) + if keyword is None: + return {"ok": False, "error": f"unknown keyword '{name}'"} + return {"ok": True, **keyword.describe()} + def listen(self, topic: str, handler: Callable[[Any], None]) -> None: self.proto.listen(topic, handler)