Skip to content

Commit 7f48f98

Browse files
committed
Merge branch 'multi-tests'
1 parent a22f4c1 commit 7f48f98

27 files changed

+472
-252
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
],
99
"python.testing.unittestEnabled": false,
1010
"python.testing.pytestEnabled": true,
11-
"python.testing.autoTestDiscoverOnSavePattern": "tests/**/*.py"
11+
"python.testing.autoTestDiscoverOnSavePattern": "tests/**/*.py",
1212
}

appdaemon/__main__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,8 @@ def signal_handlers(self, loop: asyncio.AbstractEventLoop):
412412
def stop(self):
413413
"""Called by the signal handler to shut AD down."""
414414
self.stop_time = perf_counter()
415-
self.loop.create_task(self.AD.stop())
415+
task = self.loop.create_task(self.AD.stop())
416+
task.add_done_callback(lambda _: self.loop.stop())
416417

417418
def run(self) -> None:
418419
"""Start AppDaemon up after initial argument parsing."""

appdaemon/app_management.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ def valid_apps(self) -> set[str]:
166166
def start(self) -> None:
167167
self.logger.debug("Starting the app management subsystem")
168168
if self.AD.apps_enabled:
169+
self.AD.loop.create_task(self.init_admin_entities())
170+
169171
task = self.AD.loop.create_task(
170172
self.check_app_updates(mode=UpdateMode.INIT),
171173
name="check_app_updates",
@@ -202,6 +204,21 @@ async def get_state(self, name: str, **kwargs):
202204

203205
return await self.AD.state.get_state("_app_management", "admin", entity_id, **kwargs)
204206

207+
async def init_admin_entities(self):
208+
for app_name, cfg in self.app_config.root.items():
209+
match cfg:
210+
case AppConfig() as app_cfg:
211+
await self.add_entity(
212+
app_name,
213+
state="loaded",
214+
attributes={
215+
"totalcallbacks": 0,
216+
"instancecallbacks": 0,
217+
"args": app_cfg.args,
218+
"config_path": app_cfg.config_path,
219+
},
220+
)
221+
205222
async def add_entity(self, name: str, state, attributes):
206223
# not a fully qualified entity name
207224
if "." not in name:
@@ -715,7 +732,7 @@ async def check_app_config_files(self, update_actions: UpdateActions):
715732
threads_to_add = active_apps - self.AD.threading.thread_count
716733
self.logger.debug(f"Adding {threads_to_add} threads based on the active app count")
717734
for _ in range(threads_to_add):
718-
await self.AD.threading.add_thread(silent=False, pinthread=True)
735+
await self.AD.threading.add_thread(silent=False)
719736

720737
@utils.executor_decorator
721738
def read_config_file(self, file: Path) -> AllAppConfig:
@@ -814,7 +831,6 @@ async def wrapper(*args, **kwargs):
814831

815832
return wrapper
816833

817-
# @utils.timeit
818834
async def check_app_updates(
819835
self,
820836
plugin_ns: str | None = None,
@@ -1371,7 +1387,12 @@ def enable_app(self, app: str):
13711387

13721388
@contextlib.asynccontextmanager
13731389
async def app_run_context(self, app: str, **kwargs):
1374-
"""Context manager for running an app."""
1390+
"""Context manager for running an app to help during testing.
1391+
1392+
Args:
1393+
app (str): The name of the app to run. Must have an entry in the app_config root.
1394+
**kwargs: Arbitrary keyword arguments representing configuration fields to temporarily update the app with.
1395+
"""
13751396
match self.app_config.root.get(app):
13761397
case AppConfig() as app_cfg:
13771398
# Store the complete original configuration
@@ -1382,14 +1403,21 @@ async def app_run_context(self, app: str, **kwargs):
13821403
return
13831404

13841405
try:
1385-
self.update_app(app, **kwargs)
1406+
if kwargs:
1407+
self.update_app(app, **kwargs)
1408+
self.logger.debug("Temporarily updated app '%s' with: %s", app, kwargs)
1409+
1410+
# Ensure there's at least one thread available
1411+
if not self.AD.threading.thread_count:
1412+
await self.AD.threading.create_initial_threads()
13861413

1414+
created_app_object = False
13871415
if app not in self.objects:
13881416
self.logger.debug("Creating ManagedObject for app '%s'", app)
13891417
await self.create_app_object(app)
13901418
await self.AD.threading.calculate_pin_threads()
1419+
created_app_object = True
13911420

1392-
self.logger.debug("Temporarily updated app '%s' with: %s", app, kwargs)
13931421
await self.start_app(app)
13941422
yield
13951423
finally:
@@ -1399,6 +1427,8 @@ async def app_run_context(self, app: str, **kwargs):
13991427
self.logger.debug("Restored app '%s' to original state", app)
14001428
except ValidationError as e:
14011429
self.logger.warning("Failed to restore app '%s' to original state: %s", app, e)
1430+
if created_app_object:
1431+
self.objects.pop(app)
14021432

14031433
@utils.executor_decorator
14041434
def remove_app(self, app: str, **kwargs):

appdaemon/appdaemon.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,13 @@
2323
from appdaemon.threads import Threading
2424
from appdaemon.utility_loop import Utility
2525

26-
from . import utils
2726

2827
if TYPE_CHECKING:
2928
from appdaemon.http import HTTP
3029
from appdaemon.logging import Logging
3130

3231

33-
class AppDaemon(metaclass=utils.Singleton):
32+
class AppDaemon:
3433
"""Top-level container for the subsystem objects. This gets passed to the subsystem objects and stored in them as
3534
the ``self.AD`` attribute.
3635
@@ -382,6 +381,7 @@ def utility_delay(self):
382381
return self.config.utility_delay
383382

384383
def start(self) -> None:
384+
self.logger.debug("Starting AppDaemon")
385385
self.thread_async.start()
386386
self.sched.start()
387387
self.utility.start()
@@ -400,7 +400,7 @@ async def stop(self) -> None:
400400
- :class:`~.scheduler.Scheduler`
401401
- :class:`~.state.State`
402402
"""
403-
self.logger.info("Shutting down AppDaemon")
403+
self.logger.info("Stopping AppDaemon")
404404
self.stopping = True
405405

406406
# Subsystems are able to create tasks during their stop methods
@@ -419,13 +419,17 @@ async def stop(self) -> None:
419419
self.sched.stop()
420420
self.state.stop()
421421

422+
self.executor.shutdown(wait=True)
423+
422424
# This creates a task that will wait for all the ones that were running when stop() was called to finish
423425
# before stopping the event loop. This allows subsystems to create tasks during their own stop methods
424-
running_tasks = asyncio.all_tasks()
426+
current_task = asyncio.current_task()
427+
running_tasks = [task for task in asyncio.all_tasks() if task is not current_task]
425428
self.logger.debug(f"Waiting for {len(running_tasks)} tasks to finish before shutting down")
426429
all_coro = asyncio.wait(running_tasks, return_when=asyncio.ALL_COMPLETED, timeout=3)
427430
gather_task = asyncio.create_task(all_coro, name="appdaemon_stop_tasks")
428-
gather_task.add_done_callback(lambda _: self.loop.stop())
431+
gather_task.add_done_callback(lambda _: self.logger.debug("All tasks finished"))
432+
await gather_task
429433

430434
#
431435
# Utilities

appdaemon/exceptions.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,22 @@ def user_exception_block(logger: Logger, exception: AppDaemonException, app_dir:
6969
indent = ' ' * i * 2
7070

7171
match exc:
72+
case AssertionError():
73+
logger.error(f"{indent}{exc.__class__.__name__}: {exc}")
74+
continue
7275
case ValidationError():
73-
errors = exc.errors()
74-
if errors[0]['type'] == 'missing':
75-
app_name = errors[0]['loc'][0]
76-
field = errors[0]['loc'][-1]
77-
logger.error(f"{indent}App '{app_name}' is missing required field: {field}")
78-
continue
76+
for error in exc.errors():
77+
match error:
78+
case {"type": "missing", "msg": msg, "loc": loc}:
79+
app_name = loc[0]
80+
field = loc[-1]
81+
logger.error(f"{indent}App '{app_name}' has an assertion error in field '{field}': {msg}")
82+
case {"type": "assertion_error", "msg": msg, "loc": loc}:
83+
app_name = loc[0]
84+
field = loc[-1]
85+
logger.error(f"{indent}Assertion error in app '{app_name}' field '{field}': {msg}")
86+
case _:
87+
pass
7988
case AppDaemonException():
8089
for i, line in enumerate(str(exc).splitlines()):
8190
if i == 0:
@@ -239,6 +248,7 @@ def __str__(self):
239248
f"Services that exist in {self.domain}: {', '.join(self.domain_services)}"
240249
)
241250

251+
242252
@dataclass
243253
class DomainNotSpecified(AppDaemonException):
244254
namespace: str
@@ -389,6 +399,20 @@ class PinOutofRange(AppDaemonException):
389399
def __str__(self):
390400
return f"Pin thread {self.pin_thread} out of range. Must be between 0 and {self.total_threads - 1}"
391401

402+
403+
@dataclass
404+
class InvalidThreadConfiguration(AppDaemonException):
405+
total_threads: int | None
406+
pin_apps: bool
407+
pin_threads: int | None
408+
409+
def __str__(self):
410+
res = "Invalid thread configuration:\n"
411+
res += f" total_threads: {self.total_threads}\n"
412+
res += f" pin_apps: {self.pin_apps}\n"
413+
res += f" pin_threads: {self.pin_threads}\n"
414+
return res
415+
392416
@dataclass
393417
class BadClassSignature(AppDaemonException):
394418
class_name: str

appdaemon/models/config/appdaemon.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class AppDaemonConfig(BaseModel, extra="allow"):
5555
max_clock_skew: int = 1
5656

5757
loglevel: str = "INFO"
58-
module_debug: ModuleLoggingLevels = Field(default_factory=dict)
58+
module_debug: ModuleLoggingLevels = Field(default_factory=ModuleLoggingLevels)
5959

6060
api_port: int | None = None
6161
api_key: SecretStr | None = None
@@ -159,6 +159,14 @@ def validate_plugins(cls, v: Any):
159159
v[n]["name"] = n
160160
return v
161161

162+
@model_validator(mode="before")
163+
@classmethod
164+
def validate_ad_cfg(cls, data: Any) -> Any:
165+
if isinstance(data, dict):
166+
if (file := data.get("config_file")) and not data.get("config_dir"):
167+
data["config_dir"] = Path(file).parent
168+
return data
169+
162170
def model_post_init(self, __context: Any):
163171
# Convert app_dir to Path object
164172
self.app_dir = Path(self.app_dir) if not isinstance(self.app_dir, Path) else self.app_dir
@@ -169,10 +177,11 @@ def model_post_init(self, __context: Any):
169177

170178
self.ext = ".toml" if self.write_toml else ".yaml"
171179

172-
@model_validator(mode="before")
173-
@classmethod
174-
def validate_ad_cfg(cls, data: Any) -> Any:
175-
if isinstance(data, dict):
176-
if (file := data.get("config_file")) and not data.get("config_dir"):
177-
data["config_dir"] = Path(file).parent
178-
return data
180+
if self.total_threads is not None:
181+
self.pin_apps = False
182+
183+
if self.pin_threads is not None and self.total_threads is not None:
184+
# assert self.total_threads is not None, "Using pin_threads requires total_threads to be set."
185+
assert self.pin_threads <= self.total_threads, (
186+
"Number of pin threads has to be less than or equal to total threads."
187+
)

0 commit comments

Comments
 (0)