From 160a26461148456f4826b46cf5cfc1c5ff09c23f Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Tue, 19 May 2026 10:24:07 +0200 Subject: [PATCH 1/3] Fix #26, added zlib decompression for messages Apparently, the compression ratio for the active_surface topic, is around 40:1, this will enable reception of full active_surface messages --- discos_client/client.py | 3 ++- discos_client/namespace.py | 4 ++-- discos_client/utils.py | 4 +++- tests/test_client.py | 13 +++++++------ 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/discos_client/client.py b/discos_client/client.py index 4bdef93..a14e5e5 100644 --- a/discos_client/client.py +++ b/discos_client/client.py @@ -1,6 +1,7 @@ from __future__ import annotations import json import weakref +import zlib from threading import Thread, Lock, Event from collections import defaultdict from typing import Any @@ -249,7 +250,7 @@ def __receive__( sub.unsubscribe(t) t = t[len(client_id):] sub.subscribe(t) - p = json.loads(p) + p = json.loads(zlib.decompress(p)) with locks[t]: namespaces[t] <<= p diff --git a/discos_client/namespace.py b/discos_client/namespace.py index f24e6d6..7477373 100644 --- a/discos_client/namespace.py +++ b/discos_client/namespace.py @@ -24,7 +24,7 @@ class DISCOSNamespace: """ __typename__ = "DISCOSNamespace" - __private__ = ( + __private__ = frozenset({ "_lock", "_observers", "_observers_lock", @@ -36,7 +36,7 @@ class DISCOSNamespace: "unbind", "wait", "copy" - ) + }) def __init__( self, diff --git a/discos_client/utils.py b/discos_client/utils.py index 6826b0e..c132483 100644 --- a/discos_client/utils.py +++ b/discos_client/utils.py @@ -21,7 +21,9 @@ "timestamp" ] -META_KEYS = ("type", "title", "description", "format", "unit", "enum") +META_KEYS = frozenset( + {"type", "title", "description", "format", "unit", "enum"} +) def rand_id(): diff --git a/tests/test_client.py b/tests/test_client.py index 4df029b..3d8e80d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,6 +4,7 @@ import re import asyncio import sys +import zlib from unittest.mock import patch from pathlib import Path from threading import Thread, Event @@ -107,10 +108,10 @@ def _handle_events(self): if op == 1 and re.match(r"^[0-9A-Za-z]{4}_.+$", topic): t = topic.split("_", 1)[1] if t in self.messages: - message = json.dumps( + message = zlib.compress(json.dumps( self.messages[t], separators=(",", ":") - ).encode("utf-8") + ).encode("utf-8")) self.pub.send_multipart([ topic.encode("ascii"), message @@ -122,10 +123,10 @@ def _handle_events(self): subkey = key[len(t) + 1:] subparts[subkey] = val if subparts: - message = json.dumps( + message = zlib.compress(json.dumps( subparts, separators=(",", ":") - ).encode("utf-8") + ).encode("utf-8")) self.pub.send_multipart([ topic.encode("ascii"), message ]) @@ -156,10 +157,10 @@ def _send_periodic_messages(self): if "." in topic: topic, obj = topic.split(".", 1) payload = {obj: payload} - payload = json.dumps( + payload = zlib.compress(json.dumps( payload, separators=(",", ":") - ).encode("utf-8") + ).encode("utf-8")) self.pub.send_multipart([ topic.encode("ascii"), payload From 65e25360aa22ea569deab949cfb6251b183065c7 Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Mon, 25 May 2026 16:49:38 +0200 Subject: [PATCH 2/3] Fix #26, #28, #29, see changelog below Fix #26, introduced zlib compression/decompression Fix #28, reworked the DISCOSNamespace structure so that it is a view of an inner `dict` object, which is updated every time a message arrives Fix #29, added the status field to the backends message --- discos_client/client.py | 63 +- discos_client/initializer.py | 560 ++++---- discos_client/namespace.py | 1451 +++++++++----------- discos_client/schemas/common/backends.json | 4 + discos_client/utils.py | 55 - tests/messages/common/backends.json | 2 +- tests/test_client.py | 38 +- tests/test_namespace.py | 428 ++++-- 8 files changed, 1272 insertions(+), 1329 deletions(-) diff --git a/discos_client/client.py b/discos_client/client.py index a14e5e5..201b643 100644 --- a/discos_client/client.py +++ b/discos_client/client.py @@ -1,11 +1,11 @@ from __future__ import annotations -import json import weakref import zlib from threading import Thread, Lock, Event from collections import defaultdict from typing import Any from pathlib import Path +import orjson import zmq from zmq.utils.monitor import recv_monitor_message from .namespace import DISCOSNamespace @@ -143,12 +143,11 @@ def __command__(self, cmd: str, *args) -> DISCOSNamespace: if args: payload["args"] = args - payload = json.dumps(payload, separators=(",", ":")) - self._req.send_string(payload) + self._req.send(orjson.dumps(payload)) while self.__req_connected__(strict=True): if (self._req.poll(10) & zmq.POLLIN) != 0: - answer <<= json.loads(self._req.recv_string()) + answer <<= orjson.loads(self._req.recv()) return answer # We lost connection between send and receive, we need to reinitialize @@ -250,9 +249,9 @@ def __receive__( sub.unsubscribe(t) t = t[len(client_id):] sub.subscribe(t) - p = json.loads(zlib.decompress(p)) + payload = orjson.loads(zlib.decompress(p)) with locks[t]: - namespaces[t] <<= p + namespaces[t] <<= payload def __req_connected__(self, strict: bool = False) -> bool: """ @@ -328,58 +327,38 @@ def __format__(self, spec: str) -> str: """ has_e = "e" in spec has_m = "m" in spec + has_i = "i" in spec if has_e and has_m: raise ValueError( "Format specifier cannot contain both 'e' and 'm'." ) - if has_e: - fmt_spec = spec[1:] if spec.startswith("e") else spec - fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("e") else fmt_spec - elif has_m: - fmt_spec = spec[1:] if spec.startswith("m") else spec - fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("m") else fmt_spec - else: - fmt_spec = spec + fmt_spec = spec + for ch in ("e", "m", "i"): + fmt_spec = fmt_spec.replace(ch, "") + + if fmt_spec not in ("", "t"): + raise ValueError( + f"Unknown format code '{spec}' for " + f"{self.__class__.__name__}" + ) - indent = None - separators = None default = ( DISCOSNamespace.__full_dict__ if has_e else DISCOSNamespace.__metadata_dict__ if has_m else DISCOSNamespace.__message_dict__ ) - if fmt_spec == "": - pass - elif fmt_spec == "t": - separators = (",", ":") - elif fmt_spec.endswith("i"): - fmt_par = fmt_spec[:-1] - indent = 2 - if fmt_par: - try: - indent = int(fmt_par) - except ValueError as exc: - raise ValueError( - f"Invalid indent in format spec: '{fmt_spec[:-1]}'" - ) from exc - if indent <= 0: - raise ValueError("Indentation must be a positive integer") - else: - raise ValueError( - f"Unknown format code '{spec}' for {self.__class__.__name__}" - ) + option = orjson.OPT_SORT_KEYS + if has_i: + option |= orjson.OPT_INDENT_2 - return json.dumps( + return orjson.dumps( self.__public_dict__(), default=default, - indent=indent, - separators=separators, - sort_keys=True, - ensure_ascii=False - ) + option=option, + ).decode() def __public_dict__(self) -> dict[str, DISCOSNamespace]: """ diff --git a/discos_client/initializer.py b/discos_client/initializer.py index b5bad08..4ff8e64 100644 --- a/discos_client/initializer.py +++ b/discos_client/initializer.py @@ -1,10 +1,10 @@ from __future__ import annotations import re -import json from pathlib import Path from typing import Any from importlib.resources import files from collections.abc import Iterable +import orjson from .utils import META_KEYS from .namespace import DISCOSNamespace @@ -22,8 +22,10 @@ class NSInitializer: builds a mapping between logical topic names and absolute schema IDs. It finally provides :meth:`initialize`, which constructs the initial - `DISCOSNamespace` tree for a topic, enriched with schema metadata - and with all required/initialized fields present. + :class:`~discos_client.namespace.DISCOSNamespace` tree for a topic. + The tree is a **stable view** over a shared plain-dict data store: + each namespace node holds a reference to its parent container and key + rather than owning its value directly. """ def __init__(self, telescope: str | None = None): @@ -40,7 +42,7 @@ def __init__(self, telescope: str | None = None): """ base_dir = files("discos_client") / "schemas" self._pp_cache: \ - dict[int, list[tuple[str, "re.Pattern", str, dict]]] = {} + dict[int, list[tuple[str, "re.Pattern | None", dict]]] = {} self.schemas, definitions, self.node_to_id = \ self._load_schemas(base_dir, telescope) @@ -68,30 +70,32 @@ def initialize( reactive: bool = True, ) -> DISCOSNamespace: """ - Build the initial :class:`DISCOSNamespace` for the given topic. + Build the initial :class:`~discos_client.namespace.DISCOSNamespace` + tree for the given topic. - The namespace contains: - * All required fields from the schema. - * All fields listed in the schema's ``initialize`` array. - * Metadata fields copied from the schema. - * Proper structure for objects, arrays and primitives. + The tree is a **stable view** over a freshly allocated plain-dict + data store. The dict is populated with ``None`` for all primitive + leaves and empty dicts/lists for object/array nodes, covering every + field declared as ``required`` or ``initialize`` in the schema. + + The namespace tree mirrors this dict structure: each node holds a + reference to its *parent container* and its *key* rather than + embedding the value. Serialisation and updates both operate on the + plain dict, keeping DISCOSNamespace out of the hot path. :param topic: Logical topic name (schema ``node`` value). - :return: A fully initialized namespace tree ready to receive updates. + :param reactive: Whether to attach ``bind`` / ``unbind`` / ``wait`` + to the namespace nodes. + :return: A fully initialised namespace tree ready to receive updates. :raises ValueError: If the topic does not correspond to a loaded schema. """ if topic not in self.node_to_id: # pragma: no cover raise ValueError(f"Schema '{topic}' was not loaded.") - node_id = self.node_to_id[topic] - schema = self.schemas[node_id] - payload = self._initialize_from_schema(schema) - return DISCOSNamespace( - schema=schema, - node_name=topic, - reactive=reactive, - **payload - ) + schema = self.schemas[self.node_to_id[topic]] + data = self._build_data_dict(schema) + wrapper: dict[str, Any] = {topic: data} + return self._build_ns_tree(wrapper, topic, schema, reactive) def get_topics(self) -> list[str]: """ @@ -101,28 +105,203 @@ def get_topics(self) -> list[str]: """ return self.available_topics - def _literal_prefix(self, pat: str) -> str: + def _build_data_dict(self, schema: dict[str, Any]) -> dict[str, Any]: + """ + Build an initial **plain** data dict from *schema*. + + Only structural types are represented — no schema metadata is + embedded. Required and ``initialize`` fields are included; + optional fields absent from ``initialize`` are omitted. + + :param schema: Fully resolved and merged JSON Schema object. + :return: Plain dict with ``None`` for primitives, ``{}`` for objects, + ``[]`` for arrays. """ - Extract the literal prefix of a regex pattern. + result: dict[str, Any] = {} + required, initialize = self._collect_init_keys(schema) + for key in required | initialize: + prop_schema = self._find_property_schema(schema, key) + if prop_schema is None: # pragma: no cover + continue + result[key] = self._initial_value_for(prop_schema) + return result - The prefix consists of non-metacharacter characters up to the first - special symbol and is used to optimize ``patternProperties`` matching. + def _initial_value_for(self, schema: dict[str, Any]) -> Any: + """ + Return the appropriate initial value for a single property. - :param pat: Regular expression pattern. - :return: Literal prefix extracted from the pattern. + :param schema: Property schema. + :return: ``{}`` for objects, ``[]`` for arrays, ``None`` for + primitives. """ - i = 0 - if pat.startswith('^'): - i = 1 - out = [] - meta = set('.^$*+?[]{}()|\\') - while i < len(pat): - c = pat[i] - if c in meta: - break - out.append(c) # pragma: no cover - i += 1 # pragma: no cover - return ''.join(out) + t = schema.get("type") + if t == "object": + return self._build_data_dict(schema) + if t == "array": + return [] + return None + + def _build_ns_tree( + self, + parent_container: dict | list, + key: str | int, + schema: dict[str, Any], + reactive: bool, + ) -> DISCOSNamespace: + """ + Recursively build a :class:`~discos_client.namespace.DISCOSNamespace` + tree rooted at ``parent_container[key]``. + + The namespace node receives: + + * Schema metadata (``title``, ``description``, ``unit``, ``enum``, + ``format``, ``type``) stored in ``_schema_meta``. + * Pre-compiled ``patternProperties`` patterns stored in + ``_pattern_schemas`` so that dynamically keyed children (e.g. + individual backend or derotator instances) can be created on first + arrival without a reference back to this initializer. + * Pre-built child namespaces for every key present in the initial + data dict (objects) or every index present in the initial list + (arrays). + + :param parent_container: The dict or list containing the node to wrap. + :param key: Key / index of the node inside *parent_container*. + :param schema: JSON Schema for this node. + :param reactive: Whether to attach reactive helpers to nodes. + :return: Root of the constructed namespace sub-tree. + """ + schema_meta = {k: schema[k] for k in META_KEYS if k in schema} + ns = DISCOSNamespace(parent_container, key, schema_meta, reactive) + node = parent_container[key] + if isinstance(node, dict): + self._attach_pattern_schemas(ns, schema) + properties = schema.get("properties", {}) + required, initialize = self._collect_init_keys(schema) + keys_to_build = (required | initialize) & set(node.keys()) + for k in keys_to_build: + prop_schema = ( + properties.get(k) + or self._find_property_schema(schema, k) + or {} + ) + child_ns = self._build_ns_tree(node, k, prop_schema, reactive) + ns._children[k] = child_ns + elif isinstance(node, list): + item_schema = schema.get("items") or {} + item_full_meta = self._build_meta_from_schema(item_schema) + object.__setattr__(ns, '_item_full_meta', item_full_meta) + for i, _ in enumerate(node): + child_ns = self._build_ns_tree(node, i, item_schema, reactive) + ns._children[i] = child_ns + return ns + + def _attach_pattern_schemas( + self, + ns: DISCOSNamespace, + schema: dict[str, Any], + ) -> None: + """ + Populate ``_pattern_schemas`` on *ns* from the schema's + ``patternProperties``, using the precompiled cache built during + :meth:`__init__`. + + ``patternProperties`` is searched at two levels: + + * The top-level schema (e.g. active surface sector nodes). + * Each branch of an ``anyOf`` block. Schemas like ``backends``, + ``receivers`` and ``derotators`` use ``anyOf`` with one fixed branch + and one ``patternProperties`` branch for dynamically named instances + (``SARDARA``, ``CCB``, etc.). Without this second pass those topics + would never get dynamic child namespaces created on first message + arrival. + + This allows + :meth:`~discos_client.namespace.DISCOSNamespace._merge_dict` + to create namespace children for dynamic keys without holding a + reference back to this initializer. + + :param ns: The namespace node to enrich. + :param schema: The schema for that node, potentially containing + ``patternProperties`` at the top level or inside + ``anyOf`` branches. + """ + pp_dicts = [] + top_pp = schema.get("patternProperties") + if top_pp: + pp_dicts.append(top_pp) + for alt in schema.get("anyOf", []): + if isinstance(alt, dict): + alt_pp = alt.get("patternProperties") + if alt_pp: + pp_dicts.append(alt_pp) + + if not pp_dicts: + return + + pattern_schemas = [] + for pp in pp_dicts: + pp_list = self._pp_cache.get(id(pp), []) + for _, rx, pschema in pp_list: + if rx is not None: + full_meta = self._build_meta_from_schema(pschema) + pattern_schemas.append((rx, full_meta)) + + if pattern_schemas: + object.__setattr__(ns, '_pattern_schemas', pattern_schemas) + + def _collect_init_keys( + self, + schema: dict[str, Any] + ) -> tuple[set[str], set[str]]: + """ + Recursively collect all ``required`` and ``initialize`` fields declared + in a schema, including those defined inside ``anyOf`` branches. + + :param schema: A JSON Schema object, potentially containing ``anyOf`` + branches and local ``required`` / ``initialize`` + sections. + :return: A tuple where each element is a set of field names + aggregated from the entire schema hierarchy. + """ + required: set[str] = set(schema.get("required", [])) + initialize: set[str] = set(schema.get("initialize", [])) + + any_of = schema.get("anyOf") + if isinstance(any_of, list): + for alt in any_of: + if isinstance(alt, dict): + r_alt, i_alt = self._collect_init_keys(alt) + required |= r_alt + initialize |= i_alt + + return required, initialize + + def _find_property_schema( + self, + schema: dict[str, Any], + key: str + ) -> dict[str, Any] | None: + """ + Locate the schema of a named property within a schema. + + The property is first searched in the top-level ``properties`` + dictionary, then inside any ``anyOf`` alternatives. + + :param schema: Schema object in which to search. + :param key: Name of the property to look for. + :return: The matching property schema, or ``None`` if not found. + """ + props = schema.get("properties", {}) + if key in props: + return props[key] + any_of = schema.get("anyOf") + if isinstance(any_of, list): + for alt in any_of: + if isinstance(alt, dict): + found = self._find_property_schema(alt, key) + if found is not None: + return found + return None # pragma: no cover def _precompile_patternprops(self, obj: dict | list) -> None: """ @@ -166,29 +345,26 @@ def _walk_dicts(self, root: dict | list) -> Iterable[dict]: def _build_pp_list( self, pp: dict - ) -> list[tuple[str, re.Pattern | None, str, dict]]: + ) -> list[tuple[str, re.Pattern | None, dict]]: """ - Converts a ``patternProperties`` dictionary into a list of compiled + Convert a ``patternProperties`` dictionary into a list of compiled entries. Each entry contains: - * the raw pattern - * the compiled regex (or ``None`` if invalid) - * the literal prefix + * the raw pattern string + * the compiled regex (or ``None`` if compilation fails) * the associated property schema :param pp: Dictionary of raw patternProperties. - :return: Precompiled patternProperties entries. """ - compiled: list[tuple[str, re.Pattern | None, str, dict]] = [] + compiled: list[tuple[str, re.Pattern | None, dict]] = [] for pat, pschema in pp.items(): try: rx = re.compile(pat) except re.error: # pragma: no cover rx = None - pref = self._literal_prefix(pat) - compiled.append((pat, rx, pref, pschema)) + compiled.append((pat, rx, pschema)) return compiled def _load_schemas( @@ -209,9 +385,7 @@ def _load_schemas( :param base_dir: Base directory containing the schema tree. :param telescope: Optional telescope name. - :return: A tuple ``(schemas, definitions, node_to_id)``. - :raises FileNotFoundError: If the definitions directory is missing. :raises ValueError: If a schema is missing its ``node`` field. """ @@ -227,7 +401,7 @@ def _load_schemas( for f in definitions_dir.iterdir(): if f.is_file() and f.name.endswith(".json"): rel_path = f.resolve().relative_to(base_dir).as_posix() - schema = json.loads(f.read_text(encoding="utf-8")) + schema = orjson.loads(f.read_text(encoding="utf-8")) self._absolutize_refs(schema, base_dir, rel_path) schema_id = schema.get("$id", rel_path) definitions[schema_id] = schema @@ -236,7 +410,7 @@ def _load_schemas( if f.is_file() and f.name.endswith(".json"): rel_path = \ f.resolve().relative_to(base_dir).as_posix() - schema = json.loads(f.read_text(encoding="utf-8")) + schema = orjson.loads(f.read_text(encoding="utf-8")) self._absolutize_refs(schema, base_dir, rel_path) schema_id = schema.get("$id", rel_path) node_name = schema.get("node") @@ -257,9 +431,6 @@ def _absolutize_refs( """ Rewrite all ``$ref`` values in a schema to canonical absolute paths. - Relative references are normalized with respect to the current - file and base directory so they can be resolved consistently. - :param schema: Schema whose references will be rewritten in-place. :param base_dir: Base directory containing the schema tree. :param current_file: Path of the schema file currently being processed, @@ -291,12 +462,6 @@ def _normalize_ref( """ Normalize a single ``$ref`` value into an absolute, canonical form. - This handles: - * Pure fragment references (starting with ``#``). - * Relative paths (including ``..`` segments) resolved against - the current file and base directory. - * Optional fragments appended to the resolved path. - :param ref: Raw reference string as found in the schema. :param base_dir: Base directory containing all schemas. :param current_file: Path of the file that owns the reference. @@ -326,8 +491,6 @@ def _expand_refs( """ Recursively resolve all ``$ref`` occurrences inside a schema. - Referenced definitions are merged with inline overrides. - :param schema: Schema containing references. :param definitions: Mapping of absolute definition identifiers to their content. @@ -356,9 +519,6 @@ def _merge_all_of(self, schema: dict[str, Any]) -> dict[str, Any]: """ Recursively merge all ``allOf`` blocks in the schema. - ``properties`` and ``patternProperties`` are combined, - ``required`` fields are unioned, and ``initialize`` arrays are merged. - :param schema: Schema object containing ``allOf`` blocks. :return: Schema with all ``allOf`` sections flattened. """ @@ -380,14 +540,6 @@ def _merge_subschemas( """ Merge multiple subschemas into a single schema object. - This helper is used to flatten ``allOf`` blocks. It: - - * Merges ``properties`` and ``patternProperties``. - * Unions all ``required`` fields. - * Unions all ``initialize`` fields. - * Copies any other keys, letting later subschemas override - earlier ones. - :param subschemas: List of schema fragments to merge. :return: A single schema representing the merged subschemas. """ @@ -419,255 +571,15 @@ def _merge_subschemas( merged["initialize"] = list(sorted(initialize_fields)) return merged - def _replace_patterns_with_properties( - self, - schema: dict[str, Any], - message: dict[str, Any] - ) -> dict[str, Any]: - """ - Optionally strip ``patternProperties`` from a schema. - - If no message data is available, any ``patternProperties`` section - is removed from the returned schema copy. Otherwise the schema - is left unchanged. - - :param schema: Schema object that may contain ``patternProperties``. - :param message: Message payload used to decide whether patterns should - be retained or removed. - :return: The original schema or a shallow copy without - ``patternProperties``. - """ - pp = schema.get("patternProperties") - if not pp or not message: - if "patternProperties" in schema: - out = dict(schema) - out.pop("patternProperties", None) - return out - return schema - - def _enrich_properties( - self, - schema: dict[str, Any], - values: dict[str, Any], - ) -> dict[str, Any]: - """ - Enrich all relevant properties for an object schema. - - Only properties that are required or present in ``values`` are - processed. Each selected property is converted into its enriched - representation, including metadata and nested structures. - - :param schema: Object schema definition. - :param values: Current values for the object, used to decide which - properties to include and how to initialize them. - :return: A dictionary mapping property names to enriched values. - """ - schema = self._replace_patterns_with_properties(schema, values) - properties = schema.get("properties", {}) - required = set(schema.get("required", [])) - initialize = set(schema.get("initialize", [])) - result: dict[str, Any] = {} - for key, prop_schema in properties.items(): - if key in required.union(initialize) or key in values: - prop_schema = self._replace_patterns_with_properties( - prop_schema, - values.get(key, {}) - ) - result[key] = self._enrich_named_property( - key, prop_schema, values - ) + def _build_meta_from_schema(self, schema: dict) -> dict: + result = {k: schema[k] for k in META_KEYS if k in schema} + for key, prop_schema in schema.get("properties", {}).items(): + child_meta = self._build_meta_from_schema(prop_schema) + if child_meta: + result[key] = child_meta + items_schema = schema.get("items") + if isinstance(items_schema, dict): + item_meta = self._build_meta_from_schema(items_schema) + if item_meta: + result["items"] = [item_meta] return result - - def _meta(self, d: dict[str, Any]) -> dict[str, Any]: - """ - Extract metadata keys from a schema dictionary. - - Only keys listed in :data:`META_KEYS` are preserved. - - :param d: Source dictionary, typically a schema fragment. - :return: Dictionary containing only the metadata entries. - """ - return { - k: d[k] - for k in META_KEYS - if k in d - } - - def _without(self, d: dict[str, Any], *keys: str) -> dict[str, Any]: - """ - Return a shallow copy of a dictionary without the given keys. - - :param d: Original dictionary. - :param keys: Keys to exclude from the result. - :return: New dictionary without the specified keys. - """ - return { - k: v - for k, v in d.items() - if k not in keys - } - - def _enrich_object( - self, - obj_schema: dict[str, Any], - obj_value: Any - ) -> dict[str, Any]: - """ - Enrich an object-typed property according to its schema. - - Nested properties are enriched recursively and combined with the - metadata extracted from the object schema itself. - - :param obj_schema: Schema definition for the object. - :param obj_value: Current value for the object, expected to be a - dictionary or ``None``. - :return: Enriched object containing metadata and nested fields. - """ - nested_values = obj_value if isinstance(obj_value, dict) else {} - nested = self._enrich_properties(obj_schema, nested_values) - meta = self._meta(obj_schema) - if nested: - meta.update(nested) - return meta - - def _enrich_array( - self, - arr_schema: dict[str, Any], - arr_value: Any - ) -> dict[str, Any]: - """ - Enrich an array-typed property according to its schema. - - When no structured value is provided, the method returns a - metadata dictionary with an empty ``value`` list and without - the ``items`` key from the schema. - - :param arr_schema: Schema definition for the array. - :param arr_value: Current value for the array. - :return: Enriched array representation or an empty dictionary. - """ - out = {} - if not isinstance(arr_value, dict): - out = self._without(arr_schema, "items") - out["value"] = [] - return out - - def _enrich_named_property( - self, - key: str, - schema: dict[str, Any], - values: dict[str, Any] - ) -> dict[str, Any]: - """ - Enrich a single named property according to its type. - - Objects and arrays are delegated to their specific helpers, - while primitive types are wrapped with metadata and a - ``value`` field. - - :param key: Property name. - :param schema: Schema definition for the property. - :param values: Dictionary containing current values for the parent - object. - :return: Enriched representation for the property. - """ - value = values.get(key, None) - t = schema.get("type") - - if t == "object": - return self._enrich_object(schema, value) - if t == "array": - return self._enrich_array(schema, value) - out = self._meta(schema) - out["value"] = value - return out - - def _collect_init_keys( - self, - schema: dict[str, Any] - ) -> tuple[set[str], set[str]]: - """ - Recursively collect all ``required`` and ``initialize`` fields declared - in a schema, including those defined inside ``anyOf`` branches. - - :param schema: A JSON Schema object, potentially containing ``anyOf`` - branches and local ``required`` / ``initialize`` - sections. - :return: A tuple where each element is a set of field names - aggregated from the entire schema hierarchy. - """ - required: set[str] = set(schema.get("required", [])) - initialize: set[str] = set(schema.get("initialize", [])) - - any_of = schema.get("anyOf") - if isinstance(any_of, list): - for alt in any_of: - if isinstance(alt, dict): - r_alt, i_alt = self._collect_init_keys(alt) - required |= r_alt - initialize |= i_alt - - return required, initialize - - def _initialize_from_schema( - self, - schema: dict[str, Any] - ) -> dict[str, Any]: - """ - Build the initial payload from a schema. - - Includes: - * All fields required by the schema. - * All fields listed under ``initialize``. - * Metadata fields defined in the schema. - * Correctly initialized structures for objects, arrays and leaf nodes. - - :param schema: Fully normalized JSON schema. - :return: Initial structured payload used to construct a namespace. - """ - required, initialize = self._collect_init_keys(schema) - result: dict[str, Any] = {} - - for key in required.union(initialize): - prop_schema = self._find_property_schema(schema, key) - if prop_schema is None: # pragma: no cover - continue - prop_schema = self._replace_patterns_with_properties( - prop_schema, - {} - ) - result[key] = self._enrich_named_property( - key, - prop_schema, - {} - ) - meta = self._meta(schema) - meta.update(result) - return meta - - def _find_property_schema( - self, - schema: dict[str, Any], - key: str - ) -> dict[str, Any] | None: - """ - Locate the schema of a named property within a schema. - - The property is first searched in the top-level ``properties`` - dictionary, then inside any ``anyOf`` alternatives. - - :param schema: Schema object in which to search. - :param key: Name of the property to look for. - :return: The matching property schema, or ``None`` if not found. - """ - props = schema.get("properties", {}) - if key in props: - return props[key] - any_of = schema.get("anyOf") - if isinstance(any_of, list): - for alt in any_of: - if isinstance(alt, dict): - found = self._find_property_schema(alt, key) - if found is not None: - return found - return None # pragma: no cover diff --git a/discos_client/namespace.py b/discos_client/namespace.py index 7477373..2a80b91 100644 --- a/discos_client/namespace.py +++ b/discos_client/namespace.py @@ -1,299 +1,251 @@ from __future__ import annotations -import re -import json import threading from copy import deepcopy -from collections.abc import Iterable from typing import Any, Callable, Iterator -from .utils import delegated_operations, delegated_comparisons -from .utils import public_dict, META_KEYS +import orjson +from .utils import delegated_operations, delegated_comparisons, META_KEYS __all__ = ["DISCOSNamespace"] +def _plain_merge(target: dict, source: dict) -> bool: + """Recursively merge *source* into *target* without any namespace + involvement. Used for dict sub-nodes that have no pre-built child + namespace (e.g. dynamic pattern-property keys not yet in the tree). + + :return: True if at least one value changed. + """ + changed = False + for k, v in source.items(): + tv = target.get(k) + if isinstance(v, dict) and isinstance(tv, dict): + if _plain_merge(tv, v): + changed = True + elif tv != v: + target[k] = v + changed = True + return changed + + +def _snapshot_tree( + parent_container: dict | list, + key: str | int, + schema_meta: dict, + orig_children: dict +) -> "DISCOSNamespace": + """Recursively build a non-reactive snapshot namespace pointing into a + *copy* of the data. Used by :meth:`DISCOSNamespace.__copy__` and + :meth:`DISCOSNamespace.__deepcopy__`. + """ + ns = DISCOSNamespace(parent_container, key, schema_meta, reactive=False) + node = parent_container[key] + if isinstance(node, dict): + for k, child in orig_children.items(): + if k in node: + child_snap = _snapshot_tree( + node, k, + child._schema_meta, + child._children + ) + ns._children[k] = child_snap + elif isinstance(node, list): + for i in range(len(node)): + child = orig_children.get(i) + if child is not None: + child_snap = _snapshot_tree( + node, i, + child._schema_meta, + child._children + ) + ns._children[i] = child_snap + return ns + + @delegated_operations('__value_operation__') @delegated_comparisons('__value_comparison__') class DISCOSNamespace: - """ - Read-only recursive container for structured data. - - This class wraps nested dictionaries and lists into nested - DISCOSNamespace instances and allows limited operations on - primitive values. All attributes are read-only. + """Stable view node over a shared plain-dict data store. + + The tree of :class:`DISCOSNamespace` objects is built once by + :class:`~discos_client.initializer.NSInitializer` and never structurally + changes (except for array resizes and new dynamic keys). All actual data + lives in a plain Python dict/list hierarchy; each node keeps a reference + to its *parent container* and its *key* inside that container, so that + :meth:`_get_node` amounts to a single ``O(1)`` dict/list lookup. + + Consequences of this design: + + * Object identity is **stable**: + ``client.antenna is client.antenna`` → True. + * ``<<=`` is a plain ``dict`` deep-merge with no per-node object + allocation, dropping the recursive :class:`DISCOSNamespace` traversal. + * Serialisation (``format``, ``str``) calls ``json.dumps`` directly on + the plain data dict — pure C, no Python ``unwrap`` recursion. + * Per-node :class:`threading.RLock` is gone; the GIL protects single- + bytecode reads/writes (see analysis in commit history). Only the + observer list uses an explicit :class:`threading.Lock`. """ __typename__ = "DISCOSNamespace" - __private__ = frozenset({ - "_lock", - "_observers", - "_observers_lock", - "_schema", - "_reactive", - "_node_name", - "get_value", - "bind", - "unbind", - "wait", - "copy" - }) def __init__( self, - schema: dict[str, Any] | None = None, - node_name: str | None = None, + parent_dict: dict | list, + key: str | int, + schema_meta: dict | None = None, reactive: bool = True, - **kwargs: Any ) -> None: """ - Construct a DISCOSNamespace object, recursively wrapping - dictionaries and lists as DISCOSNamespace instances. - - Special keys "items" and "value" are stored as internal - value containers. - Key "schema" represent the schema of the object tree, holding metadata. - - :param schema: The schema of the object tree. - :param reactive: Whether the object should expose the bind, copy, - unbind and wait methods. - :param kwargs: Arbitrary keyword arguments to initialize attributes. - """ - object.__setattr__(self, "_lock", threading.RLock()) - object.__setattr__(self, "_observers", {}) - object.__setattr__(self, "_observers_lock", threading.Lock()) - object.__setattr__(self, "_schema", schema) - object.__setattr__(self, "_node_name", node_name) - object.__setattr__(self, "_reactive", reactive) - + :param parent_dict: The dict or list that *contains* this node. + :param key: The key / index of this node inside *parent_dict*. + :param schema_meta: Static metadata extracted from the JSON Schema + (``title``, ``description``, ``unit``, ``enum``, + ``format``, ``type``). + :param reactive: Whether to expose ``bind``, ``unbind``, ``wait`` and + ``copy``. + """ + object.__setattr__(self, '_parent_dict', parent_dict) + object.__setattr__(self, '_key', key) + object.__setattr__(self, '_schema_meta', schema_meta or {}) + object.__setattr__(self, '_item_full_meta', {}) + object.__setattr__(self, '_pattern_schemas', []) + object.__setattr__(self, '_children', {}) + object.__setattr__(self, '_reactive', reactive) if reactive: - object.__setattr__(self, "bind", self.__bind__) - object.__setattr__(self, "copy", self.__copy__) - object.__setattr__(self, "unbind", self.__unbind__) - object.__setattr__(self, "wait", self.__wait__) - - meta: dict[str, Any] = {} - if schema is not None: - for mk in META_KEYS: - if mk in schema: - meta[mk] = schema[mk] - self.__dict__.update(meta) - - clean_kwargs: dict[str, Any] = {} - for k, v in list(kwargs.items()): - if k in ["items", "value"]: - clean_kwargs["_value"] = self._wrap_value( - v, - schema, - k, - reactive - ) - else: - subschema = None - if not k.startswith("_"): - subschema = self._find_subschema(schema, k) - clean_kwargs[k] = self._wrap_value( - v, - subschema, - k, - reactive - ) - self.__dict__.update(clean_kwargs) - if self.__has_value__(self) and not self.__is__(self._value): - object.__setattr__(self, "get_value", self.__get_value__) - - @staticmethod - def _find_subschema( - schema: dict[str, Any] | None, - key: str - ) -> dict[str, Any] | None: - """ - Search and finds the subschema for a given key, used when creating a - subnode. - - :param schema: The object schema. - :param key: The key used to search for a subschema. - :return: The schema for the given key, or None if not found. - """ - if schema is None: - return None + object.__setattr__(self, '_observers', []) + object.__setattr__(self, '_observers_lock', threading.Lock()) + object.__setattr__(self, 'bind', self.__bind__) + object.__setattr__(self, 'unbind', self.__unbind__) + object.__setattr__(self, 'wait', self.__wait__) + object.__setattr__(self, 'copy', self.__copy__) + node = parent_dict[key] + if not isinstance(node, (dict, list)): + object.__setattr__(self, 'get_value', self.__get_value__) + + def _get_node(self) -> Any: + """Return the current value of this node (single dict/list lookup).""" + return self._parent_dict[self._key] - props = schema.get("properties", {}) - if key in props: - return props[key] - - pprops = schema.get("patternProperties", {}) - for pat, pschema in pprops.items(): - try: - if re.fullmatch(pat, key): - return pschema - except re.error: # pragma: no cover - continue - - any_of = schema.get("anyOf") - if isinstance(any_of, list): - for branch in any_of: - found = DISCOSNamespace._find_subschema(branch, key) - if found is not None: - return found - - return None + def __get_value__(self) -> Any: + """Return the primitive value held by this leaf node. - @staticmethod - def _wrap_value( - value: Any, - schema: dict[str, Any] | None, - node_name: str | None, - reactive: bool = True - ) -> Any: - """ - Transforms dictionaries and lists to DISCOSNamespace objects. - - :param value: The value to be transformed to DISCOSNamespace if dict or - list. - :param schema: The schema representing the object. - :param reactive: Whether the object should expose the bind, copy, - unbind and wait methods. - :return: The wrapped value if dict or list, value otherwise. + :raises TypeError: If this node contains a dict or list. """ - if isinstance(value, dict): - return DISCOSNamespace( - schema=schema, - node_name=node_name, - reactive=reactive, - **value - ) - if isinstance(value, list): - item_schema = None - if schema is not None and schema.get("type") == "array": - item_schema = schema.get("items") - return DISCOSNamespace( - schema=schema, - node_name=node_name, - reactive=reactive, - value=tuple( - DISCOSNamespace( - schema=item_schema, - node_name=node_name, - reactive=reactive, - **v - ) - if isinstance(v, dict) else v - for v in value - ) + node = self._parent_dict[self._key] + if isinstance(node, (dict, list)): + raise TypeError( + f"{self.__typename__} does not hold a primitive value" ) - return value + return node + + def __getattr__(self, name: str) -> Any: + """Return a pre-built child namespace or a schema metadata value. + + Attribute access never traverses the data dict; child namespaces are + looked up in ``_children`` by name, which is an ``O(1)`` dict lookup. + + :raises AttributeError: If the attribute is not found. + """ + children = object.__getattribute__(self, '_children') + if name in children: + return children[name] + meta = object.__getattribute__(self, '_schema_meta') + if name in meta: + return meta[name] + node = object.__getattribute__(self, '_parent_dict')[ + object.__getattribute__(self, '_key') + ] + if not isinstance(node, (dict, list)) and node is not None: + try: + return getattr(node, name) + except AttributeError: + pass + raise AttributeError( + f"'{self.__typename__}' object has no attribute '{name}'" + ) - def __get_value__(self) -> Any: - """ - Return the internal primitive value. + def __setattr__(self, name: str, value: Any) -> None: + raise TypeError( + f"{self.__typename__} is read-only and " + "does not allow attribute assignment" + ) - :return: The internal value of the instance. - """ - return self._value + def __delattr__(self, name: str) -> None: + raise TypeError( + f"{self.__typename__} is read-only and " + "does not allow attribute deletion" + ) - def __bind__( - self, - callback: Callable[[DISCOSNamespace], None], - predicate: Callable[[DISCOSNamespace], bool] = None, - unwrap: bool = False - ) -> None: - """ - Bind a callback to the DISCOSNamespace object, - to be notified when it changes. - - :param callback: A function that receives the updated object - when obj changes. - :param predicate: Optional predicate that the value must satisfy - :param unwrap: If True, evaluates the predicate and calls the callback - passing the internal primitive value instead of the - namespace. - """ - with self._observers_lock: - pred = predicate if predicate is not None else lambda _: True - self._observers.setdefault(callback, set()).add((pred, unwrap)) + def __getitem__(self, item: int) -> Any: + """Return the indexed child namespace for array nodes. - def __unbind__( - self, - callback: Callable[[DISCOSNamespace], None] | None = None, - predicate: Callable[[DISCOSNamespace], bool] = None - ) -> None: + :raises TypeError: If this node has no indexed children. """ - Unbind a previously registered callback from the DISCOSNamespace - object. + children = self._children + if item in children: + return children[item] + raise TypeError(f"{self.__typename__} object is not subscriptable") - :param callback: The callback function to remove. - :param predicate: The predicate associated to the function to remove. - If `None`, all the callbacks of that type are - removed. - """ - with self._observers_lock: - if callback is None: - self._observers.clear() - return - if callback not in self._observers: - return - if predicate is not None: - to_remove = [ - p_tuple - for p_tuple in self._observers[callback] - if p_tuple[0] == predicate - ] - for p_tuple in to_remove: - self._observers[callback].discard(p_tuple) - if predicate is None or not self._observers[callback]: - del self._observers[callback] + def __len__(self) -> int: + node = self._get_node() + if isinstance(node, (list, tuple)): + return len(node) + raise TypeError(f"{self.__typename__} object has no length") - def __wait__( - self, - predicate: Callable[[DISCOSNamespace], bool] = None, - timeout: float | None = None, - unwrap: bool = False - ) -> Any: - """ - Block until the DISCOSNamespace triggers a change notification. + def __iter__(self) -> Iterator: + node = self._get_node() + if isinstance(node, list): + children = self._children + return (children[i] for i in range(len(node))) + raise TypeError(f"{self.__typename__} object is not iterable") - :param predicate: Optional predicate that the value must satisfy. - :param timeout: Optional timeout in seconds. - :param unwrap: If True, the predicate operates on the internal value, - and the internal value itself is returned. - :return: The updated object, or the same object if timeout has expired. - """ - event = threading.Event() + def __bool__(self) -> bool: + node = self._get_node() + if isinstance(node, (bool, int, float, str)): + return bool(node) + raise TypeError( + f"{self.__typename__} object cannot be converted to bool" + ) - def callback(_): - event.set() + def __int__(self) -> int: + node = self._get_node() + if isinstance(node, (int, float)): + return int(node) + raise TypeError( + f"{self.__typename__} object cannot be converted to int" + ) - self.bind(callback, predicate, unwrap=unwrap) - try: - event.wait(timeout) - finally: - self.unbind(callback, predicate) - with self._lock: - if unwrap and self.__has_value__(self): - return self._value - return self + def __float__(self) -> float: + node = self._get_node() + if isinstance(node, (int, float)): + return float(node) + raise TypeError( + f"{self.__typename__} object cannot be converted to float" + ) - def __copy__(self) -> DISCOSNamespace: - """ - Return a copy of the DISCOSNamespace. + def __neg__(self) -> Any: + node = self._get_node() + if isinstance(node, (int, float)): + return -node + raise TypeError(f"{self.__typename__} object cannot be negated") - :return: a deep copy of the instance. - """ - with self._lock: - return deepcopy(self) + def __abs__(self) -> Any: + node = self._get_node() + if isinstance(node, (int, float)): + return abs(node) + raise TypeError(f"{self.__typename__} object is not a numeric type.") - def __value_operation__(self, operation: Callable[[Any], Any]) -> Any: - """ - Apply an operation to the internal value if it is primitive. + def __round__(self, n: int = 0) -> Any: + node = self._get_node() + if isinstance(node, (int, float)): + return round(node, n) + raise TypeError(f"{self.__typename__} object cannot be rounded.") - :param operation: A function to apply. - :return: Result of applying the operation to the internal value. - :raises TypeError: If the object does not hold a primitive value. - """ - if self.__has_value__(self) and \ - not DISCOSNamespace.__is__(self._value): - with self._lock: - return operation(self._value) + def __value_operation__(self, operation: Callable[[Any], Any]) -> Any: + node = self._get_node() + if not isinstance(node, (dict, list)) and node is not None: + return operation(node) raise TypeError( f"{self.__typename__} supports operations " "only when holding a primitive value" @@ -304,619 +256,514 @@ def __value_comparison__( op: Callable[[Any, Any], bool], other: Any ) -> bool | type(NotImplemented): - """ - Apply a comparison to the internal value if it is primitive, - or to the inner __dict__ if both operands are DISCOSNamespace - - :param op: The comparison function to apply on the instance. - :param other: The second operand for the comparison. - :return: - - True if the comparison matches, False otherwise. - - NotImplemented if the comparison is not supported for the given - operands - """ - if DISCOSNamespace.__is__(other): + if isinstance(other, DISCOSNamespace): try: - return op( - { - k: v - for k, v in vars(self).items() - if not k.startswith("_") or k == "_value" - }, - { - k: v - for k, v in vars(other).items() - if not k.startswith("_") or k == "_value" - }, - ) + return op(self._get_node(), other._get_node()) except TypeError: return False - if DISCOSNamespace.__has_value__(self): - return op(self._value, other) + node = self._get_node() + if not isinstance(node, (dict, list)): + return op(node, other) return NotImplemented - def __repr__(self) -> str: - """ - Return an unambiguous string representation of the instance. - - :return: Unanbiguous string representation of the instance. - """ - with self._lock: - if self.__has_value__(self): - return repr(self._value) - return f"<{self.__typename__}({self.__value_repr__(self)})>" + def __ilshift__(self, other: Any) -> "DISCOSNamespace": + """Update this node in-place with *other*. - def __str__(self) -> str: - """ - Return a human readable string representation of the instance. + * **dict** → deep-merge into the data dict, notify changed nodes. + * **list** → update array contents, rebuild children if length changed. + * **DISCOSNamespace** → unwrap and apply its current data. + * **primitive** → update the stored scalar value. - :return: Human readable string representation of the instance. + :raises TypeError: If *other* has an unsupported type. """ - with self._lock: - if self.__has_value__(self): - return str(self._value) - return format(self, "") - - def __int__(self) -> int: - """ - Convert the internal value to an integer. - - :return: Integer representation of the internal value. - :raises TypeError: If the instance has no internal value, or it cannot - be converted to integer. - """ - with self._lock: - if self.__has_value__(self): - return int(self._value) - raise TypeError( - f"{self.__typename__} object cannot be converted to int" - ) - - def __float__(self) -> float: - """ - Convert the internal value to a float. - - :return: Floating-point representation of the internal value. - :raises TypeError: If the instance has no internal value, or it cannot - be converted to float. - """ - with self._lock: - if self.__has_value__(self): - return float(self._value) - raise TypeError( - f"{self.__typename__} object cannot be converted to float" - ) - - def __neg__(self) -> Any: - """ - Return the arithmetic negation of the internal value. - - :return: The negated value of the internal value. - :raises TypeError: If the instance has no internal value, or it is not - a numeric type. - """ - with self._lock: - if self.__has_value__(self): - return -self._value - raise TypeError( - f"{self.__typename__} object cannot be negated" - ) - - def __abs__(self) -> Any: - """ - Return the absolute value of the internal value. - - :return: The absolute value of the internal value. - :raises TypeError: If the instance has no internal value, or it is not - a numeric type. - """ - with self._lock: - if self.__has_value__(self): - return abs(self._value) - raise TypeError( - f"{self.__typename__} object is not a numeric type." - ) - - def __round__(self, n: int = 0) -> Any: - """ - Round the internal value to a given precision. - - :param n: Number of decimal places to round to (default is 0). - :return: The rounded value of the internal value. - :raises TypeError: If the instance has no internal value, or it cannot - be rounded. - """ - with self._lock: - if self.__has_value__(self): - return round(self._value, n) - raise TypeError( - f"{self.__typename__} object cannot be rounded." - ) - - def __bool__(self) -> bool: - """ - Convert the internal value to a boolean. + if self is other: + return self + if isinstance(other, DISCOSNamespace): + other = other._get_node() + node = self._get_node() + if isinstance(other, dict) and isinstance(node, dict): + if self._merge_dict(node, other): + self.__notify__() + elif isinstance(other, list): + self._update_list(other) + elif isinstance(other, (bool, int, float, str)) or other is None: + if node != other: + self._parent_dict[self._key] = other + self.__notify__() + else: + raise TypeError( + f"Unsupported operand type for <<=: " + f"'{type(self).__name__}' and '{type(other).__name__}'" + ) + return self - :return: Boolean interpretation of the internal value. - :raises TypeError: If the instance has no internal value. - """ - with self._lock: - if self.__has_value__(self): - return bool(self._value) - raise TypeError( - f"{self.__typename__} object cannot be converted to bool" - ) + def _merge_dict(self, target: dict, source: dict) -> bool: + """Deep-merge *source* into *target*, notifying changed child nodes. + + Delegates each key to one of three helpers depending on the value + type, keeping this method within pylint's branch limit. + + :return: True if at least one value changed. + """ + changed = False + children = self._children + children_get = children.get + target_get = target.get + + for k, v in source.items(): + tv = type(v) + if tv is dict: + if self._merge_dict_value( + target, children, k, v, target_get, children_get + ): + changed = True + elif tv is list: + if self._merge_list_value( + target, k, v, target_get, children_get + ): + changed = True + else: + if self._merge_scalar_value( + target, k, v, target_get, children_get + ): + changed = True + return changed - def __getitem__(self, item: Any) -> Any: - """ - Support indexing if the object holds a subscriptable value. + def _merge_dict_value( + self, + target: dict, + children: dict, + k: str, + v: dict, + target_get, + children_get, + ) -> bool: + """Handle a single dict-typed value during a merge.""" + target_v = target_get(k) + child_ns = children_get(k) + if isinstance(target_v, dict): + if child_ns is not None: + if child_ns._merge_dict(target_v, v): + child_ns.__notify__() + return True + else: + return _plain_merge(target_v, v) + else: + target[k] = dict(v) + child_ns = self._make_dynamic_child(target, k) + if child_ns is not None: + children[k] = child_ns + return True + return False + + def _merge_list_value( + self, + target: dict, + k: str, + v: list, + target_get, + children_get, + ) -> bool: + """Handle a single list-typed value during a merge.""" + child_ns = children_get(k) + if child_ns is not None: + return child_ns._update_list(v) + target_v = target_get(k) + if target_v != v: + target[k] = list(v) + return True + return False + + def _merge_scalar_value( + self, + target: dict, + k: str, + v, + target_get, + children_get, + ) -> bool: + """Handle a single scalar (non-dict, non-list) value during a merge.""" + target_v = target_get(k) + if target_v is not v and target_v != v: + target[k] = v + child_ns = children_get(k) + if child_ns is not None: + child_ns.__notify__() + return True + return False + + def _update_list(self, new_list: list) -> bool: + """Update this array node with *new_list*. + + If the length differs the list is replaced in-place and indexed + children are rebuilt. Otherwise each element is updated individually. + + :return: True if at least one value changed. + """ + target = self._get_node() + children = self._children + + if not isinstance(target, list) or len(target) != len(new_list): + if isinstance(target, list): + del target[:] + target.extend(new_list) + else: + self._parent_dict[self._key] = list(new_list) + target = self._get_node() + self._rebuild_list_children(target) + self.__notify__() + return True + + changed = False + for i, new_item in enumerate(new_list): + old_item = target[i] + child_ns = children.get(i) + if isinstance(new_item, dict) and isinstance(old_item, dict): + if child_ns is not None: + if child_ns._merge_dict(old_item, new_item): + child_ns.__notify__() + changed = True + else: + if _plain_merge(old_item, new_item): + changed = True + elif old_item != new_item: + target[i] = new_item + changed = True + if child_ns is not None: + child_ns.__notify__() + + if changed: + self.__notify__() + return changed - :param item: Index or key. - :return: Corresponding element. - :raises TypeError: If not subscriptable. - """ - with self._lock: - if self.__has_value__(self) and isinstance(self._value, Iterable): - return self._value[item] - raise TypeError(f"{self.__typename__} object is not subscriptable") + def _rebuild_list_children(self, target_list: list) -> None: + """Rebuild indexed children after a list-length change. - def __len__(self) -> int: + Uses :attr:`_item_full_meta` to build each child via + :meth:`_build_ns_from_meta`, which recurses with full schema metadata. """ - Return the length of the internal value if the instance is a container. + children = self._children + children.clear() + item_full_meta = object.__getattribute__(self, '_item_full_meta') + for i in range(len(target_list)): + child_ns = self._build_ns_from_meta(target_list, i, item_full_meta) + children[i] = child_ns - :return: The length of the internal value. - :raises TypeError: If the instance has no internal value or has no - length. - """ - with self._lock: - if self.__has_value__(self): - return len(self._value) - raise TypeError(f"{self.__typename__} object has no length") + def _make_dynamic_child( + self, + parent_data: dict, + key: str, + ) -> "DISCOSNamespace | None": + """Create a namespace node for a previously unseen dynamic key. + + Iterates over :attr:`_pattern_schemas` to find a matching schema and + builds the child namespace accordingly. Returns ``None`` if no pattern + matches (the data was still written; only the namespace wrapper is + absent). + """ + for rx, full_meta in self._pattern_schemas: + if rx.fullmatch(str(key)): + top_meta = { + k: v + for k, v in full_meta.items() + if k in META_KEYS + } + child_ns = DISCOSNamespace( + parent_data, key, top_meta, self._reactive + ) + node = parent_data[key] + if isinstance(node, dict): + for k, v in node.items(): + child_meta_tree = full_meta.get(k, {}) + grandchild = self._build_ns_from_meta( + node, + k, + child_meta_tree + ) + child_ns._children[k] = grandchild + elif not isinstance(node, list): + object.__setattr__(child_ns, 'get_value', + child_ns.__get_value__) + return child_ns + return None - def __iter__(self) -> Iterator[Any]: - """ - Return an iterator over the internal value if iterable. + def _build_ns_from_meta(self, parent_data, key, meta_tree): + top_meta = {k: v for k, v in meta_tree.items() if k in META_KEYS} + ns = DISCOSNamespace(parent_data, key, top_meta, self._reactive) + node = parent_data[key] + if isinstance(node, dict): + for k, v in node.items(): + child_meta = meta_tree.get(k, {}) + grandchild = self._build_ns_from_meta(node, k, child_meta) + ns._children[k] = grandchild + elif isinstance(node, list): + item_meta = meta_tree.get("items", [{}]) + if isinstance(item_meta, list): + item_meta = item_meta[0] + object.__setattr__(ns, '_item_full_meta', item_meta) + for i, _ in enumerate(node): + child = self._build_ns_from_meta(node, i, item_meta) + ns._children[i] = child + else: + object.__setattr__(ns, 'get_value', ns.__get_value__) + return ns - :return: An iterator of the internal value. - :raises TypeError: If the internal value is not iterable. - """ - with self._lock: - if self.__has_value__(self) and isinstance(self._value, Iterable): - return iter(self._value) - raise TypeError(f"{self.__typename__} object is not iterable") + def __notify__(self) -> None: + """Fire registered callbacks if any. + + The first check (``if not self._observers``) is a single + ``LOAD_ATTR`` + truth-check — atomic under CPython's GIL — so no lock + is acquired when there are no observers (the common case on most + nodes). The lock is taken only when there are callbacks to snapshot and + invoke. + """ + if not self._reactive: + return + observers = self._observers # atomic read under GIL + if not observers: + return + with self._observers_lock: + observers = list(self._observers) + for cb, pred, unwrap in observers: + value = self._get_node() if unwrap else self + if pred is None or pred(value): + cb(value) - def __setattr__(self, name: str, value: Any) -> None: - """ - Prevent attribute assignment. + def __bind__( + self, + callback: Callable[[Any], None], + predicate: Callable[[Any], bool] | None = None, + unwrap: bool = False, + ) -> None: + """Register *callback* to be called when this node changes. - :param name: The new instance attribute name. - :param value: The new instance attribute value. - :raises TypeError: When an assignment on the instance is attempted. + :param callback: Called with the updated node (or its raw value when + *unwrap* is True). + :param predicate: Optional filter; the callback fires only when the + predicate returns True. + :param unwrap: If True, the predicate and callback receive the raw + primitive value instead of the namespace node. """ - raise TypeError( - f"{self.__typename__} is read-only and " - "does not allow attribute assignment" - ) + with self._observers_lock: + self._observers.append((callback, predicate, unwrap)) - def __delattr__(self, name: str) -> None: - """ - Prevent attribute deletion. + def __unbind__( + self, + callback: Callable[[Any], None] | None = None, + predicate: Callable[[Any], bool] | None = None, + ) -> None: + """Remove a previously registered callback. - :raises TypeError: When a `del` is called on an instance attribute. + :param callback: The callback to remove. If ``None``, all callbacks + are removed. + :param predicate: If given, only the entry with this exact predicate + is removed; other entries for the same callback are + kept. """ - raise TypeError( - f"{self.__typename__} is read-only and " - "does not allow attribute deletion" - ) + with self._observers_lock: + if callback is None: + self._observers.clear() + return + self._observers[:] = [ + (cb, pred, uw) + for cb, pred, uw in self._observers + if not ( + cb == callback + and (predicate is None or pred == predicate) + ) + ] - def __ilshift__(self, other: Any) -> DISCOSNamespace: - """ - In-place update of the object with another DISCOSNamespace, - dict, list or value. + def __wait__( + self, + predicate: Callable[[Any], bool] | None = None, + timeout: float | None = None, + unwrap: bool = False, + ) -> Any: + """Block until this node changes (and optionally satisfies + *predicate*). - :param other: Another DISCOSNamespace, dict, list or other object type. - :return: This object after the merge. - :raises TypeError: When `other` argument type is not supported for - merging. + :param predicate: If given, keeps waiting until the predicate returns + True. + :param timeout: Maximum wait time in seconds. + :param unwrap: If True, returns the raw primitive value instead of the + namespace node. + :return: This node (or its raw value if *unwrap*) after the change. """ - if self is other: - return self - - notify = False + event = threading.Event() - if DISCOSNamespace.__is__(other): - notify = self._ilshift_namespace(other) - elif isinstance(other, dict): - notify = self._ilshift_dict(other) - elif isinstance(other, list): - notify = self._ilshift_list(other) - elif isinstance(other, (bool, int, float, str)): - notify = self._ilshift_value(other) - else: - raise TypeError( - f"Unsupported operand type for <<=: '{type(self).__name__}' " - f"and '{type(other).__name__}'" - ) + def _cb(_: Any) -> None: + event.set() - if notify: - self.__notify__() + self.bind(_cb, predicate, unwrap=unwrap) + try: + event.wait(timeout) + finally: + self.unbind(_cb, predicate) + node = self._get_node() + if unwrap and not isinstance(node, (dict, list)): + return node return self - def _ilshift_namespace(self, other: DISCOSNamespace) -> bool: - """ - Updates the object with another DISCOSNamespace object. + def __copy__(self) -> "DISCOSNamespace": + """Return an independent, non-reactive snapshot of the current state. - :param other: Another DISCOSNamespace object whose values will - overwrite the self ones. - :return: A boolean indicating whether self should notify the waiters - or execute the bound callbacks. - """ - notify = False - for k, ov in vars(other).items(): - if k.startswith("_") and k != "_value": - continue - sv = self.__dict__.get(k, None) - if DISCOSNamespace.__is__(sv) and DISCOSNamespace.__is__(ov): - sv <<= ov - notify = True - else: - if ov == sv: - continue - with self._lock: - object.__setattr__(self, k, ov) - notify = True - return notify - - def _ilshift_dict(self, other: dict) -> bool: + The data dict is deep-copied so subsequent updates to the live tree do + not affect the snapshot. The namespace structure mirrors the original + but holds no observers. """ - Updates the object with a dict object. + data_copy = deepcopy(self._get_node()) + wrapper = {self._key: data_copy} + return _snapshot_tree( + wrapper, self._key, + self._schema_meta, + self._children + ) - :param other: A dict object whose values will overwrite the self ones. - :return: A boolean indicating whether self should notify the waiters - or execute the bound callbacks. - """ - notify = False - for k, v in other.items(): - node = self.__dict__.get(k) - if node is None: - schema = DISCOSNamespace._find_subschema(self._schema, k) - node = DISCOSNamespace( - schema=schema, - node_name=k, - reactive=self._reactive - ) - self.__dict__[k] = node - notify = True - if DISCOSNamespace.__is__(node): - node <<= v - notify = True - return notify - - def _ilshift_list(self, other: list) -> bool: - """ - Updates the object with a list object. + def __repr__(self) -> str: + node = self._get_node() + if not isinstance(node, (dict, list)): + return repr(node) + return f"<{self.__typename__}({node})>" - :param other: A list object whose values will overwrite the self ones. - :return: A boolean indicating whether self should notify the waiters - or execute the bound callbacks. - """ - notify = False - sv = self.__dict__.get("_value", ()) - if not isinstance(sv, tuple) or len(sv) != len(other): - schema = self.__dict__.get("_schema") - if schema: - schema = schema.get("items", None) - value = [] - for item in other: - d = DISCOSNamespace(schema=schema, reactive=self._reactive) - d <<= item - value.append(d) - self.__dict__["_value"] = sv = tuple(value) - notify = True - for s, o in zip(sv, other): - if DISCOSNamespace.__is__(s): - s <<= o - notify = True - return notify - - def _ilshift_value(self, other: bool | int | float | str) -> bool: - """ - Updates the object with another leaf object. + def __str__(self) -> str: + return format(self, "") - :param other: An object of bool, int, float, str type which will - overwrite the inner self value. - :return: A boolean indicating whether self should notify the waiters - or execute the bound callbacks. - """ - sdict = self.__dict__ - sv = sdict.get("_value") - if sv == other: - return False - with self._lock: - sdict["_value"] = other - return True - - # pylint: disable=too-many-branches def __format__(self, spec: str) -> str: - """ - Custom format method. + """Format this namespace as a JSON string using :mod:`orjson`. :param spec: Format specifier. - | 't' - tight JSON - | 'i' - indented JSON \ -with optional indentation level (default is 2) - | 'e' - entire representation with metadata - | 'm' - metadata only representation - | 'w' - wrap the representation in a container prepending the \ -node key - - :return: A JSON formatted string for non-leaf nodes. If self is a leaf - node, it delegates to `format(self._value, spec)`. - :raise ValueError: If the format specifier is unknown or malformed. - """ - reserved = set("tiemw") - is_container = any(c in spec for c in reserved) + | ``''`` - default JSON (data values only, compact) + | ``'i'`` - indented JSON (fixed at 2 spaces) + | ``'e'`` - full JSON including schema metadata + | ``'m'`` - metadata-only JSON (no data values) + | ``'w'`` - wrap the output in ``{node_key: ...}`` - if self.__has_value__(self) and not \ - isinstance(self._value, (tuple, list)): - if not is_container: - with self._lock: - return format(self._value, spec) + Specs can be combined, e.g. ``'wi'``, ``'ei'``, ``'mi'``. + ``'t'`` (tight) is accepted as an alias for ``''`` since + :mod:`orjson` always produces compact output by default. + :return: A JSON-formatted string. + :raises ValueError: For unknown or conflicting format specifiers. + """ has_e = "e" in spec has_m = "m" in spec has_w = "w" in spec + has_i = "i" in spec if has_e and has_m: raise ValueError( "Format specifier cannot contain both 'e' and 'm'." ) - if has_e: - fmt_spec = spec[1:] if spec.startswith("e") else spec - fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("e") else fmt_spec - elif has_m: - fmt_spec = spec[1:] if spec.startswith("m") else spec - fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("m") else fmt_spec - else: - fmt_spec = spec + node = self._get_node() + if (not isinstance(node, (dict, list)) and + not (has_e or has_m or has_w)): + return format(node, spec) - data_to_serialize = self - if has_w: - if self._node_name is None: - raise ValueError("Cannot wrap node without a key!") - data_to_serialize = {self._node_name: self} - fmt_spec = spec[1:] if spec.startswith("w") else spec - fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("w") else fmt_spec - - indent = None - separators = None - default = ( - self.__full_dict__ if has_e - else self.__metadata_dict__ if has_m - else self.__message_dict__ - ) + fmt_spec = spec + for ch in ("e", "m", "w", "i"): + fmt_spec = fmt_spec.replace(ch, "") - if fmt_spec == "": - pass - elif fmt_spec == "t": - separators = (",", ":") - elif fmt_spec.endswith("i"): - fmt_par = fmt_spec[:-1] - indent = 2 - if fmt_par: - try: - indent = int(fmt_par) - except ValueError as exc: - raise ValueError( - f"Invalid indent in format spec: '{fmt_spec[:-1]}'" - ) from exc - if indent <= 0: - raise ValueError("Indentation must be a positive integer") - else: + if fmt_spec not in ("", "t"): raise ValueError( f"Unknown format code '{spec}' for {self.__typename__}" ) - with self._lock: - return json.dumps( - data_to_serialize, - default=default, - indent=indent, - separators=separators, - sort_keys=True, - ensure_ascii=False - ) - - def __deepcopy__(self, memo): - """ - Return a deep copy of the object. + if has_e: + data = self._full_dict() + elif has_m: + data = self._meta_dict() + else: + data = node - :param memo: Internal memoization dictionary for deepcopy. - :return: A new deepcopy of this object. - """ - with self._lock: - cls = self.__class__ - public = cls.__full_dict__(self) - copied = deepcopy(public, memo) - return cls(reactive=self._reactive, **copied) + if has_w: + if self._key is None: + raise ValueError("Cannot wrap node without a key!") + data = {self._key: data} + + option = orjson.OPT_SORT_KEYS + if has_i: + option |= orjson.OPT_INDENT_2 + + return orjson.dumps(data, option=option).decode() + + def _full_dict(self) -> Any: + """Return a dict merging data values and schema metadata. + + Used by the ``'e'`` format specifier. Not in the hot path. + """ + node = self._get_node() + if isinstance(node, dict): + result = dict(self._schema_meta) + for k, child in self._children.items(): + result[k] = child._full_dict() + for k, v in node.items(): + if k not in result: + result[k] = v + return result + if isinstance(node, list): + result = dict(self._schema_meta) + result["items"] = [ + self._children[i]._full_dict() + if i in self._children else item + for i, item in enumerate(node) + ] + return result + result = dict(self._schema_meta) + result["value"] = node + return result + + def _meta_dict(self) -> dict: + """Return the schema-metadata tree without any data values. + + Used by the ``'m'`` format specifier. Not in the hot path. + """ + result = dict(self._schema_meta) + node = self._get_node() + if isinstance(node, list): + item_full_meta = object.__getattribute__(self, "_item_full_meta") + if item_full_meta: + result["items"] = [item_full_meta] + else: + for k, child in self._children.items(): + child_meta = child._meta_dict() + if child_meta: + result[str(k)] = child_meta + return result @classmethod - def __retrieve_value__(cls, obj: DISCOSNamespace) -> Any: - """ - Retrieve the internal stored value of a given DISCOSNamespace instance. - - :param obj: The DISCOSNamespace instance whose value should be - retrieved. - :return: The internal value stored in the namespace (can be primitive - or another DISCOSNamespace) - """ - with object.__getattribute__(obj, "_lock"): - value = object.__getattribute__(obj, "_value") - if isinstance(value, tuple): - value = list(value) - return value + def __full_dict__(cls, obj: "DISCOSNamespace") -> Any: + """JSON ``default`` hook: returns the enriched (data + meta) dict.""" + return obj._full_dict() @classmethod - def __has_value__(cls, obj: Any) -> bool: - """ - Check whether the given object has an internal value. - - :param obj: The object to check. - :return: True if it has an internal value, False otherwise. - """ - return "_value" in obj.__dict__ + def __message_dict__(cls, obj: "DISCOSNamespace") -> Any: + """JSON ``default`` hook: returns the plain data dict.""" + return obj._get_node() @classmethod - def __is__(cls, obj: Any) -> bool: - """ - Determine if the given object is a DISCOSNamespace instance. + def __metadata_dict__(cls, obj: "DISCOSNamespace") -> dict: + """JSON ``default`` hook: returns the metadata-only dict.""" + return obj._meta_dict() - :param obj: The object to check. - :return: True if the object is an instance of DISCOSNamespace, False - otherwise. - """ - return isinstance(obj, cls) + def __deepcopy__(self, memo: dict) -> "DISCOSNamespace": + """Produce a fully independent deep copy (data + namespace structure). - @classmethod - def __full_dict__(cls, obj: DISCOSNamespace) -> dict[str, Any]: + Schema metadata (static) is shared rather than copied. """ - Return a dictionary representation for JSON serialization. - - :param obj: The object to convert. - :return: A dictionary with public fields and metadata. - """ - return public_dict( - obj, - cls.__is__, - cls.__retrieve_value__ + new_parent = deepcopy(self._parent_dict, memo) + return _snapshot_tree( + new_parent, self._key, + self._schema_meta, + self._children ) - @classmethod - def __message_dict__(cls, obj: DISCOSNamespace) -> dict[str, Any]: - """ - Return the pure message (value-only) dictionary, - removing schema metadata. - - :param obj: The object to convert. - :return: A dictionary with public fields. - """ - def unwrap(value: Any) -> Any: - if cls.__is__(value): - if cls.__has_value__(value): - return unwrap(cls.__retrieve_value__(value)) - retval = {} - for k, v in vars(value).items(): - if k in cls.__private__ or k in META_KEYS: - continue - retval[k] = unwrap(v) - return retval - if isinstance(value, (list, tuple)): - return [unwrap(v) for v in value] - return value - return unwrap(obj) - - @classmethod - def __metadata_dict__(cls, obj: DISCOSNamespace) -> dict[str, Any]: - """ - Return only the metadata dictionary, removing pure message values. - - :param obj: The object to convert. - :return: A dictionary containing only schema/metadata fields. - """ - def strip(value: Any) -> Any: - if isinstance(value, dict): - return { - k: strip(v) for k, v in value.items() if k != "value" - } - if isinstance(value, (list, tuple)): - return [strip(v) for v in value] - return value - return strip(public_dict(obj, cls.__is__, cls.__retrieve_value__)) - - @classmethod - def __value_repr__(cls, obj: Any) -> Any: - """ - Recursively return a clean representation of the value. - - :param obj: The object to represent. - :return: A simplified structure with primitive values and lists. - """ - if cls.__is__(obj): - if cls.__has_value__(obj): - val = cls.__retrieve_value__(obj) - return cls.__value_repr__(val) - return { - k: cls.__value_repr__(v) - for k, v in vars(obj).items() - if not k.startswith("_") and k not in cls.__private__ - } - if isinstance(obj, (tuple, list)): - return [cls.__value_repr__(v) for v in obj] - return obj - - def __notify__(self) -> None: - """ - Execute the bound callbacks, if are present - """ - with self._observers_lock: - if not self._observers: - return - observers = list(self._observers.items()) - - with self._lock: - for cb, conditions in observers: - should_call = False - value_to_pass = self - - for predicate, unwrap in conditions: - value_to_test = self._value if unwrap \ - and self.__has_value__(self) else self - - if predicate(value_to_test): - should_call = True - value_to_pass = value_to_test - break - if should_call: - cb(value_to_pass) - - def __getattr__(self, name: str): - """ - Delegate attribute access to the internal value if it is primitive. - - This method is invoked when an attribute is not found in the namespace - itself. If the internal value is a primitive type, attribute access is - forwarded to it, enabling calls like `node.endswith("x")` for string - values. - - :param name: Name of the attribute being accessed. - :return: The corresponding attribute from the internal value. - :raises AttributeError: If the attribute is not present. - """ - with self._lock: - if name not in self.__private__ and self.__has_value__(self): - value = self._value - if hasattr(value, name): - return getattr(value, name) - - raise AttributeError( - f"'{self.__typename__}' object has no attribute '{name}'" - ) - def __dir__(self) -> list[str]: - """ - Extend the list of available attributes with those of the internal - value. - - This method augments the default `dir()` output so that autocompletion - tools (e.g. IPython, IDEs) also suggest methods and attributes from the - internal primitive value, when present. - - :return: Sorted list of attribute names. - """ attrs = set(super().__dir__()) - if self.__has_value__(self): - value = self._value - attrs = set(dir(value)).union(attrs) + attrs.update(str(k) for k in self._children) + attrs.update(self._schema_meta) + node = self._parent_dict[self._key] + if not isinstance(node, (dict, list)) and node is not None: + attrs.update(dir(node)) return sorted(attrs) diff --git a/discos_client/schemas/common/backends.json b/discos_client/schemas/common/backends.json index 2fa9bf9..f17320c 100644 --- a/discos_client/schemas/common/backends.json +++ b/discos_client/schemas/common/backends.json @@ -173,6 +173,9 @@ "title": "Current Setup", "description": "Currently selected backend's setup code." }, + "status": { + "$ref": "../definitions/status.json" + }, "timestamp": { "$ref": "../definitions/timestamp.json" } @@ -181,6 +184,7 @@ "availableBackends", "currentBackend", "currentSetup", + "status", "timestamp" ] }, diff --git a/discos_client/utils.py b/discos_client/utils.py index c132483..3b6d1ba 100644 --- a/discos_client/utils.py +++ b/discos_client/utils.py @@ -16,7 +16,6 @@ "rand_id", "delegated_operations", "delegated_comparisons", - "public_dict", "get_auth_keys", "timestamp" ] @@ -106,60 +105,6 @@ def method( return decorator -def public_dict( - obj: Any, - is_fn: Callable, - get_value_fn: Callable -) -> Any: - """ - Returns a copy of the dictionary containing only the public attributes of - the given object. - - :param obj: The object which a public dictionary will be returned. - :param is_fn: A function that checks if the given object is instance of a - given type. - :param get_value_fn: A function that returns the inner value of the object. - :return: The dictionary containing only the public values of the object. - """ - d = {} - for k, v in vars(obj).items(): - if callable(v): - # We don't need to include methods - continue - if k == "_value": - if isinstance(v, (list, tuple)): - d["items"] = __unwrap(v, is_fn, get_value_fn) - else: - d["value"] = v - elif not k.startswith("_"): - if k == "enum" and is_fn(v): - d[k] = __unwrap(v, is_fn, get_value_fn) - else: - d[k] = public_dict( - v, - is_fn, - get_value_fn - ) if is_fn(v) else v - return d - - -def __unwrap(value: Any, is_fn, get_value_fn) -> Any: - """ - Returns the inner value of a given object, either in its original form or - as a list. - - :param value: The object whose internal value will be returned. - :param is_fn: A function that checks if the given object is instance of a - given type. - :param get_value_fn: A function that returns the inner value of the object. - :return: The internal value if present, either as its original type or as a - list, or value itself. - """ - while is_fn(value): - value = get_value_fn(value) - return list(value) if isinstance(value, (list, tuple)) else value - - def get_client_auth_keys(identity: str) -> tuple[bytes, bytes]: """Retrieve the CURVE client key pair associated with a given identity. diff --git a/tests/messages/common/backends.json b/tests/messages/common/backends.json index 49eb482..222309e 100644 --- a/tests/messages/common/backends.json +++ b/tests/messages/common/backends.json @@ -1 +1 @@ -{"TotalPower":{"backendTime":{"iso8601":"2026-05-04T08:22:29.000Z","mjd":61164.34894675948,"omg_time":139971757490000000,"unix_time":1777882949.0},"busy":false,"channels":[{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":0,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-18.86252081925788},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":1,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":2,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":3,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-87.87325181869207},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":4,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":5,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":6,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":7,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-31.30752112150976},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":8,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":9,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":41.756043814715845},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":10,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":193.59580191228858},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":11,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":238.08411375989303},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":12,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":46.91594690101812},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":13,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-25.911116503337915}],"commandLineError":false,"dataLineError":false,"integration":40,"sampling":false,"suspended":false,"timeSync":true,"timestamp":{"iso8601":"2026-05-04T08:22:30.421Z","mjd":61164.34896320617,"omg_time":139971757504212560,"unix_time":1777882950.421256}},"availableBackends":["Sardara","TotalPower"],"currentBackend":"TotalPower","currentSetup":"KKG","timestamp":{"iso8601":"2026-05-04T08:22:30.072Z","mjd":61164.34895916656,"omg_time":139971757500724360,"unix_time":1777882950.072436}} +{"TotalPower":{"backendTime":{"iso8601":"2026-05-20T10:12:57.000Z","mjd":61180.42565972218,"omg_time":139985647770000000,"unix_time":1779271977.0},"busy":false,"channels":[{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":0,"polarization":"LHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":1,"polarization":"RHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":2,"polarization":"LHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":3,"polarization":"RHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":4,"polarization":"LHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":5,"polarization":"RHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":6,"polarization":"LHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":7,"polarization":"RHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":8,"polarization":"LHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":9,"polarization":"RHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":10,"polarization":"LHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":11,"polarization":"RHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":12,"polarization":"LHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":1250.0,"bins":1,"id":13,"polarization":"RHCP","sampleRate":0.0001,"startFrequency":50.0,"systemTemperature":0.0}],"commandLineError":false,"dataLineError":false,"integration":30,"sampling":false,"suspended":false,"timeSync":true,"timestamp":{"iso8601":"2026-05-20T10:12:57.421Z","mjd":61180.42566459486,"omg_time":139985647774210860,"unix_time":1779271977.421086}},"availableBackends":["Sardara","TotalPower"],"currentBackend":"TotalPower","currentSetup":"KKG","status":"OK","timestamp":{"iso8601":"2026-05-20T10:12:57.089Z","mjd":61180.425660752226,"omg_time":139985647770891540,"unix_time":1779271977.089154}} diff --git a/tests/test_client.py b/tests/test_client.py index 3d8e80d..3d1a67a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -15,6 +15,7 @@ SRTClient, MedicinaClient, NotoClient, \ DEFAULT_SUB_PORT, DEFAULT_REQ_PORT from discos_client.namespace import DISCOSNamespace +from discos_client.initializer import NSInitializer if sys.platform == "win32": @@ -279,13 +280,7 @@ def test_format(self): _ = f"{client:0i}" self.assertEqual( str(ex.exception), - "Indentation must be a positive integer" - ) - with self.assertRaises(ValueError) as ex: - _ = f"{client:ai}" - self.assertEqual( - str(ex.exception), - "Invalid indent in format spec: 'a'" + "Unknown format code '0i' for DISCOSClient" ) with self.assertRaises(ValueError) as ex: _ = f"{client:3c}" @@ -294,6 +289,8 @@ def test_format(self): "Unknown format code '3c' for DISCOSClient" ) self.assertNotIn("\": ", f"{client:t}") + indented = f"{client:i}" + self.assertIn("\n", indented) def test_bind(self): with TestPublisher("SRT"): @@ -496,6 +493,33 @@ def test_command_without_telescope_or_server_public_key_file( ) +class TestNSInitializer(unittest.TestCase): + + def test_build_ns_tree_non_empty_array(self): + init = NSInitializer("SRT") + item_schema = { + "type": "object", + "title": "Item", + "required": ["x"], + "properties": { + "x": {"type": "number", "title": "X"} + } + } + schema = { + "type": "array", + "title": "Test Array", + "items": item_schema + } + data = [{"x": 1.0}, {"x": 2.0}] + wrapper = {"arr": data} + ns = init._build_ns_tree(wrapper, "arr", schema, True) + self.assertIsInstance(ns, DISCOSNamespace) + self.assertIn(0, ns._children) + self.assertIn(1, ns._children) + self.assertEqual(ns._children[0].x, 1.0) + self.assertEqual(ns._children[1].x, 2.0) + + class TestTelescopeClients(unittest.TestCase): def test_srt_client(self): diff --git a/tests/test_namespace.py b/tests/test_namespace.py index c5f0415..e2094d0 100644 --- a/tests/test_namespace.py +++ b/tests/test_namespace.py @@ -1,15 +1,64 @@ import unittest -import json +import re from pathlib import Path from copy import deepcopy +import orjson from discos_client.namespace import DISCOSNamespace +from discos_client.namespace import _plain_merge + + +def _wire(parent, key, meta, reactive): + """ + Build a DISCOSNamespace node at parent[key] and recursively wire children. + No schema info available here, so children carry empty metadata. + """ + ns = DISCOSNamespace(parent, key, meta, reactive) + node = parent[key] + if isinstance(node, dict): + for k in node.keys(): + child = _wire(node, k, {}, reactive) + ns._children[k] = child + elif isinstance(node, list): + for i, _ in enumerate(node): + child = _wire(node, i, {}, reactive) + ns._children[i] = child + return ns + + +def _ns(data, meta=None, key="__root__", reactive=True): + """ + Build a fully-wired DISCOSNamespace tree from plain Python *data*. + + *data* can be a dict, list, or any primitive. *meta* sets schema + metadata on the root node. *key* is the root node's key (used by the + ``'w'`` format specifier). + """ + wrapper = {key: data} + return _wire(wrapper, key, meta or {}, reactive) + + +def _ns_with_child_meta(data, root_meta=None, children_meta=None, + key="__root__", reactive=True): + """ + Like ``_ns`` but also attaches per-child schema metadata. + + *children_meta* is ``{child_key: {meta_key: meta_value, ...}, ...}``. + """ + wrapper = {key: data} + ns = DISCOSNamespace(wrapper, key, root_meta or {}, reactive) + children_meta = children_meta or {} + if isinstance(data, dict): + for k in data.keys(): + child = _wire(data, k, children_meta.get(k, {}), reactive) + ns._children[k] = child + return ns # pylint: disable=too-many-public-methods class TestDISCOSNamespace(unittest.TestCase): def test_assignment(self): - ns = DISCOSNamespace() + ns = _ns({}) with self.assertRaises(TypeError) as ex: ns.attribute = "a" self.assertEqual( @@ -19,7 +68,7 @@ def test_assignment(self): ) def test_deletion(self): - ns = DISCOSNamespace(description="a") + ns = _ns({}, meta={"description": "a"}) with self.assertRaises(TypeError) as ex: del ns.a self.assertEqual( @@ -29,29 +78,30 @@ def test_deletion(self): ) def test_repr(self): - a = {"a": {"b": {"value": ["a", "b"]}}} - ns = DISCOSNamespace(**a) + ns = _ns({"a": {"b": ["a", "b"]}}) self.assertEqual( repr(ns), "" ) - ns = DISCOSNamespace(value="a") + # Leaf node. + ns = _ns("a") self.assertEqual(repr(ns), "'a'") def test_str(self): - a = "a" - d = {"a": a} - ns = DISCOSNamespace(**d) - self.assertEqual(str(ns), json.dumps(d)) - d = {"value": a} - ns = DISCOSNamespace(**d) - self.assertEqual(str(ns), f"{a}") + d = {"a": "a"} + ns = _ns(d) + self.assertEqual( + str(ns), + orjson.dumps(d, option=orjson.OPT_SORT_KEYS).decode() + ) + ns = _ns("a") + self.assertEqual(str(ns), "a") def test_int(self): a = 1 - ns = DISCOSNamespace(value=a) + ns = _ns(a) self.assertEqual(int(ns), a) - ns = DISCOSNamespace(a=a) + ns = _ns({"a": a}) with self.assertRaises(TypeError) as ex: int(ns) self.assertEqual( @@ -61,9 +111,9 @@ def test_int(self): def test_float(self): a = 1.0 - ns = DISCOSNamespace(value=a) + ns = _ns(a) self.assertEqual(float(ns), a) - ns = DISCOSNamespace(a=a) + ns = _ns({"a": a}) with self.assertRaises(TypeError) as ex: float(ns) self.assertEqual( @@ -73,10 +123,10 @@ def test_float(self): def test_neg(self): a = 1.0 - ns = DISCOSNamespace(value=a) + ns = _ns(a) b = -ns self.assertEqual(b, -a) - ns = DISCOSNamespace(a=a) + ns = _ns({"a": a}) with self.assertRaises(TypeError) as ex: b = -ns self.assertEqual( @@ -85,10 +135,10 @@ def test_neg(self): ) def test_abs(self): - ns = DISCOSNamespace(value=-1.0) + ns = _ns(-1.0) b = abs(ns) self.assertEqual(b, 1.0) - ns = DISCOSNamespace(a="foo") + ns = _ns({"a": "foo"}) with self.assertRaises(TypeError) as ex: _ = abs(ns) self.assertEqual( @@ -97,10 +147,10 @@ def test_abs(self): ) def test_round(self): - ns = DISCOSNamespace(value=0.123456) + ns = _ns(0.123456) b = round(ns, 3) self.assertEqual(b, 0.123) - ns = DISCOSNamespace(a="foo") + ns = _ns({"a": "foo"}) with self.assertRaises(TypeError) as ex: _ = round(ns) self.assertEqual( @@ -110,9 +160,9 @@ def test_round(self): def test_bool(self): a = True - ns = DISCOSNamespace(value=a) + ns = _ns(a) self.assertTrue(ns) - ns = DISCOSNamespace(a=a) + ns = _ns({"a": a}) with self.assertRaises(TypeError) as ex: bool(ns) self.assertEqual( @@ -122,10 +172,10 @@ def test_bool(self): def test_getitem(self): a = [1, 2] - ns = DISCOSNamespace(value=a) + ns = _ns(a) self.assertEqual(ns[0], 1) self.assertEqual(ns[1], 2) - ns = DISCOSNamespace(a=a) + ns = _ns({"a": a}) with self.assertRaises(TypeError) as ex: _ = ns[0] self.assertEqual( @@ -135,9 +185,9 @@ def test_getitem(self): def test_len(self): a = [1, 2] - ns = DISCOSNamespace(value=a) + ns = _ns(a) self.assertEqual(len(ns), len(a)) - ns = DISCOSNamespace(a=a) + ns = _ns({"a": a}) with self.assertRaises(TypeError) as ex: len(ns) self.assertEqual( @@ -147,9 +197,9 @@ def test_len(self): def test_iter(self): a = [1, 2] - ns = DISCOSNamespace(value=a) + ns = _ns(a) self.assertEqual(list(ns), a) - ns = DISCOSNamespace(a=a) + ns = _ns({"a": a}) with self.assertRaises(TypeError) as ex: list(ns) self.assertEqual( @@ -158,23 +208,28 @@ def test_iter(self): ) def test_copy(self): - d = {"a": {"b": "a"}} - ns = DISCOSNamespace(**d) + ns = _ns({"a": {"b": "a"}}) ns2 = ns.copy() self.assertFalse(ns2 is ns) def test_deepcopy(self): - d = {"a": {"b": {"value": ["a", "b"]}}} - ns = DISCOSNamespace(**d) + ns = _ns({"a": {"b": ["a", "b"]}}) ns2 = deepcopy(ns) self.assertFalse(ns2 is ns) def test_format(self): a = 1.234 - ns = DISCOSNamespace(value=a) + + ns = _ns(a) self.assertEqual(f"{ns:.3f}", f"{a:.3f}") + + ns = _ns_with_child_meta( + {"a": a}, + root_meta={"enum": ["a", "b"]}, + children_meta={"a": {"title": "a"}}, + ) b = {"a": {"title": "a", "value": a}, "enum": ["a", "b"]} - ns = DISCOSNamespace(**b) + with self.assertRaises(ValueError) as ex: _ = f"{ns:.3f}" self.assertEqual( @@ -183,7 +238,7 @@ def test_format(self): ) self.assertEqual( f"{ns:t}", - json.dumps({"a": a}, separators=(",", ":")) + orjson.dumps({"a": a}, option=orjson.OPT_SORT_KEYS).decode() ) with self.assertRaises(ValueError) as ex: _ = f"{ns:3c}" @@ -199,63 +254,47 @@ def test_format(self): ) self.assertEqual( f"{ns:i}", - json.dumps({"a": a}, indent=2) + orjson.dumps( + {"a": a}, + option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2 + ).decode() ) self.assertEqual( f"{ns:e}", - json.dumps(b) + orjson.dumps(b, option=orjson.OPT_SORT_KEYS).decode() ) b_ = deepcopy(b) b_["a"].pop("value") self.assertEqual( f"{ns:m}", - json.dumps(b_) - ) - for indent in range(1, 10): - self.assertEqual( - f"{ns:{indent}i}", - json.dumps({"a": a}, indent=indent) - ) - self.assertEqual( - f"{ns:e{indent}i}", - json.dumps(b, indent=indent) - ) - with self.assertRaises(ValueError) as ex: - _ = f"{ns:0i}" - self.assertEqual( - str(ex.exception), - "Indentation must be a positive integer" - ) - with self.assertRaises(ValueError) as ex: - _ = f"{ns:ai}" - self.assertEqual( - str(ex.exception), - "Invalid indent in format spec: 'a'" + orjson.dumps(b_, option=orjson.OPT_SORT_KEYS).decode() ) messages_dir = Path(__file__).resolve().parent - message_files = messages_dir.glob("messages/*.json") + message_files = list(messages_dir.glob("messages/*.json")) for message in message_files: - with open(message, "r", encoding="utf-8") as f: - d = json.load(f) - ns = DISCOSNamespace(**d) - self.assertEqual(f"{ns}", json.dumps(d)) - _ = f"{ns:f}" - - ns = DISCOSNamespace(node_name="a", **{"b": 1.234}) - self.assertEqual(f"{ns:w}", '{"a": {"b": 1.234}}') + d = orjson.loads(Path(message).read_bytes()) + ns_msg = _ns(d) + self.assertEqual( + f"{ns_msg}", + orjson.dumps(d, option=orjson.OPT_SORT_KEYS).decode() + ) - ns = DISCOSNamespace(**{"b": 1.234}) - with self.assertRaises(ValueError) as ex: - _ = f"{ns:w}" - self.assertEqual(str(ex.exception), "Cannot wrap node without a key!") + ns_w = _ns({"b": 1.234}, key="a") + self.assertEqual( + f"{ns_w:w}", + orjson.dumps( + {"a": {"b": 1.234}}, + option=orjson.OPT_SORT_KEYS + ).decode() + ) def test_op(self): a = 2 - ns = DISCOSNamespace(value=a) + ns = _ns(a) self.assertEqual(ns + 2, 4) self.assertEqual(2 + ns, 4) - ns = DISCOSNamespace(a=a) + ns = _ns({"a": a}) with self.assertRaises(TypeError) as ex: _ = ns + 2 self.assertEqual( @@ -265,21 +304,22 @@ def test_op(self): ) def test_ilshift(self): - d = {"value": "a", "a": {"value": "a"}, "_a": "a"} - d2 = {"value": "b", "a": {"value": "b"}, "_a": "b"} - ns = DISCOSNamespace(**d) - ns2 = DISCOSNamespace(**d2) - self.assertEqual(ns, "a") + ns = _ns({"a": "a", "b": {"c": "a"}}) + ns2 = _ns({"a": "b", "b": {"c": "b"}}) + self.assertEqual(ns.a, "a") - self.assertEqual(ns._a, "a") # noqa + self.assertEqual(ns.b.c, "a") + ns <<= ns2 - self.assertEqual(ns, "b") + self.assertEqual(ns.a, "b") - self.assertEqual(ns._a, "a") # noqa + self.assertEqual(ns.b.c, "b") self.assertFalse(ns is ns2) + ns3 = ns.copy() - ns <<= ns # Should return immediately and do nothing + ns <<= ns ns <<= ns3 + with self.assertRaises(TypeError) as ex: ns <<= b"a" self.assertEqual( @@ -289,9 +329,9 @@ def test_ilshift(self): def test_comparison(self): a = 2 - ns = DISCOSNamespace(value=a) - ns2 = DISCOSNamespace(value=a) - ns3 = DISCOSNamespace(a=a) + ns = _ns(a) + ns2 = _ns(a) + ns3 = _ns({"a": a}) self.assertEqual(ns, a) self.assertNotEqual(ns, a + 1) self.assertFalse(ns < ns2) @@ -299,20 +339,18 @@ def test_comparison(self): self.assertNotEqual(ns3, a) def test_dir(self): - ns = DISCOSNamespace(value="foo", title="title") + ns = _ns("foo", meta={"title": "title"}) attributes = dir(ns) self.assertIn("upper", attributes) self.assertIn("title", attributes) self.assertIn("startswith", attributes) - ns = DISCOSNamespace(title="title") - attributes = dir(ns) - self.assertNotIn("get_value", attributes) - ns = DISCOSNamespace(value=ns) + + ns = _ns({}, meta={"title": "title"}) attributes = dir(ns) self.assertNotIn("get_value", attributes) def test_getattr(self): - ns = DISCOSNamespace(value="foo") + ns = _ns("foo") self.assertEqual(ns.upper(), "foo".upper()) with self.assertRaises(AttributeError) as ex: _ = ns.unknown @@ -322,16 +360,18 @@ def test_getattr(self): ) def test_get_value(self): - ns = DISCOSNamespace(value="foo") + ns = _ns("foo") self.assertIsInstance(ns.get_value(), str) - ns = DISCOSNamespace(value=ns) + + ns = _ns({"title": "foo"}) with self.assertRaises(AttributeError) as ex: _ = ns.get_value() self.assertEqual( str(ex.exception), "'DISCOSNamespace' object has no attribute 'get_value'" ) - ns = DISCOSNamespace(title="foo") + + ns = _ns({}, meta={"title": "foo"}) with self.assertRaises(AttributeError) as ex: _ = ns.get_value() self.assertEqual( @@ -339,6 +379,198 @@ def test_get_value(self): "'DISCOSNamespace' object has no attribute 'get_value'" ) + def test_plain_merge(self): + data = {"outer": {"inner": {"x": 1, "y": 2}}} + wrapper = {"root": data} + ns = DISCOSNamespace(wrapper, "root", {}, True) + outer_ns = DISCOSNamespace(data, "outer", {}, True) + ns._children["outer"] = outer_ns + ns <<= {"outer": {"inner": {"x": 99, "y": 100}}} + self.assertEqual(data["outer"]["inner"]["x"], 99) + self.assertEqual(data["outer"]["inner"]["y"], 100) + + def test_get_value_raises_on_dict(self): + data = {"k": {"nested": 1}} + wrapper = {"root": data} + ns = DISCOSNamespace(wrapper, "root", {}, True) + object.__setattr__(ns, 'get_value', ns.__get_value__) + with self.assertRaises(TypeError) as ex: + ns.get_value() + self.assertIn("does not hold a primitive value", str(ex.exception)) + + def test_getattr_returns_schema_meta(self): + ns = _ns(42, meta={"title": "My Field", "unit": "degrees"}) + self.assertEqual(ns.title, "My Field") + self.assertEqual(ns.unit, "degrees") + + def test_value_comparison_type_error(self): + ns1 = _ns({"a": 1}) + ns2 = _ns({"b": 2}) + result = ns1 < ns2 + self.assertFalse(result) + + def test_ilshift_list(self): + ns = _ns([1, 2, 3]) + ns <<= [4, 5, 6] + self.assertEqual(ns[0], 4) + self.assertEqual(ns[1], 5) + self.assertEqual(ns[2], 6) + self.assertEqual(len(ns), 3) + + def test_ilshift_list_resize(self): + ns = _ns([1, 2]) + ns <<= [10, 20, 30] + self.assertEqual(len(ns), 3) + self.assertEqual(ns[2], 30) + + def test_ilshift_primitive(self): + ns = _ns(1) + ns <<= 42 + self.assertEqual(ns, 42) + ns <<= 42 + self.assertEqual(ns, 42) + + def test_ilshift_primitive_none(self): + ns = _ns(1) + ns <<= None + node = ns._get_node() + self.assertIsNone(node) + + def test_merge_dict_list_without_child(self): + data = {"lst": [1, 2]} + wrapper = {"root": data} + ns = DISCOSNamespace(wrapper, "root", {}, True) + ns <<= {"lst": [9, 8, 7]} + self.assertEqual(data["lst"], [9, 8, 7]) + + def test_make_dynamic_child_no_match(self): + ns = _ns({"existing": 1}) + result = ns._make_dynamic_child({"new_key": {"x": 1}}, "new_key") + self.assertIsNone(result) + + def test_format_w_raises_without_key(self): + wrapper = {None: {"a": 1}} + ns = DISCOSNamespace(wrapper, None, {}, True) + with self.assertRaises(ValueError) as ex: + format(ns, "w") + self.assertEqual(str(ex.exception), "Cannot wrap node without a key!") + + def test_format_full_dict_list_node(self): + ns = _ns([1, 2, 3]) + result = orjson.loads(f"{ns:e}") + self.assertIn("items", result) + + def test_meta_dict_array_node(self): + ns = _ns([1, 2]) + object.__setattr__( + ns, + '_item_full_meta', + {"title": "Item", "type": "number"} + ) + result = orjson.loads(f"{ns:m}") + self.assertIn("items", result) + self.assertEqual(result["items"][0]["title"], "Item") + + def test_full_dict_classmethod(self): + ns = _ns_with_child_meta( + {"x": 1.0}, + children_meta={"x": {"title": "X", "unit": "m"}} + ) + result = DISCOSNamespace.__full_dict__(ns) + self.assertIn("x", result) + + def test_metadata_dict_classmethod(self): + ns = _ns_with_child_meta( + {"x": 1.0}, + root_meta={"title": "Root"}, + children_meta={"x": {"title": "X"}} + ) + result = DISCOSNamespace.__metadata_dict__(ns) + self.assertIn("title", result) + self.assertNotIn("x", result.get("x", {}).get("value", {})) + + def test_update_list_item_changed(self): + notified = [] + ns = _ns([10, 20, 30]) + for i in range(3): + ns._children[i].bind(lambda v, i=i: notified.append(i)) + ns <<= [10, 99, 30] + self.assertIn(1, notified) + self.assertNotIn(0, notified) + self.assertNotIn(2, notified) + + def test_plain_merge_changes_nested(self): + data = {"outer": {"inner": {"x": 1}}} + wrapper = {"root": data} + ns = DISCOSNamespace(wrapper, "root", {}, True) + outer_ns = DISCOSNamespace(data, "outer", {}, True) + ns._children["outer"] = outer_ns + ns <<= {"outer": {"inner": {"x": 2}}} + self.assertEqual(data["outer"]["inner"]["x"], 2) + + def test_update_list_replaces_non_list_node(self): + wrapper = {"root": 42} + ns = DISCOSNamespace(wrapper, "root", {}, True) + ns <<= [1, 2, 3] + self.assertEqual(ns._get_node(), [1, 2, 3]) + + def test_update_list_notifies_changed_dict_item(self): + notified = [] + items = [{"x": 1}, {"x": 2}] + ns = _ns(items) + for i, item in enumerate(items): + child = DISCOSNamespace(ns._get_node(), i, {}, True) + for k in item.keys(): + grandchild = DISCOSNamespace(item, k, {}, True) + child._children[k] = grandchild + child.bind(lambda v: notified.append(True)) + ns._children[i] = child + ns <<= [{"x": 99}, {"x": 2}] + self.assertTrue(len(notified) > 0) + + def test_update_list_plain_merge_dict_item_no_child(self): + items = [{"x": 1, "y": 2}] + wrapper = {"root": items} + ns = DISCOSNamespace(wrapper, "root", {}, True) + ns._update_list([{"x": 99, "y": 2}]) + self.assertEqual(items[0]["x"], 99) + + def test_make_dynamic_child_primitive_node(self): + data = {"val": 42} + wrapper = {"root": data} + ns = DISCOSNamespace(wrapper, "root", {}, True) + object.__setattr__(ns, '_pattern_schemas', [ + (re.compile(r'^val$'), {"title": "Value"}) + ]) + child = ns._make_dynamic_child(data, "val") + self.assertIsNotNone(child) + self.assertEqual(child.get_value(), 42) + + def test_plain_merge_nested_dict_changed(self): + target = {"sub": {"a": 1, "b": 2}} + source = {"sub": {"a": 99, "b": 2}} + changed = _plain_merge(target, source) + self.assertTrue(changed) + self.assertEqual(target["sub"]["a"], 99) + + def test_full_dict_includes_unwired_keys(self): + data = {"wired": 1.0, "unwired": 42} + wrapper = {"root": data} + ns = DISCOSNamespace(wrapper, "root", {}, True) + wired_child = DISCOSNamespace(data, "wired", {"title": "Wired"}, True) + ns._children["wired"] = wired_child + result = orjson.loads(f"{ns:e}") + self.assertIn("unwired", result) + self.assertEqual(result["unwired"], 42) + + def test_merge_list_no_change(self): + data = {"lst": [1, 2, 3]} + wrapper = {"root": data} + ns = DISCOSNamespace(wrapper, "root", {}, True) + changed = ns._merge_dict(data, {"lst": [1, 2, 3]}) + self.assertFalse(changed) + self.assertEqual(data["lst"], [1, 2, 3]) + if __name__ == '__main__': unittest.main() From 32d5da7d4d715383edacff23c11c79da4c586ff3 Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Mon, 25 May 2026 16:55:23 +0200 Subject: [PATCH 3/3] Added missing dependency --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 356a567..b2ec5c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,8 @@ classifiers = [ ] dependencies = [ "pyzmq", - "platformdirs" + "platformdirs", + "orjson" ] [tool.setuptools]