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
13 changes: 13 additions & 0 deletions doc/source/configuration_file.inc
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ A simple configuration file could look like:
[check.RemoteUsers]
class = Users
enabled = true
error_behavior = active
name = .*
terminal = .*
host = [0-9].*
Expand Down Expand Up @@ -111,6 +112,18 @@ For each check, these generic options can be specified:
Needs to be ``true`` for a check to actually execute.
``false`` is assumed if not specified.

.. option:: error_behavior

Controls how temporary check errors are handled.
A temporary error is one that may resolve on its own, such as a network timeout or a service being briefly unavailable.

``active``
The error is treated as activity, preventing the system from suspending until the check succeeds again.
This is the default, and preserves safe behavior when a check cannot determine system activity.

``ignore``
The error is logged and the check is treated as inactive, i.e. it does not prevent suspension.

Furthermore, each check might have custom options.

Wake up check configuration
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ check_untyped_defs = True
no_implicit_optional = True
warn_unused_configs = True
warn_unused_ignores = True
enable_error_code = exhaustive-match

[tool:pytest]
pythonpath = doc
Expand Down
79 changes: 58 additions & 21 deletions src/autosuspend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib

from .checks import Activity, CheckType, ConfigurationError, TemporaryCheckError, Wakeup
from .checks import (
Activity,
CheckType,
ConfigurationError,
ConfiguredCheck,
ErrorBehavior,
TemporaryCheckError,
Wakeup,
)
from .config import GENERAL_PARAMETERS, ConfigSchema
from .util import logger_by_class_instance
from .util.systemd import LogindDBusException, has_inhibit_lock
Expand Down Expand Up @@ -117,16 +125,31 @@ def schedule_wakeup(command_template: str, wakeup_at: datetime) -> None:
)


def _safe_execute_activity(check: Activity, logger: logging.Logger) -> str | None:
def _safe_execute_activity(
check: ConfiguredCheck[Activity], logger: logging.Logger
) -> str | None:
try:
return check.check()
return check.check.check()
except TemporaryCheckError:
logger.warning("Check %s failed. Ignoring...", check, exc_info=True)
return f"Check {check.name} failed temporarily"
match check.error_behavior:
case ErrorBehavior.ACTIVE:
logger.warning(
"Check %s failed. Assuming activity as requested.",
check.name,
exc_info=True,
)
return f"Check {check.name} failed temporarily."
case ErrorBehavior.IGNORE:
logger.warning(
"Check %s failed. Ignoring as requested.", check.name, exc_info=True
)
return None


def execute_checks(
checks: Iterable[Activity], all_checks: bool, logger: logging.Logger
checks: Iterable[ConfiguredCheck[Activity]],
all_checks: bool,
logger: logging.Logger,
) -> bool:
"""Execute the provided checks sequentially.

Expand Down Expand Up @@ -156,21 +179,25 @@ def execute_checks(


def _safe_execute_wakeup(
check: Wakeup, timestamp: datetime, logger: logging.Logger
check: ConfiguredCheck[Wakeup],
timestamp: datetime,
logger: logging.Logger,
) -> datetime | None:
try:
return check.check(timestamp)
return check.check.check(timestamp)
except TemporaryCheckError:
logger.warning("Wakeup %s failed. Ignoring...", check, exc_info=True)
logger.warning("Wakeup %s failed. Ignoring...", check.name, exc_info=True)
return None


def execute_wakeups(
wakeups: Iterable[Wakeup], timestamp: datetime, logger: logging.Logger
wakeups: Iterable[ConfiguredCheck[Wakeup]],
timestamp: datetime,
logger: logging.Logger,
) -> datetime | None:
wakeup_at = None
for wakeup in wakeups:
this_at = _safe_execute_wakeup(wakeup, timestamp, logger)
for check in wakeups:
this_at = _safe_execute_wakeup(check, timestamp, logger)

# sanity checks
if this_at is None:
Expand All @@ -180,7 +207,7 @@ def execute_wakeups(
"Wakeup %s returned a scheduled wakeup at %s, "
"which is earlier than the current time %s. "
"Ignoring.",
wakeup,
check.name,
this_at,
timestamp,
)
Expand Down Expand Up @@ -220,8 +247,8 @@ class Processor:

def __init__(
self,
activities: Iterable[Activity],
wakeups: Iterable[Wakeup],
activities: Iterable[ConfiguredCheck[Activity]],
wakeups: Iterable[ConfiguredCheck[Wakeup]],
idle_time: float,
min_sleep_time: float,
wakeup_delta: float,
Expand Down Expand Up @@ -456,11 +483,21 @@ def _set_up_single_check(
prefix: str,
internal_module: str,
target_class: type[CheckType],
) -> CheckType:
) -> ConfiguredCheck[CheckType]:
name = section.name[len(f"{prefix}.") :]

class_name = _determine_check_class_name(name, section)

# parse error behavior
error_behavior_raw = section.get("error_behavior", ErrorBehavior.ACTIVE.value)
try:
error_behavior = ErrorBehavior(error_behavior_raw)
except ValueError as error:
raise ConfigurationError(
f"Invalid error_behavior value '{error_behavior_raw}' for check {name}. "
f"Valid values are: {[e.value for e in ErrorBehavior]}"
) from error
Comment thread
languitar marked this conversation as resolved.

# try to find the required class
import_module, import_class = _determine_check_class_and_module(
class_name, internal_module
Expand All @@ -481,14 +518,14 @@ def _set_up_single_check(
f"Cannot create built-in check named {class_name}: Class does not exist"
) from error

check = klass.create(name, section)
check = klass.create(section)
if not isinstance(check, target_class):
raise ConfigurationError(
"Check %s is not a correct %s instance", check, target_class.__name__
)
_logger.debug("Created check instance %s with options %s", check, check.options())

return check
return ConfiguredCheck(name=name, check=check, error_behavior=error_behavior)


def discover_available_checks(
Expand Down Expand Up @@ -529,7 +566,7 @@ def set_up_checks(
internal_module: str,
target_class: type[CheckType],
error_none: bool = False,
) -> list[CheckType]:
) -> list[ConfiguredCheck[CheckType]]:
"""Set up :py.class:`Check` instances from a given configuration.

Args:
Expand Down Expand Up @@ -739,8 +776,8 @@ def get_wakeup_delta(config: configparser.ConfigParser) -> float:
def configure_processor(
args: argparse.Namespace,
config: configparser.ConfigParser,
checks: Iterable[Activity],
wakeups: Iterable[Wakeup],
checks: Iterable[ConfiguredCheck[Activity]],
wakeups: Iterable[ConfiguredCheck[Wakeup]],
) -> Processor:
return Processor(
checks,
Expand Down
44 changes: 24 additions & 20 deletions src/autosuspend/checks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import abc
import configparser
import enum
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Self, TypeVar
from typing import Any, Generic, Self, TypeVar

from autosuspend.config import ParameterSchemaAware
from autosuspend.util import logger_by_class_instance
Expand Down Expand Up @@ -32,24 +34,36 @@ class SevereCheckError(RuntimeError):
CheckType = TypeVar("CheckType", bound="Check")


class ErrorBehavior(enum.Enum):
"""Describes how errors from a check are handled."""

ACTIVE = "active"
"""Treat the check as active (preventing suspension) on error."""
IGNORE = "ignore"
"""Ignore errors from this check."""


@dataclass
class ConfiguredCheck(Generic[CheckType]):
"""Wraps a check with its configured name and error behavior."""

name: str
check: CheckType
error_behavior: ErrorBehavior = ErrorBehavior.ACTIVE


class Check(abc.ABC, ParameterSchemaAware):
"""Base class for all kinds of checks.

Subclasses must call this class' ``__init__`` method.

Args:
name (str):
Configured name of the check
"""

@classmethod
@abc.abstractmethod
def create(cls: type[Self], name: str, config: configparser.SectionProxy) -> Self:
def create(cls: type[Self], config: configparser.SectionProxy) -> Self:
"""Create a new check instance from the provided configuration.

Args:
name:
user-defined name for the check
config:
config parser section with the configuration for this check

Comment thread
languitar marked this conversation as resolved.
Expand All @@ -59,12 +73,8 @@ def create(cls: type[Self], name: str, config: configparser.SectionProxy) -> Sel

"""

def __init__(self, name: str | None = None) -> None:
if name:
self.name = name
else:
self.name = self.__class__.__name__
self.logger = logger_by_class_instance(self, name)
def __init__(self) -> None:
self.logger = logger_by_class_instance(self)
Comment on lines +76 to +77

def options(self) -> Mapping[str, Any]:
"""Return the configured options as a mapping.
Expand All @@ -75,9 +85,6 @@ def options(self) -> Mapping[str, Any]:
k: v for k, v in self.__dict__.items() if not callable(v) and k != "logger"
}

def __str__(self) -> str:
return f"{self.name}[class={self.__class__.__name__}]"


class Activity(Check):
"""Base class for activity checks.
Expand All @@ -99,9 +106,6 @@ def check(self) -> str | None:
Check executions fails severely
"""

def __str__(self) -> str:
return f"{self.name}[class={self.__class__.__name__}]"


class Wakeup(Check):
"""Represents a check for potential wake up points."""
Expand Down
12 changes: 6 additions & 6 deletions src/autosuspend/checks/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ class CommandMixin(Check):
"""Mixin for configuring checks based on external commands."""

@classmethod
def create(cls: type[Self], name: str, config: configparser.SectionProxy) -> Self:
def create(cls: type[Self], config: configparser.SectionProxy) -> Self:
try:
return cls(name, config["command"].strip()) # type: ignore
return cls(config["command"].strip())
except KeyError as error:
raise ConfigurationError("Missing command specification") from error

Expand All @@ -54,9 +54,9 @@ class CommandActivity(CommandMixin, Activity):
* :ref:`external-command-activity-scripts` for a collection of user-provided scripts for some common use cases.
"""

def __init__(self, name: str, command: str) -> None:
def __init__(self, command: str) -> None:
CommandMixin.__init__(self, command)
Activity.__init__(self, name)
Activity.__init__(self)

def check(self) -> str | None:
try:
Expand All @@ -80,9 +80,9 @@ class CommandWakeup(CommandMixin, Wakeup):
Beware of malicious commands in obtained configuration files.
"""

def __init__(self, name: str, command: str) -> None:
def __init__(self, command: str) -> None:
CommandMixin.__init__(self, command)
Wakeup.__init__(self, name)
Wakeup.__init__(self)

def check(self, timestamp: datetime) -> datetime | None: # noqa: ARG002
try:
Expand Down
18 changes: 8 additions & 10 deletions src/autosuspend/checks/ical.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,16 +315,14 @@ class ActiveCalendarEvent(NetworkMixin, Activity):
"""

@classmethod
def create(
cls, name: str, config: configparser.SectionProxy
) -> "ActiveCalendarEvent":
def create(cls, config: configparser.SectionProxy) -> "ActiveCalendarEvent":
kwargs = NetworkMixin.collect_init_args(config)
kwargs["match"] = config.get("match", fallback=".*")
return cls(name, **kwargs)
return cls(**kwargs)

def __init__(self, name: str, match: str = ".*", **kwargs: Any) -> None:
def __init__(self, match: str = ".*", **kwargs: Any) -> None:
NetworkMixin.__init__(self, **kwargs)
Activity.__init__(self, name)
Activity.__init__(self)
self._match = match

def check(self) -> str | None:
Expand Down Expand Up @@ -370,14 +368,14 @@ class Calendar(NetworkMixin, Wakeup):
"""

@classmethod
def create(cls, name: str, config: configparser.SectionProxy) -> "Calendar":
def create(cls, config: configparser.SectionProxy) -> "Calendar":
kwargs = NetworkMixin.collect_init_args(config)
kwargs["match"] = config.get("match", fallback=".*")
return cls(name, **kwargs)
return cls(**kwargs)

def __init__(self, name: str, match: str = ".*", **kwargs: Any) -> None:
def __init__(self, match: str = ".*", **kwargs: Any) -> None:
NetworkMixin.__init__(self, **kwargs)
Wakeup.__init__(self, name)
Wakeup.__init__(self)
self._match = match

def check(self, timestamp: datetime) -> datetime | None:
Expand Down
4 changes: 2 additions & 2 deletions src/autosuspend/checks/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ def collect_init_args(cls, config: configparser.SectionProxy) -> dict[str, Any]:
except Exception as error:
raise ConfigurationError(f"JSONPath error {error}") from error

def __init__(self, name: str, jsonpath: JSONPath, **kwargs: Any) -> None:
Activity.__init__(self, name)
def __init__(self, jsonpath: JSONPath, **kwargs: Any) -> None:
Activity.__init__(self)
NetworkMixin.__init__(self, accept="application/json", **kwargs)
self._jsonpath = jsonpath

Expand Down
Loading