Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions appdaemon/adapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
from appdaemon.models.config.app import AppConfig
from appdaemon.parse import resolve_time_str
from appdaemon.state import StateCallbackType
from .utils import get_typing_argument

T = TypeVar("T")
ModelType = TypeVar("ModelType", bound="AppConfig", default=AppConfig)


# Check if the module is being imported using the legacy method
Expand All @@ -45,7 +47,7 @@
from .plugin_management import PluginBase


class ADAPI:
class ADAPI[ModelType]:
"""AppDaemon API class.

This class includes all native API calls to AppDaemon
Expand Down Expand Up @@ -73,9 +75,20 @@ class ADAPI:
namespace: str
_plugin: "PluginBase"

def __init__(self, ad: AppDaemon, config_model: "AppConfig"):
def __init__(self, ad: AppDaemon, config_model: ModelType):
self.__app_config_model_class = get_typing_argument(self) or AppConfig
self.AD = ad
self.config_model = config_model
# Re-validate/convert incoming AppConfig to the typed config model if specified
try:
if isinstance(config_model, self.__app_config_model_class):
self.config_model = config_model
else:
data = config_model.model_dump(by_alias=True, exclude_unset=True)
self.config_model = self.__app_config_model_class.model_validate(data)
except Exception:
self.err(f"{self.name} configuration does not match the expected type {self.__app_config_model_class.__name__}")
# Let AppManagement wrappers handle logging/state on failure
raise
self.dashboard_dir = None

if self.AD.http is not None:
Expand All @@ -85,12 +98,12 @@ def __init__(self, ad: AppDaemon, config_model: "AppConfig"):
self.logger = self._logging.get_child(self.name)
self.err = self._logging.get_error().getChild(self.name)

if lvl := config_model.log_level:
if lvl := self.config_model.log_level:
self.logger.setLevel(lvl)
self.err.setLevel(lvl)

self.user_logs = {}
if log_name := config_model.log:
if log_name := self.config_model.log:
if user_log := self.get_user_log(log_name):
self.logger = user_log

Expand Down Expand Up @@ -151,17 +164,17 @@ def config_dir(self, value: Path) -> None:
self.logger.warning("config_dir is read-only and needs to be set before AppDaemon starts")

@property
def config_model(self) -> AppConfig:
"""The AppConfig model only for this app."""
def config_model(self) -> ModelType:
"""The AppConfig (or specialized) model only for this app."""
return self._config_model

@config_model.setter
def config_model(self, new_config: Any) -> None:
match new_config:
case AppConfig():
case self.__app_config_model_class():
self._config_model = new_config
case _:
self._config_model = AppConfig.model_validate(new_config)
self._config_model = self.__app_config_model_class.model_validate(new_config)
self.args = self._config_model.model_dump(by_alias=True, exclude_unset=True)

@property
Expand Down
4 changes: 2 additions & 2 deletions appdaemon/plugins/hass/hassapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@
from ...models.config.app import AppConfig


class Hass(ADBase, ADAPI):
class Hass[T: AppConfig](ADBase, ADAPI[T]):
"""HASS API class for the users to inherit from.

This class provides an interface to the HassPlugin object that connects to Home Assistant.
"""

_plugin: HassPlugin

def __init__(self, ad: AppDaemon, config_model: "AppConfig"):
def __init__(self, ad: AppDaemon, config_model: T):
# Call Super Classes
ADBase.__init__(self, ad, config_model)
ADAPI.__init__(self, ad, config_model)
Expand Down
4 changes: 2 additions & 2 deletions appdaemon/plugins/mqtt/mqttapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
)


class Mqtt(adbase.ADBase, adapi.ADAPI):
class Mqtt[T: AppConfig](adbase.ADBase, adapi.ADAPI[T]):
"""
A list of API calls and information specific to the MQTT plugin.

Expand Down Expand Up @@ -73,7 +73,7 @@ def initialize(self):

_plugin: "MqttPlugin"

def __init__(self, ad: AppDaemon, config_model: "AppConfig"):
def __init__(self, ad: AppDaemon, config_model: T):
# Call Super Classes
adbase.ADBase.__init__(self, ad, config_model)
adapi.ADAPI.__init__(self, ad, config_model)
Expand Down
36 changes: 35 additions & 1 deletion appdaemon/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from logging import Logger
from pathlib import Path
from time import perf_counter
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Literal, ParamSpec, Protocol, TypeVar
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Literal, ParamSpec, Protocol, TypeVar, Type

import dateutil.parser
import tomli
Expand All @@ -46,6 +46,7 @@
file_log = logger.getChild("file")

if TYPE_CHECKING:
from .models.config import AppConfig
from .adbase import ADBase
from .appdaemon import AppDaemon

Expand Down Expand Up @@ -1236,3 +1237,36 @@ def recursive_get_files(base: Path, suffix: str, exclude: set[str] | None = None
yield item
elif item.is_dir() and os.access(item, os.R_OK):
yield from recursive_get_files(item, suffix, exclude)

TA = TypeVar("TA", bound="AppConfig")

def get_typing_argument(object) -> Type[TA]:
"""
This function is used to extract the typing argument of a generic class or object.

The purpose is to be able to create an instance of such a type during execution time.

Args:
object: The object or instance for which the typing argument is to be retrieved.

Returns:
The typing argument (TA) associated with the given object. The specific typing
argument depends on the generic type definition.
"""
from typing import get_args, get_origin
from types import get_original_bases
from .models.config import AppConfig

oc = getattr(object, "__orig_class__", None) or get_origin(object)
if oc is not None:
for arg in get_args(oc):
if issubclass(arg, AppConfig):
return arg

for base in get_original_bases(object.__class__):
if base.__name__ in {"ADAPI", "ADBase"}:
for arg in get_args(base):
if issubclass(arg, AppConfig):
return arg

return AppConfig
52 changes: 52 additions & 0 deletions docs/AD_API_REFERENCE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,47 @@ for plugins in multiple namespaces.
# handle = self.adapi.run_in(...)
# handle = self.adapi.run_every(...)

Typed app configuration (Pydantic models)
----------------------------------------

App args can be validated and accessed via a typed model by subclassing
``appdaemon.models.config.app.AppConfig`` and typing ``ADAPI`` with it:
``class MyApp(ADAPI[MyConfig]):``. The model instance is available as
``self.config_model``; the untyped dict ``self.args`` remains available for
backward compatibility.

.. code:: python

from appdaemon.adapi import ADAPI
from appdaemon.models.config import AppConfig

class MyConfig(AppConfig, extra="forbid"):
required_int: int
optional_str: str = "Hello"

class MyApp(ADAPI[MyConfig]):
def initialize(self):
# Typed access
self.log(f"Typed: {self.config_model.required_int}")
# Legacy access
self.log(f"Legacy: {self.args['required_int']}")

.. code:: yaml

# apps.yaml
my_app:
module: my_module
class: MyApp
required_int: 42

.. note::
- Validation errors are logged and prevent the app from starting.
- ``extra="forbid"`` rejects unknown keys; omit it if you want to allow
extra args.

See also the user guide section on app configuration (apps.yaml) for a
full walkthrough.

Entity Class
------------

Expand Down Expand Up @@ -261,6 +302,17 @@ Cancels a predefined sequence. The `entity_id` arg with the sequence full-qualif
Reference
---------

Configuration
~~~~~~~~~~~~~

.. py:attribute:: appdaemon.adapi.ADAPI.config_model
:type: appdaemon.models.config.app.AppConfig

Typed view of the app’s configuration. When ``ADAPI`` is used with a
generic parameter (e.g., ``ADAPI[MyConfig]``), this attribute is an instance
of that model, providing IDE-friendly, validated access to app args.
``self.args`` remains available as a plain dict.

Entity API
~~~~~~~~~~
.. automethod:: appdaemon.entity.Entity.add
Expand Down
51 changes: 51 additions & 0 deletions docs/HASS_API_REFERENCE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,57 @@ Create apps using the `Hass` API by inheriting from the :py:class:`Hass <appdaem
Read the `AppDaemon API Reference <AD_API_REFERENCE.html>`__ to learn other inherited helper functions that
can be used by Hass applications.

Typed app configuration (Pydantic models)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

App args can be validated and accessed via a typed model by subclassing
``appdaemon.models.config.app.AppConfig`` and typing the ``Hass`` API with it:
``class MyApp(Hass[MyConfig]):``. The model instance is available as
``self.config_model``; the untyped dict ``self.args`` remains available for
backward compatibility.

.. code:: python

from appdaemon.plugins.hass import Hass
from appdaemon.models.config import AppConfig


class MyConfig(AppConfig, extra="forbid"):
light: str
brightness: int = 255


class MyApp(Hass[MyConfig]):
def initialize(self) -> None:
# Typed access
self.call_service(
"light/turn_on",
target=self.config_model.light,
brightness=self.config_model.brightness,
)
# Legacy access
self.call_service(
"light/turn_on",
target=self.args["light"],
brightness=self.args.get("brightness", 255),
)

.. code:: yaml

# apps.yaml
typed_light_app:
module: my_module
class: MyApp
light: light.kitchen
brightness: 200

.. note::
- Validation errors are logged and prevent the app from starting.
- ``extra="forbid"`` rejects unknown keys; omit it if you want to allow extra args.
- See the `AD API Reference <AD_API_REFERENCE.html>`__ for the generic
``ADAPI`` usage and the ``config_model`` attribute
(`link <AD_API_REFERENCE.html#appdaemon.adapi.ADAPI.config_model>`__).

Services
~~~~~~~~

Expand Down
2 changes: 2 additions & 0 deletions docs/HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
- Add request context logging for failed HASS calls - contributed by [ekutner](https://github.com/ekutner)
- Reload modified apps on SIGUSR2 - contributed by [chatziko](https://github.com/chatziko)
- Using urlib to create endpoints from URLs - contributed by [cebtenzzre](https://github.com/cebtenzzre)
- Support for typed AppConfiguration for apps, using Pydantic models (ADAPI[MyConfig]);
also supported in Hass and MQTT APIs. Contributed by [TCampmany](https://github.com/tcampmany)

**Fixes**

Expand Down
46 changes: 44 additions & 2 deletions docs/MQTT_API_REFERENCE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,50 @@ To create apps based on just the MQTT API, use some code like the following:

def initialize(self):

Making Calls to MQTT
--------------------
Typed app configuration (Pydantic models)
----------------------------------------

App args can be validated and accessed via a typed model by subclassing
``appdaemon.models.config.app.AppConfig`` and typing the ``Mqtt`` API with it:
``class MyApp(mqtt.Mqtt[MyConfig]):``. The model instance is available as
``self.config_model``; the untyped dict ``self.args`` remains available for
backward compatibility.

.. code:: python

import mqttapi as mqtt
from appdaemon.models.config import AppConfig

class MyConfig(AppConfig, extra="forbid"):
required_topic: str
qos: int = 0

class MyApp(mqtt.Mqtt[MyConfig]):
def initialize(self):
# Typed access
topic = self.config_model.required_topic
self.mqtt_publish(topic, payload="ON", qos=self.config_model.qos)
# Legacy access
self.call_service("mqtt/publish", topic=self.args["required_topic"], payload="ON")

.. code:: yaml

# apps.yaml
my_mqtt_app:
module: my_module
class: MyApp
required_topic: "homeassistant/bedroom/light"
qos: 1

.. note::
- Validation errors are logged and prevent the app from starting.
- ``extra="forbid"`` rejects unknown keys; omit it if you want to allow extra args.
- See the `AD API Reference <AD_API_REFERENCE.html>`__ for the generic
``ADAPI`` usage and the ``config_model`` attribute
(`link <AD_API_REFERENCE.html#appdaemon.adapi.ADAPI.config_model>`__).

Making Calls to MQTT
--------------------

The MQTT Plugin uses the inherited ``call_service()`` helper function the AppDaemon API,
to carry out service calls from within an AppDaemon app. See the documentation of this
Expand Down
40 changes: 40 additions & 0 deletions tests/conf/apps/typed_hello_world/apps.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
hello_world_1:
module: hello
class: HelloWorld

hello_world_2:
module: hello
class: HelloWorld
my_kwarg: "asdf"

hello_world_3:
module: hello
clas: HelloWorld # wrong attribute name
my_kwarg: "asdf"

typed_hello_world_1:
module: typed_hello
class: TypedHelloWorld
required_int: 1
optional_str: "Hi!"

typed_hello_world_2:
module: typed_hello
class: TypedHelloWorld
required_int: 1

typed_hello_world_3:
module: typed_hello
class: TypedHelloWorld
# required_int: 1 # missing

typed_hello_world_4:
module: typed_hello
class: TypedHelloWorld
required_int: "a" # wrong_type

typed_hello_world_5:
module: typed_hello
class: TypedHelloWorld
required_int: 1
typed_hello_world: "does not accept extra fields" # Will fail at load time
Loading
Loading