├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .readthedocs.yaml ├── .releaserc.json ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── VERSION ├── commitlint.config.js ├── cosmic-ray.toml ├── data ├── autosuspend-detect-suspend.service ├── autosuspend-logging.conf ├── autosuspend.conf └── autosuspend.service ├── doc └── source │ ├── api.rst │ ├── available_checks.rst │ ├── available_wakeups.rst │ ├── changelog.rst │ ├── conf.py │ ├── configuration_file.inc │ ├── configuration_file.rst │ ├── debugging.rst │ ├── description.inc │ ├── external_command_activity_scripts.rst │ ├── faq.rst │ ├── generated_changelog.md │ ├── index.rst │ ├── installation.rst │ ├── man_command.rst │ ├── man_config.rst │ ├── old_changelog.rst │ ├── options.rst │ ├── support.rst │ └── systemd_integration.rst ├── package-lock.json ├── package.json ├── pyproject.toml ├── renovate.json ├── requirements-check.txt ├── requirements-doc.txt ├── setup.cfg ├── setup.py ├── src └── autosuspend │ ├── __init__.py │ ├── checks │ ├── __init__.py │ ├── activity.py │ ├── command.py │ ├── ical.py │ ├── json.py │ ├── kodi.py │ ├── linux.py │ ├── logs.py │ ├── mpd.py │ ├── smb.py │ ├── stub.py │ ├── systemd.py │ ├── util.py │ ├── wakeup.py │ ├── xorg.py │ └── xpath.py │ └── util │ ├── __init__.py │ ├── datetime.py │ └── systemd.py ├── tests ├── __init__.py ├── conftest.py ├── data │ └── mindeps-test.conf ├── test_autosuspend.py ├── test_checks.py ├── test_checks_activity.py ├── test_checks_command.py ├── test_checks_ical.py ├── test_checks_ical │ ├── after-horizon.ics │ ├── all-day-events.ics │ ├── all-day-recurring-exclusions.ics │ ├── all-day-recurring.ics │ ├── all-day-starts.ics │ ├── before-horizon.ics │ ├── exclusions.ics │ ├── floating.ics │ ├── issue-41.ics │ ├── long-event.ics │ ├── multiple.ics │ ├── normal-events-corner-cases.ics │ ├── old-event.ics │ ├── recurring-change-dst.ics │ ├── simple-recurring.ics │ └── single-change.ics ├── test_checks_json.py ├── test_checks_json │ └── invalid.json ├── test_checks_kodi.py ├── test_checks_linux.py ├── test_checks_logs.py ├── test_checks_mpd.py ├── test_checks_smb.py ├── test_checks_smb │ ├── smbstatus_no_connections │ └── smbstatus_with_connections ├── test_checks_stub.py ├── test_checks_systemd.py ├── test_checks_util.py ├── test_checks_util │ ├── data.txt │ └── xml_with_encoding.xml ├── test_checks_wakeup.py ├── test_checks_xorg.py ├── test_checks_xpath.py ├── test_checks_xpath │ └── xml_with_encoding.xml ├── test_integration.py ├── test_integration │ ├── dont_suspend.conf │ ├── minimal.conf │ ├── no_checks.conf │ ├── notify.conf │ ├── notify_wakeup.conf │ ├── temporary_error.conf │ ├── would_schedule.conf │ └── would_suspend.conf ├── test_util.py ├── test_util_systemd.py └── utils.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | 9 | [*.py] 10 | indent_size = 4 11 | max_line_length = 88 12 | 13 | [*.rst] 14 | indent_size = 3 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: {} 7 | schedule: 8 | - cron: "0 0 * * 0" 9 | 10 | jobs: 11 | lint-commits: 12 | runs-on: ubuntu-latest 13 | if: ${{ github.event_name == 'pull_request' }} 14 | steps: 15 | - name: install cairo 16 | run: sudo apt-get update && sudo apt-get install -y libcairo2-dev 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - uses: wagoid/commitlint-github-action@v6 21 | 22 | lint-code: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Clone repo 27 | uses: actions/checkout@v4 28 | - name: Set up Python 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: "3.13" 32 | - name: Install native dependencies 33 | run: sudo apt-get update && sudo apt-get -y install libdbus-1-dev libgirepository-2.0-dev libcairo2-dev 34 | - name: Cache Python packages 35 | uses: actions/cache@v4 36 | with: 37 | path: ~/.cache/pip 38 | key: lint-code-${{ hashFiles('setup.py') }}-${{ hashFiles('tox.ini') }} 39 | - name: Install tox 40 | run: | 41 | python -m pip install --upgrade pip 42 | pip install tox 43 | - name: Lint with tox 44 | run: tox -e check 45 | 46 | docs: 47 | runs-on: ubuntu-latest 48 | 49 | steps: 50 | - name: Clone repo 51 | uses: actions/checkout@v4 52 | - name: Set up Python 53 | uses: actions/setup-python@v5 54 | with: 55 | python-version: "3.13" 56 | - name: Install native dependencies 57 | run: sudo apt-get update && sudo apt-get -y install libdbus-1-dev libgirepository-2.0-dev plantuml libcairo2-dev 58 | - name: Cache Python packages 59 | uses: actions/cache@v4 60 | with: 61 | path: ~/.cache/pip 62 | key: docs-${{ hashFiles('setup.py') }}-${{ hashFiles('tox.ini') }}-${{ hashFiles('requirements-doc.txt') }} 63 | - name: Install tox 64 | run: | 65 | python -m pip install --upgrade pip 66 | pip install tox 67 | - name: Build Sphinx docs 68 | run: tox -e docs 69 | 70 | test-mindeps: 71 | runs-on: ubuntu-latest 72 | 73 | steps: 74 | - name: Clone repo 75 | uses: actions/checkout@v4 76 | - name: Set up Python 77 | uses: actions/setup-python@v5 78 | with: 79 | python-version: "3.13" 80 | - name: Cache Python packages 81 | uses: actions/cache@v4 82 | with: 83 | path: ~/.cache/pip 84 | key: test-mindeps-${{ hashFiles('setup.py') }}-${{ hashFiles('tox.ini') }} 85 | - name: Install tox 86 | run: | 87 | python -m pip install --upgrade pip 88 | pip install tox 89 | - name: Test execution with minimal dependencies 90 | run: tox -e mindeps 91 | 92 | test: 93 | runs-on: ubuntu-latest 94 | 95 | strategy: 96 | max-parallel: 4 97 | matrix: 98 | python-version: ["3.10", "3.11", "3.12", "3.13"] 99 | 100 | steps: 101 | - name: Clone repo 102 | uses: actions/checkout@v4 103 | - name: Set up Python ${{ matrix.python-version }} 104 | uses: actions/setup-python@v5 105 | with: 106 | python-version: ${{ matrix.python-version }} 107 | - name: Install native dependencies 108 | run: sudo apt-get update && sudo apt-get -y install libdbus-1-dev libgirepository-2.0-dev libcairo2-dev 109 | - name: Cache Python packages 110 | uses: actions/cache@v4 111 | with: 112 | path: ~/.cache/pip 113 | key: test-${{ hashFiles('setup.py') }}-${{ hashFiles('tox.ini') }}-${{ matrix.python-version }} 114 | - name: Install Python dependencies 115 | run: | 116 | python -m pip install --upgrade pip 117 | pip install coverage tox tox-gh-actions 118 | - name: Test with tox 119 | run: | 120 | tox 121 | coverage xml --rcfile=setup.cfg 122 | - name: Publish coverage to codecov.io 123 | uses: codecov/codecov-action@v5 124 | with: 125 | token: ${{ secrets.CODECOV_TOKEN }} 126 | 127 | release: 128 | runs-on: ubuntu-latest 129 | if: ${{ github.ref == 'refs/heads/main' }} 130 | needs: 131 | - lint-code 132 | - test-mindeps 133 | - test 134 | - docs 135 | steps: 136 | - name: "Generate token" 137 | id: generate_token 138 | uses: tibdex/github-app-token@v2 139 | with: 140 | app_id: ${{ secrets.RELEASE_APP_ID }} 141 | private_key: ${{ secrets.RELEASE_PRIVATE_KEY }} 142 | - name: Checkout 143 | uses: actions/checkout@v4 144 | with: 145 | fetch-depth: 0 146 | token: ${{ steps.generate_token.outputs.token }} 147 | - name: Setup Node.js 148 | uses: actions/setup-node@v4 149 | with: 150 | node-version: 22 151 | - name: Cache Node packages 152 | uses: actions/cache@v4 153 | with: 154 | path: node_modules 155 | key: release-${{ hashFiles('package.json') }}-${{ hashFiles('package-lock.json') }} 156 | - name: Install dependencies 157 | run: npm ci 158 | - name: Release 159 | run: npx semantic-release 160 | env: 161 | GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} 162 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.cache 2 | /.coverage* 3 | *.egg-info 4 | /.eggs 5 | /build 6 | /dist 7 | /htmlcov 8 | /tags 9 | __pycache__ 10 | /pytestdebug.log 11 | /doc/build/ 12 | /env/ 13 | /.ropeproject/ 14 | /.mypy_cache/ 15 | /.pytest_cache/ 16 | /.python-version 17 | /.tox/ 18 | /Session.vim 19 | /node_modules/ 20 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | sphinx: 5 | configuration: doc/source/conf.py 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.11" 11 | apt_packages: 12 | - plantuml 13 | 14 | python: 15 | install: 16 | - requirements: requirements-doc.txt 17 | - method: pip 18 | path: . 19 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | [ 7 | "@semantic-release/commit-analyzer", 8 | { 9 | "preset": "conventionalcommits" 10 | } 11 | ], 12 | [ 13 | "@semantic-release/release-notes-generator", 14 | { 15 | "preset": "angular" 16 | } 17 | ], 18 | [ 19 | "@semantic-release/changelog", 20 | { 21 | "changelogFile": "doc/source/generated_changelog.md" 22 | } 23 | ], 24 | [ 25 | "@semantic-release/exec", 26 | { 27 | "prepareCmd": "echo $(echo ${nextRelease.version} | cut -d '.' -f 1-2)'\n${nextRelease.version}' > ./VERSION" 28 | } 29 | ], 30 | [ 31 | "@semantic-release/git", 32 | { 33 | "assets": [ 34 | "VERSION", 35 | "doc/source/generated_changelog.md" 36 | ] 37 | } 38 | ], 39 | "@semantic-release/github" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include VERSION 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # autosuspend 2 | 3 | [![Actions Status](https://github.com/languitar/autosuspend/workflows/CI%20build/badge.svg)](https://github.com/languitar/autosuspend/actions) [![codecov](https://codecov.io/gh/languitar/autosuspend/branch/main/graph/badge.svg)](https://codecov.io/gh/languitar/autosuspend) [![Documentation Status](https://readthedocs.org/projects/autosuspend/badge/?version=latest)](http://autosuspend.readthedocs.io/en/latest/?badge=latest) 4 | 5 | `autosuspend` is a python daemon that suspends a system if certain conditions are met, or not met. This enables a server to sleep in case of inactivity without depending on the X infrastructure usually used by normal desktop environments. 6 | 7 | Documentation is [available here](https://autosuspend.readthedocs.io). 8 | 9 | ## Packages 10 | 11 | [![Packaging status](https://repology.org/badge/vertical-allrepos/autosuspend.svg)](https://repology.org/project/autosuspend/versions) 12 | 13 | ## License 14 | 15 | This software is licensed using the [GPL2 license](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html). 16 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 7.2 2 | 7.2.0 3 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']}; 2 | -------------------------------------------------------------------------------- /cosmic-ray.toml: -------------------------------------------------------------------------------- 1 | [cosmic-ray] 2 | module-path = "src/autosuspend" 3 | python-version = "" 4 | timeout = 20.0 5 | excluded-modules = [] 6 | test-command = "env PYTHONPATH=`pwd`/src pytest -x" 7 | 8 | [cosmic-ray.execution-engine] 9 | name = "local" 10 | 11 | [cosmic-ray.cloning] 12 | method = "copy" 13 | commands = [ 14 | "pip install .[test]" 15 | ] 16 | -------------------------------------------------------------------------------- /data/autosuspend-detect-suspend.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Notifies autosuspend about suspension 3 | Documentation=https://autosuspend.readthedocs.io/en/latest/systemd_integration.html 4 | Before=sleep.target 5 | 6 | [Service] 7 | Type=simple 8 | ExecStart=/usr/bin/autosuspend -l /etc/autosuspend-logging.conf presuspend 9 | 10 | [Install] 11 | WantedBy=sleep.target 12 | -------------------------------------------------------------------------------- /data/autosuspend-logging.conf: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root,autosuspend,checks 3 | 4 | [handlers] 5 | keys=consoleHandler 6 | 7 | [formatters] 8 | keys=simpleFormatter 9 | 10 | [logger_root] 11 | level=INFO 12 | handlers=consoleHandler 13 | 14 | [logger_autosuspend] 15 | qualname=autosuspend 16 | propagate=0 17 | level=INFO 18 | handlers=consoleHandler 19 | 20 | [logger_checks] 21 | qualname=autosuspend.checks 22 | propagate=0 23 | level=INFO 24 | handlers=consoleHandler 25 | 26 | [handler_consoleHandler] 27 | class=StreamHandler 28 | level=DEBUG 29 | formatter=simpleFormatter 30 | args=(sys.stdout,) 31 | 32 | [formatter_simpleFormatter] 33 | format=%(asctime)s - %(name)s - %(levelname)s - %(message)s 34 | datefmt= 35 | -------------------------------------------------------------------------------- /data/autosuspend.conf: -------------------------------------------------------------------------------- 1 | ## This is an exemplary documentation file that mainly serves as a syntax explanation. 2 | ## For a list of available options and checks, please refer to `man autosuspend.conf` or the online documentation. 3 | 4 | [general] 5 | interval = 30 6 | idle_time = 900 7 | suspend_cmd = /usr/bin/systemctl suspend 8 | wakeup_cmd = sh -c 'echo 0 > /sys/class/rtc/rtc0/wakealarm && echo {timestamp:.0f} > /sys/class/rtc/rtc0/wakealarm' 9 | woke_up_file = /var/run/autosuspend-just-woke-up 10 | lock_file = /var/lock/autosuspend.lock 11 | lock_timeout = 30 12 | # Can be used to call a command before suspending, either with scheduled wake up or not. 13 | # notify_cmd_wakeup = su myuser -c notify-send -a autosuspend 'Suspending the system. Wake up at {iso}' 14 | # notify_cmd_no_wakeup = su myuser -c notify-send -a autosuspend 'Suspending the system.' 15 | 16 | # Basic activity check configuration. 17 | # The check class name is derived from the section header (Ping in this case). 18 | # Remember to enable desired checks. They are disabled by default. 19 | [check.Ping] 20 | enabled = true 21 | hosts = 192.168.0.7 22 | 23 | # This check is disabled. 24 | [check.Smb] 25 | enabled = false 26 | 27 | # Example for a custom check name. 28 | # This will use the Users check with the custom name RemoteUsers. 29 | # Custom names are necessary in case a check class is used multiple times. 30 | # Custom names can also be used for clarification. 31 | [check.RemoteUsers] 32 | class = Users 33 | enabled = true 34 | name = .* 35 | terminal = .* 36 | host = [0-9].* 37 | 38 | # Here the Users activity check is used again with different settings and a different name 39 | [check.LocalUsers] 40 | class = Users 41 | enabled = true 42 | name = .* 43 | terminal = .* 44 | host = localhost 45 | 46 | # Checks to determine the next scheduled wakeup are prefixed with 'wakeup'. 47 | [wakeup.Calendar] 48 | enabled = true 49 | url = http://example.org/test.ics 50 | 51 | # Apart from this, wake up checks reuse the same configuration mechanism. 52 | -------------------------------------------------------------------------------- /data/autosuspend.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=A daemon to suspend your server in case of inactivity 3 | Documentation=https://autosuspend.readthedocs.io/en/latest/systemd_integration.html 4 | After=network.target 5 | 6 | [Service] 7 | ExecStart=/usr/bin/autosuspend -l /etc/autosuspend-logging.conf daemon 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | Also=autosuspend-detect-suspend.service 12 | -------------------------------------------------------------------------------- /doc/source/api.rst: -------------------------------------------------------------------------------- 1 | Python API documentation 2 | ######################## 3 | 4 | In case custom checks are required, the following classes have to be subclassed. 5 | 6 | .. autoclass:: autosuspend.checks.Activity 7 | :members: 8 | :inherited-members: 9 | 10 | .. autoclass:: autosuspend.checks.Wakeup 11 | :members: 12 | :inherited-members: 13 | -------------------------------------------------------------------------------- /doc/source/available_wakeups.rst: -------------------------------------------------------------------------------- 1 | .. _available-wakeups: 2 | 3 | Available wake up checks 4 | ######################## 5 | 6 | The following checks for wake up times are currently implemented. 7 | Each of the checks is described with its available configuration options and required optional dependencies. 8 | 9 | .. _wakeup-calendar: 10 | 11 | Calendar 12 | ******** 13 | 14 | .. program:: wakeup-calendar 15 | 16 | Determines next wake up time from an `iCalendar`_ file. 17 | The next event that starts after the current time is chosen as the next wake up time. 18 | 19 | Remember that updates to the calendar can only be reflected in case the system currently running. 20 | Changes to the calendar made while the system is sleeping will obviously not trigger an earlier wake up. 21 | 22 | Options 23 | ======= 24 | 25 | .. option:: url 26 | 27 | The URL to query for the XML reply. 28 | 29 | .. option:: username 30 | 31 | Optional user name to use for authenticating at a server requiring authentication. 32 | If used, also a password must be provided. 33 | 34 | .. option:: password 35 | 36 | Optional password to use for authenticating at a server requiring authentication. 37 | If used, also a user name must be provided. 38 | 39 | .. option:: xpath 40 | 41 | The XPath query to execute. 42 | Must always return number strings or nothing. 43 | 44 | .. option:: timeout 45 | 46 | Timeout for executed requests in seconds. Default: 5. 47 | 48 | 49 | Requirements 50 | ============ 51 | 52 | * `requests`_ 53 | * `icalendar `_ 54 | * `dateutil`_ 55 | * `tzlocal`_ 56 | 57 | .. _wakeup-command: 58 | 59 | Command 60 | ******* 61 | 62 | .. program:: wakeup-command 63 | 64 | Determines the wake up time by calling an external command 65 | The command always has to succeed. 66 | If something is printed on stdout by the command, this has to be the next wake up time in UTC seconds. 67 | 68 | The command is executed as is using shell execution. 69 | Beware of malicious commands in obtained configuration files. 70 | 71 | Options 72 | ======= 73 | 74 | .. option:: command 75 | 76 | The command to execute including all arguments 77 | 78 | .. _wakeup-file: 79 | 80 | File 81 | **** 82 | 83 | .. program:: wakeup-file 84 | 85 | Determines the wake up time by reading a file from a configured location. 86 | The file has to contains the planned wake up time as an int or float in seconds UTC. 87 | 88 | Options 89 | ======= 90 | 91 | .. option:: path 92 | 93 | path of the file to read in case it is present 94 | 95 | .. _wakeup-periodic: 96 | 97 | Periodic 98 | ******** 99 | 100 | .. program:: wakeup-periodic 101 | 102 | Always schedules a wake up at a specified delta from now on. 103 | 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. 104 | 105 | Options 106 | ======= 107 | 108 | .. option:: unit 109 | 110 | A string indicating in which unit the delta is specified. 111 | Valid options are: ``microseconds``, ``milliseconds``, ``seconds``, ``minutes``, ``hours``, ``days``, ``weeks``. 112 | 113 | .. option:: value 114 | 115 | The value of the delta as an int. 116 | 117 | .. _wakeup-systemd-timer: 118 | 119 | SystemdTimer 120 | ************ 121 | 122 | .. program:: wakeup-systemd-timer 123 | 124 | Ensures that the system is active when a `systemd`_ timer is scheduled to run next. 125 | 126 | Options 127 | ======= 128 | 129 | .. option:: match 130 | 131 | A regular expression selecting the `systemd`_ timers to check. 132 | This expression matches against the names of the timer units, for instance ``logrotate.timer``. 133 | Use ``systemctl list-timers`` to find out which timers exists. 134 | 135 | .. _wakeup-xpath: 136 | 137 | XPath 138 | ***** 139 | 140 | .. program:: wakeup-xpath 141 | 142 | A generic check which queries a configured URL and expects the reply to contain XML data. 143 | The returned XML document is parsed using a configured `XPath`_ expression that has to return timestamps UTC (as strings, not elements). 144 | These are interpreted as the wake up times. 145 | In case multiple entries exist, the soonest one is used. 146 | 147 | Options 148 | ======= 149 | 150 | .. option:: url 151 | 152 | The URL to query for the XML reply. 153 | 154 | .. option:: xpath 155 | 156 | The XPath query to execute. 157 | Must always return number strings or nothing. 158 | 159 | .. option:: timeout 160 | 161 | Timeout for executed requests in seconds. Default: 5. 162 | 163 | .. option:: username 164 | 165 | Optional user name to use for authenticating at a server requiring authentication. 166 | If used, also a password must be provided. 167 | 168 | .. option:: password 169 | 170 | Optional password to use for authenticating at a server requiring authentication. 171 | If used, also a user name must be provided. 172 | 173 | .. _wakeup-xpath-delta: 174 | 175 | XPathDelta 176 | ********** 177 | 178 | .. program:: wakeup-xpath-delta 179 | 180 | 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. 181 | 182 | This check can for instance be used for `tvheadend`_ with the following expression:: 183 | 184 | //recording/next/text() 185 | 186 | Options 187 | ======= 188 | 189 | .. option:: url 190 | 191 | The URL to query for the XML reply. 192 | 193 | .. option:: username 194 | 195 | Optional user name to use for authenticating at a server requiring authentication. 196 | If used, also a password must be provided. 197 | 198 | .. option:: password 199 | 200 | Optional password to use for authenticating at a server requiring authentication. 201 | If used, also a user name must be provided. 202 | 203 | .. option:: xpath 204 | 205 | The XPath query to execute. 206 | Must always return number strings or nothing. 207 | 208 | .. option:: timeout 209 | 210 | Timeout for executed requests in seconds. Default: 5. 211 | 212 | .. option:: unit 213 | 214 | A string indicating in which unit the delta is specified. 215 | Valid options are: ``microseconds``, ``milliseconds``, ``seconds``, ``minutes``, ``hours``, ``days``, ``weeks``. 216 | Default: minutes 217 | -------------------------------------------------------------------------------- /doc/source/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ######### 3 | 4 | |project| follows `Semantic Versioning `_. 5 | Hence, any breaking change to the configuration, command line interface, `systemd`_ 6 | interface, etc. will result in a new major release of |project|. 7 | 8 | .. toctree:: 9 | :maxdepth: 1 10 | 11 | generated_changelog 12 | old_changelog 13 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import os.path 6 | 7 | # needs_sphinx = '1.0' 8 | 9 | extensions = [ 10 | "sphinx.ext.ifconfig", 11 | "sphinx.ext.intersphinx", 12 | "sphinx.ext.napoleon", 13 | "sphinx.ext.autodoc", 14 | "sphinx_autodoc_typehints", 15 | "sphinxcontrib.plantuml", 16 | "sphinx_issues", 17 | "recommonmark", 18 | ] 19 | templates_path = ['_templates'] 20 | source_suffix = '.rst' 21 | 22 | master_doc = 'index' 23 | 24 | project = 'autosuspend' 25 | copyright = '2017, Johannes Wienke' 26 | author = 'Johannes Wienke' 27 | 28 | with open(os.path.join( 29 | os.path.abspath(os.path.dirname(os.path.realpath(__file__))), 30 | '../..', 31 | 'VERSION'), 'r') as version_file: 32 | lines = version_file.readlines() 33 | version = lines[0].strip() 34 | release = lines[1].strip() 35 | 36 | language = "en" 37 | 38 | exclude_patterns = [] 39 | 40 | pygments_style = 'sphinx' 41 | 42 | todo_include_todos = False 43 | 44 | rst_epilog = ''' 45 | .. _autosuspend: https://github.com/languitar/autosuspend 46 | .. _Python 3: https://docs.python.org/3/ 47 | .. _Python: https://docs.python.org/3/ 48 | .. _setuptools: https://setuptools.readthedocs.io 49 | .. _configparser: https://docs.python.org/3/library/configparser.html 50 | .. _psutil: https://github.com/giampaolo/psutil 51 | .. _lxml: http://lxml.de/ 52 | .. _MPD: http://www.musicpd.org/ 53 | .. _python-mpd2: https://pypi.python.org/pypi/python-mpd2 54 | .. _dbus-python: https://cgit.freedesktop.org/dbus/dbus-python/ 55 | .. _Kodi: https://kodi.tv/ 56 | .. _requests: https://pypi.python.org/pypi/requests 57 | .. _systemd: https://www.freedesktop.org/wiki/Software/systemd/ 58 | .. _systemd service files: http://www.freedesktop.org/software/systemd/man/systemd.service.html 59 | .. _broadcast-logging: https://github.com/languitar/broadcast-logging 60 | .. _tvheadend: https://tvheadend.org/ 61 | .. _XPath: https://www.w3.org/TR/xpath/ 62 | .. _logind: https://www.freedesktop.org/wiki/Software/systemd/logind/ 63 | .. _iCalendar: https://tools.ietf.org/html/rfc5545 64 | .. _dateutil: https://dateutil.readthedocs.io 65 | .. _python-icalendar: https://icalendar.readthedocs.io 66 | .. _tzlocal: https://pypi.org/project/tzlocal/ 67 | .. _requests-file: https://github.com/dashea/requests-file 68 | .. _Plex: https://www.plex.tv/ 69 | .. _portalocker: https://portalocker.readthedocs.io 70 | .. _jsonpath-ng: https://github.com/h2non/jsonpath-ng 71 | .. _JSONPath: https://goessner.net/articles/JsonPath/ 72 | .. _pytz: https://pythonhosted.org/pytz/ 73 | 74 | .. |project| replace:: {project} 75 | .. |project_bold| replace:: **{project}** 76 | .. |project_program| replace:: :program:`{project}`'''.format(project=project) 77 | 78 | # Intersphinx 79 | 80 | intersphinx_mapping = {'python': ('https://docs.python.org/3.7', None)} 81 | 82 | # HTML options 83 | 84 | html_theme = 'furo' 85 | # html_theme_options = {} 86 | 87 | # html_static_path = ['_static'] 88 | 89 | html_sidebars = { 90 | } 91 | 92 | # MANPAGE options 93 | 94 | man_pages = [ 95 | ('man_command', 96 | 'autosuspend', 97 | 'autosuspend Documentation', 98 | [author], 99 | 1), 100 | ('man_config', 101 | 'autosuspend.conf', 102 | 'autosuspend config file Documentation', 103 | [author], 104 | 5), 105 | ] 106 | man_show_urls = True 107 | 108 | # issues 109 | issues_github_path = 'languitar/autosuspend' 110 | 111 | # napoleon 112 | napoleon_google_docstring = True 113 | napoleon_numpye_docstring = False 114 | napoleon_include_init_with_doc = True 115 | 116 | typehints_fully_qualified = True 117 | 118 | 119 | def setup(app): 120 | app.add_config_value( 121 | 'is_preview', 122 | os.environ.get('READTHEDOCS_VERSION', '') == 'latest', 123 | 'env', 124 | ) 125 | -------------------------------------------------------------------------------- /doc/source/configuration_file.inc: -------------------------------------------------------------------------------- 1 | Syntax 2 | ~~~~~~ 3 | 4 | The |project_program| configuration file uses INI syntax and needs to be processable by the Python `configparser`_ module. 5 | 6 | A simple configuration file could look like: 7 | 8 | .. code-block:: ini 9 | 10 | [general] 11 | interval = 30 12 | idle_time = 900 13 | suspend_cmd = /usr/bin/systemctl suspend 14 | wakeup_cmd = echo {timestamp:.0f} > /sys/class/rtc/rtc0/wakealarm 15 | notify_cmd_wakeup = su myuser -c notify-send -a autosuspend 'Suspending the system. Wake up at {iso}' 16 | notify_cmd_no_wakeup = su myuser -c notify-send -a autosuspend 'Suspending the system.' 17 | lock_file = /var/lock/autosuspend.lock 18 | lock_timeout = 30 19 | 20 | [check.Ping] 21 | enabled = false 22 | hosts = 192.168.0.7 23 | 24 | [check.RemoteUsers] 25 | class = Users 26 | enabled = true 27 | name = .* 28 | terminal = .* 29 | host = [0-9].* 30 | 31 | [wakeup.File] 32 | enabled = True 33 | path = /var/run/autosuspend/wakeup 34 | 35 | The configuration file consists of a ``[general]`` section, which specifies general processing options, and multiple sections of the format ``[check.*]`` and ``[wakeup.*]``. 36 | These sections describe the activity and wake up checks to execute. 37 | 38 | General configuration 39 | ~~~~~~~~~~~~~~~~~~~~~ 40 | 41 | .. program:: config-general 42 | 43 | The ``[general]`` section contains options controlling the overall behavior of the |project_program| daemon. These are: 44 | 45 | .. option:: interval 46 | 47 | The time to wait after executing all checks in seconds. 48 | 49 | .. option:: idle_time 50 | 51 | The required amount of time in seconds with no detected activity before the host will be suspended. 52 | Default: 300 seconds 53 | 54 | .. option:: min_sleep_time 55 | 56 | The minimal amount of time in seconds the system has to sleep for actually triggering suspension. 57 | If a scheduled wake up results in an effective time below this value, the system will not sleep. 58 | Default: 1200 seconds 59 | 60 | .. option:: wakeup_delta 61 | 62 | 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. 63 | This value adds a safety margin for the time a the wake up effectively takes. 64 | Default: 30 seconds 65 | 66 | .. option:: suspend_cmd 67 | 68 | The command to execute in case the host shall be suspended. 69 | This line can contain additional command line arguments to the command to execute. 70 | 71 | .. option:: wakeup_cmd 72 | 73 | The command to execute for scheduling a wake up of the system. 74 | The given string is processed using Python's :meth:`str.format` and a format argument called ``timestamp`` encodes the UTC timestamp of the planned wake up time (float). 75 | Additionally ``iso`` can be used to acquire the timestamp in ISO 8601 format. 76 | 77 | .. option:: notify_cmd_wakeup 78 | 79 | A command to execute before the system is going to suspend for the purpose of notifying interested clients. 80 | This command is only called in case a wake up is scheduled. 81 | The given string is processed using Python's :meth:`str.format` and a format argument called ``timestamp`` encodes the UTC timestamp of the planned wake up time (float). 82 | Additionally ``iso`` can be used to acquire the timestamp in ISO 8601 format. 83 | If empty or not specified, no command will be called. 84 | 85 | .. option:: notify_cmd_no_wakeup 86 | 87 | A command to execute before the system is going to suspend for the purpose of notifying interested clients. 88 | This command is only called in case NO wake up is scheduled. 89 | Hence, no string formatting options are available. 90 | If empty or not specified, no command will be called. 91 | 92 | .. option:: woke_up_file 93 | 94 | Location of a file that indicates to |project_program| that the computer has suspended since the last time checks were executed. 95 | This file is usually created by a `systemd`_ service. 96 | Thus, changing the location also requires adapting the respective service. 97 | Refer to :ref:`systemd-integration` for further details. 98 | 99 | .. option:: lock_file 100 | 101 | Location of a file that is used to synchronize the continuously running daemon and the systemd callback. 102 | 103 | .. option:: lock_timeout 104 | 105 | Timeout in seconds used when trying to acquire the lock. 106 | This should be longer than the maximum run time of all configured checks. 107 | In the worst cases, suspending the system is delayed by this amount of time because ``presuspend`` hook has to wait before all checks have passed. 108 | 109 | Activity check configuration 110 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 111 | 112 | .. program:: config-check 113 | 114 | For each activity check to execute, a section with the name format ``[check.*]`` needs to be created. 115 | Each check has a name and an executing class which implements the behavior. 116 | The fraction of the section name ``check.`` determines the name, and in case no class option is given inside the section, also the class which implements the check. 117 | In case the :option:`class` option is specified, the name is completely user-defined and the same check can even be instantiated multiple times with differing names. 118 | 119 | For each check, these generic options can be specified: 120 | 121 | .. option:: class 122 | 123 | Name of the class implementing the check. 124 | If the name does not contain a dot (``.``), this is assumed to be one of the checks provided by |project| internally. 125 | Otherwise, this can be used to pull in third-party checks. 126 | If this option is not specified, the section name must represent a valid internal check class. 127 | 128 | .. option:: enabled 129 | 130 | Needs to be ``true`` for a check to actually execute. 131 | ``false`` is assumed if not specified. 132 | 133 | Furthermore, each check might have custom options. 134 | 135 | Wake up check configuration 136 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 137 | 138 | Wake up checks uses the same configuration logic as the previously described activity checks. 139 | However, the configuration file sections start with ``wakeup.`` instead of ``check.``. 140 | -------------------------------------------------------------------------------- /doc/source/configuration_file.rst: -------------------------------------------------------------------------------- 1 | Configuration file 2 | ################## 3 | 4 | .. include:: configuration_file.inc 5 | 6 | For options of individual checks, please refer to :ref:`available-checks` and :ref:`available-wakeups`. 7 | -------------------------------------------------------------------------------- /doc/source/debugging.rst: -------------------------------------------------------------------------------- 1 | Debugging 2 | ######### 3 | 4 | In case you need to track configuration issues to understand why a system suspends or does not, the extensive logging output of |project_program| might be used. 5 | Each iteration of the daemon logs exactly which condition detected activity or not. 6 | So you should be able to find out what is going on. 7 | The command line flag :option:`autosuspend -l` allows specifying a Python logging configuration file which specifies what to log. 8 | The provided `systemd`_ service files (see :ref:`systemd-integration`) already use :file:`/etc/autosuspend-logging.conf` as the standard location and a default file is usually installed. 9 | If you launch |project_program| manually from the console, the command line flag :option:`autosuspend -d` might also be used to get full logging to the console instead. 10 | 11 | In case one of the conditions you monitor prevents suspending the system if an external connection is established (logged-in users, open TCP port), then the logging configuration file can be changed to use the `broadcast-logging`_ package. 12 | This way, the server will broadcast new log messages on the network and external clients on the same network can listen to these messages without creating an explicit connection. 13 | Please refer to the documentation of the `broadcast-logging`_ package on how to enable and use it. 14 | Additionally, one might also examine the ``journalctl`` for |project_program| after the fact. 15 | -------------------------------------------------------------------------------- /doc/source/description.inc: -------------------------------------------------------------------------------- 1 | |project_program| is a daemon that periodically suspends a system on inactivity and wakes it up again automatically in case it is needed. 2 | For this purpose, |project_program| periodically iterates a number of user-configurable activity checks, which indicate whether an activity on the host is currently present that should prevent the host from suspending. 3 | In case one of the checks indicates such activity, no action is taken and periodic checking continues. 4 | Otherwise, in case no activity can be detected, this state needs to be present for a specified amount of time before the host is suspended by |project_program|. 5 | In addition to the activity checks, wake up checks are used to determine planned future activities of the system (for instance, a TV recording or a periodic backup). 6 | In case such activities are known before suspending, |project_program| triggers a command to wake up the system automatically before the soonest activity. 7 | -------------------------------------------------------------------------------- /doc/source/external_command_activity_scripts.rst: -------------------------------------------------------------------------------- 1 | .. _external-command-activity-scripts: 2 | 3 | External command scripts for activity detection 4 | ############################################### 5 | 6 | A collection of user-provided scripts to use with the :ref:`check-external-command` check for activity detection. 7 | 8 | pyLoad 9 | ****** 10 | 11 | `pyLoad `_ uses an uncommon login theme for its API and hence two separate requests are required to query for active downloads. 12 | Use something along the following lines to query `pyLoad`_. 13 | 14 | .. code-block:: bash 15 | 16 | #!/bin/bash 17 | 18 | SessionID=$(curl -s "http://127.0.0.1:8000/api/login" -g -H "Host: 127.0.0.1:8000" -H "Content-Type: application/x-www-form-urlencoded" --data "username=user&password=password" | jq -r) 19 | 20 | SessionStatus=$(curl -s "http://127.0.0.1:8000/api/statusServer" -g -H "Host: 127.0.0.1:8000" -H "Content-Type: application/x-www-form-urlencoded" --data "session=$SessionID" | jq -r '.active') 21 | 22 | if [ $SessionStatus -eq 1 ] 23 | then 24 | exit 0 25 | else 26 | exit 1 27 | fi 28 | 29 | Source: :issue:`102` 30 | -------------------------------------------------------------------------------- /doc/source/faq.rst: -------------------------------------------------------------------------------- 1 | .. _faq: 2 | 3 | Frequently Asked Questions 4 | ########################## 5 | 6 | Usage 7 | ***** 8 | 9 | How to check unsupported software? 10 | ================================== 11 | 12 | In case you want to detect if some piece of software running on your system that is not officially supported is performing relevant activity you have two options: 13 | 14 | * Use a script with the :ref:`check-external-command` check. 15 | 16 | * Implement a Python module with you check being a subclass of 17 | :class:`autosuspend.checks.Activity` or 18 | :class:`autosuspend.checks.Wakeup` and install it alongside |project|. 19 | The custom check class can then be referenced in the config with its full dotted path, for instance, ``mymodule.MyCheck``, in the `class` field. 20 | 21 | How do I wake up my system if needed? 22 | ===================================== 23 | 24 | |project_bold| itself only handles wake ups for events that were foreseeable at the time the system was put into sleep mode. 25 | In case the system also has to be used on-demand, a simple way to wake up the system is to enable `Wake on LAN `_. 26 | Here, a special network packet can be used to wake up the system again. 27 | Multiple front-ends exist to send these magic packets. 28 | The typical usage scenario with this approach is to manually send the magic packet when the system is needed, wait a few seconds, and then to perform the intended tasks with the system. 29 | 30 | Wake on LAN needs to be specifically enabled on the system. 31 | Typically, the documentation of common Linux distributions explains how to enable Wake on LAN: 32 | 33 | * `Archlinux `__ 34 | * `Debian `__ 35 | * `Ubuntu `__ 36 | 37 | A set of front-ends for various platforms allows sending the magic packets. 38 | For instance: 39 | 40 | * `gWakeOnLan `__: GTK GUI, Linux 41 | * `wol `__: command line, Linux 42 | * `Wake On Lan `__: GUI, Windows 43 | * `Wake On Lan `__: Android 44 | * `Wake On Lan `__: Android, open-source 45 | * `Kore (Kodi remote control) `__: Android, for Kodi users 46 | * `Mocha WOL `__: iOS 47 | 48 | How do I keep a system active at daytime 49 | ======================================== 50 | 51 | Imagine you want to have a NAS that is always available between 7 a.m. and 8 p.m. 52 | After 8 p.m. the system should go to sleep in case no one else is using it. 53 | Every morning at 7 a.m. it should wake up automatically. 54 | This workflow can be realized using the :ref:`wakeup-calendar` wakeup check and the :ref:`check-active-calendar-event` activity check based on an `iCalendar`_ file residing on the local file system of the NAS. 55 | The former check ensures that the system wakes up at the desired time of the day while the latter ensure that it stays active at daytime. 56 | 57 | The first step is to create the `iCalendar`_ file, which can conveniently and graphically be edited with `Thunderbird Lightning `_ or any other calendar frontend. 58 | Essentially, the ``*.ics`` may look like this:: 59 | 60 | BEGIN:VCALENDAR 61 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 62 | VERSION:2.0 63 | BEGIN:VEVENT 64 | CREATED:20180602T151701Z 65 | LAST-MODIFIED:20180602T152732Z 66 | DTSTAMP:20180602T152732Z 67 | UID:0ef23894-702e-40ac-ab09-94fa8c9c51fd 68 | SUMMARY:keep active 69 | RRULE:FREQ=DAILY 70 | DTSTART:20180612T070000 71 | DTEND:20180612T200000 72 | TRANSP:OPAQUE 73 | SEQUENCE:3 74 | END:VEVENT 75 | END:VCALENDAR 76 | 77 | Afterwards, edit ``autosuspend.conf`` to contain the two aforementioned checks based on the created ``ics`` file. 78 | This will end up with at least this config: 79 | 80 | .. code-block:: ini 81 | 82 | [general] 83 | interval = 30 84 | suspend_cmd = /usr/bin/systemctl suspend 85 | wakeup_cmd = echo {timestamp:.0f} > /sys/class/rtc/rtc0/wakealarm 86 | woke_up_file = /var/run/autosuspend-just-woke-up 87 | 88 | [check.ActiveCalendarEvent] 89 | enabled = true 90 | url = file:///path/to/your.ics 91 | 92 | [wakeup.Calendar] 93 | enabled = true 94 | url = file:///path/to/your.ics 95 | 96 | Adding other activity checks will ensure that the system stays awake event after 8 p.m. if it is still used. 97 | 98 | Error messages 99 | ************** 100 | 101 | No connection adapters were found for '\file://\*' 102 | ================================================== 103 | 104 | You need to install the `requests-file`_ package for ``file://`` URIs to work. 105 | -------------------------------------------------------------------------------- /doc/source/generated_changelog.md: -------------------------------------------------------------------------------- 1 | # [7.2.0](https://github.com/languitar/autosuspend/compare/v7.1.0...v7.2.0) (2025-02-23) 2 | 3 | 4 | ### Features 5 | 6 | * **systemd:** automatically enable/disable suspend hook ([772797a](https://github.com/languitar/autosuspend/commit/772797a8e4d9b3db7f609d20e73a0e66805ba2f1)), closes [#625](https://github.com/languitar/autosuspend/issues/625) 7 | 8 | # [7.1.0](https://github.com/languitar/autosuspend/compare/v7.0.3...v7.1.0) (2025-01-12) 9 | 10 | 11 | ### Features 12 | 13 | * official Python 3.13 support ([a8ea72d](https://github.com/languitar/autosuspend/commit/a8ea72d414621a13ff7705330bd731ffe94eeef8)) 14 | 15 | ## [7.0.3](https://github.com/languitar/autosuspend/compare/v7.0.2...v7.0.3) (2024-11-19) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * treat temporary failures as activity ([8c96853](https://github.com/languitar/autosuspend/commit/8c968530f011dad814df8c55794b058f3c751e8d)), closes [#589](https://github.com/languitar/autosuspend/issues/589) 21 | 22 | ## [7.0.2](https://github.com/languitar/autosuspend/compare/v7.0.1...v7.0.2) (2024-10-13) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * **icalendar:** support icalendar v6 ([49bc89f](https://github.com/languitar/autosuspend/commit/49bc89fb2461758dd4d4f07016e88c8458192161)) 28 | 29 | ## [7.0.1](https://github.com/languitar/autosuspend/compare/v7.0.0...v7.0.1) (2024-09-22) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * **kodi-idle-time:** Send proper request ([8bb6dad](https://github.com/languitar/autosuspend/commit/8bb6dad7f325024d011008fc1f0e3d52a0b9f222)) 35 | 36 | # [7.0.0](https://github.com/languitar/autosuspend/compare/v6.1.1...v7.0.0) (2024-04-25) 37 | 38 | 39 | * build!: drop Python 3.9 support ([3c4ae32](https://github.com/languitar/autosuspend/commit/3c4ae32c8e52f022f41e94c3a49dd89b9d02dcf2)) 40 | 41 | 42 | ### Features 43 | 44 | * officially support Python 3.12 ([de2f180](https://github.com/languitar/autosuspend/commit/de2f18010d166eb86fe15665aa7769f2105b02aa)) 45 | 46 | 47 | ### BREAKING CHANGES 48 | 49 | * Python 3.9 is not supported officially anymore. Python 50 | 3.10 is the supported minimum version. 51 | 52 | ## [6.1.1](https://github.com/languitar/autosuspend/compare/v6.1.0...v6.1.1) (2024-02-12) 53 | 54 | 55 | ### Bug Fixes 56 | 57 | * **docs:** add missing docs for new version subcommand ([fb248f7](https://github.com/languitar/autosuspend/commit/fb248f7a5706f81c20f7e88907e22cbd5c895cbb)) 58 | 59 | # [6.1.0](https://github.com/languitar/autosuspend/compare/v6.0.0...v6.1.0) (2024-02-11) 60 | 61 | 62 | ### Features 63 | 64 | * **cli:** provide a version subcommand ([d51d836](https://github.com/languitar/autosuspend/commit/d51d836564a53b0dd5017fcd801e43b117542ebc)), closes [#482](https://github.com/languitar/autosuspend/issues/482) 65 | 66 | # [6.0.0](https://github.com/languitar/autosuspend/compare/v5.0.0...v6.0.0) (2023-09-18) 67 | 68 | 69 | * build!: modernize supported Python version ([31c8ccc](https://github.com/languitar/autosuspend/commit/31c8cccb503218691ffb045142b1297133ce5340)) 70 | 71 | 72 | ### BREAKING CHANGES 73 | 74 | * Python 3.8 has been deprecated and is not officially 75 | supported anymore. 76 | 77 | # [5.0.0](https://github.com/languitar/autosuspend/compare/v4.3.3...v5.0.0) (2023-08-13) 78 | 79 | 80 | * feat(logind)!: configure which session classes to process ([986e558](https://github.com/languitar/autosuspend/commit/986e558c2913bf30ebbab87025fe9722d5997aa7)), closes [#366](https://github.com/languitar/autosuspend/issues/366) 81 | 82 | 83 | ### BREAKING CHANGES 84 | 85 | * LogindSessionIdle now only processes sessions of type 86 | "user" by default. Use the new configuration option classes to also 87 | include other types in case you need to include them in the checks. 88 | 89 | ## [4.3.3](https://github.com/languitar/autosuspend/compare/v4.3.2...v4.3.3) (2023-08-10) 90 | 91 | 92 | ### Bug Fixes 93 | 94 | * **systemd:** handle timers without next execution time ([9fb83ea](https://github.com/languitar/autosuspend/commit/9fb83eac7d6cbe981e2ebfc1ec3c3b54fca19804)), closes [#403](https://github.com/languitar/autosuspend/issues/403) 95 | 96 | ## [4.3.2](https://github.com/languitar/autosuspend/compare/v4.3.1...v4.3.2) (2023-06-05) 97 | 98 | 99 | ### Bug Fixes 100 | 101 | * release for sphinx 7 support ([569dfa5](https://github.com/languitar/autosuspend/commit/569dfa5954617929ae11529ece84f32810e10bee)) 102 | 103 | ## [4.3.1](https://github.com/languitar/autosuspend/compare/v4.3.0...v4.3.1) (2023-05-16) 104 | 105 | 106 | ### Bug Fixes 107 | 108 | * **ical:** support all versions of tzlocal ([9eb0b95](https://github.com/languitar/autosuspend/commit/9eb0b9549e11b612d47d007777cb83eac4c53f31)) 109 | 110 | # [4.3.0](https://github.com/languitar/autosuspend/compare/v4.2.0...v4.3.0) (2022-12-08) 111 | 112 | 113 | ### Features 114 | 115 | * add seconds since the system became idle to logs ([cba13db](https://github.com/languitar/autosuspend/commit/cba13db8c50a5fbab05447c3f6ce74cf85898100)), closes [#281](https://github.com/languitar/autosuspend/issues/281) 116 | 117 | # [4.2.0](https://github.com/languitar/autosuspend/compare/v4.1.1...v4.2.0) (2022-07-24) 118 | 119 | 120 | ### Features 121 | 122 | * **wakeup:** add a systemd timer wakeup check ([7c687a2](https://github.com/languitar/autosuspend/commit/7c687a23f705d46c65ef400332483a32ff6eaa79)) 123 | 124 | ## [4.1.1](https://github.com/languitar/autosuspend/compare/v4.1.0...v4.1.1) (2022-03-10) 125 | 126 | 127 | ### Bug Fixes 128 | 129 | * allow tzlocal version >= 4 ([58e8634](https://github.com/languitar/autosuspend/commit/58e8634347cc5bf25cbfbfccfe874d05420bb995)) 130 | 131 | # [4.1.0](https://github.com/languitar/autosuspend/compare/v4.0.1...v4.1.0) (2021-12-28) 132 | 133 | 134 | ### Features 135 | 136 | * add official Python 3.10 support ([e5b2e49](https://github.com/languitar/autosuspend/commit/e5b2e494986d13ac29a06cfac0c5a6601c372671)) 137 | 138 | ## [4.0.1](https://github.com/languitar/autosuspend/compare/v4.0.0...v4.0.1) (2021-10-26) 139 | 140 | 141 | ### Bug Fixes 142 | 143 | * **activity:** detect ipv4 mapped ipv6 connections ([a81e456](https://github.com/languitar/autosuspend/commit/a81e456aa89737a0a2f03ec5af5ffaf2e7738073)), closes [#116](https://github.com/languitar/autosuspend/issues/116) 144 | 145 | # [4.0.0](https://github.com/languitar/autosuspend/compare/v3.1.4...v4.0.0) (2021-09-20) 146 | 147 | 148 | * chore(build)!: drop tests on Python 3.7 ([06dce98](https://github.com/languitar/autosuspend/commit/06dce98882d5c8fa4d5e90623660c43d006eefa0)) 149 | 150 | 151 | ### BREAKING CHANGES 152 | 153 | * Python 3.7 isn't used anymore on any LTS Ubuntu or 154 | Debian release. No need to support such an old version anymore. 155 | 156 | ## [3.1.4](https://github.com/languitar/autosuspend/compare/v3.1.3...v3.1.4) (2021-09-20) 157 | 158 | 159 | ### Bug Fixes 160 | 161 | * **ical:** limit tzlocal to version <3 ([623cd37](https://github.com/languitar/autosuspend/commit/623cd371df03a6fe3305eca4cf9e57c4d76b5c8a)) 162 | 163 | ## [3.1.3](https://github.com/languitar/autosuspend/compare/v3.1.2...v3.1.3) (2021-03-29) 164 | 165 | ## [3.1.2](https://github.com/languitar/autosuspend/compare/v3.1.1...v3.1.2) (2021-03-29) 166 | 167 | ## [3.1.1](https://github.com/languitar/autosuspend/compare/v3.1.0...v3.1.1) (2021-03-28) 168 | 169 | 170 | ### Bug Fixes 171 | 172 | * fix automatic version file generation ([aeb601d](https://github.com/languitar/autosuspend/commit/aeb601d523791780e5da592476b365bbc4b3f4c5)) 173 | 174 | ## [3.1.0](https://github.com/languitar/autosuspend/compare/v3.0.1...v3.1.0) (2021-03-28) 175 | 176 | 177 | ### Features 178 | 179 | * add semantic-release for automatic releases ([ac5ec86](https://github.com/languitar/autosuspend/commit/ac5ec8617681b537714f8eb8fef4ce0872989f2a)) 180 | 181 | 182 | ### Bug Fixes 183 | 184 | * use jsonpath ext to support filter expressions ([24d1be1](https://github.com/languitar/autosuspend/commit/24d1be1fcbd59d8e29a1bbfdc162e253e2f239c4)), closes [#102](https://github.com/languitar/autosuspend/issues/102) 185 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | |project| - a daemon to automatically suspend and wake up a system 2 | ################################################################## 3 | 4 | .. ifconfig:: is_preview 5 | 6 | .. warning:: 7 | 8 | This is the documentation for an unreleased preview version of |project|. 9 | 10 | .. include:: description.inc 11 | 12 | The following diagram visualizes the periodic processing performed by |project|. 13 | 14 | .. uml:: 15 | 16 | @startuml 17 | 18 | skinparam shadowing false 19 | skinparam backgroundcolor #eeeeee 20 | 21 | skinparam Padding 8 22 | 23 | skinparam ActivityBackgroundColor #FFFFFF 24 | skinparam ActivityDiamondBackgroundColor #FFFFFF 25 | skinparam ActivityBorderColor #333333 26 | skinparam ActivityDiamondBorderColor #333333 27 | skinparam ArrowColor #333333 28 | 29 | start 30 | 31 | :Execute activity checks; 32 | 33 | if (Is the system active?) then (no) 34 | 35 | if (Was the system idle before?) then (no) 36 | :Remember current time as start of system inactivity; 37 | else (yes) 38 | endif 39 | 40 | if (Is system idle long enough?) then (yes) 41 | 42 | :Execute wake up checks; 43 | 44 | if (Is a wake up required soon?) then (yes) 45 | stop 46 | else 47 | if (Is any wake up required?) then (yes) 48 | #BBFFBB:Schedule the earliest wake up; 49 | else (no) 50 | endif 51 | endif 52 | 53 | #BBFFBB:Suspend the system; 54 | 55 | else (no) 56 | stop 57 | endif 58 | 59 | else (yes) 60 | :Forget start of system inactivity; 61 | stop 62 | endif 63 | 64 | stop 65 | 66 | @enduml 67 | 68 | 69 | .. toctree:: 70 | :maxdepth: 2 71 | :caption: Usage 72 | 73 | installation 74 | options 75 | configuration_file 76 | available_checks 77 | available_wakeups 78 | systemd_integration 79 | external_command_activity_scripts 80 | api 81 | 82 | .. toctree:: 83 | :maxdepth: 2 84 | :caption: Support 85 | 86 | faq 87 | debugging 88 | support 89 | changelog 90 | 91 | Indices and tables 92 | ################## 93 | 94 | * :ref:`genindex` 95 | -------------------------------------------------------------------------------- /doc/source/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation instructions 4 | ######################### 5 | 6 | |project_program| is designed for Python **3** and does not work with Python 2. 7 | 8 | .. note:: 9 | 10 | After installation, do not forget to enable and start |project| vis `systemd`_ as described in :ref:`systemd-integration`. 11 | 12 | Requirements 13 | ************ 14 | 15 | The minimal requirements are. 16 | 17 | * `Python 3`_ >= 3.7 18 | * `psutil`_ 19 | * `portalocker`_ 20 | 21 | Additionally, the some checks need further dependencies to function properly. 22 | Please refer to :ref:`available-checks` for individual requirements. 23 | 24 | If checks using URLs to load data should support ``file://`` URLs, `requests-file`_ is needed. 25 | 26 | Binary packages 27 | *************** 28 | 29 | .. image:: https://repology.org/badge/vertical-allrepos/autosuspend.svg 30 | :target: https://repology.org/project/autosuspend/versions 31 | 32 | Debian 33 | ====== 34 | 35 | Installation from official package sources:: 36 | 37 | apt-get install autosuspend 38 | 39 | Archlinux (AUR) 40 | =============== 41 | 42 | |project| is available as an `Archlinux AUR package `_. 43 | 44 | Installation via some `AUR helpers ` such as :program:`paru`:: 45 | 46 | paru -S autosuspend 47 | 48 | Other AUR helpers may be used, too. 49 | 50 | Gentoo 51 | ====== 52 | 53 | Patrick Holthaus has provided an ebuild for Gentoo in `his overlay `_. 54 | You can use it as follows:: 55 | 56 | eselect repository enable pholthaus-overlay 57 | emaint sync -r pholthaus-overlay 58 | emerge sys-apps/autosuspend 59 | 60 | Other distributions 61 | =================== 62 | 63 | In case you want to generate a package for a different Linux distribution, I'd be glad to hear about that. 64 | 65 | Manual installation 66 | ******************* 67 | 68 | |project_program| is a usual Python_ package and hence can be installed using the common Python_ packaging tools. 69 | Briefly, the following steps can be used to install |project_program| from source in a system-wide location (as ``root`` user): 70 | 71 | .. code-block:: bash 72 | 73 | python3 -m venv /opt/autosuspend 74 | /opt/autosuspend/bin/pip install git+https://github.com/languitar/autosuspend.git@#egg=autosuspend[all] 75 | 76 | .. note:: 77 | 78 | Replace the angle brackets with desired Git tag or branch. 79 | Use ``main`` for the latest development release. 80 | 81 | .. note:: 82 | 83 | The ``all`` in the square brackets ensures that |project_program| is installed with all optional dependencies. 84 | That way all available checks can be used. 85 | In case you only need a subset of optional requirements, replace ``all`` with a comma-separated list of package extras. 86 | The names of these extras can be found in :file:`setup.py`. 87 | 88 | Afterwards, copy the systemd_ unit files found in ``/opt/autosuspend/lib/systemd/system/`` to ``/etc/systemd`` and adapt the contained paths to the installation location. 89 | -------------------------------------------------------------------------------- /doc/source/man_command.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. _man-command: 4 | 5 | |project| 6 | ######### 7 | 8 | Synopsis 9 | ******** 10 | 11 | |project_bold| [*options*] **daemon|presuspend|version** [*subcommand options*] 12 | 13 | Description 14 | *********** 15 | 16 | .. include:: description.inc 17 | 18 | If not specified via a command line argument, |project_program| looks for a default configuration at :file:`/etc/autosuspend.conf`. 19 | :manpage:`autosuspend.conf(5)` describes the configuration file, the available checks, and their configuration options. 20 | 21 | Options 22 | ******* 23 | 24 | .. toctree:: 25 | 26 | options 27 | 28 | Bugs 29 | **** 30 | 31 | Please report bugs at the project repository at https://github.com/languitar/autosuspend. 32 | 33 | See also 34 | ******** 35 | 36 | :manpage:`autosuspend.conf(5)`, online documentation including FAQs at https://autosuspend.readthedocs.io/ 37 | -------------------------------------------------------------------------------- /doc/source/man_config.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | |project|.conf 4 | ############## 5 | 6 | Synopsis 7 | ******** 8 | 9 | :file:`/etc/autosuspend.conf` 10 | 11 | General Configuration 12 | ********************* 13 | 14 | Configures the |project_program| daemon. 15 | 16 | .. toctree:: 17 | configuration_file 18 | 19 | Available Activity Check 20 | ************************ 21 | 22 | .. toctree:: 23 | available_checks 24 | 25 | Available Wakeup Check 26 | ********************** 27 | 28 | .. toctree:: 29 | available_wakeups 30 | -------------------------------------------------------------------------------- /doc/source/old_changelog.rst: -------------------------------------------------------------------------------- 1 | 3.1 2 | *** 3 | 4 | New features 5 | ============ 6 | 7 | New activity checks 8 | ------------------- 9 | 10 | * :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`). 11 | * :ref:`check-last-log-activity`: Check log files for most recent contained timestamps (:issue:`98`, :issue:`99`). 12 | 13 | Fixed bugs 14 | ========== 15 | 16 | * Connection errors are now properly handled by :ref:`check-mpd` (:issue:`77`). 17 | 18 | Notable changes 19 | =============== 20 | 21 | * The required `Python`_ version is now declared in the :ref:`installation` and :file:`setup.py` (:issue:`76`) 22 | * Python 3.9 is officially supported and tested (:issue:`89`). 23 | * Some code cleanup work has been performed (:issue:`93` and :issue:`92`). 24 | * The daemon now better distinguished between temporary and permanent issues, for instance, by terminating in case a required program is not installed (:issue:`78`). 25 | 26 | 3.0 27 | *** 28 | 29 | This version splits the executable into two distinct subcommands, one for activity checking and one for scheduling wake ups. 30 | This way, the wake up scheduling mechanism can be hooked into system tools such as `systemd`_ to ensure that wake ups are scheduled correctly every time the system suspends. 31 | This increases the reliability of the mechanism but also changes the way |project_program| has to be called. 32 | You now need to enable two `systemd`_ units as describe in :ref:`systemd-integration` and the command line interface has changed. 33 | 34 | New features 35 | ============ 36 | 37 | * The :ref:`check-kodi-idle-time` activity check can now be parameterized whether to indicate activity on a paused player or not (:issue:`59`, :issue:`60`). 38 | * New structure as described above in the version introduction (:issue:`43`). 39 | 40 | Fixed bugs 41 | ========== 42 | 43 | * Documented default URL for the ``Kodi*`` checks did not actually exist in code, which has been fixed now (:issue:`58`, :issue:`61`). 44 | * A bug in :ref:`check-logind-session-idle` has been fixed (:issue:`71`, :issue:`72`). 45 | 46 | Notable changes 47 | =============== 48 | 49 | * The executable now uses subcommands. 50 | The previous behavior as a long-running daemon is now available under the ``daemon`` subcommand. 51 | * The command line flags for logging have changed. 52 | The previous ``-l`` flag, which combined boolean behavior and file reading, has been split into two distinct flags: ``-d`` is a boolean switch to enable full debug logging to console, whereas the old ``-l`` is now only used for reading logging configuration files. 53 | This change prevents nasty subtleties and issues when parsing the command line and became mandatory to support subcommands after the general configuration arguments such as logging. 54 | * Dropped support for Python 3.6 and included Python 3.8 in CI infrastructure. 55 | Everything works on Python 3.8. 56 | * The documentation has been restructured and improved. For instance, there is now a :ref:`faq` section. 57 | * Some build and test dependencies have changed. 58 | * CI-builds have been converted to Github Actions. 59 | 60 | 2.0.4 61 | ***** 62 | 63 | This is a minor bug fix release. 64 | 65 | Fixed bugs 66 | ========== 67 | 68 | * :ref:`check-active-connection` did not handle local IPv6 addresses with scope such as ``fe80::5193:518c:5c69:aedb%enp3s0`` (:issue:`50`) 69 | 70 | 2.0.3 71 | ***** 72 | 73 | This is a minor bug fix release. 74 | 75 | Fixed bugs 76 | ========== 77 | 78 | * :ref:`check-network-bandwidth` did not update its internal state and therefore did not work as documented (:issue:`49`) 79 | 80 | 2.0.2 81 | ***** 82 | 83 | This is a minor bug fix release. 84 | 85 | Fixed bugs 86 | ========== 87 | 88 | * :ref:`check-kodi` and :ref:`check-kodi-idle-time` checks now catch ``JSONDecodeErrors`` (:issue:`45`) 89 | * :ref:`check-kodi` and :ref:`check-kodi-idle-time` checks now support authentication (:issue:`47`) 90 | 91 | 2.0 92 | *** 93 | 94 | This version adds scheduled wake ups as its main features. 95 | In addition to checks for activity, a set of checks for future activities can now be configured to determine times at which the systems needs to be online again. 96 | The daemon will start suspending in case the next detected wake up time is far enough in the future and schedule an automatic system wake up at the closest determined wake up time. 97 | This can, for instance, be used to ensure that the system is up again when a TV show has to be recorded to disk. 98 | 99 | Below is a detailed list of notable changes. 100 | 101 | New features 102 | ============ 103 | 104 | * Scheduled wake ups (:issue:`9`). 105 | * Ability to call configurable user commands before suspending for notification purposes (:issue:`25`). 106 | * Checks using network requests now support authentication (:issue:`32`). 107 | * Checks using network requests now support ``file://`` URIs (:issue:`36`). 108 | 109 | New activity checks 110 | ------------------- 111 | 112 | * :ref:`check-active-calendar-event`: Uses an `iCalendar`_ file (via network request) to prevent suspending in case an event in the calendar is currently active (:issue:`24`). 113 | * :ref:`check-kodi-idle-time`: Checks the idle time of `Kodi`_ to prevent suspending in case the menu is used (:issue:`33`). 114 | 115 | New wakeup checks 116 | ----------------- 117 | 118 | * :ref:`wakeup-calendar`: Wake up the system at the next event in an `iCalendar`_ file (requested via network, :issue:`30`). 119 | * :ref:`wakeup-command`: Call an external command to determine the next wake up time (:issue:`26`). 120 | * :ref:`wakeup-file`: Read the next wake up time from a file (:issue:`9`). 121 | * :ref:`wakeup-periodic`: Wake up at a defined interval, for instance, to refresh calendars for the :ref:`wakeup-calendar` check (:issue:`34`). 122 | * :ref:`wakeup-xpath` and :ref:`wakeup-xpath-delta`: Request an XML document and use `XPath`_ to extract the next wakeup time. 123 | 124 | Fixed bugs 125 | ========== 126 | 127 | * `XPath`_ checks now support responses with explicit encodings (:issue:`29`). 128 | 129 | Notable changes 130 | =============== 131 | 132 | * The namespace of the logging systems has been rearranged (:issue:`38`). 133 | Existing logging configurations might require changes. 134 | * The default configuration file has been reduced to explain the syntax and semantics. 135 | For a list of all available checks, refer the manual instead (:issue:`39`). 136 | 137 | For a complete list of all addressed issues and new features, please refer to the respective `Github milestone `_. 138 | 139 | -------------------------------------------------------------------------------- /doc/source/options.rst: -------------------------------------------------------------------------------- 1 | Command line options 2 | #################### 3 | 4 | General syntax: 5 | 6 | |project_bold| [*options*] **daemon|presuspend|version** [*subcommand options*] 7 | 8 | General options 9 | *************** 10 | 11 | .. program:: autosuspend 12 | 13 | .. option:: -h, --help 14 | 15 | Displays an online help. 16 | 17 | .. option:: -c FILE, --config FILE 18 | 19 | Specifies an alternate config file to use instead of the default on at :file:`/etc/autosuspend.conf`. 20 | 21 | .. option:: -l FILE, --logging FILE 22 | 23 | Configure the logging system with the provided logging file. 24 | This file needs to follow the conventions for :ref:`Python logging files `. 25 | 26 | .. option:: -d 27 | 28 | Configure full debug logging in the command line. 29 | Mutually exclusive to :option:`autosuspend -l`. 30 | 31 | Subcommand ``daemon`` 32 | ********************* 33 | 34 | Starts the continuously running daemon. 35 | 36 | .. program:: autosuspend daemon 37 | 38 | .. option:: -a, --allchecks 39 | 40 | Usually, |project_program| stops checks in each iteration as soon as the first matching check indicates system activity. 41 | If this flag is set, all subsequent checks are still executed. 42 | Useful mostly for debugging purposes. 43 | 44 | .. option:: -r SECONDS, --runfor SECONDS 45 | 46 | If specified, do not run endlessly. 47 | Instead, operate only for the specified amount of seconds, then exit. 48 | Useful mostly for debugging purposes. 49 | 50 | Subcommand ``presuspend`` 51 | ************************* 52 | 53 | Should be called by the system before suspending. 54 | 55 | .. program:: autosuspend presuspend 56 | 57 | No options 58 | 59 | Subcommand ``version`` 60 | ************************* 61 | 62 | Outputs the currently installed version of |project_program| to stdout. 63 | 64 | .. program:: autosuspend version 65 | 66 | No options 67 | -------------------------------------------------------------------------------- /doc/source/support.rst: -------------------------------------------------------------------------------- 1 | Support requests 2 | ################ 3 | 4 | For questions, please first consult the issue tracker at the `Github project `_ for existing issues and questions. 5 | Questions are marked with the `question` tag. 6 | If your question is not answered, open a new issue with the question. 7 | 8 | In case you have found a bug or you want to request a new feature, please also open an issue at the `Github project `_. 9 | -------------------------------------------------------------------------------- /doc/source/systemd_integration.rst: -------------------------------------------------------------------------------- 1 | .. _systemd-integration: 2 | 3 | systemd integration 4 | ################### 5 | 6 | Even though it is possible to run |project_program| manually (cf. :ref:`the manpage `), in production use cases, the daemon will usually be run from `systemd`_. 7 | For this purpose, the package ships with `service definition files `_ for `systemd`_, so that you should be able to manage |project_program| via `systemd`_. 8 | These files need to be installed in the appropriate locations for such service files, which depend on the Linux distribution. 9 | Some common locations are: 10 | 11 | * :file:`/usr/lib/systemd/system` (e.g. Archlinux packaged service files) 12 | * :file:`/lib/systemd/system` (e.g. Debian packaged service files) 13 | * :file:`/etc/systemd/system` (e.g. Archlinux manually added service files) 14 | 15 | Binary installation packages for Linux distributions should have installed the service files at the appropriate locations already. 16 | 17 | To start |project_program| via `systemd`_, execute: 18 | 19 | .. code-block:: bash 20 | 21 | systemctl enable autosuspend.service 22 | 23 | To start |project_program| automatically at system start, execute: 24 | 25 | .. code-block:: bash 26 | 27 | systemctl start autosuspend.service 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@commitlint/cli": "19.8.1", 4 | "@commitlint/config-conventional": "19.8.1", 5 | "@semantic-release/changelog": "6.0.3", 6 | "@semantic-release/exec": "7.1.0", 7 | "@semantic-release/git": "10.0.1", 8 | "semantic-release": "24.2.5" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | 4 | [tool.ruff] 5 | src = ["src"] 6 | target-version = "py310" 7 | 8 | [tool.ruff.lint] 9 | select = [ 10 | "E", 11 | "F", 12 | "D", 13 | "ANN", 14 | "S", 15 | # "BLE", 16 | "B", 17 | "A", 18 | "C4", 19 | "T10", 20 | "DTZ", 21 | "EXE", 22 | "ISC", 23 | "G", 24 | "PIE", 25 | "T20", 26 | "PT", 27 | "Q", 28 | "RET", 29 | "SLF", 30 | "SIM", 31 | "TID", 32 | "TCH", 33 | "ARG", 34 | "PTH", 35 | "ERA", 36 | "TRY", 37 | "RUF", 38 | "UP", 39 | ] 40 | ignore = [ 41 | # We do this deliberately when extending modules with functionality 42 | "A005", 43 | # Not available in all supported Python versions 44 | "B905", 45 | # Black will handle this 46 | "E501", 47 | # Do not require docstrings everywhere 48 | "D1", 49 | # No need to add type annotation to self and cls 50 | "ANN10", 51 | # Allow Any 52 | "ANN401", 53 | # We use assert only for documentation purposes and debugging. 54 | "S101", 55 | # We want this as a feature of the configuration. The user is warned. 56 | "S602", 57 | # This one is hard to get around here 58 | "S603", 59 | # Required to be location-independent 60 | "S607", 61 | # I don't like this style 62 | "TRY300", 63 | # Gives some readability sometimes, No need to prevent this style 64 | "RET505", 65 | # This is the style used in this project. 66 | "TID252", 67 | # Will be fixed lated. 68 | "TRY003", 69 | "TRY301", 70 | ] 71 | 72 | [tool.ruff.lint.per-file-ignores] 73 | "tests/**" = [ 74 | # Allow hard-coded passwords in tests 75 | "S105", 76 | "S106", 77 | # Allow potentially insecure temp directory access 78 | "S108", 79 | # shell access in tests it ok 80 | "S604", 81 | # Sometimes needed for the current tests 82 | "SLF", 83 | ] 84 | "src/autosuspend/checks/ical.py" = [ 85 | # Terrible hack accessing internal members required to handle rrules correctly. 86 | "SLF001", 87 | ] 88 | 89 | [tool.ruff.lint.pydocstyle] 90 | convention = "google" 91 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:recommended" 4 | ], 5 | "pip_requirements": { 6 | "fileMatch": [ 7 | "^requirements.*\\.txt$" 8 | ] 9 | }, 10 | "customManagers": [ 11 | { 12 | "customType": "regex", 13 | "fileMatch": [ 14 | "^\\.github/.*\\.ya?ml$" 15 | ], 16 | "matchStrings": [ 17 | "node-version: (?.*)" 18 | ], 19 | "depNameTemplate": "node-version", 20 | "datasourceTemplate": "node-version" 21 | } 22 | ], 23 | "packageRules": [ 24 | { 25 | "matchUpdateTypes": [ 26 | "minor", 27 | "patch", 28 | "pin", 29 | "digest" 30 | ], 31 | "automerge": true 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /requirements-check.txt: -------------------------------------------------------------------------------- 1 | ruff==0.11.12 2 | black==25.1.0 3 | isort==6.0.1 4 | mypy==1.16.0 5 | types-tzlocal 6 | types-requests 7 | types-pytz 8 | types-freezegun 9 | types-python-dateutil 10 | -------------------------------------------------------------------------------- /requirements-doc.txt: -------------------------------------------------------------------------------- 1 | sphinx-issues==5.0.1 2 | sphinx==8.2.3 3 | furo==2024.8.6 4 | sphinxcontrib-plantuml==0.30 5 | sphinx-autodoc-typehints==3.2.0 6 | recommonmark==0.7.1 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [build_sphinx] 2 | source-dir = doc/source 3 | build-dir = doc/build 4 | 5 | [mypy] 6 | ignore_missing_imports = True 7 | disallow_untyped_defs = True 8 | check_untyped_defs = True 9 | no_implicit_optional = True 10 | warn_unused_configs = True 11 | warn_unused_ignores = True 12 | 13 | [tool:pytest] 14 | log_level = DEBUG 15 | markers = 16 | integration: longer-running integration tests 17 | filterwarnings = 18 | ignore::DeprecationWarning 19 | default::DeprecationWarning:autosuspend 20 | addopts = 21 | --cov-config=setup.cfg 22 | 23 | [coverage:run] 24 | branch = True 25 | source = autosuspend 26 | 27 | [coverage:paths] 28 | source = 29 | src/ 30 | */site-packages/ 31 | 32 | [coverage:report] 33 | exclude_lines = 34 | pragma: no cover 35 | def __repr__ 36 | if __name__ == "__main__": 37 | if TYPE_CHECKING: 38 | @abc.abstractmethod 39 | 40 | [isort] 41 | profile = google 42 | known_local_folder = tests 43 | case_sensitive = false 44 | combine_as_imports = true 45 | force_single_line = false 46 | multi_line_output = 3 47 | include_trailing_comma = true 48 | lines_after_imports = 2 49 | line_length = 88 50 | force_grid_wrap = false 51 | reverse_relative = true 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from setuptools import find_packages, setup 4 | 5 | 6 | name = "autosuspend" 7 | 8 | version_file = Path(__file__).absolute().parent / "VERSION" 9 | lines = version_file.read_text().splitlines() 10 | release = lines[1].strip() 11 | 12 | extras_require = { 13 | "Mpd": ["python-mpd2"], 14 | "Kodi": ["requests"], 15 | "XPath": ["lxml", "requests"], 16 | "JSONPath": ["jsonpath-ng", "requests"], 17 | "Logind": ["dbus-python"], 18 | "ical": ["requests", "icalendar", "python-dateutil", "tzlocal"], 19 | "localfiles": ["requests-file"], 20 | "logactivity": ["python-dateutil", "pytz"], 21 | "test": [ 22 | "pytest", 23 | "pytest-cov", 24 | "pytest-mock", 25 | "freezegun", 26 | "python-dbusmock", 27 | "PyGObject", 28 | "pytest-datadir", 29 | "pytest-httpserver", 30 | ], 31 | } 32 | extras_require["test"].extend( 33 | {dep for k, v in extras_require.items() if k != "test" for dep in v}, 34 | ) 35 | extras_require["all"] = list( 36 | {dep for k, v in extras_require.items() if k != "test" for dep in v}, 37 | ) 38 | 39 | setup( 40 | name=name, 41 | version=release, 42 | description="A daemon to suspend your server in case of inactivity", 43 | author="Johannes Wienke", 44 | author_email="languitar@semipol.de", 45 | license="GPL2", 46 | zip_safe=False, 47 | python_requires=">=3.7", 48 | install_requires=[ 49 | "psutil>=5.0", 50 | "portalocker", 51 | ], 52 | extras_require=extras_require, 53 | package_dir={ 54 | "": "src", 55 | }, 56 | packages=find_packages("src"), 57 | entry_points={ 58 | "console_scripts": [ 59 | "autosuspend = autosuspend:main", 60 | ], 61 | }, 62 | data_files=[ 63 | ("etc", ["data/autosuspend.conf", "data/autosuspend-logging.conf"]), 64 | ( 65 | "lib/systemd/system", 66 | ["data/autosuspend.service", "data/autosuspend-detect-suspend.service"], 67 | ), 68 | ], 69 | ) 70 | -------------------------------------------------------------------------------- /src/autosuspend/checks/__init__.py: -------------------------------------------------------------------------------- 1 | """Provides the basic types used for checks.""" 2 | 3 | import abc 4 | from collections.abc import Mapping 5 | import configparser 6 | from datetime import datetime 7 | from typing import Any, TypeVar 8 | 9 | from autosuspend.util import logger_by_class_instance 10 | 11 | 12 | class ConfigurationError(RuntimeError): 13 | """Indicates an error in the configuration of a :class:`Check`.""" 14 | 15 | 16 | class TemporaryCheckError(RuntimeError): 17 | """Indicates a temporary error while performing a check. 18 | 19 | Such an error can be ignored for some time since it might recover 20 | automatically. 21 | """ 22 | 23 | 24 | class SevereCheckError(RuntimeError): 25 | """Indicates a sever check error that will probably not recover. 26 | 27 | There is no hope this situation recovers. 28 | """ 29 | 30 | 31 | CheckType = TypeVar("CheckType", bound="Check") 32 | 33 | 34 | class Check(abc.ABC): 35 | """Base class for all kinds of checks. 36 | 37 | Subclasses must call this class' ``__init__`` method. 38 | 39 | Args: 40 | name (str): 41 | Configured name of the check 42 | """ 43 | 44 | @classmethod 45 | @abc.abstractmethod 46 | def create( 47 | cls: type[CheckType], name: str, config: configparser.SectionProxy 48 | ) -> CheckType: 49 | """Create a new check instance from the provided configuration. 50 | 51 | Args: 52 | name: 53 | user-defined name for the check 54 | config: 55 | config parser section with the configuration for this check 56 | 57 | Raises: 58 | ConfigurationError: 59 | Configuration for this check is inappropriate 60 | 61 | """ 62 | 63 | def __init__(self, name: str | None = None) -> None: 64 | if name: 65 | self.name = name 66 | else: 67 | self.name = self.__class__.__name__ 68 | self.logger = logger_by_class_instance(self, name) 69 | 70 | def options(self) -> Mapping[str, Any]: 71 | """Return the configured options as a mapping. 72 | 73 | This is used for debugging purposes only. 74 | """ 75 | return { 76 | k: v for k, v in self.__dict__.items() if not callable(v) and k != "logger" 77 | } 78 | 79 | def __str__(self) -> str: 80 | return f"{self.name}[class={self.__class__.__name__}]" 81 | 82 | 83 | class Activity(Check): 84 | """Base class for activity checks. 85 | 86 | Subclasses must call this class' __init__ method. 87 | """ 88 | 89 | @abc.abstractmethod 90 | def check(self) -> str | None: 91 | """Determine if system activity exists that prevents suspending. 92 | 93 | Returns: 94 | A string describing which condition currently prevents sleep, else ``None``. 95 | 96 | Raises: 97 | TemporaryCheckError: 98 | Check execution currently fails but might recover later 99 | SevereCheckError: 100 | Check executions fails severely 101 | """ 102 | 103 | def __str__(self) -> str: 104 | return f"{self.name}[class={self.__class__.__name__}]" 105 | 106 | 107 | class Wakeup(Check): 108 | """Represents a check for potential wake up points.""" 109 | 110 | @abc.abstractmethod 111 | def check(self, timestamp: datetime) -> datetime | None: 112 | """Indicate if a wakeup has to be scheduled for this check. 113 | 114 | Args: 115 | timestamp: 116 | the time at which the call to the wakeup check is made 117 | 118 | Returns: 119 | a datetime describing when the system needs to be running again or 120 | ``None`` if no wakeup is required. Use timezone aware datetimes. 121 | 122 | Raises: 123 | TemporaryCheckError: 124 | Check execution currently fails but might recover later 125 | SevereCheckError: 126 | Check executions fails severely 127 | """ 128 | -------------------------------------------------------------------------------- /src/autosuspend/checks/activity.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | 3 | 4 | # isort: off 5 | 6 | from .command import CommandActivity as ExternalCommand # noqa 7 | from .linux import ( # noqa 8 | ActiveConnection, 9 | Load, 10 | NetworkBandwidth, 11 | Ping, 12 | Processes, 13 | Users, 14 | ) 15 | from .smb import Smb # noqa 16 | from .xorg import XIdleTime # noqa 17 | 18 | with suppress(ModuleNotFoundError): 19 | from .ical import ActiveCalendarEvent # noqa 20 | with suppress(ModuleNotFoundError): 21 | from .json import JsonPath # noqa 22 | with suppress(ModuleNotFoundError): 23 | from .logs import LastLogActivity # noqa 24 | with suppress(ModuleNotFoundError): 25 | from .xpath import XPathActivity as XPath # noqa 26 | with suppress(ModuleNotFoundError): 27 | from .systemd import LogindSessionsIdle # noqa 28 | with suppress(ModuleNotFoundError): 29 | from .mpd import Mpd # noqa 30 | 31 | from .kodi import Kodi, KodiIdleTime # noqa 32 | 33 | # isort: on 34 | -------------------------------------------------------------------------------- /src/autosuspend/checks/command.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from datetime import datetime, timezone 3 | import subprocess 4 | 5 | from . import ( 6 | Activity, 7 | Check, 8 | CheckType, 9 | ConfigurationError, 10 | SevereCheckError, 11 | TemporaryCheckError, 12 | Wakeup, 13 | ) 14 | 15 | 16 | def raise_severe_if_command_not_found(error: subprocess.CalledProcessError) -> None: 17 | if error.returncode == 127: 18 | # see http://tldp.org/LDP/abs/html/exitcodes.html 19 | raise SevereCheckError(f"Command '{' '.join(error.cmd)}' does not exist") 20 | 21 | 22 | class CommandMixin(Check): 23 | """Mixin for configuring checks based on external commands.""" 24 | 25 | @classmethod 26 | def create( 27 | cls: type[CheckType], name: str, config: configparser.SectionProxy 28 | ) -> CheckType: 29 | try: 30 | return cls(name, config["command"].strip()) # type: ignore 31 | except KeyError as error: 32 | raise ConfigurationError("Missing command specification") from error 33 | 34 | def __init__(self, command: str) -> None: 35 | self._command = command 36 | 37 | 38 | class CommandActivity(CommandMixin, Activity): 39 | def __init__(self, name: str, command: str) -> None: 40 | CommandMixin.__init__(self, command) 41 | Activity.__init__(self, name) 42 | 43 | def check(self) -> str | None: 44 | try: 45 | subprocess.check_call(self._command, shell=True) 46 | return f"Command {self._command} succeeded" 47 | except subprocess.CalledProcessError as error: 48 | raise_severe_if_command_not_found(error) 49 | return None 50 | 51 | 52 | class CommandWakeup(CommandMixin, Wakeup): 53 | """Determine wake up times based on an external command. 54 | 55 | The called command must return a timestamp in UTC or nothing in case no 56 | wake up is planned. 57 | """ 58 | 59 | def __init__(self, name: str, command: str) -> None: 60 | CommandMixin.__init__(self, command) 61 | Wakeup.__init__(self, name) 62 | 63 | def check(self, timestamp: datetime) -> datetime | None: # noqa: ARG002 64 | try: 65 | output = subprocess.check_output( 66 | self._command, 67 | shell=True, 68 | ).splitlines()[0] 69 | self.logger.debug( 70 | "Command %s succeeded with output %s", self._command, output 71 | ) 72 | if output.strip(): 73 | return datetime.fromtimestamp(float(output.strip()), timezone.utc) 74 | else: 75 | return None 76 | 77 | except subprocess.CalledProcessError as error: 78 | raise_severe_if_command_not_found(error) 79 | raise TemporaryCheckError( 80 | "Unable to call the configured command" 81 | ) from error 82 | except ValueError as error: 83 | raise TemporaryCheckError( 84 | "Return value cannot be interpreted as a timestamp" 85 | ) from error 86 | -------------------------------------------------------------------------------- /src/autosuspend/checks/json.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import json 3 | from textwrap import shorten 4 | from typing import Any 5 | 6 | from jsonpath_ng import JSONPath 7 | import requests 8 | import requests.exceptions 9 | 10 | from . import Activity, ConfigurationError, TemporaryCheckError 11 | from .util import NetworkMixin 12 | 13 | 14 | class JsonPath(NetworkMixin, Activity): 15 | """Requests a URL and evaluates whether a JSONPath expression matches.""" 16 | 17 | @classmethod 18 | def collect_init_args(cls, config: configparser.SectionProxy) -> dict[str, Any]: 19 | from jsonpath_ng.ext import parse 20 | 21 | try: 22 | args = NetworkMixin.collect_init_args(config) 23 | args["jsonpath"] = parse(config["jsonpath"]) 24 | return args 25 | except KeyError as error: 26 | raise ConfigurationError("Property jsonpath is missing") from error 27 | except Exception as error: 28 | raise ConfigurationError(f"JSONPath error {error}") from error 29 | 30 | def __init__(self, name: str, jsonpath: JSONPath, **kwargs: Any) -> None: 31 | Activity.__init__(self, name) 32 | NetworkMixin.__init__(self, accept="application/json", **kwargs) 33 | self._jsonpath = jsonpath 34 | 35 | def check(self) -> str | None: 36 | try: 37 | reply = self.request().json() 38 | matched = self._jsonpath.find(reply) 39 | if matched: 40 | # shorten to avoid excessive logging output 41 | return f"JSONPath {self._jsonpath} found elements " + shorten( 42 | str(matched), 24 43 | ) 44 | return None 45 | except (json.JSONDecodeError, requests.exceptions.RequestException) as error: 46 | raise TemporaryCheckError(error) from error 47 | -------------------------------------------------------------------------------- /src/autosuspend/checks/kodi.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import json 3 | from typing import Any 4 | 5 | from . import Activity, ConfigurationError, TemporaryCheckError 6 | from .util import NetworkMixin 7 | 8 | 9 | def _add_default_kodi_url(config: configparser.SectionProxy) -> None: 10 | if "url" not in config: 11 | config["url"] = "http://localhost:8080/jsonrpc" 12 | 13 | 14 | class Kodi(NetworkMixin, Activity): 15 | @classmethod 16 | def collect_init_args(cls, config: configparser.SectionProxy) -> dict[str, Any]: 17 | try: 18 | _add_default_kodi_url(config) 19 | args = NetworkMixin.collect_init_args(config) 20 | args["suspend_while_paused"] = config.getboolean( 21 | "suspend_while_paused", fallback=False 22 | ) 23 | return args 24 | except ValueError as error: 25 | raise ConfigurationError(f"Configuration error {error}") from error 26 | 27 | @classmethod 28 | def create(cls, name: str, config: configparser.SectionProxy) -> "Kodi": 29 | return cls(name, **cls.collect_init_args(config)) 30 | 31 | def __init__( 32 | self, name: str, url: str, suspend_while_paused: bool = False, **kwargs: Any 33 | ) -> None: 34 | self._suspend_while_paused = suspend_while_paused 35 | if self._suspend_while_paused: 36 | request = url + ( 37 | '?request={"jsonrpc": "2.0", "id": 1, ' 38 | '"method": "XBMC.GetInfoBooleans",' 39 | '"params": {"booleans": ["Player.Playing"]} }' 40 | ) 41 | else: 42 | request = url + ( 43 | '?request={"jsonrpc": "2.0", "id": 1, ' 44 | '"method": "Player.GetActivePlayers"}' 45 | ) 46 | NetworkMixin.__init__(self, url=request, **kwargs) 47 | Activity.__init__(self, name) 48 | 49 | def _safe_request_result(self) -> dict: 50 | try: 51 | return self.request().json()["result"] 52 | except (KeyError, TypeError, json.JSONDecodeError) as error: 53 | raise TemporaryCheckError("Unable to get or parse Kodi state") from error 54 | 55 | def check(self) -> str | None: 56 | reply = self._safe_request_result() 57 | if self._suspend_while_paused: 58 | return ( 59 | "Kodi actively playing media" if reply.get("Player.Playing") else None 60 | ) 61 | else: 62 | return "Kodi currently playing" if reply else None 63 | 64 | 65 | class KodiIdleTime(NetworkMixin, Activity): 66 | @classmethod 67 | def collect_init_args(cls, config: configparser.SectionProxy) -> dict[str, Any]: 68 | try: 69 | _add_default_kodi_url(config) 70 | args = NetworkMixin.collect_init_args(config) 71 | args["idle_time"] = config.getint("idle_time", fallback=120) 72 | return args 73 | except ValueError as error: 74 | raise ConfigurationError("Configuration error " + str(error)) from error 75 | 76 | @classmethod 77 | def create(cls, name: str, config: configparser.SectionProxy) -> "KodiIdleTime": 78 | return cls(name, **cls.collect_init_args(config)) 79 | 80 | def __init__(self, name: str, url: str, idle_time: int, **kwargs: Any) -> None: 81 | request = url + ( 82 | '?request={"jsonrpc": "2.0", "id": 1, ' 83 | '"method": "XBMC.GetInfoBooleans",' 84 | f'"params": {{"booleans": ["System.IdleTime({idle_time})"]}}}}' 85 | ) 86 | NetworkMixin.__init__(self, url=request, **kwargs) 87 | Activity.__init__(self, name) 88 | self._idle_time = idle_time 89 | 90 | def check(self) -> str | None: 91 | try: 92 | reply = self.request().json() 93 | if not reply["result"][f"System.IdleTime({self._idle_time})"]: 94 | return "Someone interacts with Kodi" 95 | else: 96 | return None 97 | except (KeyError, TypeError, json.JSONDecodeError) as error: 98 | raise TemporaryCheckError("Unable to get or parse Kodi state") from error 99 | -------------------------------------------------------------------------------- /src/autosuspend/checks/logs.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | import configparser 3 | from datetime import datetime, timedelta, timezone 4 | from pathlib import Path 5 | import re 6 | from re import Pattern 7 | 8 | from dateutil.parser import parse 9 | from dateutil.utils import default_tzinfo 10 | import pytz 11 | 12 | from . import Activity, ConfigurationError, TemporaryCheckError 13 | 14 | 15 | class LastLogActivity(Activity): 16 | @classmethod 17 | def create(cls, name: str, config: configparser.SectionProxy) -> "LastLogActivity": 18 | try: 19 | return cls( 20 | name, 21 | Path(config["log_file"]), 22 | re.compile(config["pattern"]), 23 | timedelta(minutes=config.getint("minutes", fallback=10)), 24 | config.get("encoding", "ascii"), 25 | pytz.timezone(config.get("timezone", "UTC")), # type: ignore 26 | ) 27 | except KeyError as error: 28 | raise ConfigurationError( 29 | f"Missing config key {error}", 30 | ) from error 31 | except re.error as error: 32 | raise ConfigurationError( 33 | f"Regular expression is invalid: {error}", 34 | ) from error 35 | except ValueError as error: 36 | raise ConfigurationError( 37 | f"Unable to parse configuration: {error}", 38 | ) from error 39 | 40 | def __init__( 41 | self, 42 | name: str, 43 | log_file: Path, 44 | pattern: Pattern, 45 | delta: timedelta, 46 | encoding: str, 47 | default_timezone: timezone, 48 | ) -> None: 49 | if delta.total_seconds() < 0: 50 | raise ValueError("Given delta must be positive") 51 | if pattern.groups != 1: 52 | raise ValueError("Given pattern must have exactly one capture group") 53 | super().__init__(name=name) 54 | self.log_file = log_file 55 | self.pattern = pattern 56 | self.delta = delta 57 | self.encoding = encoding 58 | self.default_timezone = default_timezone 59 | 60 | def _safe_parse_date(self, match: str, now: datetime) -> datetime: 61 | try: 62 | match_date = default_tzinfo(parse(match), self.default_timezone) 63 | if match_date > now: 64 | raise TemporaryCheckError( 65 | f"Detected date {match_date} is in the future" 66 | ) 67 | return match_date 68 | except ValueError as error: 69 | raise TemporaryCheckError( 70 | f"Detected date {match} cannot be parsed as a date" 71 | ) from error 72 | except OverflowError as error: 73 | raise TemporaryCheckError( 74 | f"Detected date {match} is out of the valid range" 75 | ) from error 76 | 77 | def _file_lines_reversed(self) -> Iterable[str]: 78 | try: 79 | # Probably not the most effective solution for large log files. Might need 80 | # optimizations later on. 81 | return reversed( 82 | self.log_file.read_text(encoding=self.encoding).splitlines() 83 | ) 84 | except OSError as error: 85 | raise TemporaryCheckError( 86 | f"Cannot access log file {self.log_file}" 87 | ) from error 88 | 89 | def check(self) -> str | None: 90 | lines = self._file_lines_reversed() 91 | 92 | now = datetime.now(tz=timezone.utc) 93 | for line in lines: 94 | match = self.pattern.match(line) 95 | if not match: 96 | continue 97 | 98 | match_date = self._safe_parse_date(match.group(1), now) 99 | 100 | # Only check the first line (reverse order) that has a match, not all 101 | if (now - match_date) < self.delta: 102 | return f"Log activity in {self.log_file} at {match_date}" 103 | else: 104 | return None 105 | 106 | # No line matched at all 107 | return None 108 | -------------------------------------------------------------------------------- /src/autosuspend/checks/mpd.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import socket 3 | 4 | from mpd import MPDClient, MPDError 5 | 6 | from . import Activity, Check, ConfigurationError, TemporaryCheckError 7 | 8 | 9 | class Mpd(Activity): 10 | @classmethod 11 | def create(cls, name: str, config: configparser.SectionProxy) -> "Mpd": 12 | try: 13 | host = config.get("host", fallback="localhost") 14 | port = config.getint("port", fallback=6600) 15 | timeout = config.getint("timeout", fallback=5) 16 | return cls(name, host, port, timeout) 17 | except ValueError as error: 18 | raise ConfigurationError( 19 | f"Host port or timeout configuration wrong: {error}" 20 | ) from error 21 | 22 | def __init__(self, name: str, host: str, port: int, timeout: float) -> None: 23 | Check.__init__(self, name) 24 | self._host = host 25 | self._port = port 26 | self._timeout = timeout 27 | 28 | def _get_state(self) -> dict: 29 | client = MPDClient() 30 | client.timeout = self._timeout 31 | client.connect(self._host, self._port) 32 | state = client.status() 33 | client.close() 34 | client.disconnect() 35 | return state 36 | 37 | def check(self) -> str | None: 38 | try: 39 | state = self._get_state() 40 | if state["state"] == "play": 41 | return "MPD currently playing" 42 | else: 43 | return None 44 | except (TimeoutError, MPDError, ConnectionError, socket.gaierror) as error: 45 | raise TemporaryCheckError("Unable to get the current MPD state") from error 46 | -------------------------------------------------------------------------------- /src/autosuspend/checks/smb.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import subprocess 3 | 4 | from . import Activity, SevereCheckError, TemporaryCheckError 5 | 6 | 7 | class Smb(Activity): 8 | @classmethod 9 | def create( 10 | cls, name: str, config: configparser.SectionProxy | None # noqa: ARG003 11 | ) -> "Smb": 12 | return cls(name) 13 | 14 | def _safe_get_status(self) -> str: 15 | try: 16 | return subprocess.check_output(["smbstatus", "-b"]).decode("utf-8") 17 | except FileNotFoundError as error: 18 | raise SevereCheckError("smbstatus binary not found") from error 19 | except subprocess.CalledProcessError as error: 20 | raise TemporaryCheckError("Unable to execute smbstatus") from error 21 | 22 | def check(self) -> str | None: 23 | status_output = self._safe_get_status() 24 | 25 | self.logger.debug("Received status output:\n%s", status_output) 26 | 27 | connections = [] 28 | start_seen = False 29 | for line in status_output.splitlines(): 30 | if start_seen: 31 | connections.append(line) 32 | else: 33 | if line.startswith("----"): 34 | start_seen = True 35 | 36 | if connections: 37 | return "SMB clients are connected:\n{}".format("\n".join(connections)) 38 | else: 39 | return None 40 | -------------------------------------------------------------------------------- /src/autosuspend/checks/stub.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from datetime import datetime, timedelta 3 | 4 | from . import ConfigurationError, Wakeup 5 | 6 | 7 | class Periodic(Wakeup): 8 | """Always indicates a wake up after a specified delta of time from now on. 9 | 10 | Use this to periodically wake up a system. 11 | """ 12 | 13 | @classmethod 14 | def create(cls, name: str, config: configparser.SectionProxy) -> "Periodic": 15 | try: 16 | kwargs = {config["unit"]: float(config["value"])} 17 | return cls(name, timedelta(**kwargs)) 18 | except (ValueError, KeyError, TypeError) as error: 19 | raise ConfigurationError(str(error)) from error 20 | 21 | def __init__(self, name: str, delta: timedelta) -> None: 22 | Wakeup.__init__(self, name) 23 | self._delta = delta 24 | 25 | def check(self, timestamp: datetime) -> datetime | None: 26 | return timestamp + self._delta 27 | -------------------------------------------------------------------------------- /src/autosuspend/checks/systemd.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | import configparser 3 | from datetime import datetime, timedelta, timezone 4 | import re 5 | from re import Pattern 6 | from typing import Any 7 | 8 | import dbus 9 | 10 | from . import Activity, ConfigurationError, TemporaryCheckError, Wakeup 11 | from ..util.systemd import list_logind_sessions, LogindDBusException 12 | 13 | 14 | _UINT64_MAX = 18446744073709551615 15 | 16 | 17 | def next_timer_executions() -> dict[str, datetime]: 18 | bus = dbus.SystemBus() 19 | 20 | systemd = bus.get_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1") 21 | units = systemd.ListUnits(dbus_interface="org.freedesktop.systemd1.Manager") 22 | timers = [unit for unit in units if unit[0].endswith(".timer")] 23 | 24 | def get_if_set(props: dict[str, Any], key: str) -> int | None: 25 | # For timers running after boot, next execution time might not be available. In 26 | # this case, the expected keys are all set to uint64 max. 27 | if props[key] and props[key] != _UINT64_MAX: 28 | return props[key] 29 | else: 30 | return None 31 | 32 | result: dict[str, datetime] = {} 33 | for timer in timers: 34 | obj = bus.get_object("org.freedesktop.systemd1", timer[6]) 35 | properties_interface = dbus.Interface(obj, "org.freedesktop.DBus.Properties") 36 | props = properties_interface.GetAll("org.freedesktop.systemd1.Timer") 37 | 38 | realtime = get_if_set(props, "NextElapseUSecRealtime") 39 | monotonic = get_if_set(props, "NextElapseUSecMonotonic") 40 | next_time: datetime | None = None 41 | if realtime is not None: 42 | next_time = datetime.fromtimestamp( 43 | realtime / 1000000, 44 | tz=timezone.utc, 45 | ) 46 | elif monotonic is not None: 47 | next_time = datetime.now(tz=timezone.utc) + timedelta( 48 | seconds=monotonic / 1000000 49 | ) 50 | 51 | if next_time: 52 | result[str(timer[0])] = next_time 53 | 54 | return result 55 | 56 | 57 | class SystemdTimer(Wakeup): 58 | """Ensures that the system is active when some selected SystemD timers will run.""" 59 | 60 | @classmethod 61 | def create(cls, name: str, config: configparser.SectionProxy) -> "SystemdTimer": 62 | try: 63 | return cls(name, re.compile(config["match"])) 64 | except (re.error, ValueError, KeyError, TypeError) as error: 65 | raise ConfigurationError(str(error)) from error 66 | 67 | def __init__(self, name: str, match: Pattern) -> None: 68 | Wakeup.__init__(self, name) 69 | self._match = match 70 | 71 | def check(self, timestamp: datetime) -> datetime | None: # noqa: ARG002 72 | executions = next_timer_executions() 73 | matching_executions = [ 74 | next_run for name, next_run in executions.items() if self._match.match(name) 75 | ] 76 | try: 77 | return min(matching_executions) 78 | except ValueError: 79 | return None 80 | 81 | 82 | class LogindSessionsIdle(Activity): 83 | """Prevents suspending in case a logind session is marked not idle. 84 | 85 | The decision is based on the ``IdleHint`` property of logind sessions. 86 | """ 87 | 88 | @classmethod 89 | def create( 90 | cls, 91 | name: str, 92 | config: configparser.SectionProxy, 93 | ) -> "LogindSessionsIdle": 94 | types = config.get("types", fallback="tty,x11,wayland").split(",") 95 | types = [t.strip() for t in types] 96 | states = config.get("states", fallback="active,online").split(",") 97 | states = [t.strip() for t in states] 98 | classes = config.get("classes", fallback="user").split(",") 99 | classes = [t.strip() for t in classes] 100 | return cls(name, types, states, classes) 101 | 102 | def __init__( 103 | self, 104 | name: str, 105 | types: Iterable[str], 106 | states: Iterable[str], 107 | classes: Iterable[str] = ("user"), 108 | ) -> None: 109 | Activity.__init__(self, name) 110 | self._types = types 111 | self._states = states 112 | self._classes = classes 113 | 114 | @staticmethod 115 | def _list_logind_sessions() -> Iterable[tuple[str, dict]]: 116 | try: 117 | return list_logind_sessions() 118 | except LogindDBusException as error: 119 | raise TemporaryCheckError(error) from error 120 | 121 | def check(self) -> str | None: 122 | for session_id, properties in self._list_logind_sessions(): 123 | self.logger.debug("Session %s properties: %s", session_id, properties) 124 | 125 | if properties["Type"] not in self._types: 126 | self.logger.debug( 127 | "Ignoring session of wrong type %s", properties["Type"] 128 | ) 129 | continue 130 | if properties["State"] not in self._states: 131 | self.logger.debug( 132 | "Ignoring session because its state is %s", properties["State"] 133 | ) 134 | continue 135 | if properties["Class"] not in self._classes: 136 | self.logger.debug( 137 | "Ignoring session because its class is %s", properties["Class"] 138 | ) 139 | continue 140 | 141 | if not properties["IdleHint"]: 142 | return f"Login session {session_id} is not idle" 143 | 144 | return None 145 | -------------------------------------------------------------------------------- /src/autosuspend/checks/util.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from contextlib import suppress 3 | from typing import Any, TYPE_CHECKING 4 | 5 | from . import ( 6 | Check, 7 | CheckType, 8 | ConfigurationError, 9 | SevereCheckError, 10 | TemporaryCheckError, 11 | ) 12 | 13 | 14 | if TYPE_CHECKING: 15 | import requests 16 | import requests.models 17 | 18 | 19 | class NetworkMixin(Check): 20 | @staticmethod 21 | def _ensure_credentials_consistent(args: dict[str, Any]) -> None: 22 | if (args["username"] is None) != (args["password"] is None): 23 | raise ConfigurationError("Username and password must be set") 24 | 25 | @classmethod 26 | def collect_init_args( 27 | cls, 28 | config: configparser.SectionProxy, 29 | ) -> dict[str, Any]: 30 | try: 31 | args: dict[str, Any] = {} 32 | args["timeout"] = config.getint("timeout", fallback=5) 33 | args["url"] = config["url"] 34 | args["username"] = config.get("username") 35 | args["password"] = config.get("password") 36 | cls._ensure_credentials_consistent(args) 37 | return args 38 | except ValueError as error: 39 | raise ConfigurationError("Configuration error " + str(error)) from error 40 | except KeyError as error: 41 | raise ConfigurationError("Lacks " + str(error) + " config entry") from error 42 | 43 | @classmethod 44 | def create( 45 | cls: type[CheckType], name: str, config: configparser.SectionProxy 46 | ) -> CheckType: 47 | return cls(name, **cls.collect_init_args(config)) # type: ignore 48 | 49 | def __init__( 50 | self, 51 | url: str, 52 | timeout: int, 53 | username: str | None = None, 54 | password: str | None = None, 55 | accept: str | None = None, 56 | ) -> None: 57 | self._url = url 58 | self._timeout = timeout 59 | self._username = username 60 | self._password = password 61 | self._accept = accept 62 | 63 | @staticmethod 64 | def _create_session() -> "requests.Session": 65 | import requests 66 | 67 | session = requests.Session() 68 | 69 | with suppress(ImportError): 70 | from requests_file import FileAdapter 71 | 72 | session.mount("file://", FileAdapter()) 73 | 74 | return session 75 | 76 | def _request_headers(self) -> dict[str, str] | None: 77 | if self._accept: 78 | return {"Accept": self._accept} 79 | else: 80 | return None 81 | 82 | def _create_auth_from_failed_request( 83 | self, 84 | reply: "requests.models.Response", 85 | username: str, 86 | password: str, 87 | ) -> Any: 88 | from requests.auth import HTTPBasicAuth, HTTPDigestAuth 89 | 90 | auth_map = { 91 | "basic": HTTPBasicAuth, 92 | "digest": HTTPDigestAuth, 93 | } 94 | 95 | auth_scheme = reply.headers["WWW-Authenticate"].split(" ")[0].lower() 96 | if auth_scheme not in auth_map: 97 | raise SevereCheckError(f"Unsupported authentication scheme {auth_scheme}") 98 | 99 | return auth_map[auth_scheme](username, password) 100 | 101 | def request(self) -> "requests.models.Response": 102 | import requests 103 | import requests.exceptions 104 | 105 | session = self._create_session() 106 | 107 | try: 108 | reply = session.get( 109 | self._url, timeout=self._timeout, headers=self._request_headers() 110 | ) 111 | 112 | # replace reply with an authenticated version if credentials are 113 | # available and the server has requested authentication 114 | if self._username and self._password and reply.status_code == 401: 115 | reply = session.get( 116 | self._url, 117 | timeout=self._timeout, 118 | auth=self._create_auth_from_failed_request( 119 | reply, self._username, self._password 120 | ), 121 | headers=self._request_headers(), 122 | ) 123 | 124 | reply.raise_for_status() 125 | return reply 126 | except requests.exceptions.RequestException as error: 127 | raise TemporaryCheckError(error) from error 128 | -------------------------------------------------------------------------------- /src/autosuspend/checks/wakeup.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | 3 | 4 | # isort: off 5 | 6 | from .command import CommandWakeup as Command # noqa 7 | from .linux import File # noqa 8 | from .stub import Periodic # noqa 9 | 10 | with suppress(ModuleNotFoundError): 11 | from .ical import Calendar # noqa 12 | with suppress(ModuleNotFoundError): 13 | from .xpath import XPathWakeup as XPath # noqa 14 | from .xpath import XPathDeltaWakeup as XPathDelta # noqa 15 | with suppress(ModuleNotFoundError): 16 | from .systemd import SystemdTimer # noqa 17 | 18 | # isort: on 19 | -------------------------------------------------------------------------------- /src/autosuspend/checks/xorg.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | import configparser 3 | from contextlib import suppress 4 | import copy 5 | from dataclasses import dataclass 6 | import logging 7 | import os 8 | from pathlib import Path 9 | import re 10 | from re import Pattern 11 | import subprocess 12 | import warnings 13 | 14 | import psutil 15 | 16 | from . import Activity, ConfigurationError, SevereCheckError, TemporaryCheckError 17 | from ..util.systemd import list_logind_sessions, LogindDBusException 18 | 19 | 20 | @dataclass 21 | class XorgSession: 22 | display: int 23 | user: str 24 | 25 | 26 | _logger = logging.getLogger(__name__) 27 | 28 | 29 | def list_sessions_sockets(socket_path: Path | None = None) -> list[XorgSession]: 30 | """List running X sessions by iterating the X sockets. 31 | 32 | This method assumes that X servers are run under the users using the 33 | server. 34 | """ 35 | folder = socket_path or Path("/tmp/.X11-unix/") # noqa: S108 expected default path 36 | sockets = folder.glob("X*") 37 | _logger.debug("Found sockets: %s", sockets) 38 | 39 | results = [] 40 | for sock in sockets: 41 | # determine the number of the X display by stripping the X prefix 42 | try: 43 | display = int(sock.name[1:]) 44 | except ValueError: 45 | _logger.warning( 46 | "Cannot parse display number from socket %s. Skipping.", 47 | sock, 48 | exc_info=True, 49 | ) 50 | continue 51 | 52 | # determine the user of the display 53 | try: 54 | user = sock.owner() 55 | except (FileNotFoundError, KeyError): 56 | _logger.warning( 57 | "Cannot get the owning user from socket %s. Skipping.", 58 | sock, 59 | exc_info=True, 60 | ) 61 | continue 62 | 63 | results.append(XorgSession(display, user)) 64 | 65 | return results 66 | 67 | 68 | def list_sessions_logind() -> list[XorgSession]: 69 | """List running X sessions using logind. 70 | 71 | This method assumes that a ``Display`` variable is set in the logind 72 | sessions. 73 | 74 | Raises: 75 | LogindDBusException: cannot connect or extract sessions 76 | """ 77 | results = [] 78 | 79 | for session_id, properties in list_logind_sessions(): 80 | if "Name" not in properties or "Display" not in properties: 81 | _logger.debug( 82 | "Skipping session %s because it does not contain " 83 | "a user name and a display", 84 | session_id, 85 | ) 86 | continue 87 | 88 | try: 89 | results.append( 90 | XorgSession( 91 | int(properties["Display"].replace(":", "")), 92 | str(properties["Name"]), 93 | ) 94 | ) 95 | except ValueError: 96 | _logger.warning( 97 | "Unable to parse display from session properties %s", 98 | properties, 99 | exc_info=True, 100 | ) 101 | 102 | return results 103 | 104 | 105 | class XIdleTime(Activity): 106 | """Check that local X display have been idle long enough.""" 107 | 108 | @classmethod 109 | def create(cls, name: str, config: configparser.SectionProxy) -> "XIdleTime": 110 | with warnings.catch_warnings(): 111 | warnings.simplefilter("ignore", FutureWarning) 112 | try: 113 | return cls( 114 | name, 115 | config.getint("timeout", fallback=600), 116 | config.get("method", fallback="sockets"), 117 | re.compile(config.get("ignore_if_process", fallback=r"a^")), 118 | re.compile(config.get("ignore_users", fallback=r"a^")), 119 | ) 120 | except re.error as error: 121 | raise ConfigurationError( 122 | f"Regular expression is invalid: {error}", 123 | ) from error 124 | except ValueError as error: 125 | raise ConfigurationError( 126 | f"Unable to parse configuration: {error}", 127 | ) from error 128 | 129 | @staticmethod 130 | def _get_session_method(method: str) -> Callable[[], list[XorgSession]]: 131 | if method == "sockets": 132 | return list_sessions_sockets 133 | elif method == "logind": 134 | return list_sessions_logind 135 | else: 136 | raise ValueError(f"Unknown session discovery method {method}") 137 | 138 | def __init__( 139 | self, 140 | name: str, 141 | timeout: float, 142 | method: str, 143 | ignore_process_re: Pattern, 144 | ignore_users_re: Pattern, 145 | ) -> None: 146 | Activity.__init__(self, name) 147 | self._timeout = timeout 148 | self._provide_sessions: Callable[[], list[XorgSession]] 149 | self._provide_sessions = self._get_session_method(method) 150 | self._ignore_process_re = ignore_process_re 151 | self._ignore_users_re = ignore_users_re 152 | 153 | @staticmethod 154 | def _get_user_processes(user: str) -> list[psutil.Process]: 155 | user_processes = [] 156 | for process in psutil.process_iter(): 157 | with suppress( 158 | psutil.NoSuchProcess, psutil.ZombieProcess, psutil.AccessDenied 159 | ): 160 | if process.username() == user: 161 | user_processes.append(process.name()) 162 | return user_processes 163 | 164 | def _is_skip_process_running(self, user: str) -> bool: 165 | for process in self._get_user_processes(user): 166 | if self._ignore_process_re.match(process) is not None: 167 | self.logger.debug( 168 | "Process %s with pid %s matches the ignore regex '%s'." 169 | " Skipping idle time check for this user.", 170 | process.name(), 171 | process.pid, 172 | self._ignore_process_re, 173 | ) 174 | return True 175 | 176 | return False 177 | 178 | def _safe_provide_sessions(self) -> list[XorgSession]: 179 | try: 180 | return self._provide_sessions() 181 | except LogindDBusException as error: 182 | raise TemporaryCheckError(error) from error 183 | 184 | def _get_idle_time(self, session: XorgSession) -> float: 185 | env = copy.deepcopy(os.environ) 186 | env["DISPLAY"] = f":{session.display}" 187 | env["XAUTHORITY"] = str(Path("~" + session.user).expanduser() / ".Xauthority") 188 | 189 | try: 190 | idle_time_output = subprocess.check_output( 191 | ["sudo", "-u", session.user, "xprintidle"], env=env 192 | ) 193 | return float(idle_time_output.strip()) / 1000.0 194 | except FileNotFoundError as error: 195 | raise SevereCheckError("sudo executable not found") from error 196 | except (subprocess.CalledProcessError, ValueError) as error: 197 | self.logger.warning( 198 | "Unable to determine the idle time for display %s.", 199 | session.display, 200 | exc_info=True, 201 | ) 202 | raise TemporaryCheckError("Unable to call xprintidle") from error 203 | 204 | def check(self) -> str | None: 205 | for session in self._safe_provide_sessions(): 206 | self.logger.info("Checking session %s", session) 207 | 208 | # check whether this users should be ignored completely 209 | if self._ignore_users_re.match(session.user) is not None: 210 | self.logger.debug("Skipping user '%s' due to request", session.user) 211 | continue 212 | 213 | # check whether any of the running processes of this user matches 214 | # the ignore regular expression. In that case we skip idletime 215 | # checking because we assume the user has a process running that 216 | # inevitably tampers with the idle time. 217 | if self._is_skip_process_running(session.user): 218 | continue 219 | 220 | idle_time = self._get_idle_time(session) 221 | self.logger.debug( 222 | "Idle time for display %s of user %s is %s seconds.", 223 | session.display, 224 | session.user, 225 | idle_time, 226 | ) 227 | 228 | if idle_time < self._timeout: 229 | return ( 230 | f"X session {session.display} of user {session.user} " 231 | f"has idle time {idle_time} < threshold {self._timeout}" 232 | ) 233 | 234 | return None 235 | -------------------------------------------------------------------------------- /src/autosuspend/checks/xpath.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | import configparser 3 | from datetime import datetime, timedelta, timezone 4 | from typing import Any 5 | 6 | from lxml import etree # using safe parser 7 | from lxml.etree import XPath, XPathSyntaxError # our input 8 | import requests 9 | import requests.exceptions 10 | 11 | from . import Activity, CheckType, ConfigurationError, TemporaryCheckError, Wakeup 12 | from .util import NetworkMixin 13 | 14 | 15 | class XPathMixin(NetworkMixin): 16 | @classmethod 17 | def collect_init_args(cls, config: configparser.SectionProxy) -> dict[str, Any]: 18 | try: 19 | args = NetworkMixin.collect_init_args(config) 20 | args["xpath"] = config["xpath"].strip() 21 | # validate the expression 22 | try: 23 | XPath(args["xpath"]) 24 | except XPathSyntaxError as error: 25 | raise ConfigurationError( 26 | "Invalid xpath expression: " + args["xpath"] 27 | ) from error 28 | return args 29 | except KeyError as error: 30 | raise ConfigurationError("Lacks " + str(error) + " config entry") from error 31 | 32 | @classmethod 33 | def create( 34 | cls: type[CheckType], name: str, config: configparser.SectionProxy 35 | ) -> CheckType: 36 | return cls(name, **cls.collect_init_args(config)) # type: ignore 37 | 38 | def __init__(self, xpath: str, **kwargs: Any) -> None: 39 | NetworkMixin.__init__(self, **kwargs) 40 | self._xpath = xpath 41 | 42 | self._parser = etree.XMLParser(resolve_entities=False) 43 | 44 | def evaluate(self) -> Sequence[Any]: 45 | try: 46 | reply = self.request().content 47 | root = etree.fromstring(reply, parser=self._parser) # noqa: S320 48 | return root.xpath(self._xpath) 49 | except requests.exceptions.RequestException as error: 50 | raise TemporaryCheckError(error) from error 51 | except etree.XMLSyntaxError as error: 52 | raise TemporaryCheckError(error) from error 53 | 54 | 55 | class XPathActivity(XPathMixin, Activity): 56 | def __init__(self, name: str, **kwargs: Any) -> None: 57 | Activity.__init__(self, name) 58 | XPathMixin.__init__(self, **kwargs) 59 | 60 | def check(self) -> str | None: 61 | if self.evaluate(): 62 | return "XPath matches for url " + self._url 63 | else: 64 | return None 65 | 66 | 67 | class XPathWakeup(XPathMixin, Wakeup): 68 | """Determine wake up times from a network resource using XPath expressions. 69 | 70 | The matched results are expected to represent timestamps in seconds UTC. 71 | """ 72 | 73 | def __init__(self, name: str, **kwargs: Any) -> None: 74 | Wakeup.__init__(self, name) 75 | XPathMixin.__init__(self, **kwargs) 76 | 77 | def convert_result( 78 | self, 79 | result: str, 80 | timestamp: datetime, # noqa: ARG002 81 | ) -> datetime: 82 | return datetime.fromtimestamp(float(result), timezone.utc) 83 | 84 | def check(self, timestamp: datetime) -> datetime | None: 85 | matches = self.evaluate() 86 | try: 87 | if matches: 88 | return min(self.convert_result(m, timestamp) for m in matches) 89 | else: 90 | return None 91 | except TypeError as error: 92 | raise TemporaryCheckError( 93 | "XPath returned a result that is not a string: " + str(error) 94 | ) from None 95 | except ValueError as error: 96 | raise TemporaryCheckError( 97 | "Result cannot be parsed: " + str(error) 98 | ) from error 99 | 100 | 101 | class XPathDeltaWakeup(XPathWakeup): 102 | UNITS = ( 103 | "days", 104 | "seconds", 105 | "microseconds", 106 | "milliseconds", 107 | "minutes", 108 | "hours", 109 | "weeks", 110 | ) 111 | 112 | @classmethod 113 | def create(cls, name: str, config: configparser.SectionProxy) -> "XPathDeltaWakeup": 114 | try: 115 | args = XPathWakeup.collect_init_args(config) 116 | args["unit"] = config.get("unit", fallback="minutes") 117 | return cls(name, **args) 118 | except ValueError as error: 119 | raise ConfigurationError(str(error)) from error 120 | 121 | def __init__(self, name: str, unit: str, **kwargs: Any) -> None: 122 | if unit not in self.UNITS: 123 | raise ValueError("Unsupported unit") 124 | XPathWakeup.__init__(self, name, **kwargs) 125 | self._unit = unit 126 | 127 | def convert_result(self, result: str, timestamp: datetime) -> datetime: 128 | kwargs = {self._unit: float(result)} 129 | return timestamp + timedelta(**kwargs) 130 | -------------------------------------------------------------------------------- /src/autosuspend/util/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | 5 | def logger_by_class(klass: type, name: str | None = None) -> logging.Logger: 6 | return logging.getLogger( 7 | "{module}.{klass}{name}".format( 8 | module=klass.__module__, 9 | klass=klass.__name__, 10 | name=f".{name}" if name else "", 11 | ) 12 | ) 13 | 14 | 15 | def logger_by_class_instance( 16 | instance: Any, 17 | name: str | None = None, 18 | ) -> logging.Logger: 19 | return logger_by_class(instance.__class__, name=name) 20 | -------------------------------------------------------------------------------- /src/autosuspend/util/datetime.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, tzinfo 2 | 3 | 4 | def is_aware(dt: datetime) -> bool: 5 | return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None 6 | 7 | 8 | def to_tz_unaware(dt: datetime, tz: tzinfo | None) -> datetime: 9 | return dt.astimezone(tz).replace(tzinfo=None) 10 | -------------------------------------------------------------------------------- /src/autosuspend/util/systemd.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from typing import TYPE_CHECKING 3 | 4 | 5 | if TYPE_CHECKING: 6 | import dbus 7 | 8 | 9 | def _get_bus() -> "dbus.SystemBus": 10 | import dbus 11 | 12 | return dbus.SystemBus() 13 | 14 | 15 | class LogindDBusException(RuntimeError): 16 | """Indicates an error communicating to Logind via DBus.""" 17 | 18 | 19 | def list_logind_sessions() -> Iterable[tuple[str, dict]]: 20 | """List running logind sessions and their properties. 21 | 22 | Returns: 23 | list of (session_id, properties dict): 24 | A list with tuples of sessions ids and their associated properties 25 | represented as dicts. 26 | """ 27 | import dbus 28 | 29 | try: 30 | bus = _get_bus() 31 | login1 = bus.get_object("org.freedesktop.login1", "/org/freedesktop/login1") 32 | 33 | sessions = login1.ListSessions(dbus_interface="org.freedesktop.login1.Manager") 34 | 35 | results = [] 36 | for session_id, path in [(s[0], s[4]) for s in sessions]: 37 | session = bus.get_object("org.freedesktop.login1", path) 38 | properties_interface = dbus.Interface( 39 | session, "org.freedesktop.DBus.Properties" 40 | ) 41 | properties = properties_interface.GetAll("org.freedesktop.login1.Session") 42 | results.append((session_id, properties)) 43 | except dbus.exceptions.DBusException as error: 44 | raise LogindDBusException(error) from error 45 | 46 | return results 47 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from autosuspend.checks import Check 4 | 5 | 6 | class CheckTest(abc.ABC): 7 | @abc.abstractmethod 8 | def create_instance(self, name: str) -> Check: 9 | pass 10 | 11 | def test_the_configured_name_is_used(self) -> None: 12 | name = "checktestname" 13 | assert self.create_instance(name).name == name 14 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Iterable 2 | from pathlib import Path 3 | from typing import Any 4 | 5 | from dbus import Bus 6 | from dbus.proxies import ProxyObject 7 | import dbusmock 8 | from dbusmock.pytest_fixtures import dbusmock_system, PrivateDBus # noqa: F401 9 | import pytest 10 | from pytest_httpserver import HTTPServer 11 | from werkzeug.wrappers import Request, Response 12 | 13 | from autosuspend.util import systemd as util_systemd 14 | 15 | 16 | @pytest.fixture 17 | def serve_file(httpserver: HTTPServer) -> Callable[[Path], str]: 18 | """Serve a file via HTTP. 19 | 20 | Returns: 21 | A callable that expected the file path to server. It returns the URL to 22 | use for accessing the file. 23 | """ 24 | 25 | def serve(the_file: Path) -> str: 26 | path = f"/{the_file.name}" 27 | httpserver.expect_request(path).respond_with_data(the_file.read_bytes()) 28 | return httpserver.url_for(path) 29 | 30 | return serve 31 | 32 | 33 | @pytest.fixture 34 | def serve_protected(httpserver: HTTPServer) -> Callable[[Path], tuple[str, str, str]]: 35 | """Serve a file behind basic authentication. 36 | 37 | Returns: 38 | A callable that accepts the file path to serve. It returns as a tuple 39 | the URL to use for the file, valid username and password 40 | """ 41 | realm = "the_realm" 42 | username = "the_user" 43 | password = "the_password" # only for testing 44 | 45 | def serve(the_file: Path) -> tuple[str, str, str]: 46 | def handler(request: Request) -> Response: 47 | auth = request.authorization 48 | 49 | if not auth or not ( 50 | auth.username == username and auth.password == password 51 | ): 52 | return Response( 53 | "Authentication required", 54 | 401, 55 | {"WWW-Authenticate": f"Basic realm={realm}"}, 56 | ) 57 | 58 | else: 59 | return Response(the_file.read_bytes()) 60 | 61 | path = f"/{the_file.name}" 62 | httpserver.expect_request(path).respond_with_handler(handler) 63 | return (httpserver.url_for(path), username, password) 64 | 65 | return serve 66 | 67 | 68 | @pytest.fixture 69 | def logind( 70 | monkeypatch: Any, 71 | dbusmock_system: PrivateDBus, # noqa 72 | ) -> Iterable[ProxyObject]: 73 | pytest.importorskip("dbus") 74 | pytest.importorskip("gi") 75 | 76 | with dbusmock.SpawnedMock.spawn_with_template("logind") as server: 77 | 78 | def get_bus() -> Bus: 79 | return dbusmock_system.bustype.get_connection() 80 | 81 | monkeypatch.setattr(util_systemd, "_get_bus", get_bus) 82 | 83 | yield server.obj 84 | 85 | 86 | @pytest.fixture 87 | def _logind_dbus_error( 88 | monkeypatch: Any, dbusmock_system: PrivateDBus # noqa 89 | ) -> Iterable[None]: 90 | pytest.importorskip("dbus") 91 | pytest.importorskip("gi") 92 | 93 | with dbusmock.SpawnedMock.spawn_with_template("logind"): 94 | 95 | def get_bus() -> Bus: 96 | import dbus 97 | 98 | raise dbus.exceptions.ValidationException("Test") 99 | 100 | monkeypatch.setattr(util_systemd, "_get_bus", get_bus) 101 | 102 | yield 103 | -------------------------------------------------------------------------------- /tests/data/mindeps-test.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | interval = 5 3 | idle_time = 900 4 | suspend_cmd = /usr/bin/systemctl suspend 5 | wakeup_cmd = echo {timestamp:.0f} > /sys/class/rtc/rtc0/wakealarm 6 | woke_up_file = /var/run/autosuspend-just-woke-up 7 | lock_file = /tmp/autosuspend-test-mindeps.lock 8 | 9 | [check.Ping] 10 | enabled = true 11 | hosts = localhost 12 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | 3 | from autosuspend.checks import Check 4 | 5 | 6 | class DummyCheck(Check): 7 | @classmethod 8 | def create(cls, name: str, config: configparser.SectionProxy) -> "DummyCheck": 9 | raise NotImplementedError() 10 | 11 | def check(self) -> str | None: 12 | pass 13 | 14 | 15 | class TestCheck: 16 | class TestName: 17 | def test_returns_the_provided_name(self) -> None: 18 | name = "test" 19 | assert DummyCheck(name).name == name 20 | 21 | def test_has_a_sensible_default(self) -> None: 22 | assert DummyCheck().name is not None 23 | 24 | def test_has_a_string_representation(self) -> None: 25 | assert isinstance(str(DummyCheck("test")), str) 26 | -------------------------------------------------------------------------------- /tests/test_checks_activity.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from autosuspend.checks import Activity 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "name", 8 | [ 9 | "ActiveCalendarEvent", 10 | "ActiveConnection", 11 | "ExternalCommand", 12 | "JsonPath", 13 | "Kodi", 14 | "KodiIdleTime", 15 | "LastLogActivity", 16 | "Load", 17 | "LogindSessionsIdle", 18 | "Mpd", 19 | "NetworkBandwidth", 20 | "Ping", 21 | "Processes", 22 | "Smb", 23 | "Users", 24 | "XIdleTime", 25 | "XPath", 26 | ], 27 | ) 28 | def test_legacy_check_names_are_available(name: str) -> None: 29 | res = __import__("autosuspend.checks.activity", fromlist=[name]) 30 | assert issubclass(getattr(res, name), Activity) 31 | -------------------------------------------------------------------------------- /tests/test_checks_command.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | import subprocess 3 | 4 | import pytest 5 | from pytest_mock import MockerFixture 6 | 7 | from autosuspend.checks import ( 8 | Activity, 9 | Check, 10 | ConfigurationError, 11 | SevereCheckError, 12 | TemporaryCheckError, 13 | ) 14 | from autosuspend.checks.command import CommandActivity, CommandMixin, CommandWakeup 15 | 16 | from . import CheckTest 17 | from .utils import config_section 18 | 19 | 20 | class _CommandMixinSub(CommandMixin, Activity): 21 | def __init__(self, name: str, command: str) -> None: 22 | Activity.__init__(self, name) 23 | CommandMixin.__init__(self, command) 24 | 25 | def check(self) -> str | None: 26 | pass 27 | 28 | 29 | class TestCommandMixin: 30 | class TestCreate: 31 | def test_it_works(self) -> None: 32 | section = config_section({"command": "narf bla"}) 33 | check: _CommandMixinSub = _CommandMixinSub.create( 34 | "name", 35 | section, 36 | ) 37 | assert check._command == "narf bla" 38 | 39 | def test_throws_if_no_command_is_configured(self) -> None: 40 | with pytest.raises(ConfigurationError): 41 | _CommandMixinSub.create("name", config_section()) 42 | 43 | 44 | class TestCommandActivity(CheckTest): 45 | def create_instance(self, name: str) -> Check: 46 | return CommandActivity(name, "asdfasdf") 47 | 48 | def test_reports_activity_if_the_command_succeeds( 49 | self, mocker: MockerFixture 50 | ) -> None: 51 | mock = mocker.patch("subprocess.check_call") 52 | assert ( 53 | CommandActivity.create( 54 | "name", config_section({"command": "foo bar"}) 55 | ).check() 56 | is not None 57 | ) 58 | mock.assert_called_once_with("foo bar", shell=True) 59 | 60 | def test_reports_no_activity_if_the_command_fails( 61 | self, mocker: MockerFixture 62 | ) -> None: 63 | mock = mocker.patch("subprocess.check_call") 64 | mock.side_effect = subprocess.CalledProcessError(2, "foo bar") 65 | assert ( 66 | CommandActivity.create( 67 | "name", config_section({"command": "foo bar"}) 68 | ).check() 69 | is None 70 | ) 71 | mock.assert_called_once_with("foo bar", shell=True) 72 | 73 | def test_reports_missing_commands(self) -> None: 74 | with pytest.raises(SevereCheckError): 75 | CommandActivity.create( 76 | "name", config_section({"command": "thisreallydoesnotexist"}) 77 | ).check() 78 | 79 | 80 | class TestCommandWakeup(CheckTest): 81 | def create_instance(self, name: str) -> Check: 82 | return CommandWakeup(name, "asdf") 83 | 84 | def test_reports_the_wakup_time_received_from_the_command(self) -> None: 85 | check = CommandWakeup("test", "echo 1234") 86 | assert check.check(datetime.now(timezone.utc)) == datetime.fromtimestamp( 87 | 1234, timezone.utc 88 | ) 89 | 90 | def test_reports_no_wakeup_without_command_output(self) -> None: 91 | check = CommandWakeup("test", "echo") 92 | assert check.check(datetime.now(timezone.utc)) is None 93 | 94 | def test_raises_an_error_if_the_command_output_cannot_be_parsed(self) -> None: 95 | check = CommandWakeup("test", "echo asdfasdf") 96 | with pytest.raises(TemporaryCheckError): 97 | check.check(datetime.now(timezone.utc)) 98 | 99 | def test_uses_only_the_first_output_line(self, mocker: MockerFixture) -> None: 100 | mock = mocker.patch("subprocess.check_output") 101 | mock.return_value = "1234\nignore\n" 102 | check = CommandWakeup("test", "echo bla") 103 | assert check.check(datetime.now(timezone.utc)) == datetime.fromtimestamp( 104 | 1234, timezone.utc 105 | ) 106 | 107 | def test_uses_only_the_first_line_even_if_empty( 108 | self, mocker: MockerFixture 109 | ) -> None: 110 | mock = mocker.patch("subprocess.check_output") 111 | mock.return_value = " \nignore\n" 112 | check = CommandWakeup("test", "echo bla") 113 | assert check.check(datetime.now(timezone.utc)) is None 114 | 115 | def test_raises_if_the_called_command_fails(self, mocker: MockerFixture) -> None: 116 | mock = mocker.patch("subprocess.check_output") 117 | mock.side_effect = subprocess.CalledProcessError(2, "foo bar") 118 | check = CommandWakeup("test", "echo bla") 119 | with pytest.raises(TemporaryCheckError): 120 | check.check(datetime.now(timezone.utc)) 121 | 122 | def test_reports_missing_executables(self) -> None: 123 | check = CommandWakeup("test", "reallydoesntexist bla") 124 | with pytest.raises(SevereCheckError): 125 | check.check(datetime.now(timezone.utc)) 126 | -------------------------------------------------------------------------------- /tests/test_checks_ical/after-horizon.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20180603T194125Z 23 | LAST-MODIFIED:20180603T194144Z 24 | DTSTAMP:20180603T194144Z 25 | UID:6ff13ee1-e548-41b1-8e08-d7725423743a 26 | SUMMARY:long-event 27 | DTSTART;TZID=Europe/Berlin:20040618T000000 28 | DTEND;TZID=Europe/Berlin:20040618T150000 29 | TRANSP:OPAQUE 30 | SEQUENCE:1 31 | END:VEVENT 32 | END:VCALENDAR 33 | -------------------------------------------------------------------------------- /tests/test_checks_ical/all-day-events.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VEVENT 5 | CREATED:20180601T194043Z 6 | LAST-MODIFIED:20180601T194050Z 7 | DTSTAMP:20180601T194050Z 8 | UID:0f82aa78-1478-4093-85c5-16d754f362f6 9 | SUMMARY:between 10 | DTSTART;VALUE=DATE:20180613 11 | DTEND;VALUE=DATE:20180615 12 | TRANSP:TRANSPARENT 13 | END:VEVENT 14 | BEGIN:VEVENT 15 | CREATED:20180601T194002Z 16 | LAST-MODIFIED:20180601T194303Z 17 | DTSTAMP:20180601T194303Z 18 | UID:630f3b71-865e-4125-977d-a2fd0009ce7d 19 | SUMMARY:start 20 | DTSTART;VALUE=DATE:20180609 21 | DTEND;VALUE=DATE:20180612 22 | TRANSP:TRANSPARENT 23 | X-MOZ-GENERATION:1 24 | END:VEVENT 25 | BEGIN:VEVENT 26 | CREATED:20180601T194054Z 27 | LAST-MODIFIED:20180601T194307Z 28 | DTSTAMP:20180601T194307Z 29 | UID:dc1c0bfc-633c-4d34-8de4-f6e9bcdb5fc6 30 | SUMMARY:end 31 | DTSTART;VALUE=DATE:20180617 32 | DTEND;VALUE=DATE:20180620 33 | TRANSP:TRANSPARENT 34 | X-MOZ-GENERATION:1 35 | END:VEVENT 36 | BEGIN:VEVENT 37 | CREATED:20180601T194313Z 38 | LAST-MODIFIED:20180601T194317Z 39 | DTSTAMP:20180601T194317Z 40 | UID:5095407e-5e63-4609-93a0-5dcd45ed5bf5 41 | SUMMARY:after 42 | DTSTART;VALUE=DATE:20180619 43 | DTEND;VALUE=DATE:20180620 44 | TRANSP:TRANSPARENT 45 | END:VEVENT 46 | BEGIN:VEVENT 47 | CREATED:20180601T195811Z 48 | LAST-MODIFIED:20180601T195814Z 49 | DTSTAMP:20180601T195814Z 50 | UID:550119de-eef7-4820-9843-d260515807d2 51 | SUMMARY:before 52 | DTSTART;VALUE=DATE:20180605 53 | DTEND;VALUE=DATE:20180606 54 | TRANSP:TRANSPARENT 55 | END:VEVENT 56 | END:VCALENDAR 57 | -------------------------------------------------------------------------------- /tests/test_checks_ical/all-day-recurring-exclusions.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VEVENT 5 | CREATED:20180627T111330Z 6 | LAST-MODIFIED:20180627T111340Z 7 | DTSTAMP:20180627T111340Z 8 | UID:ccf1c6b9-44c4-4fdb-8a98-0165e6f2e369 9 | SUMMARY:single all day 10 | DTSTART;VALUE=DATE:20180625 11 | DTEND;VALUE=DATE:20180626 12 | EXDATE:20180630 13 | RRULE:FREQ=DAILY 14 | TRANSP:TRANSPARENT 15 | END:VEVENT 16 | END:VCALENDAR 17 | -------------------------------------------------------------------------------- /tests/test_checks_ical/all-day-recurring.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VEVENT 5 | CREATED:20180627T111330Z 6 | LAST-MODIFIED:20180627T111340Z 7 | DTSTAMP:20180627T111340Z 8 | UID:ccf1c6b9-44c4-4fdb-8a98-0165e6f2e369 9 | SUMMARY:single all day 10 | DTSTART;VALUE=DATE:20180625 11 | DTEND;VALUE=DATE:20180626 12 | RRULE:FREQ=DAILY 13 | TRANSP:TRANSPARENT 14 | END:VEVENT 15 | END:VCALENDAR 16 | -------------------------------------------------------------------------------- /tests/test_checks_ical/all-day-starts.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VEVENT 5 | CREATED:20180627T111330Z 6 | LAST-MODIFIED:20180627T111340Z 7 | DTSTAMP:20180627T111340Z 8 | UID:ccf1c6b9-44c4-4fdb-8a98-0165e6f2e369 9 | SUMMARY:single all day 10 | DTSTART;VALUE=DATE:20180625 11 | DTEND;VALUE=DATE:20180626 12 | TRANSP:TRANSPARENT 13 | END:VEVENT 14 | BEGIN:VEVENT 15 | CREATED:20180627T111347Z 16 | LAST-MODIFIED:20180627T111357Z 17 | DTSTAMP:20180627T111357Z 18 | UID:a2dab4dd-1ede-4733-af8e-90cff0e26f79 19 | SUMMARY:two all days 20 | DTSTART;VALUE=DATE:20180628 21 | DTEND;VALUE=DATE:20180630 22 | TRANSP:TRANSPARENT 23 | BEGIN:VALARM 24 | ACTION:DISPLAY 25 | TRIGGER;VALUE=DURATION:-PT15M 26 | DESCRIPTION:Default Mozilla Description 27 | END:VALARM 28 | END:VEVENT 29 | END:VCALENDAR 30 | -------------------------------------------------------------------------------- /tests/test_checks_ical/before-horizon.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20180603T194125Z 23 | LAST-MODIFIED:20180603T194144Z 24 | DTSTAMP:20180603T194144Z 25 | UID:6ff13ee1-e548-41b1-8e08-d7725423743a 26 | SUMMARY:long-event 27 | DTSTART;TZID=Europe/Berlin:20040617T000000 28 | DTEND;TZID=Europe/Berlin:20040617T150000 29 | TRANSP:OPAQUE 30 | SEQUENCE:1 31 | END:VEVENT 32 | END:VCALENDAR 33 | -------------------------------------------------------------------------------- /tests/test_checks_ical/exclusions.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20180602T160606Z 23 | LAST-MODIFIED:20180602T160632Z 24 | DTSTAMP:20180602T160632Z 25 | UID:a40c5b76-e3f5-4259-92f5-26692f99f131 26 | SUMMARY:recurring 27 | RRULE:FREQ=DAILY;UNTIL=20180617T120000Z 28 | EXDATE:20180614T120000Z 29 | DTSTART;TZID=Europe/Berlin:20180611T140000 30 | DTEND;TZID=Europe/Berlin:20180611T160000 31 | TRANSP:OPAQUE 32 | X-MOZ-GENERATION:4 33 | SEQUENCE:2 34 | END:VEVENT 35 | END:VCALENDAR 36 | -------------------------------------------------------------------------------- /tests/test_checks_ical/floating.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VEVENT 5 | CREATED:20180602T151629Z 6 | LAST-MODIFIED:20180602T152512Z 7 | DTSTAMP:20180602T152512Z 8 | UID:f0028400-24e2-4f10-81a0-032372781443 9 | SUMMARY:floating 10 | DTSTART:20180610T150000 11 | DTEND:20180610T170000 12 | TRANSP:OPAQUE 13 | SEQUENCE:5 14 | X-MOZ-GENERATION:3 15 | END:VEVENT 16 | BEGIN:VEVENT 17 | CREATED:20180602T151701Z 18 | LAST-MODIFIED:20180602T152732Z 19 | DTSTAMP:20180602T152732Z 20 | UID:0ef23894-702e-40ac-ab09-94fa8c9c51fd 21 | SUMMARY:floating recurring 22 | RRULE:FREQ=DAILY 23 | DTSTART:20180612T180000 24 | DTEND:20180612T200000 25 | TRANSP:OPAQUE 26 | X-MOZ-GENERATION:5 27 | SEQUENCE:3 28 | END:VEVENT 29 | END:VCALENDAR 30 | -------------------------------------------------------------------------------- /tests/test_checks_ical/issue-41.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Inverse inc./SOGo 4.0.0//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | X-LIC-LOCATION:Europe/Berlin 7 | BEGIN:DAYLIGHT 8 | TZOFFSETFROM:+0100 9 | TZOFFSETTO:+0200 10 | TZNAME:CEST 11 | DTSTART:19700329T020000 12 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 13 | END:DAYLIGHT 14 | BEGIN:STANDARD 15 | TZOFFSETFROM:+0200 16 | TZOFFSETTO:+0100 17 | TZNAME:CET 18 | DTSTART:19701025T030000 19 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 20 | END:STANDARD 21 | END:VTIMEZONE 22 | BEGIN:VEVENT 23 | UID:2C-5B315480-3-4D014C80 24 | SUMMARY:StayAlive 25 | LOCATION:Home 26 | CLASS:PUBLIC 27 | X-SOGO-SEND-APPOINTMENT-NOTIFICATIONS:NO 28 | RRULE:FREQ=DAILY 29 | TRANSP:OPAQUE 30 | DTSTART;TZID=Europe/Berlin:20180626T170000 31 | DTEND;TZID=Europe/Berlin:20180626T210000 32 | CREATED:20180625T204700Z 33 | DTSTAMP:20180625T204700Z 34 | LAST-MODIFIED:20180625T204700Z 35 | END:VEVENT 36 | END:VCALENDAR 37 | -------------------------------------------------------------------------------- /tests/test_checks_ical/long-event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20180603T194125Z 23 | LAST-MODIFIED:20180603T194144Z 24 | DTSTAMP:20180603T194144Z 25 | UID:6ff13ee1-e548-41b1-8e08-d7725423743a 26 | SUMMARY:long-event 27 | DTSTART;TZID=Europe/Berlin:20160605T130000 28 | DTEND;TZID=Europe/Berlin:20260605T150000 29 | TRANSP:OPAQUE 30 | SEQUENCE:1 31 | END:VEVENT 32 | END:VCALENDAR 33 | -------------------------------------------------------------------------------- /tests/test_checks_ical/multiple.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20180603T194125Z 23 | LAST-MODIFIED:20180603T194144Z 24 | DTSTAMP:20180603T194144Z 25 | UID:6ff13ee1-e548-41b1-8e08-d7725423743a 26 | SUMMARY:long-event 27 | DTSTART;TZID=Europe/Berlin:20040605T130000 28 | DTEND;TZID=Europe/Berlin:20040605T150000 29 | TRANSP:OPAQUE 30 | SEQUENCE:1 31 | END:VEVENT 32 | BEGIN:VEVENT 33 | CREATED:20180403T194125Z 34 | LAST-MODIFIED:20180403T194144Z 35 | DTSTAMP:20180403T194144Z 36 | UID:6ff13ee1-e548-41b1-8e08-d7725423743b 37 | SUMMARY:early-event 38 | DTSTART;TZID=Europe/Berlin:20040405T130000 39 | DTEND;TZID=Europe/Berlin:20040405T150000 40 | TRANSP:OPAQUE 41 | SEQUENCE:1 42 | END:VEVENT 43 | END:VCALENDAR 44 | -------------------------------------------------------------------------------- /tests/test_checks_ical/normal-events-corner-cases.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20180601T200433Z 23 | LAST-MODIFIED:20180601T200455Z 24 | DTSTAMP:20180601T200455Z 25 | UID:1c056498-9c83-4e0f-bb77-777c967c9a54 26 | SUMMARY:before include 27 | DTSTART;TZID=Europe/Berlin:20180603T210000 28 | DTEND;TZID=Europe/Berlin:20180604T020000 29 | TRANSP:OPAQUE 30 | X-MOZ-GENERATION:2 31 | SEQUENCE:1 32 | END:VEVENT 33 | BEGIN:VEVENT 34 | CREATED:20180601T200328Z 35 | LAST-MODIFIED:20180601T200511Z 36 | DTSTAMP:20180601T200511Z 37 | UID:db4b1c02-6ac2-4def-bfb0-9a96b510387e 38 | SUMMARY:direct start 39 | DTSTART;TZID=Europe/Berlin:20180604T000000 40 | DTEND;TZID=Europe/Berlin:20180604T030000 41 | TRANSP:OPAQUE 42 | X-MOZ-GENERATION:2 43 | SEQUENCE:1 44 | END:VEVENT 45 | BEGIN:VEVENT 46 | CREATED:20180601T200518Z 47 | LAST-MODIFIED:20180601T200531Z 48 | DTSTAMP:20180601T200531Z 49 | UID:06622f56-d945-490b-9fd7-0fe5015f3188 50 | SUMMARY:in between 51 | DTSTART;TZID=Europe/Berlin:20180607T040000 52 | DTEND;TZID=Europe/Berlin:20180607T090000 53 | TRANSP:OPAQUE 54 | X-MOZ-GENERATION:1 55 | END:VEVENT 56 | BEGIN:VEVENT 57 | CREATED:20180601T200351Z 58 | LAST-MODIFIED:20180601T200555Z 59 | DTSTAMP:20180601T200555Z 60 | UID:48d1debe-e457-4bde-9bea-ab18be136d4a 61 | SUMMARY:before do not include 62 | DTSTART;TZID=Europe/Berlin:20180603T220000 63 | DTEND;TZID=Europe/Berlin:20180604T000000 64 | TRANSP:OPAQUE 65 | X-MOZ-GENERATION:4 66 | SEQUENCE:2 67 | END:VEVENT 68 | BEGIN:VEVENT 69 | CREATED:20180601T200531Z 70 | LAST-MODIFIED:20180601T200615Z 71 | DTSTAMP:20180601T200615Z 72 | UID:0a36a2e8-fac3-4337-8464-f52e5cf17bd5 73 | SUMMARY:direct end 74 | DTSTART;TZID=Europe/Berlin:20180610T220000 75 | DTEND;TZID=Europe/Berlin:20180611T000000 76 | TRANSP:OPAQUE 77 | X-MOZ-GENERATION:4 78 | SEQUENCE:1 79 | END:VEVENT 80 | BEGIN:VEVENT 81 | CREATED:20180601T200619Z 82 | LAST-MODIFIED:20180601T200633Z 83 | DTSTAMP:20180601T200633Z 84 | UID:19bf0d84-3286-44d8-8376-67549a419001 85 | SUMMARY:end overlap 86 | DTSTART;TZID=Europe/Berlin:20180610T210000 87 | DTEND;TZID=Europe/Berlin:20180611T020000 88 | TRANSP:OPAQUE 89 | X-MOZ-GENERATION:2 90 | SEQUENCE:1 91 | END:VEVENT 92 | BEGIN:VEVENT 93 | CREATED:20180601T200643Z 94 | LAST-MODIFIED:20180601T200651Z 95 | DTSTAMP:20180601T200651Z 96 | UID:ae376911-eab5-45fe-bb5b-14e9fd904b44 97 | SUMMARY:end after 98 | DTSTART;TZID=Europe/Berlin:20180611T000000 99 | DTEND;TZID=Europe/Berlin:20180611T030000 100 | TRANSP:OPAQUE 101 | X-MOZ-GENERATION:1 102 | END:VEVENT 103 | BEGIN:VEVENT 104 | CREATED:20180602T144323Z 105 | LAST-MODIFIED:20180602T144338Z 106 | DTSTAMP:20180602T144338Z 107 | UID:f52ee7b1-810f-4b08-bf28-80e8ae226ac3 108 | SUMMARY:overlapping 109 | DTSTART;TZID=Europe/Berlin:20180602T200000 110 | DTEND;TZID=Europe/Berlin:20180612T230000 111 | TRANSP:OPAQUE 112 | X-MOZ-GENERATION:2 113 | SEQUENCE:1 114 | END:VEVENT 115 | END:VCALENDAR 116 | -------------------------------------------------------------------------------- /tests/test_checks_ical/old-event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20180603T194125Z 23 | LAST-MODIFIED:20180603T194144Z 24 | DTSTAMP:20180603T194144Z 25 | UID:6ff13ee1-e548-41b1-8e08-d7725423743a 26 | SUMMARY:long-event 27 | DTSTART;TZID=Europe/Berlin:20040605T130000 28 | DTEND;TZID=Europe/Berlin:20040605T150000 29 | TRANSP:OPAQUE 30 | SEQUENCE:1 31 | END:VEVENT 32 | END:VCALENDAR 33 | -------------------------------------------------------------------------------- /tests/test_checks_ical/recurring-change-dst.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20180603T200159Z 23 | LAST-MODIFIED:20180603T200414Z 24 | DTSTAMP:20180603T200414Z 25 | UID:d083699e-6f37-4a85-b20d-f03750aa6691 26 | SUMMARY:recurring 27 | RRULE:FREQ=DAILY 28 | EXDATE:20181214T130000Z 29 | DTSTART;TZID=Europe/Berlin:20180606T140000 30 | DTEND;TZID=Europe/Berlin:20180606T160000 31 | TRANSP:OPAQUE 32 | X-MOZ-GENERATION:4 33 | SEQUENCE:2 34 | END:VEVENT 35 | BEGIN:VEVENT 36 | CREATED:20180603T200213Z 37 | LAST-MODIFIED:20180603T200243Z 38 | DTSTAMP:20180603T200243Z 39 | UID:d083699e-6f37-4a85-b20d-f03750aa6691 40 | SUMMARY:recurring 41 | RECURRENCE-ID;TZID=Europe/Berlin:20180612T140000 42 | DTSTART;TZID=Europe/Berlin:20180612T140000 43 | DTEND;TZID=Europe/Berlin:20180612T160000 44 | SEQUENCE:5 45 | TRANSP:OPAQUE 46 | X-MOZ-GENERATION:4 47 | END:VEVENT 48 | BEGIN:VEVENT 49 | CREATED:20180603T200401Z 50 | LAST-MODIFIED:20180603T200407Z 51 | DTSTAMP:20180603T200407Z 52 | UID:d083699e-6f37-4a85-b20d-f03750aa6691 53 | SUMMARY:recurring 54 | RECURRENCE-ID;TZID=Europe/Berlin:20181212T140000 55 | DTSTART;TZID=Europe/Berlin:20181212T110000 56 | DTEND;TZID=Europe/Berlin:20181212T130000 57 | SEQUENCE:2 58 | TRANSP:OPAQUE 59 | X-MOZ-GENERATION:4 60 | END:VEVENT 61 | END:VCALENDAR 62 | -------------------------------------------------------------------------------- /tests/test_checks_ical/simple-recurring.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20180601T182719Z 23 | LAST-MODIFIED:20180601T182803Z 24 | DTSTAMP:20180601T182803Z 25 | UID:74c93379-f763-439b-9d11-eca4d431bfc7 26 | SUMMARY:Stay awake 27 | RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR 28 | DTSTART;TZID=Europe/Berlin:20180327T090000 29 | DTEND;TZID=Europe/Berlin:20180327T180000 30 | TRANSP:OPAQUE 31 | X-MOZ-GENERATION:2 32 | SEQUENCE:1 33 | END:VEVENT 34 | END:VCALENDAR 35 | -------------------------------------------------------------------------------- /tests/test_checks_ical/single-change.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20180603T194125Z 23 | LAST-MODIFIED:20180603T194144Z 24 | DTSTAMP:20180603T194144Z 25 | UID:6ff13ee1-e548-41b1-8e08-d7725423743a 26 | SUMMARY:recurring 27 | RRULE:FREQ=DAILY 28 | DTSTART;TZID=Europe/Berlin:20180605T130000 29 | DTEND;TZID=Europe/Berlin:20180605T150000 30 | TRANSP:OPAQUE 31 | X-MOZ-GENERATION:4 32 | SEQUENCE:1 33 | END:VEVENT 34 | BEGIN:VEVENT 35 | CREATED:20180603T194138Z 36 | LAST-MODIFIED:20180603T194140Z 37 | DTSTAMP:20180603T194140Z 38 | UID:6ff13ee1-e548-41b1-8e08-d7725423743a 39 | SUMMARY:recurring 40 | RECURRENCE-ID;TZID=Europe/Berlin:20180613T130000 41 | DTSTART;TZID=Europe/Berlin:20180613T160000 42 | DTEND;TZID=Europe/Berlin:20180613T180000 43 | SEQUENCE:2 44 | TRANSP:OPAQUE 45 | X-MOZ-GENERATION:4 46 | END:VEVENT 47 | BEGIN:VEVENT 48 | CREATED:20180603T194141Z 49 | LAST-MODIFIED:20180603T194144Z 50 | DTSTAMP:20180603T194144Z 51 | UID:6ff13ee1-e548-41b1-8e08-d7725423743a 52 | SUMMARY:recurring 53 | RECURRENCE-ID;TZID=Europe/Berlin:20180615T130000 54 | DTSTART;TZID=Europe/Berlin:20180615T110000 55 | DTEND;TZID=Europe/Berlin:20180615T130000 56 | SEQUENCE:2 57 | TRANSP:OPAQUE 58 | X-MOZ-GENERATION:4 59 | END:VEVENT 60 | END:VCALENDAR 61 | -------------------------------------------------------------------------------- /tests/test_checks_json.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from pathlib import Path 3 | from typing import Any 4 | 5 | from jsonpath_ng.ext import parse 6 | import pytest 7 | from pytest_mock import MockerFixture 8 | 9 | from autosuspend.checks import ConfigurationError, TemporaryCheckError 10 | from autosuspend.checks.json import JsonPath 11 | 12 | from . import CheckTest 13 | from .utils import config_section 14 | 15 | 16 | class TestJsonPath(CheckTest): 17 | def create_instance(self, name: str) -> JsonPath: 18 | return JsonPath( 19 | name=name, 20 | url="url", 21 | timeout=5, 22 | username="userx", 23 | password="pass", 24 | jsonpath=parse("b"), 25 | ) 26 | 27 | @staticmethod 28 | @pytest.fixture 29 | def json_get_mock(mocker: MockerFixture) -> Any: 30 | mock_reply = mocker.MagicMock() 31 | mock_reply.json.return_value = {"a": {"b": 42, "c": "ignore"}} 32 | return mocker.patch("requests.Session.get", return_value=mock_reply) 33 | 34 | def test_matching(self, json_get_mock: Any) -> None: 35 | url = "nourl" 36 | assert ( 37 | JsonPath("foo", jsonpath=parse("a.b"), url=url, timeout=5).check() 38 | is not None 39 | ) 40 | 41 | json_get_mock.assert_called_once_with( 42 | url, timeout=5, headers={"Accept": "application/json"} 43 | ) 44 | json_get_mock().json.assert_called_once() 45 | 46 | def test_filter_expressions_work(self, json_get_mock: Any) -> None: 47 | url = "nourl" 48 | assert ( 49 | JsonPath( 50 | "foo", jsonpath=parse("$[?(@.c=='ignore')]"), url=url, timeout=5 51 | ).check() 52 | is not None 53 | ) 54 | 55 | json_get_mock.assert_called_once_with( 56 | url, timeout=5, headers={"Accept": "application/json"} 57 | ) 58 | json_get_mock().json.assert_called_once() 59 | 60 | def test_not_matching(self, json_get_mock: Any) -> None: 61 | url = "nourl" 62 | assert ( 63 | JsonPath("foo", jsonpath=parse("not.there"), url=url, timeout=5).check() 64 | is None 65 | ) 66 | 67 | json_get_mock.assert_called_once_with( 68 | url, timeout=5, headers={"Accept": "application/json"} 69 | ) 70 | json_get_mock().json.assert_called_once() 71 | 72 | def test_network_errors_are_passed( 73 | self, datadir: Path, serve_protected: Callable[[Path], tuple[str, str, str]] 74 | ) -> None: 75 | with pytest.raises(TemporaryCheckError): 76 | JsonPath( 77 | name="name", 78 | url=serve_protected(datadir / "data.txt")[0], 79 | timeout=5, 80 | username="wrong", 81 | password="wrong", 82 | jsonpath=parse("b"), 83 | ).check() 84 | 85 | def test_not_json(self, datadir: Path, serve_file: Callable[[Path], str]) -> None: 86 | with pytest.raises(TemporaryCheckError): 87 | JsonPath( 88 | name="name", 89 | url=serve_file(datadir / "invalid.json"), 90 | timeout=5, 91 | jsonpath=parse("b"), 92 | ).check() 93 | 94 | class TestCreate: 95 | def test_it_works(self) -> None: 96 | check: JsonPath = JsonPath.create( 97 | "name", 98 | config_section( 99 | { 100 | "url": "url", 101 | "jsonpath": "a.b", 102 | "username": "user", 103 | "password": "pass", 104 | "timeout": "42", 105 | } 106 | ), 107 | ) 108 | assert check._jsonpath == parse("a.b") 109 | assert check._url == "url" 110 | assert check._username == "user" 111 | assert check._password == "pass" 112 | assert check._timeout == 42 113 | 114 | def test_raises_on_missing_json_path(self) -> None: 115 | with pytest.raises(ConfigurationError): 116 | JsonPath.create( 117 | "name", 118 | config_section( 119 | { 120 | "url": "url", 121 | "username": "user", 122 | "password": "pass", 123 | "timeout": "42", 124 | } 125 | ), 126 | ) 127 | 128 | def test_raises_on_invalid_json_path(self) -> None: 129 | with pytest.raises(ConfigurationError): 130 | JsonPath.create( 131 | "name", 132 | config_section( 133 | { 134 | "url": "url", 135 | "jsonpath": ",.asdfjasdklf", 136 | "username": "user", 137 | "password": "pass", 138 | "timeout": "42", 139 | } 140 | ), 141 | ) 142 | -------------------------------------------------------------------------------- /tests/test_checks_json/invalid.json: -------------------------------------------------------------------------------- 1 | {"broken 2 | -------------------------------------------------------------------------------- /tests/test_checks_kodi.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from pytest_mock import MockerFixture 5 | import requests.exceptions 6 | 7 | from autosuspend.checks import Check, ConfigurationError, TemporaryCheckError 8 | from autosuspend.checks.kodi import Kodi, KodiIdleTime 9 | 10 | from . import CheckTest 11 | from .utils import config_section 12 | 13 | 14 | class TestKodi(CheckTest): 15 | def create_instance(self, name: str) -> Check: 16 | return Kodi(name, url="url", timeout=10) 17 | 18 | def test_playing(self, mocker: MockerFixture) -> None: 19 | mock_reply = mocker.MagicMock() 20 | mock_reply.json.return_value = { 21 | "id": 1, 22 | "jsonrpc": "2.0", 23 | "result": [{"playerid": 0, "type": "audio"}], 24 | } 25 | mocker.patch("requests.Session.get", return_value=mock_reply) 26 | 27 | assert Kodi("foo", url="url", timeout=10).check() is not None 28 | 29 | mock_reply.json.assert_called_once_with() 30 | 31 | def test_not_playing(self, mocker: MockerFixture) -> None: 32 | mock_reply = mocker.MagicMock() 33 | mock_reply.json.return_value = {"id": 1, "jsonrpc": "2.0", "result": []} 34 | mocker.patch("requests.Session.get", return_value=mock_reply) 35 | 36 | assert Kodi("foo", url="url", timeout=10).check() is None 37 | 38 | mock_reply.json.assert_called_once_with() 39 | 40 | def test_playing_suspend_while_paused(self, mocker: MockerFixture) -> None: 41 | mock_reply = mocker.MagicMock() 42 | mock_reply.json.return_value = { 43 | "id": 1, 44 | "jsonrpc": "2.0", 45 | "result": {"Player.Playing": True}, 46 | } 47 | mocker.patch("requests.Session.get", return_value=mock_reply) 48 | 49 | assert ( 50 | Kodi("foo", url="url", timeout=10, suspend_while_paused=True).check() 51 | is not None 52 | ) 53 | 54 | mock_reply.json.assert_called_once_with() 55 | 56 | def test_not_playing_suspend_while_paused(self, mocker: MockerFixture) -> None: 57 | mock_reply = mocker.MagicMock() 58 | mock_reply.json.return_value = { 59 | "id": 1, 60 | "jsonrpc": "2.0", 61 | "result": {"Player.Playing": False}, 62 | } 63 | mocker.patch("requests.Session.get", return_value=mock_reply) 64 | 65 | assert ( 66 | Kodi("foo", url="url", timeout=10, suspend_while_paused=True).check() 67 | is None 68 | ) 69 | 70 | mock_reply.json.assert_called_once_with() 71 | 72 | def test_assertion_no_result(self, mocker: MockerFixture) -> None: 73 | mock_reply = mocker.MagicMock() 74 | mock_reply.json.return_value = {"id": 1, "jsonrpc": "2.0"} 75 | mocker.patch("requests.Session.get", return_value=mock_reply) 76 | 77 | with pytest.raises(TemporaryCheckError): 78 | Kodi("foo", url="url", timeout=10).check() 79 | 80 | def test_request_error(self, mocker: MockerFixture) -> None: 81 | mocker.patch( 82 | "requests.Session.get", side_effect=requests.exceptions.RequestException() 83 | ) 84 | 85 | with pytest.raises(TemporaryCheckError): 86 | Kodi("foo", url="url", timeout=10).check() 87 | 88 | def test_json_error(self, mocker: MockerFixture) -> None: 89 | mock_reply = mocker.MagicMock() 90 | mock_reply.json.side_effect = json.JSONDecodeError("test", "test", 42) 91 | mocker.patch("requests.Session.get", return_value=mock_reply) 92 | 93 | with pytest.raises(TemporaryCheckError): 94 | Kodi("foo", url="url", timeout=10).check() 95 | 96 | def test_create(self) -> None: 97 | check = Kodi.create( 98 | "name", 99 | config_section( 100 | { 101 | "url": "anurl", 102 | "timeout": "12", 103 | } 104 | ), 105 | ) 106 | 107 | assert check._url.startswith("anurl") 108 | assert check._timeout == 12 109 | assert not check._suspend_while_paused 110 | 111 | def test_create_default_url(self) -> None: 112 | check = Kodi.create("name", config_section()) 113 | 114 | assert check._url.split("?")[0] == "http://localhost:8080/jsonrpc" 115 | 116 | def test_create_timeout_no_number(self) -> None: 117 | with pytest.raises(ConfigurationError): 118 | Kodi.create("name", config_section({"url": "anurl", "timeout": "string"})) 119 | 120 | def test_create_suspend_while_paused(self) -> None: 121 | check = Kodi.create( 122 | "name", config_section({"url": "anurl", "suspend_while_paused": "True"}) 123 | ) 124 | 125 | assert check._url.startswith("anurl") 126 | assert check._suspend_while_paused 127 | 128 | 129 | class TestKodiIdleTime(CheckTest): 130 | def create_instance(self, name: str) -> Check: 131 | return KodiIdleTime(name, url="url", timeout=10, idle_time=10) 132 | 133 | def test_create(self) -> None: 134 | check = KodiIdleTime.create( 135 | "name", config_section({"url": "anurl", "timeout": "12", "idle_time": "42"}) 136 | ) 137 | 138 | assert check._url.startswith("anurl") 139 | assert check._timeout == 12 140 | assert check._idle_time == 42 141 | 142 | def test_create_default_url(self) -> None: 143 | check = KodiIdleTime.create("name", config_section()) 144 | 145 | assert check._url.split("?")[0] == "http://localhost:8080/jsonrpc" 146 | 147 | def test_create_timeout_no_number(self) -> None: 148 | with pytest.raises(ConfigurationError): 149 | KodiIdleTime.create( 150 | "name", config_section({"url": "anurl", "timeout": "string"}) 151 | ) 152 | 153 | def test_create_idle_time_no_number(self) -> None: 154 | with pytest.raises(ConfigurationError): 155 | KodiIdleTime.create( 156 | "name", config_section({"url": "anurl", "idle_time": "string"}) 157 | ) 158 | 159 | def test_no_result(self, mocker: MockerFixture) -> None: 160 | mock_reply = mocker.MagicMock() 161 | mock_reply.json.return_value = {"id": 1, "jsonrpc": "2.0"} 162 | mocker.patch("requests.Session.get", return_value=mock_reply) 163 | 164 | with pytest.raises(TemporaryCheckError): 165 | KodiIdleTime("foo", url="url", timeout=10, idle_time=42).check() 166 | 167 | def test_result_is_list(self, mocker: MockerFixture) -> None: 168 | mock_reply = mocker.MagicMock() 169 | mock_reply.json.return_value = {"id": 1, "jsonrpc": "2.0", "result": []} 170 | mocker.patch("requests.Session.get", return_value=mock_reply) 171 | 172 | with pytest.raises(TemporaryCheckError): 173 | KodiIdleTime("foo", url="url", timeout=10, idle_time=42).check() 174 | 175 | def test_result_no_entry(self, mocker: MockerFixture) -> None: 176 | mock_reply = mocker.MagicMock() 177 | mock_reply.json.return_value = {"id": 1, "jsonrpc": "2.0", "result": {}} 178 | mocker.patch("requests.Session.get", return_value=mock_reply) 179 | 180 | with pytest.raises(TemporaryCheckError): 181 | KodiIdleTime("foo", url="url", timeout=10, idle_time=42).check() 182 | 183 | def test_result_wrong_entry(self, mocker: MockerFixture) -> None: 184 | mock_reply = mocker.MagicMock() 185 | mock_reply.json.return_value = { 186 | "id": 1, 187 | "jsonrpc": "2.0", 188 | "result": {"narf": True}, 189 | } 190 | mocker.patch("requests.Session.get", return_value=mock_reply) 191 | 192 | with pytest.raises(TemporaryCheckError): 193 | KodiIdleTime("foo", url="url", timeout=10, idle_time=42).check() 194 | 195 | def test_active(self, mocker: MockerFixture) -> None: 196 | mock_reply = mocker.MagicMock() 197 | mock_reply.json.return_value = { 198 | "id": 1, 199 | "jsonrpc": "2.0", 200 | "result": {"System.IdleTime(42)": False}, 201 | } 202 | mocker.patch("requests.Session.get", return_value=mock_reply) 203 | 204 | assert ( 205 | KodiIdleTime("foo", url="url", timeout=10, idle_time=42).check() is not None 206 | ) 207 | 208 | def test_inactive(self, mocker: MockerFixture) -> None: 209 | mock_reply = mocker.MagicMock() 210 | mock_reply.json.return_value = { 211 | "id": 1, 212 | "jsonrpc": "2.0", 213 | "result": {"System.IdleTime(42)": True}, 214 | } 215 | mocker.patch("requests.Session.get", return_value=mock_reply) 216 | 217 | assert KodiIdleTime("foo", url="url", timeout=10, idle_time=42).check() is None 218 | 219 | def test_request_error(self, mocker: MockerFixture) -> None: 220 | mocker.patch( 221 | "requests.Session.get", side_effect=requests.exceptions.RequestException() 222 | ) 223 | 224 | with pytest.raises(TemporaryCheckError): 225 | KodiIdleTime("foo", url="url", timeout=10, idle_time=42).check() 226 | -------------------------------------------------------------------------------- /tests/test_checks_logs.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta, timezone 2 | from pathlib import Path 3 | import re 4 | 5 | from freezegun import freeze_time 6 | import pytest 7 | import pytz 8 | 9 | from autosuspend.checks import ConfigurationError, TemporaryCheckError 10 | from autosuspend.checks.logs import LastLogActivity 11 | 12 | from . import CheckTest 13 | from .utils import config_section 14 | 15 | 16 | class TestLastLogActivity(CheckTest): 17 | def create_instance(self, name: str) -> LastLogActivity: 18 | return LastLogActivity( 19 | name=name, 20 | log_file=Path("some_file"), 21 | pattern=re.compile("^(.*)$"), 22 | delta=timedelta(minutes=10), 23 | encoding="ascii", 24 | default_timezone=timezone.utc, 25 | ) 26 | 27 | def test_is_active(self, tmpdir: Path) -> None: 28 | file_path = tmpdir / "test.log" 29 | file_path.write_text("2020-02-02 12:12:23", encoding="ascii") 30 | 31 | with freeze_time("2020-02-02 12:15:00"): 32 | assert ( 33 | LastLogActivity( 34 | "test", 35 | file_path, 36 | re.compile(r"^(.*)$"), 37 | timedelta(minutes=10), 38 | "ascii", 39 | timezone.utc, 40 | ).check() 41 | is not None 42 | ) 43 | 44 | def test_is_not_active(self, tmpdir: Path) -> None: 45 | file_path = tmpdir / "test.log" 46 | file_path.write_text("2020-02-02 12:12:23", encoding="ascii") 47 | 48 | with freeze_time("2020-02-02 12:35:00"): 49 | assert ( 50 | LastLogActivity( 51 | "test", 52 | file_path, 53 | re.compile(r"^(.*)$"), 54 | timedelta(minutes=10), 55 | "ascii", 56 | timezone.utc, 57 | ).check() 58 | is None 59 | ) 60 | 61 | def test_uses_last_line(self, tmpdir: Path) -> None: 62 | file_path = tmpdir / "test.log" 63 | # last line is too old and must be used 64 | file_path.write_text( 65 | "\n".join(["2020-02-02 12:12:23", "1900-01-01"]), encoding="ascii" 66 | ) 67 | 68 | with freeze_time("2020-02-02 12:15:00"): 69 | assert ( 70 | LastLogActivity( 71 | "test", 72 | file_path, 73 | re.compile(r"^(.*)$"), 74 | timedelta(minutes=10), 75 | "ascii", 76 | timezone.utc, 77 | ).check() 78 | is None 79 | ) 80 | 81 | def test_ignores_lines_that_do_not_match(self, tmpdir: Path) -> None: 82 | file_path = tmpdir / "test.log" 83 | file_path.write_text("ignored", encoding="ascii") 84 | 85 | assert ( 86 | LastLogActivity( 87 | "test", 88 | file_path, 89 | re.compile(r"^foo(.*)$"), 90 | timedelta(minutes=10), 91 | "ascii", 92 | timezone.utc, 93 | ).check() 94 | is None 95 | ) 96 | 97 | def test_uses_pattern(self, tmpdir: Path) -> None: 98 | file_path = tmpdir / "test.log" 99 | file_path.write_text("foo2020-02-02 12:12:23bar", encoding="ascii") 100 | 101 | with freeze_time("2020-02-02 12:15:00"): 102 | assert ( 103 | LastLogActivity( 104 | "test", 105 | file_path, 106 | re.compile(r"^foo(.*)bar$"), 107 | timedelta(minutes=10), 108 | "ascii", 109 | timezone.utc, 110 | ).check() 111 | is not None 112 | ) 113 | 114 | def test_uses_given_timezone(self, tmpdir: Path) -> None: 115 | file_path = tmpdir / "test.log" 116 | # would match if timezone wasn't used 117 | file_path.write_text("2020-02-02 12:12:00", encoding="ascii") 118 | 119 | with freeze_time("2020-02-02 12:15:00"): 120 | assert ( 121 | LastLogActivity( 122 | "test", 123 | file_path, 124 | re.compile(r"^(.*)$"), 125 | timedelta(minutes=10), 126 | "ascii", 127 | timezone(offset=timedelta(hours=10)), 128 | ).check() 129 | is None 130 | ) 131 | 132 | def test_prefers_parsed_timezone(self, tmpdir: Path) -> None: 133 | file_path = tmpdir / "test.log" 134 | # would not match if provided timezone wasn't used 135 | file_path.write_text("2020-02-02T12:12:01-01:00", encoding="ascii") 136 | 137 | with freeze_time("2020-02-02 13:15:00"): 138 | assert ( 139 | LastLogActivity( 140 | "test", 141 | file_path, 142 | re.compile(r"^(.*)$"), 143 | timedelta(minutes=10), 144 | "ascii", 145 | timezone.utc, 146 | ).check() 147 | is not None 148 | ) 149 | 150 | def test_fails_if_dates_cannot_be_parsed(self, tmpdir: Path) -> None: 151 | file_path = tmpdir / "test.log" 152 | # would match if timezone wasn't used 153 | file_path.write_text("202000xxx", encoding="ascii") 154 | 155 | with pytest.raises(TemporaryCheckError): 156 | LastLogActivity( 157 | "test", 158 | file_path, 159 | re.compile(r"^(.*)$"), 160 | timedelta(minutes=10), 161 | "ascii", 162 | timezone.utc, 163 | ).check() 164 | 165 | def test_fails_if_dates_are_in_the_future(self, tmpdir: Path) -> None: 166 | file_path = tmpdir / "test.log" 167 | # would match if timezone wasn't used 168 | file_path.write_text("2022-01-01", encoding="ascii") 169 | 170 | with freeze_time("2020-02-02 12:15:00"), pytest.raises(TemporaryCheckError): 171 | LastLogActivity( 172 | "test", 173 | file_path, 174 | re.compile(r"^(.*)$"), 175 | timedelta(minutes=10), 176 | "ascii", 177 | timezone.utc, 178 | ).check() 179 | 180 | def test_fails_if_file_cannot_be_read(self, tmpdir: Path) -> None: 181 | file_path = tmpdir / "test.log" 182 | 183 | with pytest.raises(TemporaryCheckError): 184 | LastLogActivity( 185 | "test", 186 | file_path, 187 | re.compile(r"^(.*)$"), 188 | timedelta(minutes=10), 189 | "ascii", 190 | timezone.utc, 191 | ).check() 192 | 193 | def test_create(self) -> None: 194 | created = LastLogActivity.create( 195 | "thename", 196 | config_section( 197 | { 198 | "name": "somename", 199 | "log_file": "/some/file", 200 | "pattern": "^foo(.*)bar$", 201 | "minutes": "42", 202 | "encoding": "utf-8", 203 | "timezone": "Europe/Berlin", 204 | } 205 | ), 206 | ) 207 | 208 | assert created.log_file == Path("/some/file") 209 | assert created.pattern == re.compile(r"^foo(.*)bar$") 210 | assert created.delta == timedelta(minutes=42) 211 | assert created.encoding == "utf-8" 212 | assert created.default_timezone == pytz.timezone("Europe/Berlin") 213 | 214 | def test_create_handles_pattern_errors(self) -> None: 215 | with pytest.raises(ConfigurationError): 216 | LastLogActivity.create( 217 | "thename", 218 | config_section( 219 | { 220 | "name": "somename", 221 | "log_file": "/some/file", 222 | "pattern": "^^foo((.*)bar$", 223 | } 224 | ), 225 | ) 226 | 227 | def test_create_handles_delta_errors(self) -> None: 228 | with pytest.raises(ConfigurationError): 229 | LastLogActivity.create( 230 | "thename", 231 | config_section( 232 | { 233 | "name": "somename", 234 | "log_file": "/some/file", 235 | "pattern": "(.*)", 236 | "minutes": "test", 237 | } 238 | ), 239 | ) 240 | 241 | def test_create_handles_negative_deltas(self) -> None: 242 | with pytest.raises(ConfigurationError): 243 | LastLogActivity.create( 244 | "thename", 245 | config_section( 246 | { 247 | "name": "somename", 248 | "log_file": "/some/file", 249 | "pattern": "(.*)", 250 | "minutes": "-42", 251 | } 252 | ), 253 | ) 254 | 255 | def test_create_handles_missing_pattern_groups(self) -> None: 256 | with pytest.raises(ConfigurationError): 257 | LastLogActivity.create( 258 | "thename", 259 | config_section( 260 | { 261 | "name": "somename", 262 | "log_file": "/some/file", 263 | "pattern": ".*", 264 | } 265 | ), 266 | ) 267 | 268 | def test_create_handles_missing_keys(self) -> None: 269 | with pytest.raises(ConfigurationError): 270 | LastLogActivity.create( 271 | "thename", 272 | config_section( 273 | { 274 | "name": "somename", 275 | } 276 | ), 277 | ) 278 | -------------------------------------------------------------------------------- /tests/test_checks_mpd.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import mpd 4 | import pytest 5 | from pytest_mock import MockerFixture 6 | 7 | from autosuspend.checks import Check, ConfigurationError, TemporaryCheckError 8 | from autosuspend.checks.mpd import Mpd 9 | 10 | from . import CheckTest 11 | from .utils import config_section 12 | 13 | 14 | class TestMpd(CheckTest): 15 | def create_instance(self, name: str) -> Check: 16 | # concrete values are never used in the tests 17 | return Mpd(name, None, None, None) # type: ignore 18 | 19 | def test_playing(self, monkeypatch: Any) -> None: 20 | check = Mpd("test", None, None, None) # type: ignore 21 | 22 | def get_state() -> dict: 23 | return {"state": "play"} 24 | 25 | monkeypatch.setattr(check, "_get_state", get_state) 26 | 27 | assert check.check() is not None 28 | 29 | def test_not_playing(self, monkeypatch: Any) -> None: 30 | check = Mpd("test", None, None, None) # type: ignore 31 | 32 | def get_state() -> dict: 33 | return {"state": "pause"} 34 | 35 | monkeypatch.setattr(check, "_get_state", get_state) 36 | 37 | assert check.check() is None 38 | 39 | def test_correct_mpd_interaction(self, mocker: MockerFixture) -> None: 40 | mock_instance = mocker.MagicMock(spec=mpd.MPDClient) 41 | mock_instance.status.return_value = {"state": "play"} 42 | timeout_property = mocker.PropertyMock() 43 | type(mock_instance).timeout = timeout_property 44 | mock = mocker.patch("autosuspend.checks.mpd.MPDClient") 45 | mock.return_value = mock_instance 46 | 47 | host = "foo" 48 | port = 42 49 | timeout = 17 50 | 51 | assert Mpd("name", host, port, timeout).check() is not None 52 | 53 | timeout_property.assert_called_once_with(timeout) 54 | mock_instance.connect.assert_called_once_with(host, port) 55 | mock_instance.status.assert_called_once_with() 56 | mock_instance.close.assert_called_once_with() 57 | mock_instance.disconnect.assert_called_once_with() 58 | 59 | @pytest.mark.parametrize("exception_type", [ConnectionError, mpd.ConnectionError]) 60 | def test_handle_connection_errors(self, exception_type: type) -> None: 61 | check = Mpd("test", None, None, None) # type: ignore 62 | 63 | def _get_state() -> dict: 64 | raise exception_type() 65 | 66 | # https://github.com/python/mypy/issues/2427 67 | check._get_state = _get_state # type: ignore 68 | 69 | with pytest.raises(TemporaryCheckError): 70 | check.check() 71 | 72 | def test_create(self) -> None: 73 | check = Mpd.create( 74 | "name", 75 | config_section( 76 | { 77 | "host": "host", 78 | "port": "1234", 79 | "timeout": "12", 80 | } 81 | ), 82 | ) 83 | 84 | assert check._host == "host" 85 | assert check._port == 1234 86 | assert check._timeout == 12 87 | 88 | def test_create_port_no_number(self) -> None: 89 | with pytest.raises(ConfigurationError): 90 | Mpd.create( 91 | "name", 92 | config_section( 93 | { 94 | "host": "host", 95 | "port": "string", 96 | "timeout": "12", 97 | } 98 | ), 99 | ) 100 | 101 | def test_create_timeout_no_number(self) -> None: 102 | with pytest.raises(ConfigurationError): 103 | Mpd.create( 104 | "name", 105 | config_section( 106 | { 107 | "host": "host", 108 | "port": "10", 109 | "timeout": "string", 110 | } 111 | ), 112 | ) 113 | -------------------------------------------------------------------------------- /tests/test_checks_smb.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import subprocess 3 | 4 | import pytest 5 | from pytest_mock import MockerFixture 6 | 7 | from autosuspend.checks import Check, SevereCheckError, TemporaryCheckError 8 | from autosuspend.checks.smb import Smb 9 | 10 | from . import CheckTest 11 | 12 | 13 | class TestSmb(CheckTest): 14 | def create_instance(self, name: str) -> Check: 15 | return Smb(name) 16 | 17 | def test_no_connections(self, datadir: Path, mocker: MockerFixture) -> None: 18 | mocker.patch("subprocess.check_output").return_value = ( 19 | datadir / "smbstatus_no_connections" 20 | ).read_bytes() 21 | 22 | assert Smb("foo").check() is None 23 | 24 | def test_with_connections(self, datadir: Path, mocker: MockerFixture) -> None: 25 | mocker.patch("subprocess.check_output").return_value = ( 26 | datadir / "smbstatus_with_connections" 27 | ).read_bytes() 28 | 29 | res = Smb("foo").check() 30 | assert res is not None 31 | assert len(res.splitlines()) == 3 32 | 33 | def test_call_error(self, mocker: MockerFixture) -> None: 34 | mocker.patch( 35 | "subprocess.check_output", 36 | side_effect=subprocess.CalledProcessError(2, "cmd"), 37 | ) 38 | 39 | with pytest.raises(TemporaryCheckError): 40 | Smb("foo").check() 41 | 42 | def test_missing_executable(self, mocker: MockerFixture) -> None: 43 | mocker.patch("subprocess.check_output", side_effect=FileNotFoundError) 44 | 45 | with pytest.raises(SevereCheckError): 46 | Smb("foo").check() 47 | 48 | def test_create(self) -> None: 49 | assert isinstance(Smb.create("name", None), Smb) 50 | -------------------------------------------------------------------------------- /tests/test_checks_smb/smbstatus_no_connections: -------------------------------------------------------------------------------- 1 | 2 | Samba version 4.7.0 3 | PID Username Group Machine Protocol Version Encryption Signing 4 | ---------------------------------------------------------------------------------------------------------------------------------------- 5 | -------------------------------------------------------------------------------- /tests/test_checks_smb/smbstatus_with_connections: -------------------------------------------------------------------------------- 1 | 2 | Samba version 3.5.1 3 | PID Username Group Machine 4 | ------------------------------------------------------------------- 5 | 14944 it 131.169.214.117 (131.169.214.117) 6 | 14944 it 131.169.214.117 (131.169.214.117) 7 | -------------------------------------------------------------------------------- /tests/test_checks_stub.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | import pytest 4 | 5 | from autosuspend.checks import Check, ConfigurationError 6 | from autosuspend.checks.stub import Periodic 7 | 8 | from . import CheckTest 9 | from .utils import config_section 10 | 11 | 12 | class TestPeriodic(CheckTest): 13 | def create_instance(self, name: str) -> Check: 14 | delta = timedelta(seconds=10, minutes=42) 15 | return Periodic(name, delta) 16 | 17 | def test_create(self) -> None: 18 | check = Periodic.create( 19 | "name", config_section({"unit": "seconds", "value": "13"}) 20 | ) 21 | assert check._delta == timedelta(seconds=13) 22 | 23 | def test_create_wrong_unit(self) -> None: 24 | with pytest.raises(ConfigurationError): 25 | Periodic.create("name", config_section({"unit": "asdfasdf", "value": "13"})) 26 | 27 | def test_create_not_numeric(self) -> None: 28 | with pytest.raises(ConfigurationError): 29 | Periodic.create( 30 | "name", config_section({"unit": "seconds", "value": "asdfasd"}) 31 | ) 32 | 33 | def test_create_no_unit(self) -> None: 34 | with pytest.raises(ConfigurationError): 35 | Periodic.create("name", config_section({"value": "13"})) 36 | 37 | def test_create_float(self) -> None: 38 | Periodic.create( 39 | "name", config_section({"unit": "seconds", "value": "21312.12"}) 40 | ) 41 | 42 | def test_check(self) -> None: 43 | delta = timedelta(seconds=10, minutes=42) 44 | check = Periodic("test", delta) 45 | now = datetime.now(timezone.utc) 46 | assert check.check(now) == now + delta 47 | -------------------------------------------------------------------------------- /tests/test_checks_systemd.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | import re 3 | from unittest.mock import Mock 4 | 5 | from dbus.proxies import ProxyObject 6 | import pytest 7 | from pytest_mock import MockerFixture 8 | 9 | from autosuspend.checks import Check, ConfigurationError, TemporaryCheckError 10 | from autosuspend.checks.systemd import ( 11 | LogindSessionsIdle, 12 | next_timer_executions, 13 | SystemdTimer, 14 | ) 15 | 16 | from . import CheckTest 17 | from .utils import config_section 18 | 19 | 20 | @pytest.mark.skip(reason="No dbusmock implementation available") 21 | def test_next_timer_executions() -> None: 22 | assert next_timer_executions() is not None 23 | 24 | 25 | class TestSystemdTimer(CheckTest): 26 | @staticmethod 27 | @pytest.fixture 28 | def next_timer_executions(mocker: MockerFixture) -> Mock: 29 | return mocker.patch("autosuspend.checks.systemd.next_timer_executions") 30 | 31 | def create_instance(self, name: str) -> Check: 32 | return SystemdTimer(name, re.compile(".*")) 33 | 34 | def test_create_handles_incorrect_expressions(self) -> None: 35 | with pytest.raises(ConfigurationError): 36 | SystemdTimer.create("somename", config_section({"match": "(.*"})) 37 | 38 | def test_create_raises_if_match_is_missing(self) -> None: 39 | with pytest.raises(ConfigurationError): 40 | SystemdTimer.create("somename", config_section()) 41 | 42 | def test_works_without_timers(self, next_timer_executions: Mock) -> None: 43 | next_timer_executions.return_value = {} 44 | now = datetime.now(timezone.utc) 45 | 46 | assert SystemdTimer("foo", re.compile(".*")).check(now) is None 47 | 48 | def test_ignores_non_matching_timers(self, next_timer_executions: Mock) -> None: 49 | now = datetime.now(timezone.utc) 50 | next_timer_executions.return_value = {"ignored": now} 51 | 52 | assert SystemdTimer("foo", re.compile("needle")).check(now) is None 53 | 54 | def test_finds_matching_timers(self, next_timer_executions: Mock) -> None: 55 | pattern = "foo" 56 | now = datetime.now(timezone.utc) 57 | next_timer_executions.return_value = {pattern: now} 58 | 59 | assert SystemdTimer("foo", re.compile(pattern)).check(now) is now 60 | 61 | def test_selects_the_closest_execution_if_multiple_match( 62 | self, next_timer_executions: Mock 63 | ) -> None: 64 | now = datetime.now(timezone.utc) 65 | next_timer_executions.return_value = { 66 | "later": now + timedelta(minutes=1), 67 | "matching": now, 68 | } 69 | 70 | assert SystemdTimer("foo", re.compile(".*")).check(now) is now 71 | 72 | 73 | class TestLogindSessionsIdle(CheckTest): 74 | def create_instance(self, name: str) -> Check: 75 | return LogindSessionsIdle(name, ["tty", "x11", "wayland"], ["active", "online"]) 76 | 77 | def test_active(self, logind: ProxyObject) -> None: 78 | logind.AddSession("c1", "seat0", 1042, "auser", True) 79 | 80 | check = LogindSessionsIdle("test", ["test"], ["active", "online"]) 81 | assert check.check() is not None 82 | 83 | @pytest.mark.skip(reason="No known way to set idle hint in dbus mock right now") 84 | def test_inactive(self, logind: ProxyObject) -> None: 85 | logind.AddSession("c1", "seat0", 1042, "auser", False) 86 | 87 | check = LogindSessionsIdle("test", ["test"], ["active", "online"]) 88 | assert check.check() is None 89 | 90 | def test_ignore_unknow_type(self, logind: ProxyObject) -> None: 91 | logind.AddSession("c1", "seat0", 1042, "auser", True) 92 | 93 | check = LogindSessionsIdle("test", ["not_test"], ["active", "online"]) 94 | assert check.check() is None 95 | 96 | def test_ignore_unknown_class(self, logind: ProxyObject) -> None: 97 | logind.AddSession("c1", "seat0", 1042, "user", True) 98 | 99 | check = LogindSessionsIdle( 100 | "test", ["test"], ["active", "online"], ["nosuchclass"] 101 | ) 102 | assert check.check() is None 103 | 104 | def test_configure_defaults(self) -> None: 105 | check = LogindSessionsIdle.create("name", config_section()) 106 | assert check._types == ["tty", "x11", "wayland"] 107 | assert check._states == ["active", "online"] 108 | 109 | def test_configure_types(self) -> None: 110 | check = LogindSessionsIdle.create( 111 | "name", config_section({"types": "test, bla,foo"}) 112 | ) 113 | assert check._types == ["test", "bla", "foo"] 114 | 115 | def test_configure_states(self) -> None: 116 | check = LogindSessionsIdle.create( 117 | "name", config_section({"states": "test, bla,foo"}) 118 | ) 119 | assert check._states == ["test", "bla", "foo"] 120 | 121 | def test_configure_classes(self) -> None: 122 | check = LogindSessionsIdle.create( 123 | "name", config_section({"classes": "test, bla,foo"}) 124 | ) 125 | assert check._classes == ["test", "bla", "foo"] 126 | 127 | @pytest.mark.usefixtures("_logind_dbus_error") 128 | def test_dbus_error(self) -> None: 129 | check = LogindSessionsIdle("test", ["test"], ["active", "online"]) 130 | 131 | with pytest.raises(TemporaryCheckError): 132 | check.check() 133 | -------------------------------------------------------------------------------- /tests/test_checks_util.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from pathlib import Path 3 | from unittest.mock import ANY 4 | 5 | import pytest 6 | from pytest_httpserver import HTTPServer 7 | from pytest_mock import MockerFixture 8 | import requests 9 | 10 | from autosuspend.checks import ConfigurationError, TemporaryCheckError 11 | from autosuspend.checks.util import NetworkMixin 12 | 13 | from .utils import config_section 14 | 15 | 16 | class TestNetworkMixin: 17 | def test_collect_missing_url(self) -> None: 18 | with pytest.raises(ConfigurationError, match=r"^Lacks 'url'.*"): 19 | NetworkMixin.collect_init_args(config_section()) 20 | 21 | def test_username_missing(self) -> None: 22 | with pytest.raises(ConfigurationError, match=r"^Username and.*"): 23 | NetworkMixin.collect_init_args( 24 | config_section({"url": "required", "password": "lacks username"}) 25 | ) 26 | 27 | def test_password_missing(self) -> None: 28 | with pytest.raises(ConfigurationError, match=r"^Username and.*"): 29 | NetworkMixin.collect_init_args( 30 | config_section({"url": "required", "username": "lacks password"}) 31 | ) 32 | 33 | def test_collect_default_timeout(self) -> None: 34 | args = NetworkMixin.collect_init_args(config_section({"url": "required"})) 35 | assert args["timeout"] == 5 36 | 37 | def test_collect_timeout(self) -> None: 38 | args = NetworkMixin.collect_init_args( 39 | config_section({"url": "required", "timeout": "42"}) 40 | ) 41 | assert args["timeout"] == 42 42 | 43 | def test_collect_invalid_timeout(self) -> None: 44 | with pytest.raises(ConfigurationError, match=r"^Configuration error .*"): 45 | NetworkMixin.collect_init_args( 46 | config_section({"url": "required", "timeout": "xx"}) 47 | ) 48 | 49 | def test_request(self, datadir: Path, serve_file: Callable[[Path], str]) -> None: 50 | reply = NetworkMixin( 51 | serve_file(datadir / "xml_with_encoding.xml"), 52 | 5, 53 | ).request() 54 | assert reply is not None 55 | assert reply.status_code == 200 56 | 57 | def test_requests_exception(self, mocker: MockerFixture) -> None: 58 | mock_method = mocker.patch("requests.Session.get") 59 | mock_method.side_effect = requests.exceptions.ReadTimeout() 60 | 61 | with pytest.raises(TemporaryCheckError): 62 | NetworkMixin("url", timeout=5).request() 63 | 64 | def test_smoke(self, datadir: Path, serve_file: Callable[[Path], str]) -> None: 65 | response = NetworkMixin(serve_file(datadir / "data.txt"), timeout=5).request() 66 | assert response is not None 67 | assert response.text == "iamhere\n" 68 | 69 | def test_exception_404(self, httpserver: HTTPServer) -> None: 70 | with pytest.raises(TemporaryCheckError): 71 | NetworkMixin(httpserver.url_for("/does/not/exist"), timeout=5).request() 72 | 73 | def test_authentication( 74 | self, datadir: Path, serve_protected: Callable[[Path], tuple[str, str, str]] 75 | ) -> None: 76 | url, username, password = serve_protected(datadir / "data.txt") 77 | NetworkMixin(url, 5, username=username, password=password).request() 78 | 79 | def test_invalid_authentication( 80 | self, datadir: Path, serve_protected: Callable[[Path], tuple[str, str, str]] 81 | ) -> None: 82 | with pytest.raises(TemporaryCheckError): 83 | NetworkMixin( 84 | serve_protected(datadir / "data.txt")[0], 85 | 5, 86 | username="userx", 87 | password="pass", 88 | ).request() 89 | 90 | def test_file_url(self) -> None: 91 | NetworkMixin("file://" + __file__, 5).request() 92 | 93 | def test_content_type(self, mocker: MockerFixture) -> None: 94 | mock_method = mocker.patch("requests.Session.get") 95 | 96 | content_type = "foo/bar" 97 | NetworkMixin("url", timeout=5, accept=content_type).request() 98 | 99 | mock_method.assert_called_with( 100 | ANY, timeout=ANY, headers={"Accept": content_type} 101 | ) 102 | -------------------------------------------------------------------------------- /tests/test_checks_util/data.txt: -------------------------------------------------------------------------------- 1 | iamhere 2 | -------------------------------------------------------------------------------- /tests/test_checks_util/xml_with_encoding.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/test_checks_wakeup.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from autosuspend.checks import Wakeup 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "name", 8 | [ 9 | "Calendar", 10 | "Command", 11 | "File", 12 | "Periodic", 13 | "SystemdTimer", 14 | "XPath", 15 | "XPathDelta", 16 | ], 17 | ) 18 | def test_legacy_check_names_are_available(name: str) -> None: 19 | res = __import__("autosuspend.checks.wakeup", fromlist=[name]) 20 | assert issubclass(getattr(res, name), Wakeup) 21 | -------------------------------------------------------------------------------- /tests/test_checks_xorg.py: -------------------------------------------------------------------------------- 1 | from getpass import getuser 2 | import logging 3 | from pathlib import Path 4 | import re 5 | import subprocess 6 | from typing import Any 7 | 8 | import pytest 9 | from pytest_mock import MockerFixture 10 | 11 | from autosuspend.checks import ( 12 | Check, 13 | ConfigurationError, 14 | SevereCheckError, 15 | TemporaryCheckError, 16 | ) 17 | from autosuspend.checks.xorg import ( 18 | list_sessions_logind, 19 | list_sessions_sockets, 20 | XIdleTime, 21 | XorgSession, 22 | ) 23 | from autosuspend.util.systemd import LogindDBusException 24 | 25 | from . import CheckTest 26 | from .utils import config_section 27 | 28 | 29 | class TestListSessionsSockets: 30 | def test_empty(self, tmp_path: Path) -> None: 31 | assert list_sessions_sockets(tmp_path) == [] 32 | 33 | @pytest.mark.parametrize("number", [0, 10, 1024]) 34 | def test_extracts_valid_sockets(self, tmp_path: Path, number: int) -> None: 35 | session_sock = tmp_path / f"X{number}" 36 | session_sock.touch() 37 | 38 | assert list_sessions_sockets(tmp_path) == [ 39 | XorgSession(number, session_sock.owner()) 40 | ] 41 | 42 | @pytest.mark.parametrize("invalid_number", ["", "string", " "]) 43 | def test_ignores_and_warns_on_invalid_numbers( 44 | self, 45 | tmp_path: Path, 46 | invalid_number: str, 47 | caplog: Any, 48 | ) -> None: 49 | (tmp_path / f"X{invalid_number}").touch() 50 | 51 | with caplog.at_level(logging.WARNING): 52 | assert list_sessions_sockets(tmp_path) == [] 53 | assert caplog.records != [] 54 | 55 | def test_ignores_and_warns_on_unknown_users( 56 | self, 57 | tmp_path: Path, 58 | mocker: MockerFixture, 59 | caplog: Any, 60 | ) -> None: 61 | (tmp_path / "X0").touch() 62 | mocker.patch("pathlib.Path.owner").side_effect = KeyError() 63 | 64 | with caplog.at_level(logging.WARNING): 65 | assert list_sessions_sockets(tmp_path) == [] 66 | assert caplog.records != [] 67 | 68 | def test_ignores_other_files( 69 | self, 70 | tmp_path: Path, 71 | ) -> None: 72 | (tmp_path / "asdf").touch() 73 | 74 | assert list_sessions_sockets(tmp_path) == [] 75 | 76 | def test_returns_multiple(self, tmp_path: Path) -> None: 77 | (tmp_path / "X0").touch() 78 | (tmp_path / "X1").touch() 79 | 80 | assert len(list_sessions_sockets(tmp_path)) == 2 81 | 82 | 83 | _LIST_LOGIND_SESSIONS_TO_PATCH = "autosuspend.checks.xorg.list_logind_sessions" 84 | 85 | 86 | class TestListSessionsLogind: 87 | def test_extracts_valid_sessions(self, mocker: MockerFixture) -> None: 88 | username = "test_user" 89 | display = 42 90 | mocker.patch(_LIST_LOGIND_SESSIONS_TO_PATCH).return_value = [ 91 | ("id", {"Name": username, "Display": f":{display}"}) 92 | ] 93 | 94 | assert list_sessions_logind() == [XorgSession(display, username)] 95 | 96 | def test_ignores_sessions_with_missing_properties( 97 | self, mocker: MockerFixture 98 | ) -> None: 99 | mocker.patch(_LIST_LOGIND_SESSIONS_TO_PATCH).return_value = [ 100 | ("id", {"Name": "someuser"}), 101 | ("id", {"Display": ":42"}), 102 | ] 103 | 104 | assert list_sessions_logind() == [] 105 | 106 | def test_ignores_and_warns_on_invalid_display_numbers( 107 | self, 108 | mocker: MockerFixture, 109 | caplog: Any, 110 | ) -> None: 111 | mocker.patch(_LIST_LOGIND_SESSIONS_TO_PATCH).return_value = [ 112 | ("id", {"Name": "someuser", "Display": "XXX"}), 113 | ] 114 | 115 | with caplog.at_level(logging.WARNING): 116 | assert list_sessions_logind() == [] 117 | assert caplog.records != [] 118 | 119 | 120 | class TestXIdleTime(CheckTest): 121 | def create_instance(self, name: str) -> Check: 122 | # concrete values are never used in the test 123 | return XIdleTime(name, 10, "sockets", None, None) # type: ignore 124 | 125 | def test_smoke(self, mocker: MockerFixture) -> None: 126 | check = XIdleTime("name", 100, "logind", re.compile(r"a^"), re.compile(r"a^")) 127 | mocker.patch.object(check, "_provide_sessions").return_value = [ 128 | XorgSession(42, getuser()), 129 | ] 130 | 131 | co_mock = mocker.patch("subprocess.check_output") 132 | co_mock.return_value = "123" 133 | 134 | res = check.check() 135 | assert res is not None 136 | assert " 0.123 " in res 137 | 138 | args, kwargs = co_mock.call_args 139 | assert getuser() in args[0] 140 | assert kwargs["env"]["DISPLAY"] == ":42" 141 | assert getuser() in kwargs["env"]["XAUTHORITY"] 142 | 143 | def test_no_activity(self, mocker: MockerFixture) -> None: 144 | check = XIdleTime("name", 100, "logind", re.compile(r"a^"), re.compile(r"a^")) 145 | mocker.patch.object(check, "_provide_sessions").return_value = [ 146 | XorgSession(42, getuser()), 147 | ] 148 | 149 | mocker.patch("subprocess.check_output").return_value = "120000" 150 | 151 | assert check.check() is None 152 | 153 | def test_multiple_sessions(self, mocker: MockerFixture) -> None: 154 | check = XIdleTime("name", 100, "logind", re.compile(r"a^"), re.compile(r"a^")) 155 | mocker.patch.object(check, "_provide_sessions").return_value = [ 156 | XorgSession(42, getuser()), 157 | XorgSession(17, "root"), 158 | ] 159 | 160 | co_mock = mocker.patch("subprocess.check_output") 161 | co_mock.side_effect = [ 162 | "120000", 163 | "123", 164 | ] 165 | 166 | res = check.check() 167 | assert res is not None 168 | assert " 0.123 " in res 169 | 170 | assert co_mock.call_count == 2 171 | # check second call for correct values, not checked before 172 | args, kwargs = co_mock.call_args_list[1] 173 | assert "root" in args[0] 174 | assert kwargs["env"]["DISPLAY"] == ":17" 175 | assert "root" in kwargs["env"]["XAUTHORITY"] 176 | 177 | def test_handle_call_error(self, mocker: MockerFixture) -> None: 178 | check = XIdleTime("name", 100, "logind", re.compile(r"a^"), re.compile(r"a^")) 179 | mocker.patch.object(check, "_provide_sessions").return_value = [ 180 | XorgSession(42, getuser()), 181 | ] 182 | 183 | mocker.patch( 184 | "subprocess.check_output", 185 | ).side_effect = subprocess.CalledProcessError(2, "foo") 186 | 187 | with pytest.raises(TemporaryCheckError): 188 | check.check() 189 | 190 | def test_create_default(self) -> None: 191 | check = XIdleTime.create("name", config_section()) 192 | assert check._timeout == 600 193 | assert check._ignore_process_re == re.compile(r"a^") 194 | assert check._ignore_users_re == re.compile(r"a^") 195 | assert check._provide_sessions == list_sessions_sockets 196 | 197 | def test_create(self) -> None: 198 | check = XIdleTime.create( 199 | "name", 200 | config_section( 201 | { 202 | "timeout": "42", 203 | "ignore_if_process": ".*test", 204 | "ignore_users": "test.*test", 205 | "method": "logind", 206 | } 207 | ), 208 | ) 209 | assert check._timeout == 42 210 | assert check._ignore_process_re == re.compile(r".*test") 211 | assert check._ignore_users_re == re.compile(r"test.*test") 212 | assert check._provide_sessions == list_sessions_logind 213 | 214 | def test_create_no_int(self) -> None: 215 | with pytest.raises(ConfigurationError): 216 | XIdleTime.create("name", config_section({"timeout": "string"})) 217 | 218 | def test_create_broken_process_re(self) -> None: 219 | with pytest.raises(ConfigurationError): 220 | XIdleTime.create("name", config_section({"ignore_if_process": "[[a-9]"})) 221 | 222 | def test_create_broken_users_re(self) -> None: 223 | with pytest.raises(ConfigurationError): 224 | XIdleTime.create("name", config_section({"ignore_users": "[[a-9]"})) 225 | 226 | def test_create_unknown_method(self) -> None: 227 | with pytest.raises(ConfigurationError): 228 | XIdleTime.create("name", config_section({"method": "asdfasdf"})) 229 | 230 | def test_list_sessions_logind_dbus_error(self, mocker: MockerFixture) -> None: 231 | check = XIdleTime.create("name", config_section()) 232 | mocker.patch.object(check, "_provide_sessions").side_effect = ( 233 | LogindDBusException() 234 | ) 235 | 236 | with pytest.raises(TemporaryCheckError): 237 | check._safe_provide_sessions() 238 | 239 | def test_sudo_not_found(self, mocker: MockerFixture) -> None: 240 | check = XIdleTime("name", 100, "logind", re.compile(r"a^"), re.compile(r"a^")) 241 | mocker.patch.object(check, "_provide_sessions").return_value = [ 242 | XorgSession(42, getuser()), 243 | ] 244 | 245 | mocker.patch("subprocess.check_output").side_effect = FileNotFoundError 246 | 247 | with pytest.raises(SevereCheckError): 248 | check.check() 249 | -------------------------------------------------------------------------------- /tests/test_checks_xpath/xml_with_encoding.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from datetime import datetime, timedelta, timezone 3 | import logging 4 | from pathlib import Path 5 | from typing import Any 6 | 7 | from freezegun import freeze_time 8 | import pytest 9 | from pytest_mock import MockerFixture 10 | 11 | import autosuspend 12 | 13 | 14 | pytestmark = pytest.mark.integration 15 | 16 | 17 | SUSPENSION_FILE = "would_suspend" 18 | SCHEDULED_FILE = "wakeup_at" 19 | WOKE_UP_FILE = "test-woke-up" 20 | LOCK_FILE = "test-woke-up.lock" 21 | NOTIFY_FILE = "notify" 22 | 23 | 24 | def configure_config(config: str, datadir: Path, tmp_path: Path) -> Path: 25 | out_path = tmp_path / config 26 | with out_path.open("w") as out_config: 27 | out_config.write( 28 | (datadir / config).read_text().replace("@TMPDIR@", str(tmp_path)), 29 | ) 30 | return out_path 31 | 32 | 33 | @pytest.fixture 34 | def _rapid_sleep(mocker: MockerFixture) -> Iterable[None]: 35 | with freeze_time() as frozen_time: 36 | sleep_mock = mocker.patch("time.sleep") 37 | sleep_mock.side_effect = lambda seconds: frozen_time.tick( 38 | timedelta(seconds=seconds) 39 | ) 40 | yield 41 | 42 | 43 | @pytest.mark.usefixtures("_rapid_sleep") 44 | def test_no_suspend_if_matching(datadir: Path, tmp_path: Path) -> None: 45 | autosuspend.main( 46 | [ 47 | "-c", 48 | str(configure_config("dont_suspend.conf", datadir, tmp_path)), 49 | "-d", 50 | "daemon", 51 | "-r", 52 | "10", 53 | ] 54 | ) 55 | 56 | assert not (tmp_path / SUSPENSION_FILE).exists() 57 | 58 | 59 | @pytest.mark.usefixtures("_rapid_sleep") 60 | def test_suspend(tmp_path: Path, datadir: Path) -> None: 61 | autosuspend.main( 62 | [ 63 | "-c", 64 | str(configure_config("would_suspend.conf", datadir, tmp_path)), 65 | "-d", 66 | "daemon", 67 | "-r", 68 | "10", 69 | ] 70 | ) 71 | 72 | assert (tmp_path / SUSPENSION_FILE).exists() 73 | 74 | 75 | @pytest.mark.usefixtures("_rapid_sleep") 76 | def test_wakeup_scheduled(tmp_path: Path, datadir: Path) -> None: 77 | # configure when to wake up 78 | now = datetime.now(timezone.utc) 79 | wakeup_at = now + timedelta(hours=4) 80 | (tmp_path / "wakeup_time").write_text(str(wakeup_at.timestamp())) 81 | 82 | autosuspend.main( 83 | [ 84 | "-c", 85 | str(configure_config("would_schedule.conf", datadir, tmp_path)), 86 | "-d", 87 | "daemon", 88 | "-r", 89 | "10", 90 | ] 91 | ) 92 | 93 | assert (tmp_path / SUSPENSION_FILE).exists() 94 | assert (tmp_path / SCHEDULED_FILE).exists() 95 | assert int((tmp_path / SCHEDULED_FILE).read_text()) == round( 96 | (wakeup_at - timedelta(seconds=30)).timestamp() 97 | ) 98 | 99 | 100 | @pytest.mark.usefixtures("_rapid_sleep") 101 | def test_woke_up_file_removed(tmp_path: Path, datadir: Path) -> None: 102 | (tmp_path / WOKE_UP_FILE).touch() 103 | autosuspend.main( 104 | [ 105 | "-c", 106 | str(configure_config("dont_suspend.conf", datadir, tmp_path)), 107 | "-d", 108 | "daemon", 109 | "-r", 110 | "5", 111 | ] 112 | ) 113 | assert not (tmp_path / WOKE_UP_FILE).exists() 114 | 115 | 116 | @pytest.mark.usefixtures("_rapid_sleep") 117 | def test_notify_call(tmp_path: Path, datadir: Path) -> None: 118 | autosuspend.main( 119 | [ 120 | "-c", 121 | str(configure_config("notify.conf", datadir, tmp_path)), 122 | "-d", 123 | "daemon", 124 | "-r", 125 | "10", 126 | ] 127 | ) 128 | 129 | assert (tmp_path / SUSPENSION_FILE).exists() 130 | assert (tmp_path / NOTIFY_FILE).exists() 131 | assert len((tmp_path / NOTIFY_FILE).read_text()) == 0 132 | 133 | 134 | @pytest.mark.usefixtures("_rapid_sleep") 135 | def test_notify_call_wakeup(tmp_path: Path, datadir: Path) -> None: 136 | # configure when to wake up 137 | now = datetime.now(timezone.utc) 138 | wakeup_at = now + timedelta(hours=4) 139 | (tmp_path / "wakeup_time").write_text(str(wakeup_at.timestamp())) 140 | 141 | autosuspend.main( 142 | [ 143 | "-c", 144 | str(configure_config("notify_wakeup.conf", datadir, tmp_path)), 145 | "-d", 146 | "daemon", 147 | "-r", 148 | "10", 149 | ] 150 | ) 151 | 152 | assert (tmp_path / SUSPENSION_FILE).exists() 153 | assert (tmp_path / NOTIFY_FILE).exists() 154 | assert int((tmp_path / NOTIFY_FILE).read_text()) == round( 155 | (wakeup_at - timedelta(seconds=10)).timestamp() 156 | ) 157 | 158 | 159 | def test_error_no_checks_configured(tmp_path: Path, datadir: Path) -> None: 160 | with pytest.raises(autosuspend.ConfigurationError): 161 | autosuspend.main( 162 | [ 163 | "-c", 164 | str(configure_config("no_checks.conf", datadir, tmp_path)), 165 | "-d", 166 | "daemon", 167 | "-r", 168 | "10", 169 | ] 170 | ) 171 | 172 | 173 | @pytest.mark.usefixtures("_rapid_sleep") 174 | def test_temporary_errors_logged(tmp_path: Path, datadir: Path, caplog: Any) -> None: 175 | autosuspend.main( 176 | [ 177 | "-c", 178 | str(configure_config("temporary_error.conf", datadir, tmp_path)), 179 | "-d", 180 | "daemon", 181 | "-r", 182 | "10", 183 | ] 184 | ) 185 | 186 | warnings = [ 187 | r 188 | for r in caplog.record_tuples 189 | if r[1] == logging.WARNING and "XPath" in r[2] and "failed" in r[2] 190 | ] 191 | 192 | assert len(warnings) > 0 193 | 194 | 195 | def test_loop_defaults(tmp_path: Path, datadir: Path, mocker: MockerFixture) -> None: 196 | loop = mocker.patch("autosuspend.loop") 197 | loop.side_effect = StopIteration 198 | with pytest.raises(StopIteration): 199 | autosuspend.main( 200 | [ 201 | "-c", 202 | str(configure_config("minimal.conf", datadir, tmp_path)), 203 | "-d", 204 | "daemon", 205 | "-r", 206 | "10", 207 | ] 208 | ) 209 | args, kwargs = loop.call_args 210 | assert args[1] == 60 211 | assert kwargs["run_for"] == 10 212 | assert kwargs["woke_up_file"] == Path("/var/run/autosuspend-just-woke-up") 213 | 214 | 215 | def test_hook_success(tmp_path: Path, datadir: Path) -> None: 216 | autosuspend.main( 217 | [ 218 | "-c", 219 | str(configure_config("would_suspend.conf", datadir, tmp_path)), 220 | "-d", 221 | "presuspend", 222 | ] 223 | ) 224 | 225 | assert (tmp_path / WOKE_UP_FILE).exists() 226 | 227 | 228 | def test_hook_call_wakeup(tmp_path: Path, datadir: Path) -> None: 229 | # configure when to wake up 230 | now = datetime.now(timezone.utc) 231 | wakeup_at = now + timedelta(hours=4) 232 | (tmp_path / "wakeup_time").write_text(str(wakeup_at.timestamp())) 233 | 234 | autosuspend.main( 235 | [ 236 | "-c", 237 | str(configure_config("would_schedule.conf", datadir, tmp_path)), 238 | "-d", 239 | "presuspend", 240 | ] 241 | ) 242 | 243 | assert (tmp_path / SCHEDULED_FILE).exists() 244 | assert int((tmp_path / SCHEDULED_FILE).read_text()) == round( 245 | (wakeup_at - timedelta(seconds=30)).timestamp() 246 | ) 247 | 248 | 249 | def test_version(tmp_path: Path, datadir: Path) -> None: 250 | autosuspend.main( 251 | [ 252 | "-c", 253 | str(configure_config("would_schedule.conf", datadir, tmp_path)), 254 | "version", 255 | ] 256 | ) 257 | -------------------------------------------------------------------------------- /tests/test_integration/dont_suspend.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | interval = 2 3 | idle_time = 5 4 | suspend_cmd = touch @TMPDIR@/would_suspend 5 | wakeup_cmd = echo {timestamp:d} > @TMPDIR@/wakeup_at 6 | woke_up_file = @TMPDIR@/test-woke-up 7 | lock_file = @TMPDIR@/test-woke-up.lock 8 | 9 | [check.ExternalCommand] 10 | enabled = True 11 | command = true 12 | -------------------------------------------------------------------------------- /tests/test_integration/minimal.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | suspend_cmd = touch @TMPDIR@/would_suspend 3 | wakeup_cmd = echo {timestamp:d} > @TMPDIR@/wakeup_at 4 | 5 | [check.ExternalCommand] 6 | enabled = True 7 | command = false 8 | -------------------------------------------------------------------------------- /tests/test_integration/no_checks.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | interval = 2 3 | idle_time = 5 4 | suspend_cmd = touch @TMPDIR@/would_suspend 5 | wakeup_cmd = echo {timestamp:d} > @TMPDIR@/wakeup_at 6 | woke_up_file = @TMPDIR@/test-woke-up 7 | 8 | [check.ExternalCommand] 9 | # lacks enabled=True 10 | command = false 11 | -------------------------------------------------------------------------------- /tests/test_integration/notify.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | interval = 2 3 | idle_time = 5 4 | suspend_cmd = touch @TMPDIR@/would_suspend 5 | wakeup_cmd = echo {timestamp:.0f} > @TMPDIR@/wakeup_at 6 | notify_cmd_wakeup = echo {timestamp:.0f} > @TMPDIR@/notify 7 | notify_cmd_no_wakeup = touch @TMPDIR@/notify 8 | woke_up_file = @TMPDIR@/test-woke-up 9 | lock_file = @TMPDIR@/test-woke-up.lock 10 | 11 | [check.ExternalCommand] 12 | enabled = True 13 | command = false 14 | -------------------------------------------------------------------------------- /tests/test_integration/notify_wakeup.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | interval = 2 3 | idle_time = 5 4 | suspend_cmd = touch @TMPDIR@/would_suspend 5 | wakeup_cmd = echo {timestamp:.0f} > @TMPDIR@/wakeup_at 6 | notify_cmd_wakeup = echo {timestamp:.0f} > @TMPDIR@/notify 7 | notify_cmd_no_wakeup = touch @TMPDIR@/notify 8 | woke_up_file = @TMPDIR@/test-woke-up 9 | lock_file = @TMPDIR@/test-woke-up.lock 10 | wakeup_delta = 10 11 | 12 | [check.ExternalCommand] 13 | enabled = True 14 | command = false 15 | 16 | [wakeup.File] 17 | enabled = True 18 | path = @TMPDIR@/wakeup_time 19 | -------------------------------------------------------------------------------- /tests/test_integration/temporary_error.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | interval = 20 3 | idle_time = 50 4 | suspend_cmd = touch @TMPDIR@/would_suspend 5 | wakeup_cmd = echo {timestamp:d} > @TMPDIR@/wakeup_at 6 | woke_up_file = @TMPDIR@/test-woke-up 7 | lock_file = @TMPDIR@/test-woke-up.lock 8 | 9 | [check.XPath] 10 | enabled = True 11 | xpath = /a 12 | url = asdfjlkasdjkfkasdlfjaklsdf 13 | -------------------------------------------------------------------------------- /tests/test_integration/would_schedule.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | interval = 2 3 | idle_time = 5 4 | suspend_cmd = touch @TMPDIR@/would_suspend 5 | wakeup_cmd = echo {timestamp:.0f} > @TMPDIR@/wakeup_at 6 | woke_up_file = @TMPDIR@/test-woke-up 7 | lock_file = @TMPDIR@/test-woke-up.lock 8 | 9 | [check.ExternalCommand] 10 | enabled = True 11 | command = false 12 | 13 | [wakeup.File] 14 | enabled = True 15 | path = @TMPDIR@/wakeup_time 16 | -------------------------------------------------------------------------------- /tests/test_integration/would_suspend.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | interval = 2 3 | idle_time = 5 4 | suspend_cmd = touch @TMPDIR@/would_suspend 5 | wakeup_cmd = echo {timestamp:d} > @TMPDIR@/wakeup_at 6 | woke_up_file = @TMPDIR@/test-woke-up 7 | lock_file = @TMPDIR@/test-woke-up.lock 8 | 9 | [check.ExternalCommand] 10 | enabled = True 11 | command = false 12 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from autosuspend.util import logger_by_class, logger_by_class_instance 2 | 3 | 4 | class DummyClass: 5 | pass 6 | 7 | 8 | class TestLoggerByClass: 9 | def test_smoke(self) -> None: 10 | logger = logger_by_class(DummyClass) 11 | assert logger is not None 12 | assert logger.name == "tests.test_util.DummyClass" 13 | 14 | def test_name(self) -> None: 15 | logger = logger_by_class(DummyClass, "foo") 16 | assert logger is not None 17 | assert logger.name == "tests.test_util.DummyClass.foo" 18 | 19 | 20 | class TestLoggerByClassInstance: 21 | def test_smoke(self) -> None: 22 | logger = logger_by_class_instance(DummyClass()) 23 | assert logger is not None 24 | assert logger.name == "tests.test_util.DummyClass" 25 | 26 | def test_name(self) -> None: 27 | logger = logger_by_class_instance(DummyClass(), "foo") 28 | assert logger is not None 29 | assert logger.name == "tests.test_util.DummyClass.foo" 30 | -------------------------------------------------------------------------------- /tests/test_util_systemd.py: -------------------------------------------------------------------------------- 1 | from dbus.proxies import ProxyObject 2 | import pytest 3 | 4 | from autosuspend.util.systemd import list_logind_sessions, LogindDBusException 5 | 6 | 7 | def test_list_logind_sessions_empty(logind: ProxyObject) -> None: 8 | assert len(list(list_logind_sessions())) == 0 9 | 10 | logind.AddSession("c1", "seat0", 1042, "auser", True) 11 | sessions = list(list_logind_sessions()) 12 | assert len(sessions) == 1 13 | assert sessions[0][0] == "c1" 14 | 15 | 16 | @pytest.mark.usefixtures("_logind_dbus_error") 17 | def test_list_logind_sessions_dbus_error() -> None: 18 | with pytest.raises(LogindDBusException): 19 | list_logind_sessions() 20 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | import configparser 3 | 4 | 5 | def config_section( 6 | entries: Mapping[str, str] | None = None, 7 | ) -> configparser.SectionProxy: 8 | parser = configparser.ConfigParser() 9 | section_name = "a_section" 10 | parser.read_dict({section_name: entries or {}}) 11 | return parser[section_name] 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = coverage-clean,test-py310-psutil59-dateutil28-tzlocal2, test-py{310,311,312,313}-psutillatest-dateutillatest-tzlocal{4,latest}, integration-py{310,311,312,313}, mindeps, check, docs, coverage 3 | 4 | [testenv] 5 | extras = test 6 | setenv = 7 | COVERAGE_FILE = ./.coverage.{envname} 8 | deps = 9 | psutil59: psutil>=5.9,<5.10 10 | psutillatest: psutil 11 | dateutil28: python-dateutil>=2.8,<2.9 12 | dateutillatest: python-dateutil 13 | tzlocal2: tzlocal<3 14 | tzlocal4: tzlocal>3,<5 15 | tzlocallatest: tzlocal>4 16 | commands = 17 | {envbindir}/python -V 18 | {envbindir}/python -c 'import psutil; print(psutil.__version__)' 19 | {envbindir}/python -c 'import dateutil; print(dateutil.__version__)' 20 | test: {envbindir}/pytest --cov -m "not integration" {posargs} 21 | integration: {envbindir}/pytest --cov -m "integration" {posargs} 22 | depends = coverage-clean 23 | 24 | [testenv:coverage-clean] 25 | deps = coverage 26 | skip_install = true 27 | commands = coverage erase 28 | depends = 29 | 30 | [testenv:coverage] 31 | depends = test-py310-psutil{59,latest}-dateutil{28,latest}, test-py{310,311,312,313}-psutillatest-dateutillatest, integration-py{310,311,312,313} 32 | deps = 33 | coverage 34 | skip_install = true 35 | setenv = 36 | commands = 37 | - coverage combine 38 | {envbindir}/coverage html 39 | {envbindir}/coverage report 40 | 41 | [testenv:mindeps] 42 | description = tests whether the project can be used without any extras 43 | extras = 44 | deps = 45 | depends = 46 | commands = 47 | {envbindir}/python -V 48 | {envbindir}/python -c "import autosuspend; import autosuspend.checks.activity; import autosuspend.checks.wakeup" 49 | {envbindir}/autosuspend -c tests/data/mindeps-test.conf daemon -r 1 50 | 51 | [testenv:check] 52 | depends = 53 | deps = 54 | -rrequirements-check.txt 55 | commands = 56 | {envbindir}/python -V 57 | {envbindir}/ruff check src tests 58 | {envbindir}/isort --check src tests 59 | {envbindir}/black --check src tests 60 | {envbindir}/mypy src tests 61 | 62 | [testenv:docs] 63 | basepython = python3.13 64 | depends = 65 | deps = -rrequirements-doc.txt 66 | commands = {envbindir}/sphinx-build -W -b html -d {envtmpdir}/doctrees doc/source {envtmpdir}/html 67 | 68 | [gh-actions] 69 | python = 70 | 3.10: py310, coverage 71 | 3.11: py311, coverage 72 | 3.12: py312, coverage 73 | 3.13: py313, coverage 74 | --------------------------------------------------------------------------------