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