Source code for pydoover.config

import copy
import json
import logging
import pathlib
import re

from enum import EnumType, Enum as _Enum
from typing import Any

from ..utils.utils import sanitize_display_name

log = logging.getLogger(__name__)
KEY_VALIDATOR = re.compile(r"^[ a-zA-Z0-9_-]*$")


def check_key(key: str) -> None:
    if not KEY_VALIDATOR.match(key):
        raise ValueError(
            f"Invalid config key {key}. Keys must only contain alphanumeric characters, "
            f"hyphens (-), underscores (_) and spaces ( )."
        )


class NotSet:
    """Sentinel used when a config value has not been assigned."""


# Attribute names reserved for ConfigElement internal use.
# Config elements declared as class attributes must not use these names.
RESERVED_NAMES = frozenset(
    {
        "default",
        "description",
        "hidden",
        "deprecated",
        "format",
        "required",
        "value",
        "choices",
        "element",
        "elements",
        "minimum",
        "exclusive_minimum",
        "maximum",
        "exclusive_maximum",
        "multiple_of",
        "length",
        "pattern",
        "min_items",
        "max_items",
        "unique_items",
        "additional_elements",
        "collapsible",
        "default_collapsed",
    }
)


[docs] class Schema: """Represents the configuration schema for a Doover application. A config schema is a definition of the config for your application. It is used as `.config` in your application and can generate a JSON Schema which will provide user validation and a "form" in the Doover UI. Any attributes added in the `__init__` method will be added to the schema. Order is preserved from the order you define them in the `__init__` method. The schema can be exported to a JSON file using the `export` method, although in a template application this is done for you. To export the schema to the `doover_config.json` file, use the Doover cli: ``doover config-schema export``. This will validate and export the schema to the `doover_config.json` file in the root of your Doover project. If you want to mark the entire application as "Advanced Config" and hide it from the UI, set `advanced=True` in the subclass init. Examples -------- >>> from pydoover import config >>> class MyAppConfig(config.Schema): ... pump_pin = config.Integer("Digital Output Number", description="The digital output pin to drive the pump.") ... pump_on_time = config.Number("Pump On Time", default=5.2, description="The time in seconds to run the pump.") ... engine_type = config.Enum( ... "Engine Type", ... choices=["Honda", "John Deere", "Cat"], ... description="The type of diesel engine attached to the pump.", ... ) """ _element_map: "dict[str, ConfigElement]" = {} @classmethod def add_element(cls, element): if element._position is None: element._position = len(cls._element_map) if element._name in cls._element_map: raise ValueError(f"Duplicate element name {element._name} not allowed.") cls._element_map[element._name] = element def __init_subclass__(cls, name: str = "$default", advanced: bool = None, **kwargs): super().__init_subclass__(**kwargs) cls.name = name cls._advanced = advanced cls._element_map = {} cls._load_elements() @classmethod def _load_elements(cls): # inherit parent elements for base in cls.__mro__[1:]: if base is Schema or not issubclass(base, Schema): continue for name, elem in getattr(base, "_element_map", {}).items(): if name not in cls._element_map: cls._element_map[name] = elem # collect own elements (subclass declarations override inherited fields with the same name) for k, v in cls.__dict__.items(): if isinstance(v, ConfigElement): if k in RESERVED_NAMES: raise ValueError( f"Config element name '{k}' is reserved. " f"Choose a different attribute name." ) if v._name in cls._element_map: if v._position is None: v._position = cls._element_map[v._name]._position cls._element_map[v._name] = v else: cls.add_element(v) @classmethod def clear_elements(cls): cls._element_map.clear() @classmethod def to_schema(cls): payload = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "", "title": cls.name, "type": "object", "properties": { name: element.to_dict() for name, element in cls._element_map.items() if isinstance(element, ConfigElement) }, "additionalElements": True, "required": [ name for name, element in cls._element_map.items() if isinstance(element, ConfigElement) and element.required ], } if cls._advanced is not None: payload["x-advanced"] = cls._advanced return payload def _inject_deployment_config(self, config: dict[str, Any]): for name, value in config.items(): try: elem = self._element_map[name] except KeyError: log.debug("Loading new element: %s, %s", name, value) v = ConfigElement(name) v.load_data(value) setattr(self, name, v) else: elem.load_data(value) for elem_name in set(self._element_map.keys()) - set(config.keys()): # catch missing required elements, and set any other elements to their default value elem = self._element_map[elem_name] if elem.required: raise ValueError( f"Required config element {elem_name} not found in deployment config." ) elem.load_data(elem.default)
[docs] @classmethod def export(cls, fp: pathlib.Path, app_name: str): """Export the config schema to a JSON file. This will export the config schema to the ``config_schema`` field in the ``doover_config.json`` file in the root of your project . Examples -------- Generally, this will be done in the application template. >>> from pydoover import config >>> class MyAppConfig(config.Schema): ... def __init__(self): ... pump_pin = config.Integer("Digital Output Number", description="The digital output pin to drive the pump.") ... ... if __name__ == "__main__": ... from pathlib import Path ... MyAppConfig.export(pathlib.Path("/path/to/my/app/doover_config.json"), "my_app_name") Parameters ---------- fp: pathlib.Path The path to the JSON file to export the config schema to. app_name: str The name of the application to export the config schema for. This will be used as the key in the `doover_config.json` file. """ if fp.exists(): data = json.loads(fp.read_text()) else: data = {} try: data[app_name]["config_schema"] = cls.to_schema() except KeyError: data[app_name] = {"config_schema": cls.to_schema()} fp.write_text(json.dumps(data, indent=4))
[docs] class ConfigElement: """Represents a config element in the Doover configuration schema. Attributes ---------- display_name: str The display name of the config element. This is used in the UI. default: Any The default value for the element. If ``NotSet`` (and ``required`` is left at ``None``), the element is treated as required. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. required: bool | None Explicit override of the required-ness derived from ``default``. ``None`` (default) → required is True iff ``default`` is ``NotSet`` (the historical behaviour). ``True`` → required regardless of ``default`` (config file must carry the key explicitly even though a fallback exists). ``False`` → not required; the loader uses ``default`` when absent. Passing ``required=False`` without a ``default`` raises ``ValueError`` because there'd be no value to fall back to. """ _type = "unknown" def __init__( self, display_name, *, default: Any = NotSet, description: str | None = None, deprecated: bool | None = None, hidden: bool = False, format: str | None = None, position: int | None = None, name: str | None = None, advanced: bool | None = None, required: bool | None = None, ): if name is not None: check_key(name) self._name = name else: self._name = sanitize_display_name(display_name) if required is False and default is NotSet: raise ValueError( f"Config element {self._name!r}: required=False needs a " f"default to fall back to, but none was supplied." ) self._position = position self._display_name = display_name self.default = default self.description = description self.hidden = hidden self.deprecated = deprecated self.format = format self.advanced = advanced self._required = required self._value = NotSet if ( default is not NotSet and not isinstance(default, Variable) and default is not None ): match self._type: case "integer": assert isinstance(default, int) case "number": assert isinstance(default, float) case "string": assert isinstance(default, str) case "boolean": assert isinstance(default, bool) case "array": assert isinstance(default, list) # fixme: we don't really need to do this, but assert all values in default list are the correct type # for item in default: # assert isinstance(item, self.element.primitive) case "object": assert isinstance(default, dict) @property def required(self): """Whether the config element is required. If an explicit override was passed to ``__init__`` (``required=True`` or ``required=False``), that wins. Otherwise falls back to the historical default-derived behaviour: required iff ``default`` is ``NotSet``. """ if self._required is not None: return self._required return self.default is NotSet @property def value(self): """The value of the config element.""" if self._value is NotSet: if self.default is None: # this is strange, but we can't set None values in channels because None = clear field... # so we need to inject the default here return None raise ValueError(f"Value for {self._name} not set. Check your config file?") return self._value @value.setter def value(self, value): self._value = value def to_dict(self): payload = { "title": self._display_name, "x-name": self._name, "x-hidden": self.hidden, } if self.format is not None: payload["format"] = self.format if self._type is not None: if self.required: payload["type"] = self._type else: payload["type"] = [self._type, "null"] payload["x-required"] = self.required if self.description is not None: payload["description"] = self.description if isinstance(self.default, Variable): payload["default"] = str(self.default) elif self.default is not NotSet: payload["default"] = self.default if self._position is not None: payload["x-position"] = self._position if self.deprecated is not None: payload["deprecated"] = self.deprecated if self.format is not None: payload["format"] = self.format if self.advanced is not None: payload["x-advanced"] = self.advanced return payload def load_data(self, data): self.value = data
[docs] class Integer(ConfigElement): """Represents a JSON Integer type. Internally represented as an int. Attributes ----------- display_name: str The display name of the config element. This is used in the UI. default: int The default value for the integer. If NotSet, the value is required. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. minimum: int | None The minimum value for the integer. If None, no minimum is enforced. exclusive_minimum: int | None The exclusive minimum value for the integer. If None, no exclusive minimum is enforced. maximum: int | None The maximum value for the integer. If None, no maximum is enforced. exclusive_maximum: int | None The exclusive maximum value for the integer. If None, no exclusive maximum is enforced. multiple_of: int | None The value that the integer must be a multiple of. If None, no multiple is enforced. """ _type = "integer" value: int def __init__( self, display_name, *, minimum: int = None, exclusive_minimum: int = None, maximum: int = None, exclusive_maximum: int = None, multiple_of: int = None, **kwargs, ): super().__init__(display_name, **kwargs) self.minimum = minimum self.exclusive_minimum = exclusive_minimum self.maximum = maximum self.exclusive_maximum = exclusive_maximum self.multiple_of = multiple_of def load_data(self, data): if isinstance(data, float) and data.is_integer(): data = int(data) self.value = data def to_dict(self): res = super().to_dict() if self.minimum is not None: res["minimum"] = self.minimum if self.exclusive_minimum is not None: res["exclusiveMinimum"] = self.exclusive_minimum if self.maximum is not None: res["maximum"] = self.maximum if self.exclusive_maximum is not None: res["exclusiveMaximum"] = self.exclusive_maximum if self.multiple_of is not None: res["multipleOf"] = self.multiple_of return res
[docs] class Number(Integer): """Represents a JSON Number type, for any numeric type. Internally represented as a float. Attributes ---------- display_name: str The display name of the config element. This is used in the UI. default: float The default value for the integer. If NotSet, the value is required. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. """ _type = "number" value: float
[docs] class Boolean(ConfigElement): """Represents a JSON Boolean type. Internally represented as a bool. Attributes ---------- display_name: str The display name of the config element. This is used in the UI. default: bool The default value for the integer. If NotSet, the value is required. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. """ _type = "boolean" value: bool
[docs] class String(ConfigElement): """Represents a JSON String type. Internally represented as a str. Attributes ---------- display_name: str The display name of the config element. This is used in the UI. default: str The default value for the integer. If NotSet, the value is required. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. length: int | None The length of the string. If None, no length is enforced. pattern: str | None A regex pattern that the string must match. If None, no pattern is enforced. """ _type = "string" value: str def __init__( self, display_name, *, length: int | None = None, pattern: str | None = None, **kwargs, ): super().__init__(display_name, **kwargs) self.length = length self.pattern = pattern def to_dict(self): res = super().to_dict() if self.length is not None: res["length"] = self.length if self.pattern is not None: res["pattern"] = self.pattern return res
class DateTime(ConfigElement): """Represents a JSON Number type, for any numeric type. Internally represented as a float. Attributes ---------- display_name: str The display name of the config element. This is used in the UI. default: float The default value for the integer. If NotSet, the value is required. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. """ _type = "string" value: str def to_dict(self): res = super().to_dict() res["format"] = "date-time" return res
[docs] class Enum(ConfigElement): """Represents a JSON Enum type. Internally represented as a list of choices. The UI renders this as a drop-down. Examples -------- You can specify a list of choices as strings or floats, or use an EnumType:: from pydoover import config class MyChoice(enum.Enum): A = "Choice 1" B = "Choice 2" C = "Choice 3" class AppConfig(config.Schema): def __init__(self): self.choice = config.Enum( "Choose Something", choices=MyChoice, default=MyChoice.A, ) self.other_choice = config.Enum( "Other Choice", choices=["a", "b", "c"], default="a" ) You can also set enum values to be objects to allow for custom attributes, provided your object implements ``__str__``:: from pydoover import config class Choice: def __init__(self, name, level): self.name = name self.level = level def __str__(self): return self.name class ChoiceType(enum.Enum): A = Choice("A", 1) B = Choice("B", 2) C = Choice("C", 3) class AppConfig(config.Schema): def __init__(self): self.choice = config.Enum( "Choose Something", choices=ChoiceType, default=ChoiceType.A, ) @property def choice_value(self): return self.choice.value.level Attributes ---------- display_name: str The display name of the config element. This is used in the UI. default: same type as choices. The default value for the integer. If NotSet, the value is required. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. choices: EnumType or list of str | float A list of choices for the enum. All choices must be of the same type (str or float). This optionally accepts an EnumType, with the value of the enum denoting the choice. The value can be an object which implements the ``__str__`` method. Each ``__str__`` value must be unique. """ _type = None def __init__( self, display_name, *, choices: list | EnumType = None, default: Any, **kwargs ): if isinstance(default, _Enum): default = str(default.value) super().__init__(display_name, default=default, **kwargs) if isinstance(choices, EnumType): self._enum_lookup = {str(member.value): member for member in choices} choices = list(self._enum_lookup.keys()) else: self._enum_lookup = None if all(isinstance(choice, str) for choice in choices): self._type = "string" elif all(isinstance(choice, float) for choice in choices): self._type = "number" self.choices = choices @property def value(self): return super().value @value.setter def value(self, value): if self._enum_lookup is None: self._value = value else: self._value = self._enum_lookup[value] def to_dict(self): return { "enum": self.choices, **super().to_dict(), }
[docs] class Array(ConfigElement): """Represents a JSON Array type. Internally represented as a list. Only a subset of JSON Schema is supported: - Item type - Minimum and maximum number of items - Unique items Attributes ---------- display_name: str The display name of the config element. This is used in the UI. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. element: ConfigElement The type of elements in the array. This can be any ConfigElement, such as String, Integer, etc. min_items: int | None The minimum number of items in the array. If None, no minimum is enforced. max_items: int | None The maximum number of items in the array. If None, no maximum is enforced. unique_items: bool | None Whether the items in the array must be unique. If None, no uniqueness is enforced. """ _type = "array" def __init__( self, display_name, *, element: ConfigElement | None = None, min_items: int | None = None, max_items: int | None = None, unique_items: bool | None = None, **kwargs, ): if element and not isinstance(element, ConfigElement): raise ValueError("Many element must be a ConfigElement instance") super().__init__(display_name, **kwargs) self.element = element or ConfigElement("unknown") self.min_items = min_items self.max_items = max_items self.unique_items = unique_items self._elements = [] def to_dict(self): res = super().to_dict() if self.element is not None: res["items"] = self.element.to_dict() if self.min_items is not None: res["minItems"] = self.min_items if self.max_items is not None: res["maxItems"] = self.max_items if self.unique_items is not None: res["uniqueItems"] = self.unique_items return res @property def elements(self) -> list[ConfigElement]: return self._elements @property def value(self) -> list[ConfigElement]: return self._elements def load_data(self, data): self._elements.clear() for row in data: elem = copy.deepcopy(self.element) elem.load_data(row) self._elements.append(elem)
[docs] class Object(ConfigElement): """Represents a JSON Object type. This is a complex type that can contain multiple elements, each with its own type. It can also have additional elements that are not defined in the schema. The UI renders this as a form with fields for each element. Examples -------- >>> from pydoover import config >>> class MyAppConfig(config.Schema): ... def __init__(self): ... self.pump = config.Object( ... "Pump Settings", ... ) ... self.pump.add_elements( ... config.Integer("Digital Output Number", description="The digital output pin to drive the pump."), ... config.Number("On Time", default=5.2, description="The time in seconds to run the pump."), ... ) Attributes ---------- display_name: str The display name of the config element. This is used in the UI. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. additional_elements: bool | dict[str, Any] If True, allows additional elements that are not defined in the schema. If a dict, defines the schema for additional elements. If False, no additional elements are allowed. """ _type = "object" _cls_elements: dict[str, ConfigElement] = {} def __init__( self, display_name, *, additional_elements: bool | dict[str, Any] = True, collapsible: bool = True, default_collapsed: bool = False, **kwargs, ): if default_collapsed and not collapsible: raise ValueError("default_collapsed is not allowed if collapsible is False") # Django-style nested overrides: a kwarg whose key contains ``__`` is # routed to a child element rather than passed up to ConfigElement. # ``reference_name__advanced=True`` becomes # ``self._elements["reference_name"].advanced = True``; deeper paths # like ``alerts__minimum_clear_duration_s__default=30.0`` walk the # element tree segment-by-segment with the final segment as the attr. # Constraint: element ``_name``s must not contain ``__`` (mirrors # Django's field-name rule). Pop these before super().__init__ so # ConfigElement doesn't choke on unknown kwargs. child_overrides = {k: kwargs.pop(k) for k in list(kwargs) if "__" in k} super().__init__(display_name, **kwargs) self._elements = copy.deepcopy(self._cls_elements) self.additional_elements = additional_elements self.collapsible = collapsible self.default_collapsed = default_collapsed for path_str, value in child_overrides.items(): self._apply_child_override(path_str, value) def _apply_child_override(self, path_str: str, value: Any) -> None: *child_path, attr = path_str.split("__") if not child_path or not attr or any(not seg for seg in child_path): raise TypeError( f"Invalid override key {path_str!r}: expected " f"'<child>__<attr>' (at least one element segment plus an " f"attribute name, separated by '__'; no empty segments)." ) target: ConfigElement = self walked: list[str] = [] for seg in child_path: elements = getattr(target, "_elements", None) if elements is None or seg not in elements: walked_str = ".".join(walked) or "<self>" raise TypeError( f"Cannot apply override {path_str!r}: no child element " f"named {seg!r} on {walked_str} ({type(target).__name__})." ) target = elements[seg] walked.append(seg) if not hasattr(target, attr): raise TypeError( f"Cannot apply override {path_str!r}: " f"{type(target).__name__} has no attribute {attr!r}." ) setattr(target, attr, value) def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) cls._cls_elements = {} cls._attr_to_name = {} cls.load_elements() def __getattribute__(self, key): # Map Python attr name -> element _name, then look up in _elements if not key.startswith("_"): try: attr_map = super().__getattribute__("_attr_to_name") elements = super().__getattribute__("_elements") return elements[attr_map[key]] except (KeyError, AttributeError): pass return super().__getattribute__(key) @classmethod def load_elements(cls): # inherit parent elements for base in cls.__mro__[1:]: if base is Object or not issubclass(base, Object): continue for name, elem in getattr(base, "_cls_elements", {}).items(): if name not in cls._cls_elements: cls._cls_elements[name] = elem for attr, name in getattr(base, "_attr_to_name", {}).items(): if attr not in cls._attr_to_name: cls._attr_to_name[attr] = name # collect own elements for k, v in cls.__dict__.items(): if isinstance(v, ConfigElement): if k in RESERVED_NAMES: raise ValueError( f"Config element name '{k}' is reserved. " f"Choose a different attribute name." ) cls._attr_to_name[k] = v._name cls._add_cls_element(v) @classmethod def _add_cls_element(cls, element): if element._name in cls._cls_elements: raise ValueError(f"Duplicate element name {element._name} not allowed.") cls._cls_elements[element._name] = element if element._position is None: element._position = len(cls._cls_elements) def add_elements(self, *element): for element in element: if element._name in self._elements: raise ValueError(f"Duplicate element name {element._name} not allowed.") self._elements[element._name] = element if element._position is None: element._position = len(self._elements) def to_dict(self): res = super().to_dict() res["properties"] = { element._name: element.to_dict() for element in self._elements.values() } res["additionalElements"] = self.additional_elements res["required"] = [ elem._name for elem in self._elements.values() if elem.required is True ] res["x-collapsible"] = self.collapsible res["x-defaultCollapsed"] = self.default_collapsed return res def load_data(self, data): # An optional Object (``default=None`` → ``required is False``) sent # as ``null`` or ``{}`` represents "operator left this blank". Leave # sub-elements at NotSet so callers can detect "not provided" — and # crucially, skip the required-sub-element check below, which would # otherwise raise for fields the user never intended to fill in. if not data and not self.required: return data = data or {} for name, value in data.items(): try: self._elements[name].load_data(value) except KeyError: if self.additional_elements is True: # Without ``load_data``, the synthesised element ends up # with ``_value=NotSet`` and ``.value`` raises for any # non-None value — making free-form keys unreadable. elem = ConfigElement(name, default=value) elem.load_data(value) self._elements[name] = elem else: raise ValueError(f"Unknown element {name} in config.") # Mirror ``Schema._inject_deployment_config``: declared elements # absent from the incoming data either raise (if required) or # fall back to their declared default. for name, elem in self._elements.items(): if name in data: continue if elem.required: raise ValueError(f"Required config element {name} not found in config.") elem.load_data(elem.default)
[docs] class Variable: """Represents a variable in the config schema. This is a special type of config element that is used to reference other config elements. It is used to create dynamic references to other config elements, such as device-specific settings. Attributes ---------- display_name: str The display name of the config element. This is used in the UI. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. scope: str The scope of the variable, which is usually the application name. name: str The name of the variable, which is usually the key of the config element. """ def __init__(self, scope: str, name: str): self._scope = sanitize_display_name(scope) self._name = sanitize_display_name(name) def __str__(self): return f"${self._scope}.{self._name}"
[docs] class Application(String): """Represents a Doover application configuration element. This is used to reference other Doover applications in the configuration schema. This is rendered as a dropdown in the UI, allowing the user to select an available application. Attributes ---------- display_name: str The display name of the config element. This is used in the UI. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. """ def __init__( self, display_name: str = "Application", *, description: str = "Application", **kwargs, ): super().__init__( display_name, description=description, format="doover-resource-application", **kwargs, )
class ApplicationInstall(String): """Represents a Doover application (installation) configuration element. This is used to reference other Doover applications in the configuration schema. This is rendered as a dropdown in the UI, allowing the user to select an installed application. Attributes ---------- display_name: str The display name of the config element. This is used in the UI. description: str | None A help text for the config element. hidden: bool Whether the config element should be hidden in the UI. """ def __init__( self, display_name: str = "ApplicationInstall", *, description: str = "Application Installation", **kwargs, ): super().__init__( display_name, description=description, format="doover-application", **kwargs, ) class Device(String): def __init__( self, display_name: str = "Device", *, description: str = "Device ID", **kwargs ): super().__init__( display_name, description=description, pattern=r"\d+", format="doover-resource-device", **kwargs, ) class DevicesConfig(Array): def __init__( self, display_name: str = "Devices", *, description: str = "List of devices to grant permissions to.", **kwargs, ): super().__init__( display_name, element=Device(), description=description, **kwargs, ) class Group(String): def __init__( self, display_name: str = "Group", *, description: str = "Group ID", **kwargs ): super().__init__( display_name, description=description, pattern=r"\d+", format="doover-resource-group", **kwargs, ) class GroupsConfig(Array): def __init__( self, display_name: str = "Groups", *, description: str = "List of groups to grant permissions to.", **kwargs, ): super().__init__( display_name, element=Group(), description=description, **kwargs, ) class ApplicationPosition(Integer): def __init__( self, display_name: str = "Position", *, description: str = "Position of Application in UI Structure. Smaller numbers are closer to the top.", default: int = 100, **kwargs, ): super().__init__( display_name, description=description, minimum=0, default=default, name="dv_app_position", hidden=True, **kwargs, ) class ApplicationDefaultOpen(Boolean): def __init__( self, display_name: str = "Default Open", *, description: str = "Whether the application is default open in the UI. " "By default this is not set - which makes it dynamic on the number of apps installed.", default: bool = None, **kwargs, ): super().__init__( display_name, description=description, default=default, name="dv_app_default_open", hidden=True, **kwargs, ) class TagRef(Object): """Represents a reference to a tag exposed by another application (or, in future, another agent). The user supplies four sub-fields: - ``reference_name`` — a local handle that the developer codes against, matched at setup time against a :class:`pydoover.tags.RemoteTag` declared with the same ``reference_name``. - ``agent_id`` — the agent that owns the upstream tag. Leave blank to target this agent. (Cross-agent resolution is not yet wired up; the field is exposed so the schema does not need to change later.) - ``app_name`` — the upstream application's app key. - ``tag_name`` — the upstream tag's name within that app. A custom ``format`` (``doover-tag-reference``) is set so a future UI can render this as a single cascading picker without any schema migration. """ reference_name = String( "Reference Name", description="Local handle for this tag. Match this in your `RemoteTag` declaration.", name="reference_name", ) agent_id = Device( "Agent", description="Agent that owns the upstream tag. Leave blank to use this agent.", default=None, name="agent_id", ) app_name = ApplicationInstall( "Application", description="Application that publishes the upstream tag.", name="app_name", ) tag_name = String( "Tag Name", description="Name of the upstream tag within the chosen application.", name="tag_name", ) def __init__( self, display_name: str = "Tag Reference", *, description: str | None = "Reference to a tag in another application.", **kwargs, ): super().__init__( display_name, description=description, format="doover-tag-reference", **kwargs, ) class LLMAPIKey(String): def __init__( self, display_name: str = "LLM API Key", *, description: str = "API key for the LLM service.", **kwargs, ): super().__init__( display_name, description=description, hidden=True, default="placeholder", **kwargs, ) self._name = "dv-llm-api-key" ApplicationConfig = Schema