Skip to content

Commit d255a5e

Browse files
committed
Merge branch 'hass-connection'
1 parent f0f7974 commit d255a5e

File tree

6 files changed

+232
-219
lines changed

6 files changed

+232
-219
lines changed

appdaemon/exceptions.py

Lines changed: 65 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Exceptions used by appdaemon
33
44
"""
5+
56
import asyncio
67
import functools
78
import inspect
@@ -17,6 +18,7 @@
1718
from pathlib import Path
1819
from typing import TYPE_CHECKING, Any, Type
1920

21+
from aiohttp.client_exceptions import ClientConnectorError
2022
from pydantic import ValidationError
2123

2224
if TYPE_CHECKING:
@@ -34,24 +36,27 @@ def get_callback_sig(funcref) -> str:
3436
@dataclass
3537
class AppDaemonException(Exception, ABC):
3638
"""Abstract base class for all AppDaemon exceptions to inherit from"""
37-
# msg: str
3839

3940
def __post_init__(self):
40-
if msg := getattr(self, 'msg', None):
41+
if msg := getattr(self, "msg", None):
4142
super(Exception, self).__init__(msg)
4243

4344

44-
def exception_handler(appdaemon: "AppDaemon", loop: asyncio.AbstractEventLoop, context: dict):
45+
def exception_handler(appdaemon: "AppDaemon", loop: asyncio.AbstractEventLoop, context: dict[str, Any]):
4546
"""Handler to attach to the main event loop as a backstop for any async exception"""
46-
user_exception_block(
47-
logging.getLogger('Error'),
48-
context.get('exception'),
49-
appdaemon.app_dir,
50-
header='Unhandled exception in event loop'
51-
)
47+
match context:
48+
case {"exception": Exception() as exc, "future": asyncio.Task() as task}:
49+
user_exception_block(
50+
logging.getLogger("Error"),
51+
exception=exc,
52+
app_dir=appdaemon.app_dir,
53+
header=f"Unhandled exception in {task.get_name()}",
54+
)
55+
case _:
56+
logging.getLogger("Error").error(f"Unhandled exception in event loop: {context}")
5257

5358

54-
def user_exception_block(logger: Logger, exception: AppDaemonException, app_dir: Path, header: str | None = None):
59+
def user_exception_block(logger: Logger, exception: Exception, app_dir: Path, header: str | None = None):
5560
"""Generate a user-friendly block of text for an exception.
5661
5762
Gets the whole chain of exception causes to decide what to do.
@@ -60,15 +65,15 @@ def user_exception_block(logger: Logger, exception: AppDaemonException, app_dir:
6065
spacing = 4
6166
inset = 5
6267
if header is not None:
63-
header = f'{"=" * inset} {header} {"=" * (width - spacing - inset - len(header))}'
68+
header = f"{'=' * inset} {header} {'=' * (width - spacing - inset - len(header))}"
6469
else:
65-
header = '=' * width
70+
header = "=" * width
6671
logger.error(header)
6772

6873
chain = get_exception_cause_chain(exception)
6974

7075
for i, exc in enumerate(chain):
71-
indent = ' ' * i * 2
76+
indent = " " * i * 2
7277

7378
match exc:
7479
case AssertionError():
@@ -90,53 +95,56 @@ def user_exception_block(logger: Logger, exception: AppDaemonException, app_dir:
9095
case AppDaemonException():
9196
for i, line in enumerate(str(exc).splitlines()):
9297
if i == 0:
93-
logger.error(f'{indent}{exc.__class__.__name__}: {line}')
98+
logger.error(f"{indent}{exc.__class__.__name__}: {line}")
9499
else:
95-
logger.error(f'{indent} {line}')
100+
logger.error(f"{indent} {line}")
96101

97102
if user_line := get_user_line(exc, app_dir):
98103
for line, filename, func_name in list(user_line)[::-1]:
99-
logger.error(f'{indent}{filename} line {line} in {func_name}')
100-
case OSError() if str(exc).endswith('address already in use'):
101-
logger.error(f'{indent}{exc.__class__.__name__}: {exc}')
104+
logger.error(f"{indent}{filename} line {line} in {func_name}")
105+
case ClientConnectorError():
106+
logger.error(f"{indent}{exc.__class__.__name__}: {exc}")
107+
break
108+
case OSError() if str(exc).endswith("address already in use"):
109+
logger.error(f"{indent}{exc.__class__.__name__}: {exc}")
102110
case NameError() | ImportError():
103-
logger.error(f'{indent}{exc.__class__.__name__}: {exc}')
111+
logger.error(f"{indent}{exc.__class__.__name__}: {exc}")
104112
if tb := traceback.extract_tb(exc.__traceback__):
105113
frame = tb[-1]
106114
file = Path(frame.filename).relative_to(app_dir.parent)
107-
logger.error(f'{indent} line {frame.lineno} in {file.name}')
108-
logger.error(f'{indent} {frame._line.rstrip()}')
115+
logger.error(f"{indent} line {frame.lineno} in {file.name}")
116+
logger.error(f"{indent} {frame._line.rstrip()}")
109117
error_len = frame.end_colno - frame.colno
110-
logger.error(f'{indent} {" " * (frame.colno - 1)}{"^" * error_len}')
118+
logger.error(f"{indent} {' ' * (frame.colno - 1)}{'^' * error_len}")
111119
case SyntaxError():
112-
logger.error(f'{indent}{exc.__class__.__name__}: {exc}')
113-
logger.error(f'{indent} {exc.text.rstrip()}')
120+
logger.error(f"{indent}{exc.__class__.__name__}: {exc}")
121+
logger.error(f"{indent} {exc.text.rstrip()}")
114122

115123
if exc.end_offset == 0:
116124
error_len = len(exc.text) - exc.offset
117125
else:
118126
error_len = exc.end_offset - exc.offset
119-
logger.error(f'{indent} {" " * (exc.offset - 1)}{"^" * error_len}')
127+
logger.error(f"{indent} {' ' * (exc.offset - 1)}{'^' * error_len}")
120128
case _:
121-
logger.error(f'{indent}{exc.__class__.__name__}: {exc}')
129+
logger.error(f"{indent}{exc.__class__.__name__}: {exc}")
122130
if tb := traceback.extract_tb(exc.__traceback__):
123131
# filtered = (fs for fs in tb if 'appdaemon' in fs.filename)
124132
# filtered = tb
125133
# ss = traceback.StackSummary.from_list(filtered)
126134
lines = (line for fl in tb.format() for line in fl.splitlines())
127135
for line in lines:
128-
logger.error(f'{indent}{line}')
136+
logger.error(f"{indent}{line}")
129137

130-
logger.error('=' * width)
138+
logger.error("=" * width)
131139

132140

133141
def unexpected_block(logger: Logger, exception: Exception):
134-
logger.error('=' * 75)
135-
logger.error(f'Unexpected error: {exception}')
142+
logger.error("=" * 75)
143+
logger.error(f"Unexpected error: {exception}")
136144
formatted = traceback.format_exc()
137145
for line in formatted.splitlines():
138146
logger.error(line)
139-
logger.error('=' * 75)
147+
logger.error("=" * 75)
140148

141149

142150
def get_cause_lines(chain: Iterable[Exception]) -> dict[Exception, list[traceback.FrameSummary]]:
@@ -172,7 +180,9 @@ async def wrapper(*args, **kwargs):
172180
user_exception_block(logger, e, app_dir, header)
173181
except Exception as e:
174182
unexpected_block(logger, e)
183+
175184
return wrapper
185+
176186
return decorator
177187

178188

@@ -186,7 +196,9 @@ def wrapper(*args, **kwargs):
186196
user_exception_block(logger, e, app_dir, header)
187197
except Exception as e:
188198
unexpected_block(logger, e)
199+
189200
return wrapper
201+
190202
return decorator
191203

192204

@@ -244,11 +256,7 @@ class ServiceException(AppDaemonException):
244256
domain_services: list[str]
245257

246258
def __str__(self):
247-
return (
248-
f"domain '{self.domain}' exists in namespace '{self.namespace}', "
249-
f"but does not contain service '{self.service}'. "
250-
f"Services that exist in {self.domain}: {', '.join(self.domain_services)}"
251-
)
259+
return f"domain '{self.domain}' exists in namespace '{self.namespace}', but does not contain service '{self.service}'. Services that exist in {self.domain}: {', '.join(self.domain_services)}"
252260

253261

254262
@dataclass
@@ -263,17 +271,18 @@ def __str__(self):
263271
@dataclass
264272
class AppCallbackFail(AppDaemonException):
265273
"""Base class for exceptions caused by callbacks made in user apps."""
274+
266275
app_name: str
267276
funcref: functools.partial
268277

269278
def __str__(self, base: str | None = None):
270279
base = base or f"Callback failed for app '{self.app_name}'"
271280

272281
if args := self.funcref.args:
273-
base += f'\nargs: {args}'
282+
base += f"\nargs: {args}"
274283

275284
if kwargs := self.funcref.keywords:
276-
base += f'\nkwargs: {json.dumps(kwargs, indent=4, default=str)}'
285+
base += f"\nkwargs: {json.dumps(kwargs, indent=4, default=str)}"
277286

278287
return base
279288

@@ -287,10 +296,10 @@ def __str__(self):
287296

288297
# Type errors are a special case where we can give some more advice about how the callback should be written
289298
if isinstance(self.__cause__, TypeError):
290-
res += f'\n{self.__cause__}'
291-
res += '\nState callbacks should have the following signature:'
292-
res += '\n state_callback(self, entity, attribute, old, new, **kwargs)'
293-
res += '\nSee https://appdaemon.readthedocs.io/en/latest/APPGUIDE.html#state-callbacks for more information'
299+
res += f"\n{self.__cause__}"
300+
res += "\nState callbacks should have the following signature:"
301+
res += "\n state_callback(self, entity, attribute, old, new, **kwargs)"
302+
res += "\nSee https://appdaemon.readthedocs.io/en/latest/APPGUIDE.html#state-callbacks for more information"
294303

295304
return res
296305

@@ -301,8 +310,8 @@ def __str__(self):
301310
res = super().__str__(f"Scheduled callback failed for app '{self.app_name}'")
302311

303312
if isinstance(self.__cause__, TypeError):
304-
res += f'\nCallback has signature: {get_callback_sig(self.funcref)}'
305-
res += f'\n{self.__cause__}\n'
313+
res += f"\nCallback has signature: {get_callback_sig(self.funcref)}"
314+
res += f"\n{self.__cause__}\n"
306315
return res
307316

308317

@@ -314,10 +323,10 @@ def __str__(self):
314323
res = super().__str__(f"Scheduled callback failed for app '{self.app_name}'")
315324

316325
if isinstance(self.__cause__, TypeError):
317-
res += f'\n{self.__cause__}'
318-
res += '\nState callbacks should have the following signature:'
319-
res += '\n my_callback(self, event_name, data, **kwargs):'
320-
res += '\nSee https://appdaemon.readthedocs.io/en/latest/APPGUIDE.html#event-callbacks for more information'
326+
res += f"\n{self.__cause__}"
327+
res += "\nState callbacks should have the following signature:"
328+
res += "\n my_callback(self, event_name, data, **kwargs):"
329+
res += "\nSee https://appdaemon.readthedocs.io/en/latest/APPGUIDE.html#event-callbacks for more information"
321330
return res
322331

323332

@@ -416,6 +425,7 @@ def __str__(self):
416425
res += f" pin_threads: {self.pin_threads}\n"
417426
return res
418427

428+
419429
@dataclass
420430
class BadClassSignature(AppDaemonException):
421431
class_name: str
@@ -439,7 +449,7 @@ class AppDependencyError(AppDaemonException):
439449
dep_name: str
440450
dependencies: set[str]
441451

442-
def __str__(self, base: str = ''):
452+
def __str__(self, base: str = ""):
443453
res = base
444454
res += f"\nall dependencies: {self.dependencies}"
445455
res += f"\n{self.rel_path}"
@@ -473,11 +483,8 @@ def __str__(self):
473483
res = f"Failed to import '{self.module_name}'\n"
474484
if isinstance(self.__cause__, ModuleNotFoundError):
475485
res += "Import paths:\n"
476-
paths = set(
477-
p for p in sys.path
478-
if Path(p).is_relative_to(self.app_dir)
479-
)
480-
res += '\n'.join(f' {p}' for p in sorted(paths))
486+
paths = set(p for p in sys.path if Path(p).is_relative_to(self.app_dir))
487+
res += "\n".join(f" {p}" for p in sorted(paths))
481488
return res
482489

483490

@@ -521,9 +528,9 @@ class InitializationFail(AppDaemonException):
521528
def __str__(self):
522529
res = f"initialize() method failed for app '{self.app_name}'"
523530
if isinstance(self.__cause__, TypeError):
524-
res += f'\n{self.__cause__}'
525-
res += '\ninitialize() should be structured like this:'
526-
res += '\n def initialize(self):'
531+
res += f"\n{self.__cause__}"
532+
res += "\ninitialize() should be structured like this:"
533+
res += "\n def initialize(self):"
527534
# res += '\n ...'
528535
return res
529536

@@ -544,7 +551,7 @@ class SequenceExecutionFail(AppDaemonException):
544551
def __str__(self):
545552
res = "Failed to execute sequence:"
546553
if isinstance(self.bad_seq, str):
547-
res += f' {self.bad_seq}'
554+
res += f" {self.bad_seq}"
548555
return res
549556

550557

appdaemon/plugin_management.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -298,13 +298,9 @@ def __init__(self, ad: "AppDaemon", config: dict[str, PluginConfig]):
298298
# Create app entry for the plugin so we can listen_state/event
299299
#
300300
if self.AD.apps_enabled:
301-
self.AD.app_management.add_plugin_object(
302-
name,
303-
plugin,
304-
self.config[name].use_dictionary_unpacking
305-
)
301+
self.AD.app_management.add_plugin_object(name, plugin, self.config[name].use_dictionary_unpacking)
306302

307-
self.AD.loop.create_task(plugin.get_updates())
303+
self.AD.loop.create_task(plugin.get_updates(), name=f"plugin.get_updates for {name}")
308304
except Exception:
309305
self.logger.warning("error loading plugin: %s - ignoring", name)
310306
self.logger.warning("-" * 60)
@@ -428,8 +424,9 @@ async def notify_plugin_stopped(self, name: str, namespace: str):
428424
self.AD.loop.create_task(
429425
self.AD.app_management.check_app_updates(
430426
plugin_ns=namespace,
431-
mode=UpdateMode.PLUGIN_FAILED
432-
))
427+
mode=UpdateMode.PLUGIN_FAILED,
428+
)
429+
)
433430

434431
def get_plugin_meta(self, namespace: str) -> dict:
435432
return self.plugin_meta.get(namespace, {})
@@ -439,14 +436,31 @@ async def wait_for_plugins(self, timeout: float | None = None):
439436
440437
Specifically, this waits for each of their ready events
441438
"""
442-
self.logger.info('Waiting for plugins to be ready')
443-
events: Generator[asyncio.Event, None, None] = (
444-
plugin['object'].ready_event for plugin in self.plugin_objs.values()
439+
self.logger.info("Waiting for plugins to be ready")
440+
wait_tasks = [
441+
self.AD.loop.create_task(
442+
plugin["object"].ready_event.wait(),
443+
name=f"waiting for {plugin['name']} to be ready",
444+
)
445+
for plugin in self.plugin_objs.values()
446+
]
447+
readiness = self.AD.loop.create_task(
448+
asyncio.wait(wait_tasks, timeout=timeout, return_when=asyncio.ALL_COMPLETED),
449+
name="waiting for all plugins to be ready",
450+
)
451+
452+
early_stop = self.AD.loop.create_task(self.AD.stop_event.wait(), name="waiting for appdaemon to stop")
453+
await self.AD.loop.create_task(
454+
asyncio.wait((readiness, early_stop), timeout=timeout, return_when=asyncio.FIRST_COMPLETED),
455+
name="waiting for plugins or stop event",
445456
)
446-
tasks = [self.AD.loop.create_task(e.wait()) for e in events]
447-
if tasks:
448-
await asyncio.wait(tasks, timeout=timeout)
449-
self.logger.info('All plugins ready')
457+
if readiness.done():
458+
# The readiness wait completed
459+
self.logger.info("All plugins ready")
460+
elif self.AD.stopping:
461+
self.logger.info("AppDaemon stopping before all plugins ready, cancelling readiness waits")
462+
for task in wait_tasks:
463+
task.cancel()
450464

451465
def get_config_for_namespace(self, namespace: str) -> PluginConfig:
452466
plugin_name = self.get_plugin_from_namespace(namespace)
@@ -473,7 +487,7 @@ async def update_plugin_state(self):
473487
try:
474488
state = await asyncio.wait_for(
475489
plugin.get_complete_state(),
476-
timeout=cfg.refresh_timeout
490+
timeout=cfg.refresh_timeout,
477491
)
478492
except asyncio.TimeoutError:
479493
self.logger.warning(

0 commit comments

Comments
 (0)