Skip to content

Commit 5882974

Browse files
committed
fleshed out top-level pydantic config
1 parent cf7a155 commit 5882974

12 files changed

Lines changed: 174 additions & 118 deletions

File tree

appdaemon/__main__.py

Lines changed: 30 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
from appdaemon.logging import Logging
3232
from appdaemon.models.config import AppDaemonConfig
3333

34+
from .models.config.yaml import MainConfig
35+
3436
try:
3537
import pid
3638
except ImportError:
@@ -107,16 +109,13 @@ def stop(self):
107109
self.http_object.stop()
108110

109111
# noinspection PyBroadException,PyBroadException
110-
def run(self, ad_config_model: AppDaemonConfig, hadashboard, admin, aui, api, http):
112+
def run(self, ad_config_model: AppDaemonConfig, *args, http):
111113
"""Start AppDaemon up after initial argument parsing.
112114
113115
Args:
114116
ad_config_model: Config for AppDaemon Object.
115-
hadashboard: Config for HADashboard Object.
116-
admin: Config for admin Object.
117-
aui: Config for aui Object.
118-
api: Config for API Object
119-
http: Config for HTTP Object
117+
*args: Gets used to create the HTTP object.
118+
http: Main HTTP config
120119
"""
121120

122121
try:
@@ -142,16 +141,9 @@ def run(self, ad_config_model: AppDaemonConfig, hadashboard, admin, aui, api, ht
142141

143142
# Initialize Dashboard/API/admin
144143

145-
if http is not None and (hadashboard is not None or admin is not None or aui is not None or api is not False):
144+
if http is not None and any(arg is not None for arg in args):
146145
self.logger.info("Initializing HTTP")
147-
self.http_object = HTTP(
148-
self.AD,
149-
hadashboard,
150-
admin,
151-
aui,
152-
api,
153-
http,
154-
)
146+
self.http_object = HTTP(self.AD, *args, http)
155147
self.AD.register_http(self.http_object)
156148
else:
157149
if http is not None:
@@ -314,11 +306,17 @@ def main(self): # noqa: C901
314306
else:
315307
ad_kwargs["module_debug"] = module_debug_cli
316308

317-
# Validate the AppDaemon configuration
318-
ad_config_model = AppDaemonConfig.model_validate(ad_kwargs)
309+
if hadashboard := config.get("hadashboard"):
310+
hadashboard["config_dir"] = config_dir
311+
hadashboard["config_file"] = config_file
312+
hadashboard["dashboard"] = True
313+
hadashboard["profile_dashboard"] = args.profiledash
314+
315+
model = MainConfig.model_validate(config)
316+
dump_kwargs = dict(mode='json', by_alias=True, exclude_unset=True)
319317

320318
if args.debug.upper() == "DEBUG":
321-
model_json = ad_config_model.model_dump(by_alias=True, exclude_unset=True)
319+
model_json = model.model_dump(**dump_kwargs)
322320
print(json.dumps(model_json, indent=4, default=str, sort_keys=True))
323321
except ValidationError as e:
324322
print(f"Configuration error in: {config_file}")
@@ -332,59 +330,7 @@ def main(self): # noqa: C901
332330
print(e)
333331
sys.exit(1)
334332

335-
hadashboard = None
336-
if "hadashboard" in config:
337-
if config["hadashboard"] is None:
338-
hadashboard = {}
339-
else:
340-
hadashboard = config["hadashboard"]
341-
342-
hadashboard["profile_dashboard"] = args.profiledash
343-
hadashboard["config_dir"] = config_dir
344-
hadashboard["config_file"] = config_file
345-
if args.profiledash:
346-
hadashboard["profile_dashboard"] = True
347-
348-
if "dashboard" not in hadashboard:
349-
hadashboard["dashboard"] = True
350-
351-
old_admin = None
352-
if "old_admin" in config:
353-
if config["old_admin"] is None:
354-
old_admin = {}
355-
else:
356-
old_admin = config["old_admin"]
357-
admin = None
358-
if "admin" in config:
359-
if config["admin"] is None:
360-
admin = {}
361-
else:
362-
admin = config["admin"]
363-
api = None
364-
if "api" in config:
365-
if config["api"] is None:
366-
api = {}
367-
else:
368-
api = config["api"]
369-
370-
http = None
371-
if "http" in config:
372-
http = config["http"]
373-
374-
# Setup _logging
375-
376-
if "log" in config:
377-
print(
378-
"ERROR",
379-
"'log' directive deprecated, please convert to new 'logs' syntax",
380-
)
381-
sys.exit(1)
382-
if "logs" in config:
383-
logs = config["logs"]
384-
else:
385-
logs = {}
386-
387-
self.logging = Logging(logs, args.debug)
333+
self.logging = Logging(model.logs.model_dump(**dump_kwargs), args.debug)
388334
self.logger = self.logging.get_logger()
389335

390336
if "time_zone" in config["appdaemon"]:
@@ -407,23 +353,33 @@ def main(self): # noqa: C901
407353
)
408354
self.logger.info("Configuration read from: %s", config_file)
409355

410-
utils.deprecation_warnings(ad_config_model, self.logger)
356+
utils.deprecation_warnings(model.appdaemon, self.logger)
411357

412358
self.logging.dump_log_config()
413359
self.logger.debug("AppDaemon Section: %s", config.get("appdaemon"))
414360
self.logger.debug("HADashboard Section: %s", config.get("hadashboard"))
415361

362+
run = functools.partial(
363+
self.run,
364+
model.appdaemon,
365+
model.hadashboard.model_dump(**dump_kwargs),
366+
model.old_admin,
367+
model.admin,
368+
model.api,
369+
http=model.http.model_dump(**dump_kwargs),
370+
)
371+
416372
if pidfile is not None:
417373
self.logger.info("Using pidfile: %s", pidfile)
418374
dir = os.path.dirname(pidfile)
419375
name = os.path.basename(pidfile)
420376
try:
421377
with pid.PidFile(name, dir):
422-
self.run(ad_config_model, hadashboard, old_admin, admin, api, http)
378+
run()
423379
except pid.PidFileError:
424380
self.logger.error("Unable to acquire pidfile - terminating")
425381
else:
426-
self.run(ad_config_model, hadashboard, old_admin, admin, api, http)
382+
run()
427383

428384

429385
def main():

appdaemon/models/config/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
"""This sub-package contains all the pydantic models for the appdaemon.yaml file.
22
3-
The top-level model can be found in ``yaml.py``
3+
Modules:
4+
app: Pydantic models for the app configuration files
5+
appdaemon: Pydantic models for the appdaemon section of the appdaemon.yaml file
6+
common: Common types used in multiple places
7+
http: Pydantic models for the http section of the appdaemon.yaml file
8+
log: Pydantic models for the log section of the appdaemon.yaml file
9+
plugin: Pydantic models for the plugin section of the appdaemon.yaml file
10+
sequence: Pydantic models for the sequences defined in app configuration files
11+
yaml: Top-level pydantic model for the appdaemon.yaml file
412
"""
513

614
from .app import AllAppConfig, AppConfig, GlobalModule

appdaemon/models/config/api.py

Whitespace-only changes.

appdaemon/models/config/app.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import logging
22
import sys
33
from abc import ABC
4+
from collections.abc import Iterable, Iterator
45
from copy import deepcopy
56
from pathlib import Path
67
from typing import Annotated, Any, Literal
7-
from collections.abc import Iterable, Iterator
88

99
from pydantic import BaseModel, Discriminator, Field, RootModel, Tag, ValidationError, field_validator
1010
from pydantic_core import PydanticUndefinedType
@@ -21,6 +21,7 @@ class GlobalModules(RootModel):
2121

2222
class BaseApp(BaseModel, ABC):
2323
"""Abstract class to contain logic that's common to both apps and global modules"""
24+
2425
name: str
2526
config_path: Path | None = None # Needs to remain optional because it gets set later
2627
module_name: str = Field(alias="module")
@@ -113,7 +114,7 @@ def __iter__(self) -> Iterator[Path]:
113114

114115
@classmethod
115116
def from_config_file(cls, path: Path):
116-
return cls.model_validate(read_config_file(path))
117+
return cls.model_validate(read_config_file(path, app_config=True))
117118

118119
@classmethod
119120
def from_config_files(cls, paths: Iterable[Path], app_dir: Path | None = None):
@@ -126,7 +127,7 @@ def from_config_files(cls, paths: Iterable[Path], app_dir: Path | None = None):
126127
cfg = {}
127128
for p in paths:
128129
try:
129-
for new, new_cfg in read_config_file(p).items():
130+
for new, new_cfg in read_config_file(p, app_config=True).items():
130131
try:
131132
cls.model_validate({new: new_cfg})
132133
except ValidationError:
@@ -145,7 +146,7 @@ def from_config_files(cls, paths: Iterable[Path], app_dir: Path | None = None):
145146
else:
146147
cfg[new] = new_cfg
147148
except ade.ConfigReadFailure as e:
148-
logging.getLogger('AppDaemon').warning(f'Failed to read file: {e}')
149+
logging.getLogger("AppDaemon").warning(f"Failed to read file: {e}")
149150
continue
150151
else:
151152
return cls.model_validate(cfg)
@@ -156,7 +157,7 @@ def depedency_graph(self) -> dict[str, set[str]]:
156157
app_name: cfg.dependencies
157158
for app_name, cfg in self.root.items()
158159
if isinstance(cfg, (AppConfig, GlobalModule))
159-
}
160+
} # fmt: skip
160161

161162
def reversed_dependency_graph(self) -> dict[str, set[str]]:
162163
"""Maps each app to the other apps that depend on it"""
@@ -168,13 +169,13 @@ def app_definitions(self):
168169
(app_name, cfg)
169170
for app_name, cfg in self.root.items()
170171
if isinstance(cfg, (BaseApp, SequenceConfig))
171-
)
172+
) # fmt: skip
172173

173174
def global_modules(self) -> list[GlobalModule]:
174175
return [
175176
cfg for cfg in self.root.values()
176177
if isinstance(cfg, GlobalModule)
177-
]
178+
] # fmt: skip
178179

179180
def app_names(self) -> set[str]:
180181
"""Returns all the app names for regular user apps and global module apps"""
@@ -189,7 +190,7 @@ def apps_from_file(self, paths: Iterable[Path]):
189190
for app_name, cfg in self.root.items()
190191
if isinstance(cfg, BaseApp) and
191192
cfg.config_path in paths
192-
)
193+
) # fmt: skip
193194

194195
@property
195196
def active_app_count(self) -> int:

appdaemon/models/config/appdaemon.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import logging
2+
from collections.abc import Callable
23
from datetime import datetime, timedelta
34
from pathlib import Path
45
from typing import Annotated, Any, Literal
5-
from collections.abc import Callable
66

77
import pytz
8-
from pydantic import BaseModel, BeforeValidator, ConfigDict, Discriminator, Field, RootModel, Tag, field_validator, model_validator
8+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Discriminator, Field, RootModel, SecretStr, Tag, field_validator, model_validator
99
from pytz.tzinfo import BaseTzInfo
1010
from typing_extensions import deprecated
1111

12+
from appdaemon.models.config.http import CoercedPath
13+
1214
from ...models.config.plugin import HASSConfig, MQTTConfig
1315
from ...version import __version__
1416
from .misc import FilterConfig, NamespaceConfig
@@ -45,7 +47,7 @@ class AppDaemonConfig(BaseModel, extra="allow"):
4547
write_toml: bool = False
4648
ext: Literal[".yaml", ".toml"] = ".yaml"
4749

48-
filters: list[FilterConfig] = []
50+
filters: list[FilterConfig] = Field(default_factory=list)
4951

5052
starttime: datetime | None = None
5153
endtime: datetime | None = None
@@ -56,6 +58,9 @@ class AppDaemonConfig(BaseModel, extra="allow"):
5658
module_debug: ModuleLoggingLevels = Field(default_factory=dict)
5759

5860
api_port: int | None = None
61+
api_key: SecretStr | None = None
62+
api_ssl_certificate: CoercedPath | None = None
63+
api_ssl_key: CoercedPath | None = None
5964
stop_function: Callable | None = None
6065

6166
utility_delay: int = 1

appdaemon/models/config/common.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from datetime import timedelta
2+
from pathlib import Path
3+
from typing import Annotated, Any, Literal
4+
5+
from pydantic import BeforeValidator, PlainSerializer, ValidationError
6+
7+
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
8+
9+
10+
CoercedPath = Annotated[Path, BeforeValidator(lambda v: Path(v).resolve())]
11+
12+
13+
def validate_timedelta(v: Any):
14+
match v:
15+
case str():
16+
parts = tuple(map(float, v.split(":")))
17+
match len(parts):
18+
case 1:
19+
return timedelta(seconds=parts[0])
20+
case 2:
21+
return timedelta(minutes=parts[0], seconds=parts[1])
22+
case 3:
23+
return timedelta(hours=parts[0], minutes=parts[1], seconds=parts[2])
24+
case _:
25+
raise ValidationError(f"Invalid timedelta format: {v}")
26+
case int() | float():
27+
return timedelta(seconds=v)
28+
case _:
29+
raise ValidationError(f"Invalid type for timedelta: {v}")
30+
31+
32+
TimeType = Annotated[timedelta, BeforeValidator(validate_timedelta), PlainSerializer(lambda td: td.total_seconds())]
33+
34+
35+
BoolNum = Annotated[bool, BeforeValidator(lambda v: False if int(v) == 0 else True)]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from pydantic import BaseModel
2+
3+
from .common import BoolNum, CoercedPath
4+
5+
6+
class DashboardConfig(BaseModel):
7+
config_dir: CoercedPath | None = None
8+
config_file: CoercedPath | None = None
9+
10+
dashboard_dir: CoercedPath | None = None
11+
force_compile: BoolNum = False
12+
compile_on_start: BoolNum = False
13+
profile_dashboard: bool = False
14+
dashboard: bool

appdaemon/models/config/http.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import Literal
2+
3+
from pydantic import BaseModel, HttpUrl, SecretStr
4+
5+
from .common import CoercedPath
6+
7+
8+
class HTTPConfig(BaseModel, extra="allow"):
9+
url: HttpUrl | None = None
10+
password: SecretStr | None = None
11+
transport: Literal["ws", "socketio"] = "ws"
12+
ssl_certificate: CoercedPath | None = None
13+
ssl_key: CoercedPath | None = None
14+
static_dirs: dict[str, CoercedPath] | None = None
15+
headers: dict[str, str] | None = None

0 commit comments

Comments
 (0)