Skip to content

Commit e7ae491

Browse files
committed
Merge branch 'testing-docs'
1 parent adbadef commit e7ae491

File tree

10 files changed

+301
-449
lines changed

10 files changed

+301
-449
lines changed

appdaemon/__main__.py

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import os
1818
import signal
1919
import sys
20-
from collections.abc import Callable
20+
from collections.abc import Callable, Generator
2121
from contextlib import ExitStack, contextmanager
2222
from logging import Logger
2323
from pathlib import Path
@@ -271,8 +271,9 @@ def parse_config(args: argparse.Namespace, stop_function: Callable) -> MainConfi
271271

272272

273273
class ADMain:
274-
"""
275-
Class to encapsulate all main() functionality.
274+
"""Main application class for AppDaemon, which contains the parsed CLI arguments, top-level config model, and the async event loop.
275+
276+
When this class is instantiated, it creates a :py:class:`~appdaemon.dependency_manager.DependencyManager` from the app directory. This causes
276277
"""
277278

278279
AD: AppDaemon
@@ -286,13 +287,13 @@ class ADMain:
286287
_cleanup_stack: ExitStack
287288

288289
model: MainConfig
290+
"""Pydantic model of the top-level object for the appdaemon.yaml file."""
289291
args: argparse.Namespace
290292

291293
stop_time: float = 0.0
292294
"""Stores the value of perf_counter() when self.stop is first called."""
293295

294-
def __init__(self, args: argparse.Namespace):
295-
"""Constructor."""
296+
def __init__(self, args: argparse.Namespace) -> None:
296297
self.args = args
297298
self.http_object = None
298299
self._cleanup_stack = ExitStack()
@@ -302,7 +303,7 @@ def __init__(self, args: argparse.Namespace):
302303
self.setup_logging()
303304
utils.deprecation_warnings(self.model.appdaemon, self.logger)
304305

305-
# # Create the dependency manager here so that all the initial file reading happens in here
306+
# Create the dependency manager here so that all the initial file reading happens in here
306307
self.dep_manager = DependencyManager.from_app_directory(
307308
self.model.appdaemon.app_dir,
308309
exclude=self.model.appdaemon.exclude_dirs,
@@ -325,10 +326,10 @@ def __enter__(self):
325326
pidfile_path = Path(self.args.pidfile).resolve()
326327
self.logger.info("Using pidfile: %s", pidfile_path)
327328
pid_file = pid.PidFile(pidfile_path.name, pidfile_path.parent)
328-
self.enter_context(pid_file)
329+
self._cleanup_stack.enter_context(pid_file)
329330

330-
self.enter_context(self.loop_context())
331-
self.enter_context(self.signal_handlers(self.loop))
331+
self._cleanup_stack.enter_context(self.loop_context())
332+
self._cleanup_stack.enter_context(self.signal_handlers(self.loop))
332333
return self
333334
except Exception as e:
334335
ade.user_exception_block(self.logger, e, self.model.appdaemon.app_dir, header="ADMain __enter__")
@@ -342,10 +343,6 @@ def add_cleanup(self, cleanup_func, *args, **kwargs):
342343
"""Add a cleanup function to be called on exit."""
343344
self._cleanup_stack.callback(cleanup_func, *args, **kwargs)
344345

345-
def enter_context(self, context_manager):
346-
"""Enter a context manager and ensure it's cleaned up on exit."""
347-
return self._cleanup_stack.enter_context(context_manager)
348-
349346
def handle_sig(self, signum: int):
350347
"""Function to handle signals.
351348
@@ -366,13 +363,13 @@ def handle_sig(self, signum: int):
366363
case (signal.SIGINT | signal.SIGTERM) as sig:
367364
self.logger.info(f"Received signal: {signal.Signals(sig).name}")
368365
self.stop()
369-
# case signal.SIGWINCH:
370-
# ... # disregard window changes
371-
# case _:
372-
# self.logger.error(f'Unhandled signal: {signal.Signals(signum).name}')
373366

374367
@contextmanager
375-
def loop_context(self):
368+
def loop_context(self) -> Generator[asyncio.AbstractEventLoop]:
369+
"""Context manager that creates a new async event loop and cleans it up afterwards.
370+
371+
Includes the logic to install uvloop if it's enabled.
372+
"""
376373
# uvloop needs to be installed outside of self.run_context
377374
if self.model.appdaemon.uvloop and uvloop is not None:
378375
uvloop.install()
@@ -410,15 +407,18 @@ def signal_handlers(self, loop: asyncio.AbstractEventLoop):
410407
pass
411408

412409
def stop(self):
413-
"""Called by the signal handler to shut AD down."""
410+
"""Stop AppDaemon and stop the event loop afterwards."""
414411
self.stop_time = perf_counter()
415412
task = self.loop.create_task(self.AD.stop())
416413
task.add_done_callback(lambda _: self.loop.stop())
417414

418415
def run(self) -> None:
419-
"""Start AppDaemon up after initial argument parsing."""
420-
self.enter_context(self.startup_text())
421-
self.enter_context(self.run_context(self.loop))
416+
"""Start AppDaemon up after initial argument parsing.
417+
418+
This uses :py:meth:`~asyncio.loop.run_forever` on the event loop to run it indefinitely.
419+
"""
420+
self._cleanup_stack.enter_context(self.startup_text())
421+
self._cleanup_stack.enter_context(self.run_context(self.loop))
422422
self.AD.start()
423423
self.logger.debug("Running async event loop forever")
424424
self.loop.run_forever()
@@ -495,17 +495,22 @@ def startup_text(self):
495495
self.logger.info("AppDaemon main() stopped gracefully in %s", utils.format_timedelta(stop_duration))
496496

497497

498-
def main():
498+
def main() -> None:
499+
"""Top-level entrypoint for AppDaemon
500+
501+
Parses the CLI arguments, configures logging, and runs the AppDaemon.
502+
"""
499503
args = parse_arguments()
500504

505+
CLI_LOG_CFG = PRE_LOGGING.copy()
506+
501507
if args.debug is not None:
502-
CLI_LOG_CFG = PRE_LOGGING.copy()
503508
CLI_LOG_CFG["root"]["level"] = args.debug
504-
logging.config.dictConfig(CLI_LOG_CFG)
505509
logger.debug("Configured logging level from command line argument")
506510

511+
logging.config.dictConfig(CLI_LOG_CFG)
512+
507513
with ADMain(args) as admain:
508-
# raise ade.StartupAbortedException()
509514
admain.run()
510515

511516

appdaemon/app_management.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,15 @@ def valid_apps(self) -> set[str]:
164164
return self.running_apps | self.loaded_globals
165165

166166
def start(self) -> None:
167-
self.logger.debug("Starting the app management subsystem")
167+
"""Start the app management subsystem, which creates async tasks to
168+
169+
* Initialize admin entities
170+
* Call :meth:`~.check_app_updates`
171+
* Fire an ``appd_started`` event in the ``global`` namespace.
172+
173+
"""
168174
if self.AD.apps_enabled:
175+
self.logger.debug("Starting the app management subsystem")
169176
self.AD.loop.create_task(self.init_admin_entities())
170177

171178
task = self.AD.loop.create_task(
@@ -180,6 +187,10 @@ def start(self) -> None:
180187
)
181188

182189
async def stop(self) -> None:
190+
"""Stop the app management subsystem and all the running apps.
191+
192+
* Calls :py:meth:`~.check_app_updates` with ``UpdateMode.TERMINATE``
193+
"""
183194
if self.AD.apps_enabled:
184195
self.logger.debug("Stopping the app management subsystem")
185196
await self.check_app_updates(mode=UpdateMode.TERMINATE)
@@ -841,19 +852,31 @@ async def check_app_updates(
841852
842853
Called as part of :meth:`.utility_loop.Utility.loop`
843854
844-
Update Modes:
845-
- NORMAL: Checks for changes and reloads apps as necessary.
846-
- INIT: Used during startup trigger processing the import paths and initializing the dependency manager.
847-
- TERMINATE: Adds all apps to the set to be terminated.
848-
- RELOAD_APPS: Adds all apps and the modules they depend on to the respective reload sets. Used by the app
849-
reload service.
850-
- PLUGIN_FAILED: Stops all the apps of a plugin that failed.
851-
- PLUGIN_RESTART: Restarts all the apps of a plugin that has started again.
852-
- TESTING: Testing mode, used during testing to load apps without starting them.
855+
NORMAL
856+
Checks for changes and reloads apps as necessary.
857+
858+
INIT
859+
Used during startup trigger processing the import paths and initializing the dependency manager.
860+
861+
TERMINATE
862+
Adds all apps to the set to be terminated.
863+
864+
RELOAD_APPS
865+
Adds all apps and the modules they depend on to the respective reload sets. Used by the app reload service.
866+
867+
PLUGIN_FAILED
868+
Stops all the apps of a plugin that failed.
869+
870+
PLUGIN_RESTART
871+
Restarts all the apps of a plugin that has started again.
872+
873+
TESTING
874+
Testing mode, used during testing to load apps without starting them.
853875
854876
Args:
855877
plugin_ns (str, optional): Namespace of a plugin to restart, if necessary. Defaults to None.
856-
mode (UpdateMode, optional): Defaults to UpdateMode.NORMAL.
878+
mode (UpdateMode, optional): Defaults to ``UpdateMode.NORMAL``.
879+
update_actions (UpdateActions, optional): The update actions to perform. Defaults to None.
857880
"""
858881
if not self.AD.apps_enabled:
859882
return

appdaemon/appdaemon.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,14 @@ def utility_delay(self):
381381
return self.config.utility_delay
382382

383383
def start(self) -> None:
384+
"""Start AppDaemon, which also starts all the component subsystems like the scheduler, etc.
385+
386+
- :meth:`ThreadAsync <appdaemon.thread_async.ThreadAsync.start>`
387+
- :meth:`Scheduler <appdaemon.scheduler.Scheduler.start>`
388+
- :meth:`Utility <appdaemon.utility_loop.Utility.start>`
389+
- :meth:`AppManagement <appdaemon.app_management.AppManagement.start>`
390+
391+
"""
384392
self.logger.debug("Starting AppDaemon")
385393
self.thread_async.start()
386394
self.sched.start()
@@ -390,15 +398,15 @@ def start(self) -> None:
390398
self.app_management.start()
391399

392400
async def stop(self) -> None:
393-
"""Called by the signal handler to shut down AppDaemon.
401+
"""Stop AppDaemon by calling the stop method of the subsystems.
394402
395-
Also stops (in order):
403+
This does not stop the event loop, but waits for all the existings tasks to finish before returning, which has a 3s timeout.
396404
397-
- :class:`~.app_management.AppManagement`
398-
- :class:`~.thread_async.ThreadAsync`
399-
- :class:`~.plugin_management.Plugins`
400-
- :class:`~.scheduler.Scheduler`
401-
- :class:`~.state.State`
405+
- :meth:`AppManagement <appdaemon.app_management.AppManagement.stop>`
406+
- :meth:`ThreadAsync <appdaemon.thread_async.ThreadAsync.stop>`
407+
- :meth:`Plugins <appdaemon.plugin_management.Plugins.stop>`
408+
- :meth:`Scheduler <appdaemon.scheduler.Scheduler.stop>`
409+
- :meth:`State <appdaemon.state.State.stop>`
402410
"""
403411
self.logger.info("Stopping AppDaemon")
404412
self.stopping = True

appdaemon/exceptions.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ def exception_handler(appdaemon: "AppDaemon", loop: asyncio.AbstractEventLoop, c
5252

5353

5454
def user_exception_block(logger: Logger, exception: AppDaemonException, app_dir: Path, header: str | None = None):
55-
"""Function to generate a user-friendly block of text for an exception. Gets the whole chain of exception causes to decide what to do.
55+
"""Generate a user-friendly block of text for an exception.
56+
57+
Gets the whole chain of exception causes to decide what to do.
5658
"""
5759
width = 75
5860
spacing = 4

appdaemon/scheduler.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333

3434

3535
class Scheduler:
36+
"""AppDaemon subsystem to manage internal scheduling, calculate the times of sun-based events, and parse datetime
37+
strings."""
3638
AD: "AppDaemon"
3739
logger: Logger
3840
error: Logger
@@ -42,7 +44,6 @@ class Scheduler:
4244

4345
name: str = "_scheduler"
4446
active_event: asyncio.Event
45-
stopping: bool = False
4647
loop_task: asyncio.Task[None]
4748

4849
def __init__(self, ad: "AppDaemon"):
@@ -68,7 +69,10 @@ def __init__(self, ad: "AppDaemon"):
6869
# Setup sun
6970
self.init_sun()
7071

71-
def start(self):
72+
def start(self) -> None:
73+
"""Starts the scheduler, which creates the the async task for :py:meth:`~appdaemon.scheduler.Scheduler.loop` and
74+
adds some cleanup callbacks using the Python-native :py:meth:`~asyncio.Task.add_done_callback`.
75+
"""
7276
def _set_inactive(task: asyncio.Task[None]) -> None:
7377
"""
7478
Callback to set the scheduler as inactive when the loop task is done.
@@ -92,13 +96,14 @@ def _shutdown_message(task: asyncio.Task[None]) -> None:
9296
self.loop_task.add_done_callback(_set_inactive)
9397
self.loop_task.add_done_callback(_shutdown_message)
9498

95-
def stop(self):
99+
def stop(self) -> None:
100+
"""Stops the scheduler by cancelling the task for :py:meth:`~appdaemon.scheduler.Scheduler.loop`"""
96101
self.loop_task.cancel()
97102
self.logger.debug("Scheduler loop task was cancelled")
98103

99104
@property
100105
def active(self) -> bool:
101-
"""Return whether the scheduler is active."""
106+
"""Whether the core scheduler loop is running."""
102107
return self.active_event.is_set()
103108

104109
@active.setter
@@ -110,7 +115,7 @@ def active(self, value: bool) -> None:
110115

111116
@property
112117
def realtime(self) -> bool:
113-
"""Return whether the scheduler is running in real time."""
118+
"""Whether the scheduler is running in real time (timewarp == 1)."""
114119
return self.AD.real_time
115120

116121
async def _init_loop(self):
@@ -533,6 +538,7 @@ def get_next_dst_offset(self, base, limit):
533538
return limit
534539

535540
async def loop(self): # noqa: C901
541+
"""Core scheduler loop, which processes scheduled callbacks and sleeping between them."""
536542
self.logger.debug("Starting scheduler loop()")
537543
await self._init_loop()
538544

appdaemon/services.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,23 @@
1313

1414

1515
class ServiceCallback(Protocol):
16-
def __call__(self, result: Any) -> None: ...
16+
"""Simple :py:class:`~typing.Protocol` for callbacks for service results."""
17+
def __call__(self, result: Any) -> None:
18+
"""Required form of the callback function for a service result."""
1719

1820

1921
class Services:
2022
"""Subsystem container for handling services
2123
2224
Attributes:
2325
AD: Reference to the AppDaemon container object
26+
services: AppDaemon's internal service registry, which is a set of nested dicts, organized like this:
27+
28+
* Namespace
29+
* Domain
30+
* Service name
31+
* Service Info
32+
services_lock: Re-entrant lock for preventing the service dict from being read and modified at the same time.
2433
"""
2534

2635
AD: "AppDaemon"
@@ -38,7 +47,8 @@ class Services:
3847
]
3948
] = {}
4049
services_lock: threading.RLock = threading.RLock()
41-
app_registered_services: defaultdict[str, set[str]] = defaultdict(set)
50+
app_registered_services: defaultdict[str, set[str]] = defaultdict(set) # TODO: remove this
51+
4252

4353
def __init__(self, ad: "AppDaemon"):
4454
self.AD = ad

0 commit comments

Comments
 (0)