Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
46 changes: 30 additions & 16 deletions appdaemon/adapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from datetime import timedelta
from logging import Logger
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload, Generic

from appdaemon import dependency
from appdaemon import exceptions as ade
Expand All @@ -24,9 +24,17 @@
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")
if TYPE_CHECKING:
from .models.config.app import AppConfig
from .plugin_management import PluginBase

T = TypeVar("T")
if sys.version_info >= (3, 13):
ModelType = TypeVar("ModelType", bound="AppConfig", default="AppConfig")
else:
ModelType = TypeVar("ModelType", bound="AppConfig")

# Check if the module is being imported using the legacy method
if __name__ == Path(__file__).name:
Expand All @@ -40,12 +48,7 @@
)


if TYPE_CHECKING:
from .models.config.app import AppConfig
from .plugin_management import PluginBase


class ADAPI:
class ADAPI(Generic[ModelType]):
"""AppDaemon API class.

This class includes all native API calls to AppDaemon
Expand Down Expand Up @@ -73,9 +76,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 +99,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 +165,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
17 changes: 11 additions & 6 deletions appdaemon/plugins/hass/hassapi.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import re
import sys
from ast import literal_eval
from collections.abc import Iterable
from copy import deepcopy
from datetime import datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal, Type, overload
from typing import TYPE_CHECKING, Any, Callable, Literal, Type, overload, Generic, TypeVar

from appdaemon import exceptions as ade
from appdaemon import utils
Expand All @@ -19,6 +20,9 @@
from appdaemon.plugins.hass.notifications import AndroidNotification
from appdaemon.services import ServiceCallback

if TYPE_CHECKING:
from appdaemon.models.config import AppConfig

# Check if the module is being imported using the legacy method
if __name__ == Path(__file__).name:
from appdaemon.logging import Logging
Expand All @@ -32,19 +36,20 @@
)


if TYPE_CHECKING:
from ...models.config.app import AppConfig

if sys.version_info >= (3, 13):
ModelType = TypeVar("ModelType", bound="AppConfig", default="AppConfig")
else:
ModelType = TypeVar("ModelType", bound="AppConfig")

class Hass(ADBase, ADAPI):
class Hass(Generic[ModelType], ADBase, ADAPI[ModelType]):
"""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: ModelType):
# Call Super Classes
ADBase.__init__(self, ad, config_model)
ADAPI.__init__(self, ad, config_model)
Expand Down
11 changes: 8 additions & 3 deletions appdaemon/plugins/mqtt/mqttapi.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
from typing import TYPE_CHECKING, Any, Callable, Optional, Union, TypeVar, Generic

import appdaemon.adapi as adapi
import appdaemon.adbase as adbase
Expand All @@ -24,8 +25,12 @@
"To use the Mqtt plugin use 'from appdaemon.plugins import mqtt' instead.",
)

if sys.version_info >= (3, 13):
ModelType = TypeVar("ModelType", bound="AppConfig", default="AppConfig")
else:
ModelType = TypeVar("ModelType", bound="AppConfig")

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

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

_plugin: "MqttPlugin"

def __init__(self, ad: AppDaemon, config_model: "AppConfig"):
def __init__(self, ad: AppDaemon, config_model: ModelType):
# Call Super Classes
adbase.ADBase.__init__(self, ad, config_model)
adapi.ADAPI.__init__(self, ad, config_model)
Expand Down
43 changes: 42 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,43 @@ 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 .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

# get_original_bases was added in Python 3.12
if sys.version_info >= (3, 12):
from types import get_original_bases
bases = get_original_bases(object.__class__)
else:
# For Python 3.10 and 3.11, use __orig_bases__ attribute
bases = getattr(object.__class__, "__orig_bases__", ())

for base in bases:
if hasattr(base, "__name__") and 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
Loading
Loading