diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 08ef9631..d8eaaec9 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -19,3 +19,5 @@ python: - requirements: requirements-doc.txt - method: pip path: . + extra_requirements: + - all diff --git a/doc/ext_autosuspend.py b/doc/ext_autosuspend.py new file mode 100644 index 00000000..626d4e37 --- /dev/null +++ b/doc/ext_autosuspend.py @@ -0,0 +1,227 @@ +"""Sphinx extension for autosuspend check documentation. + +This extension dynamically generates documentation for autosuspend checks +by discovering check classes and extracting their docstrings and config_params. +""" + +import inspect +import re +import sys +from pathlib import Path +from typing import Any + +from docutils import nodes +from docutils.parsers.rst import Directive +from docutils.statemachine import ViewList +from sphinx.application import Sphinx +from sphinx.util.nodes import nested_parse_with_titles + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from autosuspend import discover_available_checks +from autosuspend.checks import Activity, Wakeup +from autosuspend.config import ParameterSchema + +_GOOGLE_SECTION_RE = re.compile(r"^[A-Z][A-Za-z ]*:$") + + +def render_google_docstring(doc: str) -> list[str]: + """Convert a Google-style docstring to RST lines for use in generated docs. + + Handles the ``Requires:`` custom section by emitting a ``.. rubric::`` + directive followed by the section body indented under it. All other + content is passed through verbatim. + + After ``inspect.cleandoc`` all indentation is stripped, so section body + lines cannot be detected by indentation. Instead the body is consumed + until the next Google-style section header (a line matching + ``^[A-Z][A-Za-z ]*:$``) or end of string. + """ + lines = doc.split("\n") + out: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + if line.strip() == "Requires:": + out.append(".. rubric:: Requires") + out.append("") + i += 1 + # Consume optional blank line after section header + if i < len(lines) and lines[i].strip() == "": + i += 1 + # Consume body until the next Google-style section header or EOF + while i < len(lines): + body_line = lines[i] + if _GOOGLE_SECTION_RE.match(body_line.strip()): + break + out.append(body_line if body_line.strip() else "") + i += 1 + out.append("") + else: + out.append(line) + i += 1 + return out + + +def format_default_value(param: ParameterSchema) -> str: + """Format the default value for display.""" + if param.default is None: + return "" + if isinstance(param.default, bool): + return f"``{str(param.default).lower()}``" + if isinstance(param.default, str): + return f"``{param.default}``" + if isinstance(param.default, (list, tuple)): + formatted_items = ", ".join(f"``{item}``" for item in param.default) + return formatted_items + return f"``{param.default}``" + + +def generate_option_rst(param: ParameterSchema, program_name: str) -> list[str]: + """Generate RST lines for a single option.""" + lines = [] + lines.append(f".. option:: {param.name}") + lines.append("") + + description = param.description + if param.default is not None: + default_str = format_default_value(param) + if default_str: + # Add default at the end with proper punctuation + if not description.endswith("."): + description += "." + description = f"{description} Default: {default_str}." + + # Indent description properly + for line in description.split("\n"): + lines.append(f" {line}") + lines.append("") + + if param.enum_values is not None: + formatted = ", ".join(f"``{v}``" for v in param.enum_values) + lines.append(f" Allowed values: {formatted}.") + lines.append("") + + if param.minimum is not None and param.maximum is not None: + lines.append( + f" Value must be between ``{param.minimum}`` and ``{param.maximum}``." + ) + lines.append("") + elif param.minimum is not None: + lines.append(f" Value must be at least ``{param.minimum}``.") + lines.append("") + elif param.maximum is not None: + lines.append(f" Value must be at most ``{param.maximum}``.") + lines.append("") + + return lines + + +class AutosuspendChecksDirective(Directive): + """Directive to generate documentation for all autosuspend checks.""" + + has_content = False + required_arguments = 1 # 'activity' or 'wakeup' + optional_arguments = 0 + + def run(self) -> list[nodes.Node]: + check_type = self.arguments[0] + + if check_type == "activity": + checks = discover_available_checks("activity", Activity) + title = "Available activity checks" + check_prefix = "check" + elif check_type == "wakeup": + checks = discover_available_checks("wakeup", Wakeup) + title = "Available wake up checks" + check_prefix = "wakeup" + else: + raise self.error( + f"Unknown check type: {check_type!r}. Expected 'activity' or 'wakeup'." + ) + + # Sort checks by name (keys are already the effective names/aliases) + sorted_checks = sorted(checks.items(), key=lambda x: x[0]) + + # Generate RST content + rst = ViewList() + + # Add header - don't duplicate the label since it's already in the RST file + rst.append(title, "") + rst.append("#" * len(title), "") + rst.append("", "") + + if check_type == "activity": + rst.append( + "The following checks for activity are currently implemented.", + "", + ) + else: + rst.append( + "The following checks for wake up times are currently implemented.", + "", + ) + rst.append( + "Each of them is described with its available configuration options and required optional dependencies.", + "", + ) + rst.append("", "") + + # Add each check + for class_name, check_class in sorted_checks: + # Create reference label + label_name = self._to_kebab_case(class_name) + rst.append(f".. _{check_prefix}-{label_name}:", "") + rst.append("", "") + + # Add title + rst.append(class_name, "") + rst.append("*" * len(class_name), "") + rst.append("", "") + + # Add program directive for option linking + rst.append(f".. program:: {check_prefix}-{label_name}", "") + rst.append("", "") + + # Add docstring + if check_class.__doc__: + doc = inspect.cleandoc(check_class.__doc__) + for line in render_google_docstring(doc): + rst.append(line, "") + rst.append("", "") + + # Add Options section if there are config parameters + if check_class.config_parameters: + rst.append("Options", "") + rst.append("=======", "") + rst.append("", "") + + for param in check_class.config_parameters: + for line in generate_option_rst( + param, f"{check_prefix}-{label_name}" + ): + rst.append(line, "") + + # Parse the RST + node = nodes.section() + node.document = self.state.document + nested_parse_with_titles(self.state, rst, node) + + return node.children + + def _to_kebab_case(self, name: str) -> str: + """Convert PascalCase to kebab-case.""" + s1 = re.sub("(.)([A-Z][a-z]+)", r"\1-\2", name) + return re.sub("([a-z0-9])([A-Z])", r"\1-\2", s1).lower() + + +def setup(app: Sphinx) -> dict[str, Any]: + """Set up the Sphinx extension.""" + app.add_directive("autosuspend-checks", AutosuspendChecksDirective) + + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/doc/source/available_checks.rst b/doc/source/available_checks.rst index 26fcbc0b..ec7c450c 100644 --- a/doc/source/available_checks.rst +++ b/doc/source/available_checks.rst @@ -1,598 +1,4 @@ .. _available-checks: -Available activity checks -######################### +.. autosuspend-checks:: activity -The following checks for activity are currently implemented. -Each of the is described with its available configuration options and required optional dependencies. - -.. _check-active-calendar-event: - -ActiveCalendarEvent -******************* - -.. program:: check-active-calendar-event - -Checks an online `iCalendar`_ file for events that are currently running. -If so, this indicates activity and prevents suspending the system. -Thus, a calendar can be provided with times at which the system should not go to sleep. -If this calendar resides on an online service like a groupware it might even be possible to invite the system. - -Options -======= - -.. option:: url - - The URL to query for the iCalendar file - -.. option:: timeout - - Timeout for executed requests in seconds. Default: 5. - -.. option:: username - - Optional user name to use for authenticating at a server requiring authentication. - If used, also a password must be provided. - -.. option:: password - - Optional password to use for authenticating at a server requiring authentication. - If used, also a user name must be provided. - -Requirements -============ - -* `requests`_ -* `icalendar `_ -* `dateutil`_ -* `tzlocal`_ - -.. _check-active-connection: - -ActiveConnection -**************** - -.. program:: check-active-connection - -Checks whether there is currently a client connected to a TCP server at certain ports. -Can be used to e.g. block suspending the system in case SSH users are connected or a web server is used by clients. - -Options -======= - -.. option:: ports - - list of comma-separated port numbers - -Requirements -============ - -.. _check-external-command: - -ExternalCommand -*************** - -.. program:: check-external-command - -Executes an arbitrary command. -In case this command returns 0, the system is assumed to be active. - -The command is executed as is using shell execution. -Beware of malicious commands in obtained configuration files. - -.. seealso:: - - * :ref:`external-command-activity-scripts` for a collection of user-provided scripts for some common use cases. - -Options -======= - -.. option:: command - - The command to execute including all arguments - -Requirements -============ - -.. _check-jsonpath: - -JsonPath -******** - -.. program:: check-jsonpath - -A generic check which queries a configured URL and expects the reply to contain JSON data. -The returned JSON document is checked against a configured `JSONPath`_ expression and in case the expression matches, the system is assumed to be active. - -Options -======= - -.. option:: url - - The URL to query for the XML reply. - -.. option:: jsonpath - - The `JSONPath`_ query to execute. - In case it returns a result, the system is assumed to be active. - -.. option:: timeout - - Timeout for executed requests in seconds. Default: 5. - -.. option:: username - - Optional user name to use for authenticating at a server requiring authentication. - If used, also a password must be provided. - -.. option:: password - - Optional password to use for authenticating at a server requiring authentication. - If used, also a user name must be provided. - -Requirements -============ - -- `requests`_ -- `jsonpath-ng`_ - -.. _check-kodi: - -Kodi -**** - -.. program:: check-kodi - -Checks whether an instance of `Kodi`_ is currently playing. - -Options -======= - -.. option:: url - - Base URL of the JSON RPC API of the Kodi instance, default: ``http://localhost:8080/jsonrpc`` - -.. option:: timeout - - Request timeout in seconds, default: ``5`` - -.. option:: username - - Optional user name to use for authenticating at a server requiring authentication. - If used, also a password must be provided. - -.. option:: password - - Optional password to use for authenticating at a server requiring authentication. - If used, also a user name must be provided. - -.. option:: suspend_while_paused - - Also suspend the system when media playback is paused instead of only suspending - when playback is stopped. - Default: ``false`` - -Requirements -============ - -- `requests`_ - -.. _check-kodi-idle-time: - -KodiIdleTime -************ - -.. program:: check-kodi-idle-time - -Checks whether there has been interaction with the Kodi user interface recently. -This prevents suspending the system in case someone is currently browsing collections etc. -This check is redundant to :ref:`check-xidletime` on systems using an X server, but might be necessary in case Kodi is used standalone. -It does not replace the :ref:`check-kodi` check, as the idle time is not updated when media is playing. - -Options -======= - -.. option:: idle_time - - Marks the system active in case a user interaction has appeared within the this amount of seconds until now. - Default: ``120`` - -.. option:: url - - Base URL of the JSON RPC API of the Kodi instance, default: ``http://localhost:8080/jsonrpc`` - -.. option:: timeout - - Request timeout in seconds, default: ``5`` - -.. option:: username - - Optional user name to use for authenticating at a server requiring authentication. - If used, also a password must be provided. - -.. option:: password - - Optional password to use for authenticating at a server requiring authentication. - If used, also a user name must be provided. - -Requirements -============ - -- `requests`_ - -.. _check-last-log-activity: - -LastLogActivity -*************** - -.. program:: check-last-log-activity - -Parses a log file and uses the most recent time contained in the file to determine activity. -For this purpose, the log file lines are iterated from the back until a line matching a configurable regular expression is found. -This expression is used to extract the contained timestamp in that log line, which is then compared to the current time with an allowed delta. -The check only looks at the first line from the back that contains a timestamp. -Further lines are ignored. -A typical use case for this check would be a web server access log file. - -This check supports all date formats that are supported by the `dateutil parser `_. - -Options -======= - -.. option:: log_file - - path to the log file that should be analyzed - -.. option:: pattern - - A regular expression used to determine whether a line of the log file contains a timestamp to look at. - The expression must contain exactly one matching group. - For instance, ``^\[(.*)\] .*$`` might be used to find dates in square brackets at line beginnings. - -.. option:: minutes - - The number of minutes to allow log file timestamps to be in the past for detecting activity. - If a timestamp is older than `` - `` no activity is detected. - default: 10 - -.. option:: encoding - - The encoding with which to parse the log file. default: ascii - -.. option:: timezone - - The timezone to assume in case a timestamp extracted from the log file has not associated timezone information. - Timezones are expressed using the names from the Olson timezone database (e.g. ``Europe/Berlin``). - default: ``UTC`` - -Requirements -============ - -* `dateutil`_ -* `tzdata`_ - -.. _check-load: - -Load -**** - -.. program:: check-load - -Checks whether the `system load 5 `__ is below a certain value. - -Options -======= - -.. option:: threshold - - a float for the maximum allowed load value, default: 2.5 - -Requirements -============ - -.. _check-logind-session-idle: - -LogindSessionsIdle -****************** - -.. program:: check-logind-session-idle - -Prevents suspending in case ``IdleHint`` for one of the running sessions `logind`_ sessions is set to ``no``. -Support for setting this hint currently varies greatly across display managers, screen lockers etc. -Thus, check exactly whether the hint is set on your system via ``loginctl show-session``. - -Options -======= - -.. option:: types - - A comma-separated list of sessions types to inspect for activity. - The check ignores sessions of other types. - Default: ``tty``, ``x11``, ``wayland`` - -.. option:: states - - A comma-separated list of session states to inspect. - For instance, ``lingering`` sessions used for background programs might not be of interest. - Default: ``active``, ``online`` - -.. option:: classes - - A comma-separated list of session classes to inspect. - For instance, ``greeter`` sessions used by greeters such as lightdm might not be of interest. - Default: ``user`` - -.. _check-mpd: - -Mpd -*** - -.. program:: check-mpd - -Checks whether an instance of `MPD`_ is currently playing music. - -Options -======= - -.. option:: host - - Host containing the MPD daemon, default: ``localhost`` - -.. option:: port - - Port to connect to the MPD daemon, default: ``6600`` - -.. option:: timeout - - .. _mpd-timeout: - - Request timeout in seconds, default: ``5`` - -Requirements -============ - -- `python-mpd2`_ - -.. _check-network-bandwidth: - -NetworkBandwidth -**************** - -.. program:: check-network-bandwidth - -Checks whether more network bandwidth is currently being used than specified. -A set of specified interfaces is checked in this regard, each of the individually, based on the average bandwidth on that interface. -This average is based on the global checking interval specified in the configuration file via the :option:`interval ` option. - -.. note:: - - This check assumes stable network interface names. - If this is not the case for your system, consider adding the required udev rules to ensure persistent device names. - The `Archlinux Wiki page on network configuration `__ explains the necessary configuration steps. - -Options -======= - -.. option:: interfaces - - Comma-separated list of network interfaces to check - -.. option:: threshold_send - - If the average sending bandwidth of one of the specified interfaces is above this threshold, then activity is detected. Specified in bytes/s, default: ``100`` - -.. option:: threshold_receive - - If the average receive bandwidth of one of the specified interfaces is above this threshold, then activity is detected. Specified in bytes/s, default: ``100`` - -Requirements -============ - -.. _check-ping: - -Ping -**** - -.. program:: check-ping - -Checks whether one or more hosts answer to ICMP requests. - -Options -======= - -.. option:: hosts - - Comma-separated list of host names or IPs. - - -Requirements -============ - -.. _check-processes: - -Processes -********* - -.. program:: check-processes - -If currently running processes match an expression, the suspend will be blocked. -You might use this to hinder the system from suspending when for example your rsync runs. - -Options -======= - -.. option:: processes - - list of comma-separated process names to check for - -Requirements -============ - -.. _check-smb: - -Smb -*** - -.. program:: check-smb - -Any active Samba connection will block suspend. - -Options -======= - -.. option:: smbstatus - - executable needs to be present. - -Requirements -============ - -.. _check-users: - -Users -***** - -.. program:: check-users - -Checks whether a user currently logged in at the system matches several criteria. -All provided criteria must match to indicate activity on the host. - -To find the applicable values for a given scenario on a system, use the following command: - -.. code-block:: console - - $ python3 -c "import psutil; print(psutil.users())" - [suser(name='someone', terminal='tty7', host='', started=1670269568.0, pid=77179)] - -Options -======= - -All regular expressions are applied against the full string. -Capturing substrings needs to be explicitly enabled using wildcard matching. - -.. option:: name - - A regular expression specifying which users to capture, default: ``.*``. - -.. option:: terminal - - A regular expression specifying the terminal on which the user needs to be logged in, default: ``.*``. - -.. option:: host - - A regular expression specifying the host from which a user needs to be logged in. - Users logged in locally on the machine are usually reported with an empty string as the host value. - In case this check should only match local users, use ``^$`` as the value for this option. - default: ``.*`` (i.e. accept users from any host). - -Requirements -============ - -.. _check-xidletime: - -XIdleTime -********* - -.. program:: check-xidletime - -Checks whether all active local X displays have been idle for a sufficiently long time. -Determining which X11 sessions currently exist on a running system is a harder problem than one might expect. -Sometimes, the server runs as root, sometimes under the real user, and many other configuration variants exist. -Thus, multiple sources for active X serer instances are implemented for this check, each of them having different requirements and limitations. -They can be changed using the provided configuration option. - -Options -======= - -.. option:: timeout - - required idle time in seconds - -.. option:: method - - The method to use for acquiring running X sessions. - Valid options are ``sockets`` and ``logind``. - The default is ``sockets``. - - ``sockets`` - Uses the X server sockets files found in :file:`/tmp/.X11-unix`. - This method requires that all X server instances run with user permissions and not as root. - ``logind`` - Uses `logind`_ to obtain the running X server instances. - This does not support manually started servers. - -.. option:: ignore_if_process - - A regular expression to match against the process names executed by each X session owner. - In case the use has a running process that matches this expression, the X idle time is ignored and the check continues as if there was no activity. - This can be useful in case of processes which inevitably tinker with the idle time. - -.. option:: ignore_users - - Do not check sessions of users matching this regular expressions. - -.. _check-xpath: - -XPath -***** - -.. program:: check-xpath - -A generic check which queries a configured URL and expects the reply to contain XML data. -The returned XML document is checked against a configured `XPath`_ expression and in case the expression matches, the system is assumed to be active. - -Some common applications and their respective configuration are: - -`tvheadend`_ - The required URL for `tvheadend`_ is (if running on the same host):: - - http://127.0.0.1:9981/status.xml - - In case you want to prevent suspending in case there are active subscriptions or recordings, use the following XPath:: - - /currentload/subscriptions[number(.) > 0] | /currentload/recordings/recording/start - - If you have a permantently running subscriber like `Kodi`_, increase the ``0`` to ``1``. - -`Plex`_ - For `Plex`_, use the following URL (if running on the same host):: - - http://127.0.0.1:32400/status/sessions/?X-Plex-Token={TOKEN} - - Where acquiring the token is `documented here `_. - - If suspending should be prevented in case of any activity, this simple `XPath`_ expression will suffice:: - - /MediaContainer[@size > 2] - -Options -======= - -.. option:: url - - The URL to query for the XML reply. - -.. option:: xpath - - The XPath query to execute. - In case it returns a result, the system is assumed to be active. - -.. option:: timeout - - Timeout for executed requests in seconds. Default: 5. - -.. option:: username - - Optional user name to use for authenticating at a server requiring authentication. - If used, also a password must be provided. - -.. option:: password - - Optional password to use for authenticating at a server requiring authentication. - If used, also a user name must be provided. - -Requirements -============ - -* `requests`_ -* `lxml`_ diff --git a/doc/source/available_wakeups.rst b/doc/source/available_wakeups.rst index d7cffe43..282dd68d 100644 --- a/doc/source/available_wakeups.rst +++ b/doc/source/available_wakeups.rst @@ -1,216 +1,4 @@ .. _available-wakeups: -Available wake up checks -######################## +.. autosuspend-checks:: wakeup -The following checks for wake up times are currently implemented. -Each of the checks is described with its available configuration options and required optional dependencies. - -.. _wakeup-calendar: - -Calendar -******** - -.. program:: wakeup-calendar - -Determines next wake up time from an `iCalendar`_ file. -The next event that starts after the current time is chosen as the next wake up time. - -Remember that updates to the calendar can only be reflected in case the system currently running. -Changes to the calendar made while the system is sleeping will obviously not trigger an earlier wake up. - -Options -======= - -.. option:: url - - The URL to query for the XML reply. - -.. option:: username - - Optional user name to use for authenticating at a server requiring authentication. - If used, also a password must be provided. - -.. option:: password - - Optional password to use for authenticating at a server requiring authentication. - If used, also a user name must be provided. - -.. option:: xpath - - The XPath query to execute. - Must always return number strings or nothing. - -.. option:: timeout - - Timeout for executed requests in seconds. Default: 5. - - -Requirements -============ - -* `requests`_ -* `icalendar `_ -* `dateutil`_ -* `tzlocal`_ - -.. _wakeup-command: - -Command -******* - -.. program:: wakeup-command - -Determines the wake up time by calling an external command -The command always has to succeed. -If something is printed on stdout by the command, this has to be the next wake up time in UTC seconds. - -The command is executed as is using shell execution. -Beware of malicious commands in obtained configuration files. - -Options -======= - -.. option:: command - - The command to execute including all arguments - -.. _wakeup-file: - -File -**** - -.. program:: wakeup-file - -Determines the wake up time by reading a file from a configured location. -The file has to contains the planned wake up time as an int or float in seconds UTC. - -Options -======= - -.. option:: path - - path of the file to read in case it is present - -.. _wakeup-periodic: - -Periodic -******** - -.. program:: wakeup-periodic - -Always schedules a wake up at a specified delta from now on. -Can be used to let the system wake up every once in a while, for instance, to refresh the calendar used in the :ref:`wakeup-calendar` check. - -Options -======= - -.. option:: unit - - A string indicating in which unit the delta is specified. - Valid options are: ``microseconds``, ``milliseconds``, ``seconds``, ``minutes``, ``hours``, ``days``, ``weeks``. - -.. option:: value - - The value of the delta as an int. - -.. _wakeup-systemd-timer: - -SystemdTimer -************ - -.. program:: wakeup-systemd-timer - -Ensures that the system is active when a `systemd`_ timer is scheduled to run next. - -Options -======= - -.. option:: match - - A regular expression selecting the `systemd`_ timers to check. - This expression matches against the names of the timer units, for instance ``logrotate.timer``. - Use ``systemctl list-timers`` to find out which timers exists. - -.. _wakeup-xpath: - -XPath -***** - -.. program:: wakeup-xpath - -A generic check which queries a configured URL and expects the reply to contain XML data. -The returned XML document is parsed using a configured `XPath`_ expression that has to return timestamps UTC (as strings, not elements). -These are interpreted as the wake up times. -In case multiple entries exist, the soonest one is used. - -Options -======= - -.. option:: url - - The URL to query for the XML reply. - -.. option:: xpath - - The XPath query to execute. - Must always return number strings or nothing. - -.. option:: timeout - - Timeout for executed requests in seconds. Default: 5. - -.. option:: username - - Optional user name to use for authenticating at a server requiring authentication. - If used, also a password must be provided. - -.. option:: password - - Optional password to use for authenticating at a server requiring authentication. - If used, also a user name must be provided. - -.. _wakeup-xpath-delta: - -XPathDelta -********** - -.. program:: wakeup-xpath-delta - -Comparable to :ref:`wakeup-xpath`, but expects that the returned results represent the wake up time as a delta to the current time in a configurable unit. - -This check can for instance be used for `tvheadend`_ with the following expression:: - - //recording/next/text() - -Options -======= - -.. option:: url - - The URL to query for the XML reply. - -.. option:: username - - Optional user name to use for authenticating at a server requiring authentication. - If used, also a password must be provided. - -.. option:: password - - Optional password to use for authenticating at a server requiring authentication. - If used, also a user name must be provided. - -.. option:: xpath - - The XPath query to execute. - Must always return number strings or nothing. - -.. option:: timeout - - Timeout for executed requests in seconds. Default: 5. - -.. option:: unit - - A string indicating in which unit the delta is specified. - Valid options are: ``microseconds``, ``milliseconds``, ``seconds``, ``minutes``, ``hours``, ``days``, ``weeks``. - Default: minutes diff --git a/doc/source/conf.py b/doc/source/conf.py index 4e4b0b44..8ac354ea 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -3,6 +3,10 @@ import os import os.path +import sys + +# Add extension directory to path +sys.path.insert(0, os.path.abspath("..")) # needs_sphinx = '1.0' @@ -15,20 +19,23 @@ "sphinxcontrib.plantuml", "sphinx_issues", "recommonmark", + "ext_autosuspend", ] -templates_path = ['_templates'] -source_suffix = '.rst' +templates_path = ["_templates"] +source_suffix = ".rst" -master_doc = 'index' +master_doc = "index" -project = 'autosuspend' -copyright = '2017, Johannes Wienke' -author = 'Johannes Wienke' +project = "autosuspend" +copyright = "2017, Johannes Wienke" +author = "Johannes Wienke" -with open(os.path.join( - os.path.abspath(os.path.dirname(os.path.realpath(__file__))), - '../..', - 'VERSION'), 'r') as version_file: +with open( + os.path.join( + os.path.abspath(os.path.dirname(os.path.realpath(__file__))), "../..", "VERSION" + ), + "r", +) as version_file: lines = version_file.readlines() version = lines[0].strip() release = lines[1].strip() @@ -37,11 +44,11 @@ exclude_patterns = [] -pygments_style = 'sphinx' +pygments_style = "sphinx" todo_include_todos = False -rst_epilog = ''' +rst_epilog = """ .. _autosuspend: https://github.com/languitar/autosuspend .. _Python 3: https://docs.python.org/3/ .. _Python: https://docs.python.org/3/ @@ -73,40 +80,37 @@ .. |project| replace:: {project} .. |project_bold| replace:: **{project}** -.. |project_program| replace:: :program:`{project}`'''.format(project=project) +.. |project_program| replace:: :program:`{project}`""".format(project=project) # Intersphinx -intersphinx_mapping = {'python': ('https://docs.python.org/3.7', None)} +intersphinx_mapping = {"python": ("https://docs.python.org/3.7", None)} # HTML options -html_theme = 'furo' +html_theme = "furo" # html_theme_options = {} # html_static_path = ['_static'] -html_sidebars = { -} +html_sidebars = {} # MANPAGE options man_pages = [ - ('man_command', - 'autosuspend', - 'autosuspend Documentation', - [author], - 1), - ('man_config', - 'autosuspend.conf', - 'autosuspend config file Documentation', - [author], - 5), + ("man_command", "autosuspend", "autosuspend Documentation", [author], 1), + ( + "man_config", + "autosuspend.conf", + "autosuspend config file Documentation", + [author], + 5, + ), ] man_show_urls = True # issues -issues_github_path = 'languitar/autosuspend' +issues_github_path = "languitar/autosuspend" # napoleon napoleon_google_docstring = True @@ -118,7 +122,7 @@ def setup(app): app.add_config_value( - 'is_preview', - os.environ.get('READTHEDOCS_VERSION', '') == 'latest', - 'env', + "is_preview", + os.environ.get("READTHEDOCS_VERSION", "") == "latest", + "env", ) diff --git a/doc/source/old_changelog.rst b/doc/source/old_changelog.rst index d10016f1..5bd3b570 100644 --- a/doc/source/old_changelog.rst +++ b/doc/source/old_changelog.rst @@ -7,7 +7,7 @@ New features New activity checks ------------------- -* :ref:`check-jsonpath`: Similar to the existing :ref`check-xpath`, the new checks requests a JSON URL and evaluates it against a `JSONPath`_ expression to determine activity (:issue:`81`, :issue:`103`). +* :ref:`check-json-path`: Similar to the existing :ref:`check-x-path`, the new checks requests a JSON URL and evaluates it against a `JSONPath`_ expression to determine activity (:issue:`81`, :issue:`103`). * :ref:`check-last-log-activity`: Check log files for most recent contained timestamps (:issue:`98`, :issue:`99`). Fixed bugs @@ -41,7 +41,7 @@ Fixed bugs ========== * Documented default URL for the ``Kodi*`` checks did not actually exist in code, which has been fixed now (:issue:`58`, :issue:`61`). -* A bug in :ref:`check-logind-session-idle` has been fixed (:issue:`71`, :issue:`72`). +* A bug in :ref:`check-logind-sessions-idle` has been fixed (:issue:`71`, :issue:`72`). Notable changes =============== @@ -119,7 +119,7 @@ New wakeup checks * :ref:`wakeup-command`: Call an external command to determine the next wake up time (:issue:`26`). * :ref:`wakeup-file`: Read the next wake up time from a file (:issue:`9`). * :ref:`wakeup-periodic`: Wake up at a defined interval, for instance, to refresh calendars for the :ref:`wakeup-calendar` check (:issue:`34`). -* :ref:`wakeup-xpath` and :ref:`wakeup-xpath-delta`: Request an XML document and use `XPath`_ to extract the next wakeup time. +* :ref:`wakeup-x-path` and :ref:`wakeup-x-path-delta`: Request an XML document and use `XPath`_ to extract the next wakeup time. Fixed bugs ========== diff --git a/setup.cfg b/setup.cfg index c1a99640..1aa78718 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,6 +11,7 @@ warn_unused_configs = True warn_unused_ignores = True [tool:pytest] +pythonpath = doc log_level = DEBUG markers = integration: longer-running integration tests diff --git a/setup.py b/setup.py index c296e66e..14fd8539 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,8 @@ "PyGObject", "pytest-datadir", "pytest-httpserver", + "sphinx", + "docutils", ], } extras_require["test"].extend( diff --git a/src/autosuspend/__init__.py b/src/autosuspend/__init__.py index 54b2224c..f04ab8a8 100755 --- a/src/autosuspend/__init__.py +++ b/src/autosuspend/__init__.py @@ -4,6 +4,8 @@ import argparse import configparser import functools +import importlib +import inspect import logging import logging.config import math @@ -18,6 +20,7 @@ from gi.repository import GLib from .checks import Activity, CheckType, ConfigurationError, TemporaryCheckError, Wakeup +from .config import GENERAL_PARAMETERS, ConfigSchema from .util import logger_by_class_instance from .util.systemd import LogindDBusException, has_inhibit_lock @@ -488,6 +491,38 @@ def _set_up_single_check( return check +def discover_available_checks( + internal_module: str, check_type: type[CheckType] +) -> dict[str, type[CheckType]]: + """Find all concrete subclasses of Check in the given module. + + Args: + internal_module: either "activity" or "wakeup" to specify which module to search + check_type: the base check type class (Activity or Wakeup) to find subclasses of + + Returns: + Dictionary mapping user-facing check names (aliases) to check classes + """ + module_name = f"autosuspend.checks.{internal_module}" + module = importlib.import_module(module_name) + + available_checks = {} + for name, obj in inspect.getmembers(module, inspect.isclass): + if ( + issubclass(obj, check_type) + # exclude the base class itself + and obj is not check_type + and not inspect.isabstract(obj) + and not name.startswith("_") + # only include classes defined within the autosuspend package, + # excluding anything imported from third-party or stdlib modules + and obj.__module__.startswith("autosuspend.") + ): + available_checks[name] = obj + + return available_checks + + def set_up_checks( config: configparser.ConfigParser, prefix: str, @@ -561,21 +596,6 @@ def parse_arguments(args: Sequence[str] | None) -> argparse.Namespace: formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) - default_config_path = Path("/etc/autosuspend.conf") - default_config: Path | None = None - if default_config_path.exists(): - default_config = default_config_path - parser.add_argument( - "-c", - "--config", - dest="config_file", - type=Path, - default=default_config, - required=default_config is None, - metavar="FILE", - help="The config file to use", - ) - logging_group = parser.add_mutually_exclusive_group() logging_group.add_argument( "-l", @@ -606,6 +626,22 @@ def parse_arguments(args: Sequence[str] | None) -> argparse.Namespace: "daemon", help="Execute the continuously operating daemon" ) parser_daemon.set_defaults(func=main_daemon) + + default_config_path = Path("/etc/autosuspend.conf") + default_config: Path | None = None + if default_config_path.exists(): + default_config = default_config_path + parser_daemon.add_argument( + "-c", + "--config", + dest="config_file", + type=Path, + default=default_config, + required=default_config is None, + metavar="FILE", + help="The config file to use", + ) + parser_daemon.add_argument( "-a", "--allchecks", @@ -627,6 +663,12 @@ def parse_arguments(args: Sequence[str] | None) -> argparse.Namespace: "instead of endless execution.", ) + parser_schema = subparsers.add_parser( + "schema", + help="Prints a schema of the available configuration sections and options", + ) + parser_schema.set_defaults(func=main_schema) + result = parser.parse_args(args) _logger.debug("Parsed command line arguments %s", result) @@ -712,15 +754,29 @@ def configure_processor( ) -def main_version( - _: argparse.Namespace, - config: configparser.ConfigParser, # noqa: ARG001 -) -> None: +def main_version(_: argparse.Namespace) -> None: print(version("autosuspend")) # noqa: T201 -def main_daemon(args: argparse.Namespace, config: configparser.ConfigParser) -> None: +def main_schema(_: argparse.Namespace) -> None: + activity_checks = discover_available_checks("activity", Activity) # type: ignore + wakeup_checks = discover_available_checks("wakeup", Wakeup) # type: ignore + schema = ConfigSchema( + general_parameters=GENERAL_PARAMETERS, + activity_checks={ + name: check.config_parameters for name, check in activity_checks.items() + }, + wakeup_checks={ + name: check.config_parameters for name, check in wakeup_checks.items() + }, + ) + + print(schema.to_json()) # noqa: T201 + + +def main_daemon(args: argparse.Namespace) -> None: """Run the daemon.""" + config = parse_config(args.config_file) checks = set_up_checks( config, "check", @@ -764,9 +820,7 @@ def main(argv: Sequence[str] | None = None) -> None: configure_logging(args.logging, args.debug) - config = parse_config(args.config_file) - - args.func(args, config) + args.func(args) if __name__ == "__main__": diff --git a/src/autosuspend/checks/__init__.py b/src/autosuspend/checks/__init__.py index 7df7470d..deab3110 100644 --- a/src/autosuspend/checks/__init__.py +++ b/src/autosuspend/checks/__init__.py @@ -6,6 +6,7 @@ from datetime import datetime from typing import Any, Self, TypeVar +from autosuspend.config import ParameterSchemaAware from autosuspend.util import logger_by_class_instance @@ -31,7 +32,7 @@ class SevereCheckError(RuntimeError): CheckType = TypeVar("CheckType", bound="Check") -class Check(abc.ABC): +class Check(abc.ABC, ParameterSchemaAware): """Base class for all kinds of checks. Subclasses must call this class' ``__init__`` method. diff --git a/src/autosuspend/checks/command.py b/src/autosuspend/checks/command.py index 0d6b33b6..b5963e2b 100644 --- a/src/autosuspend/checks/command.py +++ b/src/autosuspend/checks/command.py @@ -11,6 +11,7 @@ TemporaryCheckError, Wakeup, ) +from ..config import ParameterType, config_param def raise_severe_if_command_not_found(error: subprocess.CalledProcessError) -> None: @@ -19,6 +20,12 @@ def raise_severe_if_command_not_found(error: subprocess.CalledProcessError) -> N raise SevereCheckError(f"Command '{' '.join(error.cmd)}' does not exist") +@config_param( + "command", + ParameterType.STRING, + "The command to execute including all arguments", + required=True, +) class CommandMixin(Check): """Mixin for configuring checks based on external commands.""" @@ -34,6 +41,19 @@ def __init__(self, command: str) -> None: class CommandActivity(CommandMixin, Activity): + """Execute an external command to determine activity. + + Executes an arbitrary command. + In case this command returns 0, the system is assumed to be active. + + The command is executed as is using shell execution. + Beware of malicious commands in obtained configuration files. + + .. seealso:: + + * :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: CommandMixin.__init__(self, command) Activity.__init__(self, name) @@ -48,10 +68,16 @@ def check(self) -> str | None: class CommandWakeup(CommandMixin, Wakeup): - """Determine wake up times based on an external command. + """Determine wake up times from an external command. The called command must return a timestamp in UTC or nothing in case no wake up is planned. + + The command always has to succeed. + If something is printed on stdout by the command, this has to be the next wake up time in UTC seconds. + + The command is executed as is using shell execution. + Beware of malicious commands in obtained configuration files. """ def __init__(self, name: str, command: str) -> None: diff --git a/src/autosuspend/checks/ical.py b/src/autosuspend/checks/ical.py index a3d1021b..dc841e55 100644 --- a/src/autosuspend/checks/ical.py +++ b/src/autosuspend/checks/ical.py @@ -286,7 +286,20 @@ def list_calendar_events( class ActiveCalendarEvent(NetworkMixin, Activity): - """Determines activity by checking against events in an icalendar file.""" + """Check for active calendar events. + + Checks an online `iCalendar`_ file for events that are currently running. + If so, this indicates activity and prevents suspending the system. + Thus, a calendar can be provided with times at which the system should not go to sleep. + If this calendar resides on an online service like a groupware it might even be possible to invite the system. + + Requires: + + * `requests`_ + * `icalendar `_ + * `dateutil`_ + * `tzlocal`_ + """ def __init__(self, name: str, **kwargs: Any) -> None: NetworkMixin.__init__(self, **kwargs) @@ -310,7 +323,21 @@ def check(self) -> str | None: class Calendar(NetworkMixin, Wakeup): - """Uses an ical calendar to wake up on the next scheduled event.""" + """Determine wake up times from calendar events. + + Determines next wake up time from an `iCalendar`_ file. + The next event that starts after the current time is chosen as the next wake up time. + + Remember that updates to the calendar can only be reflected in case the system currently running. + Changes to the calendar made while the system is sleeping will obviously not trigger an earlier wake up. + + Requires: + + * `requests`_ + * `icalendar `_ + * `dateutil`_ + * `tzlocal`_ + """ def __init__(self, name: str, **kwargs: Any) -> None: NetworkMixin.__init__(self, **kwargs) diff --git a/src/autosuspend/checks/json.py b/src/autosuspend/checks/json.py index cd82f382..18817436 100644 --- a/src/autosuspend/checks/json.py +++ b/src/autosuspend/checks/json.py @@ -9,10 +9,26 @@ from . import Activity, ConfigurationError, TemporaryCheckError from .util import NetworkMixin +from ..config import ParameterType, config_param +@config_param( + "jsonpath", + ParameterType.STRING, + "The JSONPath query to execute. In case it returns a result, the system is assumed to be active.", + required=True, +) class JsonPath(NetworkMixin, Activity): - """Requests a URL and evaluates whether a JSONPath expression matches.""" + """Check for activity using JSONPath expressions. + + A generic check which queries a configured URL and expects the reply to contain JSON data. + The returned JSON document is checked against a configured `JSONPath`_ expression and in case the expression matches, the system is assumed to be active. + + Requires: + + * `requests`_ + * `jsonpath-ng`_ + """ @classmethod def collect_init_args(cls, config: configparser.SectionProxy) -> dict[str, Any]: diff --git a/src/autosuspend/checks/kodi.py b/src/autosuspend/checks/kodi.py index 4501c942..6c06dd95 100644 --- a/src/autosuspend/checks/kodi.py +++ b/src/autosuspend/checks/kodi.py @@ -4,6 +4,7 @@ from . import Activity, ConfigurationError, TemporaryCheckError from .util import NetworkMixin +from ..config import ParameterType, config_param def _add_default_kodi_url(config: configparser.SectionProxy) -> None: @@ -11,7 +12,28 @@ def _add_default_kodi_url(config: configparser.SectionProxy) -> None: config["url"] = "http://localhost:8080/jsonrpc" +@config_param( + "url", + ParameterType.STRING, + "Base URL of the JSON RPC API of the Kodi instance", + default="http://localhost:8080/jsonrpc", +) +@config_param( + "suspend_while_paused", + ParameterType.BOOLEAN, + "Also suspend the system when media playback is paused instead of only suspending when playback is stopped.", + default=False, +) class Kodi(NetworkMixin, Activity): + """Check for Kodi media player activity. + + Checks whether an instance of `Kodi`_ is currently playing. + + Requires: + + * `requests`_ + """ + @classmethod def collect_init_args(cls, config: configparser.SectionProxy) -> dict[str, Any]: try: @@ -62,7 +84,31 @@ def check(self) -> str | None: return "Kodi currently playing" if reply else None +@config_param( + "url", + ParameterType.STRING, + "Base URL of the JSON RPC API of the Kodi instance", + default="http://localhost:8080/jsonrpc", +) +@config_param( + "idle_time", + ParameterType.INTEGER, + "Marks the system active in case a user interaction has appeared within this amount of seconds until now.", + default=120, +) class KodiIdleTime(NetworkMixin, Activity): + """Check for Kodi user interface activity. + + Checks whether there has been interaction with the Kodi user interface recently. + This prevents suspending the system in case someone is currently browsing collections etc. + This check is redundant to :ref:`check-x-idle-time` on systems using an X server, but might be necessary in case Kodi is used standalone. + It does not replace the :ref:`check-kodi` check, as the idle time is not updated when media is playing. + + Requires: + + * `requests`_ + """ + @classmethod def collect_init_args(cls, config: configparser.SectionProxy) -> dict[str, Any]: try: diff --git a/src/autosuspend/checks/linux.py b/src/autosuspend/checks/linux.py index 7d55f185..e27ad634 100644 --- a/src/autosuspend/checks/linux.py +++ b/src/autosuspend/checks/linux.py @@ -23,6 +23,7 @@ TemporaryCheckError, Wakeup, ) +from ..config import ParameterType, config_param try: from psutil._common import snetio @@ -30,8 +31,18 @@ from psutil._ntuples import snetio +@config_param( + "ports", + ParameterType.STRING, + "list of comma-separated port numbers", + required=True, +) class ActiveConnection(Activity): - """Checks if a client connection exists on specified ports.""" + """Check for active network connections on specific ports. + + Checks whether there is currently a client connected to a TCP server at certain ports. + Can be used to e.g. block suspending the system in case SSH users are connected or a web server is used by clients. + """ @classmethod def create( @@ -89,7 +100,18 @@ def check(self) -> str | None: return None +@config_param( + "threshold", + ParameterType.FLOAT, + "a float for the maximum allowed load value", + default=2.5, +) class Load(Activity): + """Check for system load. + + Checks whether the `system load 5 `__ is below a certain value. + """ + @classmethod def create(cls: type[Self], name: str, config: configparser.SectionProxy) -> Self: try: @@ -112,7 +134,38 @@ def check(self) -> str | None: return None +@config_param( + "interfaces", + ParameterType.STRING, + "Comma-separated list of network interfaces to check", + required=True, +) +@config_param( + "threshold_send", + ParameterType.FLOAT, + "If the average sending bandwidth of one of the specified interfaces is above this threshold, then activity is detected. Specified in bytes/s", + default=100, +) +@config_param( + "threshold_receive", + ParameterType.FLOAT, + "If the average receive bandwidth of one of the specified interfaces is above this threshold, then activity is detected. Specified in bytes/s", + default=100, +) class NetworkBandwidth(Activity): + """Check for network bandwidth usage. + + Checks whether more network bandwidth is currently being used than specified. + A set of specified interfaces is checked in this regard, each of them individually, based on the average bandwidth on that interface. + This average is based on the global checking interval specified in the configuration file via the :option:`interval ` option. + + .. note:: + + This check assumes stable network interface names. + If this is not the case for your system, consider adding the required udev rules to ensure persistent device names. + The `Archlinux Wiki page on network configuration `__ explains the necessary configuration steps. + """ + @classmethod def _ensure_interfaces_exist(cls, interfaces: Iterable[str]) -> None: host_interfaces = psutil.net_if_addrs().keys() @@ -224,8 +277,17 @@ def check(self) -> str | None: return None +@config_param( + "hosts", + ParameterType.STRING, + "Comma-separated list of host names or IPs.", + required=True, +) class Ping(Activity): - """Check if one or several hosts are reachable via ping.""" + """Check if hosts respond to ping. + + Checks whether one or more hosts answer to ICMP requests. + """ @classmethod def create(cls: type[Self], name: str, config: configparser.SectionProxy) -> Self: @@ -261,7 +323,19 @@ def check(self) -> str | None: raise SevereCheckError("Binary ping cannot be found") from error +@config_param( + "processes", + ParameterType.STRING, + "list of comma-separated process names to check for", + required=True, +) class Processes(Activity): + """Check for running processes. + + If currently running processes match an expression, the suspend will be blocked. + You might use this to hinder the system from suspending when for example your rsync runs. + """ + @classmethod def create(cls: type[Self], name: str, config: configparser.SectionProxy) -> Self: try: @@ -284,7 +358,41 @@ def check(self) -> str | None: return None +@config_param( + "name", + ParameterType.STRING, + "A regular expression specifying which users to capture", + default=".*", +) +@config_param( + "terminal", + ParameterType.STRING, + "A regular expression specifying the terminal on which the user needs to be logged in", + default=".*", +) +@config_param( + "host", + ParameterType.STRING, + "A regular expression specifying the host from which a user needs to be logged in. Users logged in locally on the machine are usually reported with an empty string as the host value. In case this check should only match local users, use ``^$`` as the value for this option.", + default=".*", +) class Users(Activity): + """Check for logged in users. + + Checks whether a user currently logged in at the system matches several criteria. + All provided criteria must match to indicate activity on the host. + + To find the applicable values for a given scenario on a system, use the following command: + + .. code-block:: console + + $ python3 -c "import psutil; print(psutil.users())" + [suser(name='someone', terminal='tty7', host='', started=1670269568.0, pid=77179)] + + All regular expressions are applied against the full string. + Capturing substrings needs to be explicitly enabled using wildcard matching. + """ + @classmethod def create(cls: type[Self], name: str, config: configparser.SectionProxy) -> Self: with warnings.catch_warnings(): @@ -331,10 +439,17 @@ def check(self) -> str | None: return None +@config_param( + "path", + ParameterType.STRING, + "path of the file to read in case it is present", + required=True, +) class File(Wakeup): - """Determines scheduled wake ups from the contents of a file on disk. + """Scheduled wake up from file contents. - File contents are interpreted as a Unix timestamp in seconds UTC. + Determines the wake up time by reading a file from a configured location. + The file has to contain the planned wake up time as an int or float in seconds UTC. """ @classmethod diff --git a/src/autosuspend/checks/logs.py b/src/autosuspend/checks/logs.py index b5187926..b40bdfa5 100644 --- a/src/autosuspend/checks/logs.py +++ b/src/autosuspend/checks/logs.py @@ -11,9 +11,57 @@ from dateutil.utils import default_tzinfo from . import Activity, ConfigurationError, TemporaryCheckError +from ..config import ParameterType, config_param +@config_param( + "log_file", + ParameterType.STRING, + "path to the log file that should be analyzed", + required=True, +) +@config_param( + "pattern", + ParameterType.STRING, + "A regular expression used to determine whether a line of the log file contains a timestamp to look at. The expression must contain exactly one matching group. For instance, ``^\\[(.*?)\\] .*$`` might be used to find dates in square brackets at line beginnings.", + required=True, +) +@config_param( + "minutes", + ParameterType.INTEGER, + "The number of minutes to allow log file timestamps to be in the past for detecting activity. If a timestamp is older than `` - `` no activity is detected.", + default=10, +) +@config_param( + "encoding", + ParameterType.STRING, + "The encoding with which to parse the log file.", + default="ascii", +) +@config_param( + "timezone", + ParameterType.STRING, + "The timezone to assume in case a timestamp extracted from the log file has not associated timezone information. Timezones are expressed using the names from the Olson timezone database (e.g. ``Europe/Berlin``).", + default="UTC", +) class LastLogActivity(Activity): + """Check for recent log file entries. + + Parses a log file and uses the most recent time contained in the file to determine activity. + For this purpose, the log file lines are iterated from the back until a line matching a configurable regular expression is found. + This expression is used to extract the contained timestamp in that log line, which is then compared to the current time with an allowed delta. + The check only looks at the first line from the back that contains a timestamp. + Further lines are ignored. + A typical use case for this check would be a web server access log file. + + This check supports all date formats that are supported by the `dateutil parser `_. + + Requires: + + * `dateutil`_ + * `tzdata`_ + """ + @classmethod def create(cls: type[Self], name: str, config: configparser.SectionProxy) -> Self: try: diff --git a/src/autosuspend/checks/mpd.py b/src/autosuspend/checks/mpd.py index 92a6bd97..d174be76 100644 --- a/src/autosuspend/checks/mpd.py +++ b/src/autosuspend/checks/mpd.py @@ -5,9 +5,37 @@ from mpd import MPDClient, MPDError from . import Activity, Check, ConfigurationError, TemporaryCheckError +from ..config import ParameterType, config_param +@config_param( + "host", + ParameterType.STRING, + "Host containing the MPD daemon", + default="localhost", +) +@config_param( + "port", + ParameterType.INTEGER, + "Port to connect to the MPD daemon", + default=6600, +) +@config_param( + "timeout", + ParameterType.INTEGER, + "Request timeout in seconds", + default=5, +) class Mpd(Activity): + """Check for MPD music player activity. + + Checks whether an instance of `MPD`_ is currently playing music. + + Requires: + + * `python-mpd2`_ + """ + @classmethod def create(cls: type[Self], name: str, config: configparser.SectionProxy) -> Self: try: diff --git a/src/autosuspend/checks/smb.py b/src/autosuspend/checks/smb.py index f6dca6bb..3ab67bea 100644 --- a/src/autosuspend/checks/smb.py +++ b/src/autosuspend/checks/smb.py @@ -6,6 +6,15 @@ class Smb(Activity): + """Check for active Samba connections. + + Any active Samba connection will block suspend. + + Requires: + + The ``smbstatus`` binary must be installed and executable. + """ + @classmethod def create( cls: type[Self], diff --git a/src/autosuspend/checks/stub.py b/src/autosuspend/checks/stub.py index ab313dca..b36265e9 100644 --- a/src/autosuspend/checks/stub.py +++ b/src/autosuspend/checks/stub.py @@ -3,12 +3,35 @@ from typing import Self from . import ConfigurationError, Wakeup +from ..config import ParameterType, config_param +@config_param( + "unit", + ParameterType.STRING, + "A string indicating in which unit the delta is specified. Valid options are: ``microseconds``, ``milliseconds``, ``seconds``, ``minutes``, ``hours``, ``days``, ``weeks``.", + required=True, + enum_values=[ + "microseconds", + "milliseconds", + "seconds", + "minutes", + "hours", + "days", + "weeks", + ], +) +@config_param( + "value", + ParameterType.FLOAT, + "The value of the delta as a float.", + required=True, +) class Periodic(Wakeup): - """Always indicates a wake up after a specified delta of time from now on. + """Schedule periodic wake ups. - Use this to periodically wake up a system. + Always schedules a wake up at a specified delta from now on. + Can be used to let the system wake up every once in a while, for instance, to refresh the calendar used in the :ref:`wakeup-calendar` check. """ @classmethod diff --git a/src/autosuspend/checks/systemd.py b/src/autosuspend/checks/systemd.py index 3955c15d..421ca265 100644 --- a/src/autosuspend/checks/systemd.py +++ b/src/autosuspend/checks/systemd.py @@ -8,6 +8,7 @@ import dbus from . import Activity, ConfigurationError, TemporaryCheckError, Wakeup +from ..config import ParameterType, config_param from ..util.systemd import LogindDBusException, list_logind_sessions _UINT64_MAX = 18446744073709551615 @@ -51,8 +52,17 @@ def get_if_set(props: dict[str, Any], key: str) -> int | None: return result +@config_param( + "match", + ParameterType.STRING, + "A regular expression selecting the systemd timers to check. This expression matches against the names of the timer units, for instance ``logrotate.timer``. Use ``systemctl list-timers`` to find out which timers exist.", + required=True, +) class SystemdTimer(Wakeup): - """Ensures that the system is active when some selected SystemD timers will run.""" + """Check for systemd timer schedules. + + Ensures that the system is active when a `systemd`_ timer is scheduled to run next. + """ @classmethod def create(cls: type[Self], name: str, config: configparser.SectionProxy) -> Self: @@ -76,10 +86,30 @@ def check(self, timestamp: datetime) -> datetime | None: # noqa: ARG002 return None +@config_param( + "types", + ParameterType.STRING, + "A comma-separated list of sessions types to inspect for activity. The check ignores sessions of other types.", + default="tty,x11,wayland", +) +@config_param( + "states", + ParameterType.STRING, + "A comma-separated list of session states to inspect. For instance, ``lingering`` sessions used for background programs might not be of interest.", + default="active,online", +) +@config_param( + "classes", + ParameterType.STRING, + "A comma-separated list of session classes to inspect. For instance, ``greeter`` sessions used by greeters such as lightdm might not be of interest.", + default="user", +) class LogindSessionsIdle(Activity): - """Prevents suspending in case a logind session is marked not idle. + """Check for logind session idle hints. - The decision is based on the ``IdleHint`` property of logind sessions. + Prevents suspending in case ``IdleHint`` for one of the running sessions `logind`_ sessions is set to ``no``. + Support for setting this hint currently varies greatly across display managers, screen lockers etc. + Thus, check exactly whether the hint is set on your system via ``loginctl show-session``. """ @classmethod diff --git a/src/autosuspend/checks/util.py b/src/autosuspend/checks/util.py index daccda82..e9986d27 100644 --- a/src/autosuspend/checks/util.py +++ b/src/autosuspend/checks/util.py @@ -3,12 +3,35 @@ from typing import TYPE_CHECKING, Any, Self from . import Check, ConfigurationError, SevereCheckError, TemporaryCheckError +from ..config import ParameterType, config_param if TYPE_CHECKING: import requests import requests.models +@config_param( + "url", + ParameterType.STRING, + "The URL to query", + required=True, +) +@config_param( + "timeout", + ParameterType.INTEGER, + "Timeout for executed requests in seconds", + default=5, +) +@config_param( + "username", + ParameterType.STRING, + "Optional user name to use for authenticating at a server requiring authentication. If used, also a password must be provided.", +) +@config_param( + "password", + ParameterType.STRING, + "Optional password to use for authenticating at a server requiring authentication. If used, also a user name must be provided.", +) class NetworkMixin(Check): @staticmethod def _ensure_credentials_consistent(args: dict[str, Any]) -> None: diff --git a/src/autosuspend/checks/xorg.py b/src/autosuspend/checks/xorg.py index 7f076f6c..d7f615a1 100644 --- a/src/autosuspend/checks/xorg.py +++ b/src/autosuspend/checks/xorg.py @@ -15,6 +15,7 @@ import psutil from . import Activity, ConfigurationError, SevereCheckError, TemporaryCheckError +from ..config import ParameterType, config_param from ..util.systemd import LogindDBusException, list_logind_sessions @@ -103,8 +104,49 @@ def list_sessions_logind() -> list[XorgSession]: return results +@config_param( + "timeout", + ParameterType.INTEGER, + "required idle time in seconds", + default=600, +) +@config_param( + "method", + ParameterType.STRING, + "The method to use for acquiring running X sessions. Valid options are ``sockets`` and ``logind``.", + default="sockets", + enum_values=["sockets", "logind"], +) +@config_param( + "ignore_if_process", + ParameterType.STRING, + "A regular expression to match against the process names executed by each X session owner. In case the user has a running process that matches this expression, the X idle time is ignored and the check continues as if there was no activity. This can be useful in case of processes which inevitably tinker with the idle time.", + default="a^", +) +@config_param( + "ignore_users", + ParameterType.STRING, + "Do not check sessions of users matching this regular expressions.", + default="a^", +) class XIdleTime(Activity): - """Check that local X display have been idle long enough.""" + """Check for X11 idle time. + + Checks whether all active local X displays have been idle for a sufficiently long time. + Determining which X11 sessions currently exist on a running system is a harder problem than one might expect. + Sometimes, the server runs as root, sometimes under the real user, and many other configuration variants exist. + Thus, multiple sources for active X server instances are implemented for this check, each of them having different requirements and limitations. + They can be changed using the provided configuration option. + + The method to use for acquiring running X sessions can be configured: + + ``sockets`` + Uses the X server sockets files found in :file:`/tmp/.X11-unix`. + This method requires that all X server instances run with user permissions and not as root. + ``logind`` + Uses `logind`_ to obtain the running X server instances. + This does not support manually started servers. + """ @classmethod def create(cls: type[Self], name: str, config: configparser.SectionProxy) -> Self: diff --git a/src/autosuspend/checks/xpath.py b/src/autosuspend/checks/xpath.py index 946510a6..f9525e30 100644 --- a/src/autosuspend/checks/xpath.py +++ b/src/autosuspend/checks/xpath.py @@ -10,8 +10,15 @@ from . import Activity, ConfigurationError, TemporaryCheckError, Wakeup from .util import NetworkMixin +from ..config import ParameterType, config_param +@config_param( + "xpath", + ParameterType.STRING, + "The XPath query to execute. In case it returns a result, the system is assumed to be active.", + required=True, +) class XPathMixin(NetworkMixin): @classmethod def collect_init_args(cls, config: configparser.SectionProxy) -> dict[str, Any]: @@ -51,6 +58,41 @@ def evaluate(self) -> Sequence[Any]: class XPathActivity(XPathMixin, Activity): + """Check for activity using XPath expressions. + + A generic check which queries a configured URL and expects the reply to contain XML data. + The returned XML document is checked against a configured `XPath`_ expression and in case the expression matches, the system is assumed to be active. + + Some common applications and their respective configuration are: + + `tvheadend`_ + The required URL for `tvheadend`_ is (if running on the same host):: + + http://127.0.0.1:9981/status.xml + + In case you want to prevent suspending in case there are active subscriptions or recordings, use the following XPath:: + + /currentload/subscriptions[number(.) > 0] | /currentload/recordings/recording/start + + If you have a permanently running subscriber like `Kodi`_, increase the ``0`` to ``1``. + + `Plex`_ + For `Plex`_, use the following URL (if running on the same host):: + + http://127.0.0.1:32400/status/sessions/?X-Plex-Token={TOKEN} + + Where acquiring the token is `documented here `_. + + If suspending should be prevented in case of any activity, this simple `XPath`_ expression will suffice:: + + /MediaContainer[@size > 2] + + Requires: + + * `requests`_ + * `lxml`_ + """ + def __init__(self, name: str, **kwargs: Any) -> None: Activity.__init__(self, name) XPathMixin.__init__(self, **kwargs) @@ -62,10 +104,24 @@ def check(self) -> str | None: return None +@config_param( + "xpath", + ParameterType.STRING, + "The XPath query to execute. Must always return number strings or nothing.", + required=True, +) class XPathWakeup(XPathMixin, Wakeup): - """Determine wake up times from a network resource using XPath expressions. + """Determine wake up times from XPath expressions. - The matched results are expected to represent timestamps in seconds UTC. + A generic check which queries a configured URL and expects the reply to contain XML data. + The returned XML document is parsed using a configured `XPath`_ expression that has to return timestamps UTC (as strings, not elements). + These are interpreted as the wake up times. + In case multiple entries exist, the soonest one is used. + + Requires: + + * `requests`_ + * `lxml`_ """ def __init__(self, name: str, **kwargs: Any) -> None: @@ -96,7 +152,36 @@ def check(self, timestamp: datetime) -> datetime | None: ) from error +@config_param( + "unit", + ParameterType.STRING, + "A string indicating in which unit the delta is specified. Valid options are: ``microseconds``, ``milliseconds``, ``seconds``, ``minutes``, ``hours``, ``days``, ``weeks``.", + default="minutes", + enum_values=[ + "microseconds", + "milliseconds", + "seconds", + "minutes", + "hours", + "days", + "weeks", + ], +) class XPathDeltaWakeup(XPathWakeup): + """Determine wake up times from XPath delta expressions. + + Comparable to :ref:`wakeup-x-path`, but expects that the returned results represent the wake up time as a delta to the current time in a configurable unit. + + This check can for instance be used for `tvheadend`_ with the following expression:: + + //recording/next/text() + + Requires: + + * `requests`_ + * `lxml`_ + """ + UNITS = ( "days", "seconds", diff --git a/src/autosuspend/config.py b/src/autosuspend/config.py new file mode 100644 index 00000000..6c7ef960 --- /dev/null +++ b/src/autosuspend/config.py @@ -0,0 +1,174 @@ +import json +from collections.abc import Callable +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Any, ClassVar + + +class ParameterType(Enum): + STRING = "string" + INTEGER = "integer" + FLOAT = "number" + BOOLEAN = "boolean" + STRING_LIST = "array" + + +@dataclass +class ParameterSchema: + """Describes the configuration schema of a single config parameter.""" + + name: str + type: ParameterType + description: str + default: Any = None + required: bool = False + minimum: float | None = None + maximum: float | None = None + pattern: str | None = None + enum_values: list | None = None + + +class ParameterSchemaAware: + """Mixin for classes that are aware of their parameter schema.""" + + config_parameters: ClassVar[list[ParameterSchema]] = [] + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + # inherit config parameters from parent classes to avoid having to repeat them + # for every subclass of a mixin etc. + inherited_params: list[ParameterSchema] = [] + for base in cls.__mro__[1:]: # Skip self, start from first parent + # add parameters from this base class that aren't already present. This + # allows overriding them if required. + for param in getattr(base, "config_parameters", []): + if not any(p.name == param.name for p in inherited_params): + inherited_params.append(param) + cls.config_parameters = inherited_params.copy() + + +def config_param( + name: str, + param_type: ParameterType, + description: str, + default: Any = None, + required: bool = False, + minimum: float | None = None, + maximum: float | None = None, + pattern: str | None = None, + enum_values: list | None = None, +) -> Callable[[type[ParameterSchemaAware]], type[ParameterSchemaAware]]: + """Decorates a check class with the description of a single configuration parameter.""" + + def decorator(cls: type[ParameterSchemaAware]) -> type[ParameterSchemaAware]: + # Check if parameter with this name already exists and remove it + cls.config_parameters = [p for p in cls.config_parameters if p.name != name] + # Insert at the front so that the topmost (first-written) decorator ends up first + cls.config_parameters.insert( + 0, + ParameterSchema( + name, + param_type, + description, + default, + required, + minimum, + maximum, + pattern, + enum_values, + ), + ) + return cls + + return decorator + + +def _remove_none_values(data: Any) -> Any: + """Recursively remove None values from dictionaries.""" + if isinstance(data, dict): + return {k: _remove_none_values(v) for k, v in data.items() if v is not None} + elif isinstance(data, list): + return [_remove_none_values(item) for item in data] + return data + + +class ConfigEncoder(json.JSONEncoder): + """Custom JSON encoder that can handle ParameterType enums.""" + + def default(self, o: Any) -> Any: + if isinstance(o, ParameterType): + return o.value + return super().default(o) + + +@dataclass +class ConfigSchema: + """Describes the overall configuration schema for the autosuspend system. + + Attributes: + general_parameters: parameters for the [general] section + activity_checks: mapping of activity check class names to their parameters + wakeup_checks: mapping of wakeup check class names to their parameters + """ + + general_parameters: list[ParameterSchema] = field(default_factory=list) + activity_checks: dict[str, list[ParameterSchema]] = field(default_factory=dict) + wakeup_checks: dict[str, list[ParameterSchema]] = field(default_factory=dict) + + def to_json(self) -> str: + data = asdict(self) + data = _remove_none_values(data) + return json.dumps(data, indent=2, cls=ConfigEncoder) + + +GENERAL_PARAMETERS = [ + ParameterSchema( + name="interval", + type=ParameterType.INTEGER, + description="The time to wait after executing all checks in seconds.", + default=60, + minimum=1, + ), + ParameterSchema( + name="idle_time", + type=ParameterType.INTEGER, + description="The required amount of time in seconds with no detected activity before the host will be suspended.", + default=300, + minimum=1, + ), + ParameterSchema( + name="min_sleep_time", + type=ParameterType.INTEGER, + description="The minimal amount of time in seconds the system has to sleep for actually triggering suspension. If a scheduled wake up results in an effective time below this value, the system will not sleep.", + default=1200, + minimum=0, + ), + ParameterSchema( + name="wakeup_delta", + type=ParameterType.INTEGER, + description="Wake up the system this amount of seconds earlier than the time that was determined for an event that requires the system to be up. This value adds a safety margin for the time that the wake up effectively takes.", + default=30, + minimum=0, + ), + ParameterSchema( + name="suspend_cmd", + type=ParameterType.STRING, + description="The command to execute in case the host shall be suspended. This line can contain additional command line arguments to the command to execute.", + required=True, + ), + ParameterSchema( + name="wakeup_cmd", + type=ParameterType.STRING, + description="The command to execute for scheduling a wake up of the system. The given string is processed using Python's str.format and a format argument called 'timestamp' encodes the UTC timestamp of the planned wake up time (float). Additionally 'iso' can be used to acquire the timestamp in ISO 8601 format. Required when any wakeup check is enabled.", + ), + ParameterSchema( + name="notify_cmd_wakeup", + type=ParameterType.STRING, + description="A command to execute before the system is going to suspend for the purpose of notifying interested clients. This command is only called in case a wake up is scheduled. The given string is processed using Python's str.format and a format argument called 'timestamp' encodes the UTC timestamp of the planned wake up time (float). Additionally 'iso' can be used to acquire the timestamp in ISO 8601 format. If empty or not specified, no command will be called.", + ), + ParameterSchema( + name="notify_cmd_no_wakeup", + type=ParameterType.STRING, + description="A command to execute before the system is going to suspend for the purpose of notifying interested clients. This command is only called in case NO wake up is scheduled. Hence, no string formatting options are available. If empty or not specified, no command will be called.", + ), +] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..214880dc --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,133 @@ +import json + +from autosuspend.config import ( + ConfigSchema, + ParameterSchema, + ParameterSchemaAware, + ParameterType, + config_param, +) + + +class TestConfigParam: + def test_adds_schema(self) -> None: + name = "baz" + description = "This is a test parameter." + default = 42 + + @config_param( + name=name, + param_type=ParameterType.INTEGER, + description=description, + default=default, + required=True, + ) + class TestCheck(ParameterSchemaAware): + pass + + assert TestCheck.config_parameters == [ + ParameterSchema( + name=name, + type=ParameterType.INTEGER, + description=description, + default=default, + required=True, + ) + ] + + def test_multiple_params_order_matches_source(self) -> None: + """Parameters must appear in the same order they are written in source. + + Decorators are applied bottom-up, so the implementation uses insert(0, …) + to counteract that, ensuring the topmost decorator ends up first. + """ + + @config_param( + name="param1", + param_type=ParameterType.STRING, + description="First parameter.", + ) + @config_param( + name="param2", + param_type=ParameterType.BOOLEAN, + description="Second parameter.", + default=True, + ) + class TestCheck(ParameterSchemaAware): + pass + + assert [p.name for p in TestCheck.config_parameters] == ["param1", "param2"] + + +class TestConfigSchema: + class TestToJson: + def test_empty(self) -> None: + assert json.loads(ConfigSchema().to_json()) == { + "general_parameters": [], + "activity_checks": {}, + "wakeup_checks": {}, + } + + def test_filled(self) -> None: + schema = ConfigSchema( + general_parameters=[ + ParameterSchema( + name="global_param", + type=ParameterType.STRING, + description="A global parameter.", + ) + ], + activity_checks={ + "check1": [ + ParameterSchema( + name="check1_param", + type=ParameterType.INTEGER, + description="A parameter for check1.", + ) + ] + }, + wakeup_checks={ + "check2": [ + ParameterSchema( + name="check2_param", + type=ParameterType.BOOLEAN, + description="A parameter for check2.", + default=False, + ) + ] + }, + ) + + expected_json = { + "general_parameters": [ + { + "name": "global_param", + "type": "string", + "description": "A global parameter.", + "required": False, + } + ], + "activity_checks": { + "check1": [ + { + "name": "check1_param", + "type": "integer", + "description": "A parameter for check1.", + "required": False, + } + ] + }, + "wakeup_checks": { + "check2": [ + { + "name": "check2_param", + "type": "boolean", + "description": "A parameter for check2.", + "default": False, + "required": False, + } + ] + }, + } + + assert json.loads(schema.to_json()) == expected_json diff --git a/tests/test_ext_autosuspend.py b/tests/test_ext_autosuspend.py new file mode 100644 index 00000000..82a392af --- /dev/null +++ b/tests/test_ext_autosuspend.py @@ -0,0 +1,103 @@ +"""Tests for the ext_autosuspend Sphinx extension.""" + +from ext_autosuspend import render_google_docstring + + +class TestRenderGoogleDocstring: + """Tests for render_google_docstring.""" + + def test_no_requires_section_passthrough(self) -> None: + doc = """\ +A plain docstring. + +With multiple paragraphs.""" + result = render_google_docstring(doc) + assert result == ["A plain docstring.", "", "With multiple paragraphs."] + + def test_requires_section_emits_rubric(self) -> None: + doc = """\ +Description. + +Requires: +some-package""" + result = render_google_docstring(doc) + assert ".. rubric:: Requires" in result + + def test_requires_blank_line_after_header_consumed(self) -> None: + doc = """\ +Description. + +Requires: + +some-package""" + result = render_google_docstring(doc) + # The blank line between "Requires:" and the body should not produce + # a doubled blank line in the output immediately after the rubric. + rubric_idx = result.index(".. rubric:: Requires") + assert result[rubric_idx + 1] == "" + assert result[rubric_idx + 2] == "some-package" + + def test_requires_body_stops_at_next_google_section(self) -> None: + doc = """\ +Description. + +Requires: +some-package + +Note: +A note.""" + result = render_google_docstring(doc) + # "Note:" is a Google-style section header and must not appear inside + # the Requires body; it should be passed through as a plain line. + body_start = result.index(".. rubric:: Requires") + 2 + requires_body = result[body_start:] + assert "some-package" in requires_body + assert "Note:" in result + + def test_plain_unindented_body_line_preserved(self) -> None: + doc = """\ +Description. + +Requires: +some-package >= 1.0""" + result = render_google_docstring(doc) + assert "some-package >= 1.0" in result + + def test_bullet_list_indentation_preserved(self) -> None: + """Leading spaces on bullet list items must be preserved.""" + doc = """\ +Description. + +Requires: + + * some-package + * other-package""" + result = render_google_docstring(doc) + assert " * some-package" in result + assert " * other-package" in result + + def test_rst_directive_indentation_preserved(self) -> None: + """Leading spaces on RST directives inside Requires: must be preserved.""" + doc = """\ +Description. + +Requires: + + .. code-block:: bash + + pip install foo""" + result = render_google_docstring(doc) + assert " .. code-block:: bash" in result + assert " pip install foo" in result + + def test_indented_continuation_preserved(self) -> None: + """Indented continuation lines must retain their leading whitespace.""" + doc = """\ +Description. + +Requires: + +some-package + indented continuation""" + result = render_google_docstring(doc) + assert " indented continuation" in result diff --git a/tests/test_integration.py b/tests/test_integration.py index 5ee9af28..32f114fd 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,3 +1,4 @@ +import json import logging import subprocess from collections.abc import Iterable @@ -132,10 +133,10 @@ def daemon_environment( def test_no_suspend_if_matching(datadir: Path, tmp_path: Path) -> None: autosuspend.main( [ - "-c", - str(configure_config("dont_suspend.conf", datadir, tmp_path)), "-d", "daemon", + "-c", + str(configure_config("dont_suspend.conf", datadir, tmp_path)), "-r", "10", ] @@ -148,10 +149,10 @@ def test_no_suspend_if_matching(datadir: Path, tmp_path: Path) -> None: def test_suspend(tmp_path: Path, datadir: Path) -> None: autosuspend.main( [ - "-c", - str(configure_config("would_suspend.conf", datadir, tmp_path)), "-d", "daemon", + "-c", + str(configure_config("would_suspend.conf", datadir, tmp_path)), "-r", "10", ] @@ -169,10 +170,10 @@ def test_wakeup_scheduled(tmp_path: Path, datadir: Path) -> None: autosuspend.main( [ - "-c", - str(configure_config("would_schedule.conf", datadir, tmp_path)), "-d", "daemon", + "-c", + str(configure_config("would_schedule.conf", datadir, tmp_path)), "-r", "10", ] @@ -189,10 +190,10 @@ def test_wakeup_scheduled(tmp_path: Path, datadir: Path) -> None: def test_notify_call(tmp_path: Path, datadir: Path) -> None: autosuspend.main( [ - "-c", - str(configure_config("notify.conf", datadir, tmp_path)), "-d", "daemon", + "-c", + str(configure_config("notify.conf", datadir, tmp_path)), "-r", "10", ] @@ -212,10 +213,10 @@ def test_notify_call_wakeup(tmp_path: Path, datadir: Path) -> None: autosuspend.main( [ - "-c", - str(configure_config("notify_wakeup.conf", datadir, tmp_path)), "-d", "daemon", + "-c", + str(configure_config("notify_wakeup.conf", datadir, tmp_path)), "-r", "10", ] @@ -232,10 +233,10 @@ def test_error_no_checks_configured(tmp_path: Path, datadir: Path) -> None: with pytest.raises(autosuspend.ConfigurationError): autosuspend.main( [ - "-c", - str(configure_config("no_checks.conf", datadir, tmp_path)), "-d", "daemon", + "-c", + str(configure_config("no_checks.conf", datadir, tmp_path)), "-r", "10", ] @@ -246,10 +247,10 @@ def test_error_no_checks_configured(tmp_path: Path, datadir: Path) -> None: def test_temporary_errors_logged(tmp_path: Path, datadir: Path, caplog: Any) -> None: autosuspend.main( [ - "-c", - str(configure_config("temporary_error.conf", datadir, tmp_path)), "-d", "daemon", + "-c", + str(configure_config("temporary_error.conf", datadir, tmp_path)), "-r", "10", ] @@ -270,10 +271,10 @@ def test_loop_defaults(tmp_path: Path, datadir: Path, mocker: MockerFixture) -> with pytest.raises(StopIteration): autosuspend.main( [ - "-c", - str(configure_config("minimal.conf", datadir, tmp_path)), "-d", "daemon", + "-c", + str(configure_config("minimal.conf", datadir, tmp_path)), "-r", "10", ] @@ -284,11 +285,22 @@ def test_loop_defaults(tmp_path: Path, datadir: Path, mocker: MockerFixture) -> assert kwargs["run_for"] == 10 -def test_version(tmp_path: Path, datadir: Path) -> None: +def test_version() -> None: autosuspend.main( [ - "-c", - str(configure_config("would_schedule.conf", datadir, tmp_path)), "version", ] ) + + +def test_schema(capsys: pytest.CaptureFixture[str]) -> None: + autosuspend.main( + [ + "schema", + ] + ) + + stdout = capsys.readouterr().out + assert stdout.strip() != "" + # should be valid + json.loads(stdout) diff --git a/tox.ini b/tox.ini index e5ce20a2..0f22e298 100644 --- a/tox.ini +++ b/tox.ini @@ -46,7 +46,7 @@ depends = commands = {envbindir}/python -V {envbindir}/python -c "import autosuspend; import autosuspend.checks.activity; import autosuspend.checks.wakeup" - {envbindir}/autosuspend -c tests/data/mindeps-test.conf daemon -r 1 + {envbindir}/autosuspend daemon -c tests/data/mindeps-test.conf -r 1 [testenv:check] depends =