Extract wake_alarm from testsAndMisc as a standalone repo

Rewrites python_pkg.wake_alarm imports to wake_alarm, vendors the
shared configure_logging helper, drops the monorepo PYTHONPATH from
install.sh and the systemd unit (package is now pip-installed), and
untracks wake_state.json (runtime HMAC state, now gitignored). Scaffolds
standalone lint/test config copied from the already-corrected diet_guard
scaffold (pylint --fail-under=10 with tests excluded and the
use-implicit-booleaness/consider-using-with disables, mypy's actual
disabled-error-code set, ruff ALL, bandit, 100% branch coverage), plus
the wave.Wave_write generated-members fix this package's _audio.py needs.
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-22 12:31:40 +02:00
parent dd01b6f846
commit 407d7cbf8f
32 changed files with 914 additions and 324 deletions

19
.github/workflows/pre-commit.yml vendored Normal file
View File

@ -0,0 +1,19 @@
name: pre-commit
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- uses: pre-commit/action@v3.0.1

28
.github/workflows/python-tests.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests with coverage
run: python -m pytest -v

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
__pycache__/
*.py[cod]
.Python
build/
dist/
*.egg-info/
.env
.venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
coverage.lcov
htmlcov/
*.log
.DS_Store

140
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,140 @@
# Pre-commit Configuration for wake-alarm
# Install: pre-commit install && pre-commit install --hook-type pre-push
# Run: pre-commit run --all-files
# Update: pre-commit autoupdate
default_language_version:
python: python3
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-added-large-files
args: [--maxkb=2000]
- id: check-merge-conflict
- id: detect-private-key
- id: debug-statements
- id: name-tests-test
args: [--pytest-test-first]
- id: check-ast
- id: mixed-line-ending
args: [--fix=lf]
- id: requirements-txt-fixer
- repo: local
hooks:
- id: no-noqa
name: Block noqa comments
entry: '(?i)#\s*(noqa|type:\s*ignore)'
language: pygrep
types: [python]
- id: no-ruff-noqa
name: Block ruff noqa file-level comments
entry: '(?i)#\s*ruff:\s*noqa'
language: pygrep
types: [python]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.2
hooks:
- id: ruff
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
types_or: [python, pyi]
- id: ruff-format
types_or: [python, pyi]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
args:
- --ignore-missing-imports
- --no-error-summary
- --disable-error-code=no-untyped-def
- --disable-error-code=no-untyped-call
- --disable-error-code=var-annotated
- --disable-error-code=no-any-unimported
- --disable-error-code=type-arg
- --disable-error-code=no-any-return
- --disable-error-code=misc
- --disable-error-code=unused-ignore
- --disable-error-code=unreachable
- --disable-error-code=assignment
- --disable-error-code=no-redef
- --disable-error-code=attr-defined
- --disable-error-code=arg-type
- --disable-error-code=union-attr
- --disable-error-code=call-overload
- --disable-error-code=return-value
- --disable-error-code=redundant-cast
- --disable-error-code=empty-body
- --disable-error-code=list-item
- repo: https://github.com/pylint-dev/pylint
rev: v3.3.2
hooks:
- id: pylint
args:
- --rcfile=pyproject.toml
- --fail-under=10
- --jobs=4
additional_dependencies:
- pytest
- python-kasa
- gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0
# Test suites intentionally use patterns (protected-access, magic-value
# comparisons) that don't belong in the source-code 10/10 gate.
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
- repo: https://github.com/PyCQA/bandit
rev: 1.7.10
hooks:
- id: bandit
args:
- -c
- pyproject.toml
- --severity-level=high
- --confidence-level=medium
- --skip=B113
additional_dependencies: ["bandit[toml]"]
exclude: ^(tests/|.*test.*\.py$)
- repo: local
hooks:
- id: pytest-coverage
name: pytest with coverage enforcement
entry: python -m pytest
language: system
types: [python]
pass_filenames: false
require_serial: true
stages: [pre-push]
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
args:
- --skip=*.json,*.lock,.git,__pycache__,.venv
- repo: local
hooks:
- id: shellcheck
name: shellcheck
entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' --
language: system
types: [shell]
- id: max-file-length
name: Max file length (500 lines)
entry: python3 scripts/check_file_length.py
language: system
types_or: [python, shell]

111
CLAUDE.md Normal file
View File

@ -0,0 +1,111 @@
# CLAUDE.md — wake_alarm
## What this does
A weekend wake alarm: on alarm days (`ALARM_DAYS` in `_constants.py` — Mon,
Fri, Sat, Sun), the machine hibernates overnight and wakes itself via
`rtcwake` at the configured alarm time. `wake-alarm.service` then opens a
fullscreen Tk window that must be dismissed (with a typed challenge — see
`_challenges.py`), ramps fans to 100% via `wake-alarm-fans.sh`, plays audio
through whatever sink comes up after the monitor wakes, and optionally
toggles a TP-Link Tapo P110 smart plug via `python-kasa`.
## Scheduling — hibernate-based, not a systemd timer
There is **no systemd timer** for this package. The wake mechanism is:
1. `shutdown-wrapper.sh`, installed to `/usr/local/bin/shutdown` (shadowing
`/usr/bin/shutdown` via PATH order), intercepts shutdown/poweroff calls on
alarm nights and calls `rtcwake -m disk` instead — hibernating with the
RTC alarm set to wake the machine at `WAKE_AFTER_HOURS` (`_constants.py`)
from now.
2. `sleep-hook.sh`, installed to `/usr/lib/systemd/system-sleep/`, fires on
resume (`$1 == post`) and starts `morning-routine.service` for every
logged-in session. That orchestrator (lives in testsAndMisc, not here —
see below) runs the alarm first, then the workout screen lock, so the two
never fight for the fullscreen.
3. `wake-alarm.service` itself is `Type=simple`, started either directly or
by the orchestrator, and exits once the alarm is dismissed.
If you change `WAKE_AFTER_HOURS` in `_constants.py`, you must also update the
duplicate constant in `shutdown-wrapper.sh` (`WAKE_AFTER_HOURS=8`) — they are
not wired together, by design (the shell wrapper has no Python runtime
available at the point it intercepts `shutdown`).
## Cross-repo coupling — not a bug
`_constants.py`'s `WORKOUT_LOG_FILE` points at
`~/screen-locker/screen_locker/workout_log.json` — a file owned by the
separate, already-standalone `screen-locker` repo
(https://github.com/kuhyx/screen-locker). This is intentional: the alarm
reads whether today's workout was already logged by screen-locker to decide
whether the morning routine should also lock the workout screen. If this
path ever raises `ModuleNotFoundError`-style confusion, the bug is almost
certainly in the **orchestrator** (`morning_routine` in testsAndMisc), not
here — see the next section.
## The morning_routine orchestrator lives elsewhere
`morning_routine._orchestrator` (in `testsAndMisc/python_pkg/morning_routine/`)
runs this package and `screen_locker` as two sequential subprocesses. When
either package is extracted to its own repo, **the orchestrator's module
reference must be updated in the same change** — this exact mistake
(orchestrator left pointing at `python_pkg.screen_locker.screen_lock` after
screen-locker's extraction on 2026-05-28) caused a month-long silent
production failure where the alarm fired and dismissed correctly but the
workout lock crashed with `ModuleNotFoundError` on every run. Once both
`wake_alarm` and `screen_locker` are pip-installed system-wide, the
orchestrator needs no `PYTHONPATH`/`cwd` plumbing — plain
`subprocess.run([sys.executable, "-m", module, "--production"])` resolves
both.
## Production dependency installation — read this before adding any dependency
`wake-alarm.service` runs `/usr/bin/python` directly — **not** a venv. Any
new non-stdlib dependency (this package itself, `gatelock`, `python-kasa`,
anything added later) must be installed into system Python's *user*
site-packages:
```bash
/usr/bin/python3 -m pip install --user --break-system-packages -e .
```
`install.sh` already does this. **If you add a dependency and only install it
into a dev venv, the production service will silently fail with
`ModuleNotFoundError` on its next run** — this exact gap caused a 3-day
diet_guard production outage (2026-06-19 to 2026-06-22) for the sibling
`gatelock` migration. Always verify against
`/usr/bin/python3 -c "import <new_dep>"`, not just the dev venv, before
considering a dependency change done.
## Operational gotchas
- **`python-kasa` is optional at runtime** (`_smart_plug.py` catches
`ImportError` and disables smart-plug control with a warning log), but it
*is* a hard dependency for this repo's own tooling (mypy/pylint/tests need
it importable) — see `pyproject.toml`/`requirements.txt`.
- **The `wave` module needs special pylint handling.** `_audio.py` opens WAV
files in write mode; pylint's stdlib stub infers the read-mode overload and
wrongly flags `setnchannels`/`setsampwidth`/`setframerate`/`writeframes` as
missing. See the `generated-members` list in `pyproject.toml`'s
`[tool.pylint.typecheck]` — don't remove it if pylint starts complaining
about `wave.Wave_write`.
- **`wake_state.json` is runtime state, not tracked.** It holds the
HMAC-signed dismissal record for the current alarm day. It used to be
accidentally committed in the monorepo; it is gitignored here and must
stay that way.
## Commands
- Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100`
- Lint: `pre-commit run --all-files`
- Test the lock manually (safe, closeable): `python -m wake_alarm._alarm --demo`
- Install for production: `bash install.sh`
## Do NOT
- Don't add a dependency without doing the production install-path check
above.
- Don't forget the orchestrator when changing this package's module path or
invocation — see "The morning_routine orchestrator lives elsewhere" above.
- Don't commit `wake_state.json`.

39
README.md Normal file
View File

@ -0,0 +1,39 @@
# wake_alarm
A hibernate-scheduled weekend wake alarm: the machine hibernates overnight
on alarm days and wakes itself via `rtcwake`, then shows a fullscreen,
challenge-dismissed alarm with fan ramp and optional TP-Link Tapo P110
smart-plug control.
## Install
```bash
bash install.sh
```
This installs the package + dependencies into system Python's user
site-packages (the systemd service runs `/usr/bin/python` directly, not a
venv — see `CLAUDE.md`), installs the systemd user service, the
systemd-sleep resume hook, the `shutdown` wrapper that triggers hibernate on
alarm nights, the fan-ramp script, and (optionally) `python-kasa` for
smart-plug control.
## Usage
```bash
python -m wake_alarm._alarm --demo # test the alarm window (safe, closeable)
```
The alarm fires automatically via the hibernate/wake cycle once installed;
no manual invocation is needed in normal operation.
## Development
```bash
python -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/pre-commit install && .venv/bin/pre-commit install --hook-type pre-push
.venv/bin/python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100
```
See `CLAUDE.md` for scheduling details, the hibernate/`rtcwake` mechanism,
the cross-repo `workout_log.json` read, and production deployment gotchas.

View File

@ -4,17 +4,22 @@
# Usage: bash install.sh # Usage: bash install.sh
# #
# What it does: # What it does:
# 1. Copies wake-alarm.service to ~/.config/systemd/user/ # 1. Installs wake_alarm + dependencies for /usr/bin/python
# 2. Enables and starts the service # 2. Installs system dependencies (alsa-utils, ddcutil)
# 3. Installs the systemd-sleep hook (restarts alarm after hibernate resume) # 3. Copies wake-alarm.service to ~/.config/systemd/user/ and enables it
# 4. Adds a sudoers entry for passwordless rtcwake # 4. Installs the systemd-sleep hook (restarts alarm after hibernate resume)
# 5. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights # 5. Adds a sudoers entry for passwordless rtcwake
# 6. Installs fan-control script so alarm can max fans on wake # 6. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights
# 7. Installs python-kasa (AUR) so the alarm can toggle a Tapo P110 smart plug # 7. Installs fan-control script so alarm can max fans on wake
# 8. Installs python-kasa (AUR) so the alarm can toggle a Tapo P110 smart plug
# 9. Installs ddcutil and grants /dev/i2c-* access for DDC/CI monitor control
set -euo pipefail set -euo pipefail
# Split declare/assign so the command-substitution exit code is not masked (SC2155).
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR
readonly REPO_DIR="$SCRIPT_DIR"
SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service" SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service"
SLEEP_HOOK_SRC="$SCRIPT_DIR/sleep-hook.sh" SLEEP_HOOK_SRC="$SCRIPT_DIR/sleep-hook.sh"
SHUTDOWN_WRAPPER_SRC="$SCRIPT_DIR/shutdown-wrapper.sh" SHUTDOWN_WRAPPER_SRC="$SCRIPT_DIR/shutdown-wrapper.sh"
@ -28,8 +33,15 @@ RTCWAKE_BIN="/usr/sbin/rtcwake"
echo "=== Weekend Wake Alarm Installer ===" echo "=== Weekend Wake Alarm Installer ==="
# 0. Install system dependencies # 1. Install this package + its dependencies into system Python -------------
echo "[0/7] Checking system dependencies..." echo "[1/9] Installing wake_alarm + dependencies for /usr/bin/python..."
/usr/bin/python3 -m pip install --user --break-system-packages -e "$REPO_DIR"
echo " Installed. Verifying import..."
/usr/bin/python3 -c "import wake_alarm; import gatelock" \
&& echo " wake_alarm and gatelock import cleanly from the system interpreter."
# 2. Install system dependencies
echo "[2/9] Checking system dependencies..."
if ! command -v speaker-test &>/dev/null; then if ! command -v speaker-test &>/dev/null; then
echo " Installing alsa-utils (required for speaker-test)..." echo " Installing alsa-utils (required for speaker-test)..."
sudo pacman -S --noconfirm alsa-utils sudo pacman -S --noconfirm alsa-utils
@ -37,26 +49,23 @@ else
echo " alsa-utils already installed" echo " alsa-utils already installed"
fi fi
# 1. Install systemd user service # 3. Install systemd user service
echo "[1/7] Installing systemd user service..." echo "[3/9] Installing systemd user service..."
mkdir -p "$SYSTEMD_USER_DIR" mkdir -p "$SYSTEMD_USER_DIR"
cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service" cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service"
systemctl --user daemon-reload systemctl --user daemon-reload
echo " Installed to $SYSTEMD_USER_DIR/wake-alarm.service" echo " Installed to $SYSTEMD_USER_DIR/wake-alarm.service"
# 2. Enable service
echo "[2/7] Enabling wake-alarm.service..."
systemctl --user enable wake-alarm.service systemctl --user enable wake-alarm.service
echo " Service enabled (will start on next boot)" echo " Service enabled (will start on next boot)"
# 3. Install systemd-sleep hook (restarts alarm after hibernate resume) # 4. Install systemd-sleep hook (restarts alarm after hibernate resume)
echo "[3/7] Installing systemd-sleep hook..." echo "[4/9] Installing systemd-sleep hook..."
sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST" sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST"
sudo chmod 0755 "$SLEEP_HOOK_DST" sudo chmod 0755 "$SLEEP_HOOK_DST"
echo " Installed to $SLEEP_HOOK_DST" echo " Installed to $SLEEP_HOOK_DST"
# 4. Add sudoers entry for rtcwake (requires root) # 5. Add sudoers entry for rtcwake (requires root)
echo "[4/7] Setting up sudoers for rtcwake..." echo "[5/9] Setting up sudoers for rtcwake..."
SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $RTCWAKE_BIN" SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $RTCWAKE_BIN"
if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$SUDOERS_LINE" "$SUDOERS_FILE"; then if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$SUDOERS_LINE" "$SUDOERS_FILE"; then
echo " Sudoers entry already exists" echo " Sudoers entry already exists"
@ -67,15 +76,15 @@ else
echo " Added: $SUDOERS_LINE" echo " Added: $SUDOERS_LINE"
fi fi
# 5. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown) # 6. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown)
echo "[5/7] Installing shutdown wrapper..." echo "[6/9] Installing shutdown wrapper..."
sudo cp "$SHUTDOWN_WRAPPER_SRC" "$SHUTDOWN_WRAPPER_DST" sudo cp "$SHUTDOWN_WRAPPER_SRC" "$SHUTDOWN_WRAPPER_DST"
sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST" sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST"
echo " Installed to $SHUTDOWN_WRAPPER_DST" echo " Installed to $SHUTDOWN_WRAPPER_DST"
echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights." echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights."
# 6. Install fan-control script and its sudoers entry # 7. Install fan-control script and its sudoers entry
echo "[6/7] Installing fan-control script..." echo "[7/9] Installing fan-control script..."
sudo cp "$FANS_SCRIPT_SRC" "$FANS_SCRIPT_DST" sudo cp "$FANS_SCRIPT_SRC" "$FANS_SCRIPT_DST"
sudo chmod 0755 "$FANS_SCRIPT_DST" sudo chmod 0755 "$FANS_SCRIPT_DST"
FANS_SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $FANS_SCRIPT_DST" FANS_SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $FANS_SCRIPT_DST"
@ -88,8 +97,8 @@ else
echo " Added fan sudoers entry" echo " Added fan sudoers entry"
fi fi
# 7. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control # 8. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control
echo "[7/8] Installing python-kasa (AUR)..." echo "[8/9] Installing python-kasa (AUR)..."
if python -c 'import kasa' 2>/dev/null; then if python -c 'import kasa' 2>/dev/null; then
echo " python-kasa already installed" echo " python-kasa already installed"
elif command -v yay &>/dev/null; then elif command -v yay &>/dev/null; then
@ -102,10 +111,10 @@ if [[ ! -f "$HOME/.config/wake_alarm/tapo.json" ]]; then
echo " Create it (mode 0600) with keys: host, email, password." echo " Create it (mode 0600) with keys: host, email, password."
fi fi
# 8. Install ddcutil for DDC/CI monitor power control # 9. Install ddcutil for DDC/CI monitor power control
# ddcutil lets the alarm force the G27Q on via DDC/CI even when the monitor # ddcutil lets the alarm force the G27Q on via DDC/CI even when the monitor
# was physically powered off (power button), bypassing DPMS limitations. # was physically powered off (power button), bypassing DPMS limitations.
echo "[8/8] Installing ddcutil (DDC/CI monitor power control)..." echo "[9/9] Installing ddcutil (DDC/CI monitor power control)..."
if command -v ddcutil &>/dev/null; then if command -v ddcutil &>/dev/null; then
echo " ddcutil already installed" echo " ddcutil already installed"
else else
@ -128,4 +137,4 @@ echo "=== Installation complete ==="
echo "The wake alarm will activate on boot for alarm days (Mon, Fri, Sat, Sun)." echo "The wake alarm will activate on boot for alarm days (Mon, Fri, Sat, Sun)."
echo "After hibernate resume the sleep hook will restart the alarm service." echo "After hibernate resume the sleep hook will restart the alarm service."
echo "Fans will ramp to 100% while the alarm is active, then restore automatically." echo "Fans will ramp to 100% while the alarm is active, then restore automatically."
echo "To test now: python -m python_pkg.wake_alarm._alarm --demo" echo "To test now: python -m wake_alarm._alarm --demo"

181
pyproject.toml Normal file
View File

@ -0,0 +1,181 @@
[project]
name = "wake-alarm"
version = "1.0.0"
description = "Hibernate-scheduled weekend wake alarm with fan ramp and smart-plug control"
requires-python = ">=3.10"
dependencies = [
"gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0",
"python-kasa>=0.7",
]
[tool.ruff]
target-version = "py310"
include = ["*.py", "**/*.py"]
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D203",
"D213",
"COM812",
"ISC001",
"S603",
]
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.per-file-ignores]
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.isort]
force-single-line = false
force-sort-within-sections = true
known-first-party = ["wake_alarm"]
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "double"
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
docstring-code-format = true
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
disallow_any_unimported = true
disallow_any_explicit = false
disallow_any_generics = true
disallow_subclassing_any = true
strict_equality = true
extra_checks = true
ignore_missing_imports = true
show_error_codes = true
color_output = true
exclude = [".venv/"]
[tool.pylint.main]
analyse-fallback-blocks = true
persistent = true
jobs = 0
py-version = "3.10"
# "tests" is a basename match: test suites intentionally use patterns
# (protected-access, magic-value comparisons) that don't apply to source
# code, matching testsAndMisc's pylint scope (tests are linted by ruff, not
# pylint there either).
ignore = [".venv", "__pycache__", "tests"]
ignore-patterns = [".*\\.pyi$"]
[tool.pylint.messages_control]
# Enable all checks by disabling disable
enable = "all"
# Globally disabled checks. Each is either a stylistic preference that conflicts
# with deliberate, clearer code, or a structural false positive that cannot be
# rewritten without harming readability. Everything else stays at max strictness.
disable = [
# use-implicit-booleaness family (C1803/C1804/C1805): pylint wants
# `not x` / `not s` instead of `x == 0` / `s == ""`. Explicit comparisons
# against 0 and "" state numeric/string intent more clearly than truthiness
# (and are not equivalent when the value may be None), so we keep them.
"use-implicit-booleaness-not-comparison",
"use-implicit-booleaness-not-comparison-to-string",
"use-implicit-booleaness-not-comparison-to-zero",
# consider-using-with (R1732): several subprocess.Popen calls are
# intentionally fire-and-forget — the process must outlive the calling
# scope and is polled/killed later, so a `with` block is wrong here.
"consider-using-with",
]
[tool.pylint.design]
min-public-methods = 0
max-module-lines = 1000
max-attributes = 10
[tool.pylint.typecheck]
# unittest.mock.MagicMock generates assertion/introspection methods at runtime.
# wave.open(path, "wb") returns a Wave_write, but pylint's stdlib stub infers the
# read-mode Wave_read overload and wrongly reports its setter/writer methods as
# missing — list them so the write API is recognised.
generated-members = [
".*\\.assert_called_once_with",
".*\\.assert_called_once",
".*\\.assert_called",
".*\\.assert_not_called",
".*\\.assert_any_call",
".*\\.call_args",
".*\\.call_args_list",
".*\\.call_count",
".*\\.setnchannels",
".*\\.setsampwidth",
".*\\.setframerate",
".*\\.writeframes",
".*\\.writeframesraw",
]
[tool.bandit]
exclude_dirs = ["tests", ".venv"]
[tool.pytest.ini_options]
testpaths = ["wake_alarm/tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
"--strict-config",
"-ra",
"--cov=wake_alarm",
"--cov-branch",
"--cov-report=term-missing",
"--cov-report=lcov",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning",
"default::pytest.PytestUnraisableExceptionWarning",
]
[tool.coverage.run]
source = ["wake_alarm"]
branch = true
omit = [
"*/__pycache__/*",
"*/tests/*",
"*/.venv/*",
]
[tool.coverage.report]
fail_under = 100
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"raise AssertionError",
"if TYPE_CHECKING:",
'if __name__ == "__main__":',
]
partial_branches = ["pragma: no branch"]

18
requirements.txt Normal file
View File

@ -0,0 +1,18 @@
# Wake Alarm — runtime + development dependencies
# Runtime: tkinter/json/hmac/wave/subprocess (stdlib) plus gatelock and
# python-kasa below (python-kasa is optional at runtime; the module degrades
# gracefully if it's missing, but is required here for mypy/pylint/tests).
bandit>=1.7.0
codespell>=2.2.0
coverage>=7.4.0
gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0
mypy>=1.8.0
pre-commit>=3.6.0
pylint>=3.0.0
pytest>=8.0.0
pytest-cov>=4.1.0
pytest-randomly>=3.15.0
pytest-sugar>=1.0.0
pytest-xdist>=3.5.0
python-kasa>=0.7
ruff>=0.8.0

26
scripts/check_file_length.py Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Pre-commit hook: fail if any file exceeds MAX_LINES lines."""
from pathlib import Path
import sys
MAX_LINES = 500
def main() -> int:
"""Return 1 if any file exceeds the line limit, else 0."""
failed = False
for filepath in sys.argv[1:]:
try:
with Path(filepath).open(encoding="utf-8", errors="replace") as fh:
count = sum(1 for _ in fh)
except OSError:
failed = True
continue
if count > MAX_LINES:
failed = True
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -11,7 +11,7 @@ set -euo pipefail
REAL_SHUTDOWN=/usr/bin/shutdown REAL_SHUTDOWN=/usr/bin/shutdown
RTCWAKE=/usr/sbin/rtcwake RTCWAKE=/usr/sbin/rtcwake
WAKE_AFTER_HOURS=8 # Must match WAKE_AFTER_HOURS in python_pkg/wake_alarm/_constants.py WAKE_AFTER_HOURS=8 # Must match WAKE_AFTER_HOURS in wake_alarm/_constants.py
# Pass through reboots and cancel commands unchanged. # Pass through reboots and cancel commands unchanged.
for arg in "$@"; do for arg in "$@"; do

20
wake-alarm.service Normal file
View File

@ -0,0 +1,20 @@
[Unit]
Description=Weekend Wake Alarm
After=graphical-session.target
[Service]
Type=simple
# DISPLAY mirrors workout-locker.service: without DISPLAY the alarm crashes
# on cold boot with "no display name and no $DISPLAY" (Tk can't open the X
# server) before systemd retries. The short sleep lets the X session export
# DISPLAY into the user environment first.
# No PYTHONPATH needed: wake_alarm is pip-installed (see install.sh /
# README), so /usr/bin/python finds it via user site-packages.
Environment=DISPLAY=:0
ExecStartPre=/bin/sleep 1
ExecStart=/usr/bin/python -m wake_alarm._alarm --production
Restart=on-failure
RestartSec=10
[Install]
WantedBy=graphical-session.target

View File

@ -20,9 +20,8 @@ import tkinter as tk
from gatelock import GateRoot, LockConfig, LockWindow from gatelock import GateRoot, LockConfig, LockWindow
from python_pkg.shared.logging_setup import configure_logging from wake_alarm._alarm_display import _restore_display, _wake_display
from python_pkg.wake_alarm._alarm_display import _restore_display, _wake_display from wake_alarm._audio import (
from python_pkg.wake_alarm._audio import (
_activate_alarm_audio, _activate_alarm_audio,
_beep_loud, _beep_loud,
_beep_medium, _beep_medium,
@ -34,11 +33,11 @@ from python_pkg.wake_alarm._audio import (
_set_max_brightness, _set_max_brightness,
_warn_if_no_real_sink, _warn_if_no_real_sink,
) )
from python_pkg.wake_alarm._challenges import ( from wake_alarm._challenges import (
_Challenge, _Challenge,
_make_challenge, _make_challenge,
) )
from python_pkg.wake_alarm._constants import ( from wake_alarm._constants import (
ALARM_DAYS, ALARM_DAYS,
DISMISS_CODE_REFRESH_SECONDS, DISMISS_CODE_REFRESH_SECONDS,
DISMISS_FLASH_SECONDS, DISMISS_FLASH_SECONDS,
@ -51,8 +50,9 @@ from python_pkg.wake_alarm._constants import (
PHASE_SOFT_END, PHASE_SOFT_END,
SOFT_BEEP_INTERVAL, SOFT_BEEP_INTERVAL,
) )
from python_pkg.wake_alarm._smart_plug import turn_off_plug, turn_on_plug from wake_alarm._logging_setup import configure_logging
from python_pkg.wake_alarm._state import ( from wake_alarm._smart_plug import turn_off_plug, turn_on_plug
from wake_alarm._state import (
save_wake_state, save_wake_state,
was_alarm_dismissed_today, was_alarm_dismissed_today,
was_workout_logged_today, was_workout_logged_today,

View File

@ -14,7 +14,7 @@ import tempfile
import time import time
import wave import wave
from python_pkg.wake_alarm._constants import ( from wake_alarm._constants import (
ALARM_AUDIO_CARD, ALARM_AUDIO_CARD,
ALARM_AUDIO_PROFILE, ALARM_AUDIO_PROFILE,
ALARM_AUDIO_SINK, ALARM_AUDIO_SINK,

View File

@ -10,7 +10,7 @@ from __future__ import annotations
import secrets import secrets
from python_pkg.wake_alarm._constants import DISMISS_CODE_LENGTH, DISMISS_FLASH_SECONDS from wake_alarm._constants import DISMISS_CODE_LENGTH, DISMISS_FLASH_SECONDS
# Uppercase alphanumeric chars with visually ambiguous characters removed: # Uppercase alphanumeric chars with visually ambiguous characters removed:
# O/0 (oh vs zero) and I/1 (capital-i vs one) are excluded so the code is # O/0 (oh vs zero) and I/1 (capital-i vs one) are excluded so the code is

View File

@ -0,0 +1,13 @@
"""Logging configuration for the wake_alarm entry point."""
from __future__ import annotations
import logging
def configure_logging() -> None:
"""Configure root logging with the standard daemon format and level."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
)

View File

@ -22,7 +22,7 @@ import json
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python_pkg.wake_alarm._constants import ( from wake_alarm._constants import (
TAPO_CONFIG_FILE, TAPO_CONFIG_FILE,
TAPO_TIMEOUT_SECONDS, TAPO_TIMEOUT_SECONDS,
) )

View File

@ -11,7 +11,7 @@ from gatelock.log_integrity import (
verify_entry_hmac, verify_entry_hmac,
) )
from python_pkg.wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE from wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)

View File

@ -13,11 +13,11 @@ import pytest
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
from python_pkg.wake_alarm._alarm import ( from wake_alarm._alarm import (
_is_alarm_day, _is_alarm_day,
_should_run_alarm, _should_run_alarm,
) )
from python_pkg.wake_alarm._audio import ( from wake_alarm._audio import (
_beep_loud, _beep_loud,
_beep_medium, _beep_medium,
_beep_soft, _beep_soft,
@ -27,11 +27,11 @@ from python_pkg.wake_alarm._audio import (
_restore_fans, _restore_fans,
_speaker_test_path, _speaker_test_path,
) )
from python_pkg.wake_alarm._challenges import ( from wake_alarm._challenges import (
_DISMISS_CHARS, _DISMISS_CHARS,
_generate_code, _generate_code,
) )
from python_pkg.wake_alarm._constants import ( from wake_alarm._constants import (
DISMISS_CODE_LENGTH, DISMISS_CODE_LENGTH,
) )
@ -60,9 +60,9 @@ def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests.""" """Prevent any real Tk windows in tests."""
mock = _make_mock_tk() mock = _make_mock_tk()
with ( with (
patch("python_pkg.wake_alarm._alarm.tk", mock), patch("wake_alarm._alarm.tk", mock),
patch( patch(
"python_pkg.wake_alarm._alarm.GateRoot", "wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value, return_value=mock.Tk.return_value,
), ),
): ):
@ -74,9 +74,9 @@ def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module.""" """Provide explicit access to the mocked tk module."""
mock = _make_mock_tk() mock = _make_mock_tk()
with ( with (
patch("python_pkg.wake_alarm._alarm.tk", mock), patch("wake_alarm._alarm.tk", mock),
patch( patch(
"python_pkg.wake_alarm._alarm.GateRoot", "wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value, return_value=mock.Tk.return_value,
), ),
): ):
@ -117,7 +117,7 @@ class TestIsAlarmDay:
# Create a date that is Monday # Create a date that is Monday
with patch( with patch(
"python_pkg.wake_alarm._alarm.datetime", "wake_alarm._alarm.datetime",
) as mock_dt: ) as mock_dt:
mock_now = MagicMock() mock_now = MagicMock()
mock_now.weekday.return_value = 0 # Monday mock_now.weekday.return_value = 0 # Monday
@ -128,7 +128,7 @@ class TestIsAlarmDay:
def test_tuesday_is_not_alarm_day(self) -> None: def test_tuesday_is_not_alarm_day(self) -> None:
"""Tuesday (weekday=1) is NOT an alarm day.""" """Tuesday (weekday=1) is NOT an alarm day."""
with patch( with patch(
"python_pkg.wake_alarm._alarm.datetime", "wake_alarm._alarm.datetime",
) as mock_dt: ) as mock_dt:
mock_now = MagicMock() mock_now = MagicMock()
mock_now.weekday.return_value = 1 # Tuesday mock_now.weekday.return_value = 1 # Tuesday
@ -138,7 +138,7 @@ class TestIsAlarmDay:
def test_friday_is_alarm_day(self) -> None: def test_friday_is_alarm_day(self) -> None:
"""Friday (weekday=4) is an alarm day.""" """Friday (weekday=4) is an alarm day."""
with patch( with patch(
"python_pkg.wake_alarm._alarm.datetime", "wake_alarm._alarm.datetime",
) as mock_dt: ) as mock_dt:
mock_now = MagicMock() mock_now = MagicMock()
mock_now.weekday.return_value = 4 # Friday mock_now.weekday.return_value = 4 # Friday
@ -148,7 +148,7 @@ class TestIsAlarmDay:
def test_saturday_is_alarm_day(self) -> None: def test_saturday_is_alarm_day(self) -> None:
"""Saturday (weekday=5) is an alarm day.""" """Saturday (weekday=5) is an alarm day."""
with patch( with patch(
"python_pkg.wake_alarm._alarm.datetime", "wake_alarm._alarm.datetime",
) as mock_dt: ) as mock_dt:
mock_now = MagicMock() mock_now = MagicMock()
mock_now.weekday.return_value = 5 mock_now.weekday.return_value = 5
@ -158,7 +158,7 @@ class TestIsAlarmDay:
def test_sunday_is_alarm_day(self) -> None: def test_sunday_is_alarm_day(self) -> None:
"""Sunday (weekday=6) is an alarm day.""" """Sunday (weekday=6) is an alarm day."""
with patch( with patch(
"python_pkg.wake_alarm._alarm.datetime", "wake_alarm._alarm.datetime",
) as mock_dt: ) as mock_dt:
mock_now = MagicMock() mock_now = MagicMock()
mock_now.weekday.return_value = 6 mock_now.weekday.return_value = 6
@ -168,7 +168,7 @@ class TestIsAlarmDay:
def test_wednesday_is_not_alarm_day(self) -> None: def test_wednesday_is_not_alarm_day(self) -> None:
"""Wednesday (weekday=2) is NOT an alarm day.""" """Wednesday (weekday=2) is NOT an alarm day."""
with patch( with patch(
"python_pkg.wake_alarm._alarm.datetime", "wake_alarm._alarm.datetime",
) as mock_dt: ) as mock_dt:
mock_now = MagicMock() mock_now = MagicMock()
mock_now.weekday.return_value = 2 mock_now.weekday.return_value = 2
@ -182,7 +182,7 @@ class TestSpeakerTestPath:
def test_returns_path_when_found(self) -> None: def test_returns_path_when_found(self) -> None:
"""Return full path when speaker-test is available.""" """Return full path when speaker-test is available."""
with patch( with patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/speaker-test", return_value="/usr/bin/speaker-test",
): ):
assert _speaker_test_path() == "/usr/bin/speaker-test" assert _speaker_test_path() == "/usr/bin/speaker-test"
@ -191,7 +191,7 @@ class TestSpeakerTestPath:
"""Raise FileNotFoundError when speaker-test is missing.""" """Raise FileNotFoundError when speaker-test is missing."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value=None, return_value=None,
), ),
pytest.raises(FileNotFoundError, match="speaker-test not found"), pytest.raises(FileNotFoundError, match="speaker-test not found"),
@ -204,7 +204,7 @@ class TestBeepFunctions:
def test_beep_soft_writes_bell(self) -> None: def test_beep_soft_writes_bell(self) -> None:
"""_beep_soft writes terminal bell character.""" """_beep_soft writes terminal bell character."""
with patch("python_pkg.wake_alarm._audio.sys") as mock_sys: with patch("wake_alarm._audio.sys") as mock_sys:
mock_sys.stdout = MagicMock() mock_sys.stdout = MagicMock()
_beep_soft() _beep_soft()
mock_sys.stdout.write.assert_called_once_with("\a") mock_sys.stdout.write.assert_called_once_with("\a")
@ -212,13 +212,13 @@ class TestBeepFunctions:
def test_beep_medium_delegates_to_play_tone(self) -> None: def test_beep_medium_delegates_to_play_tone(self) -> None:
"""_beep_medium just delegates to _play_tone.""" """_beep_medium just delegates to _play_tone."""
with patch("python_pkg.wake_alarm._audio._play_tone") as mock_play: with patch("wake_alarm._audio._play_tone") as mock_play:
_beep_medium(frequency=800) _beep_medium(frequency=800)
mock_play.assert_called_once_with(800) mock_play.assert_called_once_with(800)
def test_beep_loud_delegates_to_play_tone(self) -> None: def test_beep_loud_delegates_to_play_tone(self) -> None:
"""_beep_loud just delegates to _play_tone.""" """_beep_loud just delegates to _play_tone."""
with patch("python_pkg.wake_alarm._audio._play_tone") as mock_play: with patch("wake_alarm._audio._play_tone") as mock_play:
_beep_loud(frequency=1200) _beep_loud(frequency=1200)
mock_play.assert_called_once_with(1200) mock_play.assert_called_once_with(1200)
@ -229,7 +229,7 @@ class TestShouldRunAlarm:
def test_returns_false_on_non_alarm_day(self) -> None: def test_returns_false_on_non_alarm_day(self) -> None:
"""Return False when today is not an alarm day.""" """Return False when today is not an alarm day."""
with patch( with patch(
"python_pkg.wake_alarm._alarm._is_alarm_day", "wake_alarm._alarm._is_alarm_day",
return_value=False, return_value=False,
): ):
assert _should_run_alarm() is False assert _should_run_alarm() is False
@ -238,11 +238,11 @@ class TestShouldRunAlarm:
"""Return False when alarm was already dismissed today.""" """Return False when alarm was already dismissed today."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm._is_alarm_day", "wake_alarm._alarm._is_alarm_day",
return_value=True, return_value=True,
), ),
patch( patch(
"python_pkg.wake_alarm._alarm.was_alarm_dismissed_today", "wake_alarm._alarm.was_alarm_dismissed_today",
return_value=True, return_value=True,
), ),
): ):
@ -252,15 +252,15 @@ class TestShouldRunAlarm:
"""Return True when today is alarm day and not yet dismissed.""" """Return True when today is alarm day and not yet dismissed."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm._is_alarm_day", "wake_alarm._alarm._is_alarm_day",
return_value=True, return_value=True,
), ),
patch( patch(
"python_pkg.wake_alarm._alarm.was_alarm_dismissed_today", "wake_alarm._alarm.was_alarm_dismissed_today",
return_value=False, return_value=False,
), ),
patch( patch(
"python_pkg.wake_alarm._alarm.was_workout_logged_today", "wake_alarm._alarm.was_workout_logged_today",
return_value=False, return_value=False,
), ),
): ):
@ -270,15 +270,15 @@ class TestShouldRunAlarm:
"""Return False when workout was already logged today.""" """Return False when workout was already logged today."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm._is_alarm_day", "wake_alarm._alarm._is_alarm_day",
return_value=True, return_value=True,
), ),
patch( patch(
"python_pkg.wake_alarm._alarm.was_alarm_dismissed_today", "wake_alarm._alarm.was_alarm_dismissed_today",
return_value=False, return_value=False,
), ),
patch( patch(
"python_pkg.wake_alarm._alarm.was_workout_logged_today", "wake_alarm._alarm.was_workout_logged_today",
return_value=True, return_value=True,
), ),
): ):
@ -292,10 +292,10 @@ class TestPlayOnExtraDevices:
"""_play_on_extra_devices spawns speaker-test with PIPEWIRE_NODE set.""" """_play_on_extra_devices spawns speaker-test with PIPEWIRE_NODE set."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._speaker_test_path", "wake_alarm._audio._speaker_test_path",
return_value="/usr/bin/speaker-test", return_value="/usr/bin/speaker-test",
), ),
patch("python_pkg.wake_alarm._audio.subprocess.Popen") as mock_popen, patch("wake_alarm._audio.subprocess.Popen") as mock_popen,
): ):
_play_on_extra_devices(1000) _play_on_extra_devices(1000)
mock_popen.assert_called_once() mock_popen.assert_called_once()
@ -311,10 +311,10 @@ class TestPlayOnExtraDevices:
"""_play_on_extra_devices does nothing when speaker-test is absent.""" """_play_on_extra_devices does nothing when speaker-test is absent."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._speaker_test_path", "wake_alarm._audio._speaker_test_path",
side_effect=FileNotFoundError("not found"), side_effect=FileNotFoundError("not found"),
), ),
patch("python_pkg.wake_alarm._audio.subprocess.Popen") as mock_popen, patch("wake_alarm._audio.subprocess.Popen") as mock_popen,
): ):
_play_on_extra_devices(1000) _play_on_extra_devices(1000)
mock_popen.assert_not_called() mock_popen.assert_not_called()
@ -323,11 +323,11 @@ class TestPlayOnExtraDevices:
"""_play_on_extra_devices silently ignores OSError from Popen.""" """_play_on_extra_devices silently ignores OSError from Popen."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._speaker_test_path", "wake_alarm._audio._speaker_test_path",
return_value="/usr/bin/speaker-test", return_value="/usr/bin/speaker-test",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.Popen", "wake_alarm._audio.subprocess.Popen",
side_effect=OSError("device busy"), side_effect=OSError("device busy"),
), ),
): ):
@ -374,18 +374,18 @@ class TestMaxFans:
def test_returns_false_when_no_hwmon(self) -> None: def test_returns_false_when_no_hwmon(self) -> None:
"""No fan controller → returns False immediately.""" """No fan controller → returns False immediately."""
with patch("python_pkg.wake_alarm._audio._find_fan_hwmon", return_value=None): with patch("wake_alarm._audio._find_fan_hwmon", return_value=None):
assert _max_fans() is False assert _max_fans() is False
def test_returns_false_on_script_oserror(self, tmp_path: pathlib.Path) -> None: def test_returns_false_on_script_oserror(self, tmp_path: pathlib.Path) -> None:
"""OSError running fan script → returns False.""" """OSError running fan script → returns False."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._find_fan_hwmon", "wake_alarm._audio._find_fan_hwmon",
return_value=str(tmp_path), return_value=str(tmp_path),
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=OSError("not found"), side_effect=OSError("not found"),
), ),
): ):
@ -395,11 +395,11 @@ class TestMaxFans:
"""TimeoutExpired running fan script → returns False.""" """TimeoutExpired running fan script → returns False."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._find_fan_hwmon", "wake_alarm._audio._find_fan_hwmon",
return_value=str(tmp_path), return_value=str(tmp_path),
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=subprocess.TimeoutExpired("fan", 5), side_effect=subprocess.TimeoutExpired("fan", 5),
), ),
): ):
@ -411,11 +411,11 @@ class TestMaxFans:
mock_result.returncode = 1 mock_result.returncode = 1
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._find_fan_hwmon", "wake_alarm._audio._find_fan_hwmon",
return_value=str(tmp_path), return_value=str(tmp_path),
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
return_value=mock_result, return_value=mock_result,
), ),
): ):
@ -427,11 +427,11 @@ class TestMaxFans:
mock_result.returncode = 0 mock_result.returncode = 0
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._find_fan_hwmon", "wake_alarm._audio._find_fan_hwmon",
return_value=str(tmp_path), return_value=str(tmp_path),
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
return_value=mock_result, return_value=mock_result,
), ),
): ):
@ -443,13 +443,13 @@ class TestRestoreFans:
def test_noop_when_inactive(self) -> None: def test_noop_when_inactive(self) -> None:
"""False state → subprocess.run is never called.""" """False state → subprocess.run is never called."""
with patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run: with patch("wake_alarm._audio.subprocess.run") as mock_run:
_restore_fans(active=False) _restore_fans(active=False)
mock_run.assert_not_called() mock_run.assert_not_called()
def test_calls_fan_script_restore(self) -> None: def test_calls_fan_script_restore(self) -> None:
"""Active state → fan script called with restore (no args).""" """Active state → fan script called with restore (no args)."""
with patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run: with patch("wake_alarm._audio.subprocess.run") as mock_run:
mock_run.return_value.returncode = 0 mock_run.return_value.returncode = 0
_restore_fans(active=True) _restore_fans(active=True)
mock_run.assert_called_once() mock_run.assert_called_once()
@ -459,7 +459,7 @@ class TestRestoreFans:
def test_ignores_oserror_on_restore(self) -> None: def test_ignores_oserror_on_restore(self) -> None:
"""OSError from fan script is silently suppressed.""" """OSError from fan script is silently suppressed."""
with patch( with patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=OSError("no script"), side_effect=OSError("no script"),
): ):
_restore_fans(active=True) # must not raise _restore_fans(active=True) # must not raise
@ -467,7 +467,7 @@ class TestRestoreFans:
def test_ignores_timeout_on_restore(self) -> None: def test_ignores_timeout_on_restore(self) -> None:
"""TimeoutExpired from fan script is silently suppressed.""" """TimeoutExpired from fan script is silently suppressed."""
with patch( with patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=subprocess.TimeoutExpired("fan", 5), side_effect=subprocess.TimeoutExpired("fan", 5),
): ):
_restore_fans(active=True) # must not raise _restore_fans(active=True) # must not raise

View File

@ -12,7 +12,7 @@ if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
import pathlib import pathlib
from python_pkg.wake_alarm._audio import ( from wake_alarm._audio import (
_beep_pcspkr, _beep_pcspkr,
_ensure_tone_wav, _ensure_tone_wav,
_play_tone, _play_tone,
@ -27,8 +27,8 @@ class TestSetMaxBrightness:
def test_noop_when_xrandr_missing(self) -> None: def test_noop_when_xrandr_missing(self) -> None:
"""No xrandr on PATH → subprocess.run never called.""" """No xrandr on PATH → subprocess.run never called."""
with ( with (
patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None), patch("wake_alarm._audio.shutil.which", return_value=None),
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run, patch("wake_alarm._audio.subprocess.run") as mock_run,
): ):
_set_max_brightness() _set_max_brightness()
mock_run.assert_not_called() mock_run.assert_not_called()
@ -37,11 +37,11 @@ class TestSetMaxBrightness:
"""OSError from xrandr --query is suppressed.""" """OSError from xrandr --query is suppressed."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/xrandr", return_value="/usr/bin/xrandr",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=OSError("no display"), side_effect=OSError("no display"),
), ),
): ):
@ -51,11 +51,11 @@ class TestSetMaxBrightness:
"""TimeoutExpired from xrandr --query is suppressed.""" """TimeoutExpired from xrandr --query is suppressed."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/xrandr", return_value="/usr/bin/xrandr",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=subprocess.TimeoutExpired("xrandr", 5), side_effect=subprocess.TimeoutExpired("xrandr", 5),
), ),
): ):
@ -75,10 +75,10 @@ class TestSetMaxBrightness:
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/xrandr", return_value="/usr/bin/xrandr",
), ),
patch("python_pkg.wake_alarm._audio.subprocess.run", side_effect=fake_run), patch("wake_alarm._audio.subprocess.run", side_effect=fake_run),
): ):
_set_max_brightness() _set_max_brightness()
@ -93,11 +93,11 @@ class TestSetMaxBrightness:
mock_result.stdout = "Screen 0: minimum 320\nHDMI-0 disconnected\n" mock_result.stdout = "Screen 0: minimum 320\nHDMI-0 disconnected\n"
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/xrandr", return_value="/usr/bin/xrandr",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
return_value=mock_result, return_value=mock_result,
) as mock_run, ) as mock_run,
): ):
@ -120,11 +120,11 @@ class TestSetMaxBrightness:
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/xrandr", return_value="/usr/bin/xrandr",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=_run_side_effect, side_effect=_run_side_effect,
), ),
): ):
@ -136,11 +136,11 @@ class TestEnsureToneWav:
def test_generates_and_caches(self, tmp_path: pathlib.Path) -> None: def test_generates_and_caches(self, tmp_path: pathlib.Path) -> None:
"""First call generates the WAV; second call returns the cached path.""" """First call generates the WAV; second call returns the cached path."""
from python_pkg.wake_alarm import _audio as alarm_mod from wake_alarm import _audio as alarm_mod
alarm_mod._TONE_CACHE.clear() alarm_mod._TONE_CACHE.clear()
with patch( with patch(
"python_pkg.wake_alarm._audio.tempfile.gettempdir", "wake_alarm._audio.tempfile.gettempdir",
return_value=str(tmp_path), return_value=str(tmp_path),
): ):
path1 = _ensure_tone_wav(440) path1 = _ensure_tone_wav(440)
@ -148,7 +148,7 @@ class TestEnsureToneWav:
size = path1.stat().st_size size = path1.stat().st_size
assert size > 0 assert size > 0
# Second call must hit the cache (no regeneration). # Second call must hit the cache (no regeneration).
with patch("python_pkg.wake_alarm._audio.wave.open") as mock_open: with patch("wake_alarm._audio.wave.open") as mock_open:
path2 = _ensure_tone_wav(440) path2 = _ensure_tone_wav(440)
mock_open.assert_not_called() mock_open.assert_not_called()
assert path2 == path1 assert path2 == path1
@ -159,11 +159,11 @@ class TestEnsureToneWav:
tmp_path: pathlib.Path, tmp_path: pathlib.Path,
) -> None: ) -> None:
"""If the cached file was deleted, regenerate it.""" """If the cached file was deleted, regenerate it."""
from python_pkg.wake_alarm._audio import _TONE_CACHE from wake_alarm._audio import _TONE_CACHE
_TONE_CACHE.clear() _TONE_CACHE.clear()
with patch( with patch(
"python_pkg.wake_alarm._audio.tempfile.gettempdir", "wake_alarm._audio.tempfile.gettempdir",
return_value=str(tmp_path), return_value=str(tmp_path),
): ):
path1 = _ensure_tone_wav(880) path1 = _ensure_tone_wav(880)
@ -184,7 +184,7 @@ class TestTryPlayer:
wav = tmp_path / "x.wav" wav = tmp_path / "x.wav"
wav.write_bytes(b"\x00") wav.write_bytes(b"\x00")
with patch( with patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value=None, return_value=None,
): ):
assert _try_player("paplay", wav) is False assert _try_player("paplay", wav) is False
@ -197,11 +197,11 @@ class TestTryPlayer:
result.returncode = 0 result.returncode = 0
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/paplay", return_value="/usr/bin/paplay",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
return_value=result, return_value=result,
), ),
): ):
@ -219,11 +219,11 @@ class TestTryPlayer:
result.stderr = b"boom" result.stderr = b"boom"
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/paplay", return_value="/usr/bin/paplay",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
return_value=result, return_value=result,
), ),
): ):
@ -235,11 +235,11 @@ class TestTryPlayer:
wav.write_bytes(b"\x00") wav.write_bytes(b"\x00")
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/paplay", return_value="/usr/bin/paplay",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=subprocess.TimeoutExpired("paplay", 6), side_effect=subprocess.TimeoutExpired("paplay", 6),
), ),
): ):
@ -251,11 +251,11 @@ class TestTryPlayer:
wav.write_bytes(b"\x00") wav.write_bytes(b"\x00")
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/paplay", return_value="/usr/bin/paplay",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=OSError("nope"), side_effect=OSError("nope"),
), ),
): ):
@ -274,10 +274,10 @@ class TestBeepPcspkr:
mock_open_ctx.__exit__.return_value = False mock_open_ctx.__exit__.return_value = False
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.Path.open", "wake_alarm._audio.Path.open",
return_value=mock_open_ctx, return_value=mock_open_ctx,
), ),
patch("python_pkg.wake_alarm._audio.time.sleep"), patch("wake_alarm._audio.time.sleep"),
): ):
_beep_pcspkr(1000, 0.05) _beep_pcspkr(1000, 0.05)
# First write carries the frequency, second write carries 0 (stop). # First write carries the frequency, second write carries 0 (stop).
@ -287,7 +287,7 @@ class TestBeepPcspkr:
"""OSError opening the device must not raise.""" """OSError opening the device must not raise."""
with patch( with patch(
"python_pkg.wake_alarm._audio.Path.open", "wake_alarm._audio.Path.open",
side_effect=OSError("no device"), side_effect=OSError("no device"),
): ):
_beep_pcspkr(1000, 0.05) # must not raise _beep_pcspkr(1000, 0.05) # must not raise
@ -299,7 +299,7 @@ class TestPlayTone:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _silence_pcspkr(self) -> Iterator[None]: def _silence_pcspkr(self) -> Iterator[None]:
"""Stop tests from hitting the real /dev/input PC speaker device.""" """Stop tests from hitting the real /dev/input PC speaker device."""
with patch("python_pkg.wake_alarm._audio._beep_pcspkr"): with patch("wake_alarm._audio._beep_pcspkr"):
yield yield
def test_paplay_success_short_circuits(self, tmp_path: pathlib.Path) -> None: def test_paplay_success_short_circuits(self, tmp_path: pathlib.Path) -> None:
@ -308,15 +308,15 @@ class TestPlayTone:
wav.write_bytes(b"\x00") wav.write_bytes(b"\x00")
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._ensure_tone_wav", "wake_alarm._audio._ensure_tone_wav",
return_value=wav, return_value=wav,
), ),
patch( patch(
"python_pkg.wake_alarm._audio._try_player", "wake_alarm._audio._try_player",
return_value=True, return_value=True,
) as mock_try, ) as mock_try,
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
) as mock_run, ) as mock_run,
): ):
_play_tone(440) _play_tone(440)
@ -332,19 +332,19 @@ class TestPlayTone:
wav.write_bytes(b"\x00") wav.write_bytes(b"\x00")
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._ensure_tone_wav", "wake_alarm._audio._ensure_tone_wav",
return_value=wav, return_value=wav,
), ),
patch( patch(
"python_pkg.wake_alarm._audio._try_player", "wake_alarm._audio._try_player",
return_value=False, return_value=False,
), ),
patch( patch(
"python_pkg.wake_alarm._audio._speaker_test_path", "wake_alarm._audio._speaker_test_path",
return_value="/usr/bin/speaker-test", return_value="/usr/bin/speaker-test",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
) as mock_run, ) as mock_run,
): ):
_play_tone(1000) _play_tone(1000)
@ -362,18 +362,18 @@ class TestPlayTone:
wav.write_bytes(b"\x00") wav.write_bytes(b"\x00")
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._ensure_tone_wav", "wake_alarm._audio._ensure_tone_wav",
return_value=wav, return_value=wav,
), ),
patch( patch(
"python_pkg.wake_alarm._audio._try_player", "wake_alarm._audio._try_player",
return_value=False, return_value=False,
), ),
patch( patch(
"python_pkg.wake_alarm._audio._speaker_test_path", "wake_alarm._audio._speaker_test_path",
side_effect=FileNotFoundError("missing"), side_effect=FileNotFoundError("missing"),
), ),
patch("python_pkg.wake_alarm._audio._beep_soft") as mock_soft, patch("wake_alarm._audio._beep_soft") as mock_soft,
): ):
_play_tone(800) _play_tone(800)
mock_soft.assert_called_once() mock_soft.assert_called_once()
@ -387,22 +387,22 @@ class TestPlayTone:
wav.write_bytes(b"\x00") wav.write_bytes(b"\x00")
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._ensure_tone_wav", "wake_alarm._audio._ensure_tone_wav",
return_value=wav, return_value=wav,
), ),
patch( patch(
"python_pkg.wake_alarm._audio._try_player", "wake_alarm._audio._try_player",
return_value=False, return_value=False,
), ),
patch( patch(
"python_pkg.wake_alarm._audio._speaker_test_path", "wake_alarm._audio._speaker_test_path",
return_value="/usr/bin/speaker-test", return_value="/usr/bin/speaker-test",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=subprocess.TimeoutExpired("speaker-test", 6), side_effect=subprocess.TimeoutExpired("speaker-test", 6),
), ),
patch("python_pkg.wake_alarm._audio._beep_soft") as mock_soft, patch("wake_alarm._audio._beep_soft") as mock_soft,
): ):
_play_tone(800) _play_tone(800)
mock_soft.assert_called_once() mock_soft.assert_called_once()
@ -411,10 +411,10 @@ class TestPlayTone:
"""OSError generating WAV → soft beep.""" """OSError generating WAV → soft beep."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio._ensure_tone_wav", "wake_alarm._audio._ensure_tone_wav",
side_effect=OSError("disk full"), side_effect=OSError("disk full"),
), ),
patch("python_pkg.wake_alarm._audio._beep_soft") as mock_soft, patch("wake_alarm._audio._beep_soft") as mock_soft,
): ):
_play_tone(440) _play_tone(440)
mock_soft.assert_called_once() mock_soft.assert_called_once()

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from unittest.mock import patch from unittest.mock import patch
from python_pkg.wake_alarm._challenges import ( from wake_alarm._challenges import (
_DISMISS_CHARS, _DISMISS_CHARS,
_Challenge, _Challenge,
_make_challenge, _make_challenge,
@ -12,7 +12,7 @@ from python_pkg.wake_alarm._challenges import (
_make_math_challenge, _make_math_challenge,
_make_sort_challenge, _make_sort_challenge,
) )
from python_pkg.wake_alarm._constants import DISMISS_FLASH_SECONDS from wake_alarm._constants import DISMISS_FLASH_SECONDS
class TestMakeMathChallenge: class TestMakeMathChallenge:
@ -25,9 +25,9 @@ class TestMakeMathChallenge:
def test_answer_is_correct_for_addition(self) -> None: def test_answer_is_correct_for_addition(self) -> None:
"""Stored answer is numerically correct for addition.""" """Stored answer is numerically correct for addition."""
with ( with (
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="+"), patch("wake_alarm._challenges.secrets.choice", return_value="+"),
patch( patch(
"python_pkg.wake_alarm._challenges.secrets.randbelow", "wake_alarm._challenges.secrets.randbelow",
side_effect=[13, 35], # 10+13=23, 10+35=45 side_effect=[13, 35], # 10+13=23, 10+35=45
), ),
): ):
@ -38,9 +38,9 @@ class TestMakeMathChallenge:
def test_answer_is_correct_for_subtraction(self) -> None: def test_answer_is_correct_for_subtraction(self) -> None:
"""Stored answer is numerically correct for subtraction.""" """Stored answer is numerically correct for subtraction."""
with ( with (
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="-"), patch("wake_alarm._challenges.secrets.choice", return_value="-"),
patch( patch(
"python_pkg.wake_alarm._challenges.secrets.randbelow", "wake_alarm._challenges.secrets.randbelow",
side_effect=[30, 7], # 20+30=50, 10+7=17 side_effect=[30, 7], # 20+30=50, 10+7=17
), ),
): ):
@ -51,9 +51,9 @@ class TestMakeMathChallenge:
def test_answer_is_correct_for_multiplication(self) -> None: def test_answer_is_correct_for_multiplication(self) -> None:
"""Stored answer is numerically correct for multiplication.""" """Stored answer is numerically correct for multiplication."""
with ( with (
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="*"), patch("wake_alarm._challenges.secrets.choice", return_value="*"),
patch( patch(
"python_pkg.wake_alarm._challenges.secrets.randbelow", "wake_alarm._challenges.secrets.randbelow",
side_effect=[3, 4], # 12+3=15, 3+4=7 side_effect=[3, 4], # 12+3=15, 3+4=7
), ),
): ):

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import subprocess import subprocess
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from python_pkg.wake_alarm._alarm_display import ( from wake_alarm._alarm_display import (
_ddcutil_power_on, _ddcutil_power_on,
_restore_display, _restore_display,
_wake_display, _wake_display,
@ -19,10 +19,10 @@ class TestDdcutilPowerOn:
"""_ddcutil_power_on does nothing when ddcutil is not on PATH.""" """_ddcutil_power_on does nothing when ddcutil is not on PATH."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm_display.shutil.which", "wake_alarm._alarm_display.shutil.which",
return_value=None, return_value=None,
), ),
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
): ):
_ddcutil_power_on() _ddcutil_power_on()
mock_run.assert_not_called() mock_run.assert_not_called()
@ -31,10 +31,10 @@ class TestDdcutilPowerOn:
"""_ddcutil_power_on sends setvcp D6 01 when ddcutil is found.""" """_ddcutil_power_on sends setvcp D6 01 when ddcutil is found."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm_display.shutil.which", "wake_alarm._alarm_display.shutil.which",
return_value="/usr/bin/ddcutil", return_value="/usr/bin/ddcutil",
), ),
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
): ):
_ddcutil_power_on() _ddcutil_power_on()
mock_run.assert_called_once() mock_run.assert_called_once()
@ -45,11 +45,11 @@ class TestDdcutilPowerOn:
"""_ddcutil_power_on logs success when setvcp returns 0.""" """_ddcutil_power_on logs success when setvcp returns 0."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm_display.shutil.which", "wake_alarm._alarm_display.shutil.which",
return_value="/usr/bin/ddcutil", return_value="/usr/bin/ddcutil",
), ),
patch( patch(
"python_pkg.wake_alarm._alarm_display.subprocess.run", "wake_alarm._alarm_display.subprocess.run",
return_value=MagicMock(returncode=0), return_value=MagicMock(returncode=0),
), ),
): ):
@ -59,11 +59,11 @@ class TestDdcutilPowerOn:
"""_ddcutil_power_on does not raise on TimeoutExpired.""" """_ddcutil_power_on does not raise on TimeoutExpired."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm_display.shutil.which", "wake_alarm._alarm_display.shutil.which",
return_value="/usr/bin/ddcutil", return_value="/usr/bin/ddcutil",
), ),
patch( patch(
"python_pkg.wake_alarm._alarm_display.subprocess.run", "wake_alarm._alarm_display.subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="ddcutil", timeout=10), side_effect=subprocess.TimeoutExpired(cmd="ddcutil", timeout=10),
), ),
): ):
@ -73,11 +73,11 @@ class TestDdcutilPowerOn:
"""_ddcutil_power_on does not raise on OSError.""" """_ddcutil_power_on does not raise on OSError."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm_display.shutil.which", "wake_alarm._alarm_display.shutil.which",
return_value="/usr/bin/ddcutil", return_value="/usr/bin/ddcutil",
), ),
patch( patch(
"python_pkg.wake_alarm._alarm_display.subprocess.run", "wake_alarm._alarm_display.subprocess.run",
side_effect=OSError("no device"), side_effect=OSError("no device"),
), ),
): ):
@ -91,10 +91,10 @@ class TestDisplayHelpers:
"""_wake_display skips xset commands but still attempts ddcutil.""" """_wake_display skips xset commands but still attempts ddcutil."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm_display.shutil.which", "wake_alarm._alarm_display.shutil.which",
return_value=None, return_value=None,
), ),
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
): ):
_wake_display() _wake_display()
mock_run.assert_not_called() mock_run.assert_not_called()
@ -103,10 +103,10 @@ class TestDisplayHelpers:
"""_wake_display runs ddcutil setvcp, xset dpms force on, xset s off.""" """_wake_display runs ddcutil setvcp, xset dpms force on, xset s off."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm_display.shutil.which", "wake_alarm._alarm_display.shutil.which",
return_value="/usr/bin/xset", return_value="/usr/bin/xset",
), ),
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
): ):
_wake_display() _wake_display()
# 1 ddcutil setvcp call + 2 xset calls # 1 ddcutil setvcp call + 2 xset calls
@ -120,10 +120,10 @@ class TestDisplayHelpers:
"""_restore_display does nothing when xset is not on PATH.""" """_restore_display does nothing when xset is not on PATH."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm_display.shutil.which", "wake_alarm._alarm_display.shutil.which",
return_value=None, return_value=None,
), ),
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
): ):
_restore_display() _restore_display()
mock_run.assert_not_called() mock_run.assert_not_called()
@ -132,10 +132,10 @@ class TestDisplayHelpers:
"""_restore_display re-enables the screensaver via xset when present.""" """_restore_display re-enables the screensaver via xset when present."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm_display.shutil.which", "wake_alarm._alarm_display.shutil.which",
return_value="/usr/bin/xset", return_value="/usr/bin/xset",
), ),
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
): ):
_restore_display() _restore_display()
mock_run.assert_called_once_with( mock_run.assert_called_once_with(

View File

@ -11,7 +11,7 @@ import pytest
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
from python_pkg.wake_alarm._alarm import ( from wake_alarm._alarm import (
WakeAlarm, WakeAlarm,
main, main,
) )
@ -41,9 +41,9 @@ def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests.""" """Prevent any real Tk windows in tests."""
mock = _make_mock_tk() mock = _make_mock_tk()
with ( with (
patch("python_pkg.wake_alarm._alarm.tk", mock), patch("wake_alarm._alarm.tk", mock),
patch( patch(
"python_pkg.wake_alarm._alarm.GateRoot", "wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value, return_value=mock.Tk.return_value,
), ),
): ):
@ -54,17 +54,17 @@ def _block_real_tk() -> Generator[MagicMock]:
def _block_extra_devices() -> Generator[MagicMock]: def _block_extra_devices() -> Generator[MagicMock]:
"""Prevent real subprocess.Popen calls for extra ALSA devices.""" """Prevent real subprocess.Popen calls for extra ALSA devices."""
with ( with (
patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock, patch("wake_alarm._alarm._play_on_extra_devices") as mock,
patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False), patch("wake_alarm._alarm._max_fans", return_value=False),
patch("python_pkg.wake_alarm._alarm._restore_fans"), patch("wake_alarm._alarm._restore_fans"),
patch("python_pkg.wake_alarm._alarm._set_max_brightness"), patch("wake_alarm._alarm._set_max_brightness"),
patch("python_pkg.wake_alarm._alarm._wake_display"), patch("wake_alarm._alarm._wake_display"),
patch("python_pkg.wake_alarm._alarm._restore_display"), patch("wake_alarm._alarm._restore_display"),
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"), patch("wake_alarm._alarm._warn_if_no_real_sink"),
patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None), patch("wake_alarm._alarm._activate_alarm_audio", return_value=None),
patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"), patch("wake_alarm._alarm._restore_alarm_audio"),
patch("python_pkg.wake_alarm._alarm.turn_on_plug"), patch("wake_alarm._alarm.turn_on_plug"),
patch("python_pkg.wake_alarm._alarm.turn_off_plug"), patch("wake_alarm._alarm.turn_off_plug"),
): ):
yield mock yield mock
@ -74,9 +74,9 @@ def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module.""" """Provide explicit access to the mocked tk module."""
mock = _make_mock_tk() mock = _make_mock_tk()
with ( with (
patch("python_pkg.wake_alarm._alarm.tk", mock), patch("wake_alarm._alarm.tk", mock),
patch( patch(
"python_pkg.wake_alarm._alarm.GateRoot", "wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value, return_value=mock.Tk.return_value,
), ),
): ):
@ -137,13 +137,13 @@ class TestWakeAlarmDismiss:
mock_tk_module: MagicMock, mock_tk_module: MagicMock,
) -> None: ) -> None:
"""Entering the correct answer for every required round dismisses the alarm.""" """Entering the correct answer for every required round dismisses the alarm."""
from python_pkg.wake_alarm._constants import DISMISS_ROUNDS_REQUIRED from wake_alarm._constants import DISMISS_ROUNDS_REQUIRED
alarm = WakeAlarm(demo_mode=True) alarm = WakeAlarm(demo_mode=True)
mock_entry = mock_tk_module.Entry.return_value mock_entry = mock_tk_module.Entry.return_value
with patch( with patch(
"python_pkg.wake_alarm._alarm.save_wake_state", "wake_alarm._alarm.save_wake_state",
) as mock_save: ) as mock_save:
for _ in range(DISMISS_ROUNDS_REQUIRED): for _ in range(DISMISS_ROUNDS_REQUIRED):
mock_entry.get.return_value = alarm._progress.current_challenge.answer mock_entry.get.return_value = alarm._progress.current_challenge.answer
@ -174,15 +174,13 @@ class TestWakeAlarmDismiss:
mock_tk_module: MagicMock, mock_tk_module: MagicMock,
) -> None: ) -> None:
"""When next challenge is math, no flash countdown is started.""" """When next challenge is math, no flash countdown is started."""
from python_pkg.wake_alarm._challenges import _Challenge from wake_alarm._challenges import _Challenge
alarm = WakeAlarm(demo_mode=True) alarm = WakeAlarm(demo_mode=True)
mock_entry = mock_tk_module.Entry.return_value mock_entry = mock_tk_module.Entry.return_value
mock_entry.get.return_value = alarm._progress.current_challenge.answer mock_entry.get.return_value = alarm._progress.current_challenge.answer
next_math = _Challenge(kind="math", display="2 + 2 = ?", answer="4", hint="x") next_math = _Challenge(kind="math", display="2 + 2 = ?", answer="4", hint="x")
with patch( with patch("wake_alarm._alarm._make_challenge", return_value=next_math):
"python_pkg.wake_alarm._alarm._make_challenge", return_value=next_math
):
alarm._on_submit() alarm._on_submit()
assert alarm._progress.current_challenge.kind == "math" assert alarm._progress.current_challenge.kind == "math"
@ -194,7 +192,7 @@ class TestWakeAlarmDismiss:
mock_tk_module: MagicMock, mock_tk_module: MagicMock,
) -> None: ) -> None:
"""Entering the wrong answer shows an error without dismissing.""" """Entering the wrong answer shows an error without dismissing."""
from python_pkg.wake_alarm._alarm import _Challenge from wake_alarm._alarm import _Challenge
alarm = WakeAlarm(demo_mode=True) alarm = WakeAlarm(demo_mode=True)
# Use a pinned math challenge so the non-flash wrong-answer branch is covered. # Use a pinned math challenge so the non-flash wrong-answer branch is covered.
@ -218,7 +216,7 @@ class TestWakeAlarmDismiss:
alarm = WakeAlarm(demo_mode=True) alarm = WakeAlarm(demo_mode=True)
with patch( with patch(
"python_pkg.wake_alarm._alarm.save_wake_state", "wake_alarm._alarm.save_wake_state",
) as mock_save: ) as mock_save:
alarm._on_skip_window_expired() alarm._on_skip_window_expired()
@ -251,14 +249,14 @@ class TestWakeAlarmDismiss:
mock_tk_module: MagicMock, mock_tk_module: MagicMock,
) -> None: ) -> None:
"""Typing all rounds after the skip window stops the alarm without a skip.""" """Typing all rounds after the skip window stops the alarm without a skip."""
from python_pkg.wake_alarm._constants import DISMISS_ROUNDS_REQUIRED from wake_alarm._constants import DISMISS_ROUNDS_REQUIRED
alarm = WakeAlarm(demo_mode=True) alarm = WakeAlarm(demo_mode=True)
alarm._progress.skip_earnable = False alarm._progress.skip_earnable = False
mock_entry = mock_tk_module.Entry.return_value mock_entry = mock_tk_module.Entry.return_value
with patch( with patch(
"python_pkg.wake_alarm._alarm.save_wake_state", "wake_alarm._alarm.save_wake_state",
) as mock_save: ) as mock_save:
for _ in range(DISMISS_ROUNDS_REQUIRED): for _ in range(DISMISS_ROUNDS_REQUIRED):
mock_entry.get.return_value = alarm._progress.current_challenge.answer mock_entry.get.return_value = alarm._progress.current_challenge.answer
@ -276,10 +274,10 @@ class TestMain:
"""main() returns early when not an alarm day.""" """main() returns early when not an alarm day."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm._should_run_alarm", "wake_alarm._alarm._should_run_alarm",
return_value=False, return_value=False,
), ),
patch("python_pkg.wake_alarm._alarm.sys") as mock_sys, patch("wake_alarm._alarm.sys") as mock_sys,
): ):
mock_sys.argv = ["alarm"] mock_sys.argv = ["alarm"]
main() # Should just return without error main() # Should just return without error
@ -292,11 +290,11 @@ class TestMain:
del mock_tk_module del mock_tk_module
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm._should_run_alarm", "wake_alarm._alarm._should_run_alarm",
return_value=True, return_value=True,
), ),
patch( patch(
"python_pkg.wake_alarm._alarm.sys", "wake_alarm._alarm.sys",
) as mock_sys, ) as mock_sys,
patch.object(WakeAlarm, "run") as mock_run, patch.object(WakeAlarm, "run") as mock_run,
patch.object(WakeAlarm, "__init__", return_value=None), patch.object(WakeAlarm, "__init__", return_value=None),
@ -313,10 +311,10 @@ class TestMain:
del mock_tk_module del mock_tk_module
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm._should_run_alarm", "wake_alarm._alarm._should_run_alarm",
return_value=False, return_value=False,
) as mock_gate, ) as mock_gate,
patch("python_pkg.wake_alarm._alarm.sys") as mock_sys, patch("wake_alarm._alarm.sys") as mock_sys,
patch.object(WakeAlarm, "run") as mock_run, patch.object(WakeAlarm, "run") as mock_run,
patch.object(WakeAlarm, "__init__", return_value=None), patch.object(WakeAlarm, "__init__", return_value=None),
): ):
@ -377,7 +375,7 @@ class TestBeepLoop:
alarm._stop_beep.set() alarm._stop_beep.set()
# Loop should exit immediately # Loop should exit immediately
with patch( with patch(
"python_pkg.wake_alarm._alarm._beep_soft", "wake_alarm._alarm._beep_soft",
): ):
alarm._beep_loop() alarm._beep_loop()
alarm._stop_beep.set() alarm._stop_beep.set()

View File

@ -11,10 +11,10 @@ import pytest
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
from python_pkg.wake_alarm._alarm import ( from wake_alarm._alarm import (
WakeAlarm, WakeAlarm,
) )
from python_pkg.wake_alarm._constants import ( from wake_alarm._constants import (
PHASE_MEDIUM_END, PHASE_MEDIUM_END,
PHASE_SOFT_END, PHASE_SOFT_END,
) )
@ -40,9 +40,9 @@ def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests.""" """Prevent any real Tk windows in tests."""
mock = _make_mock_tk() mock = _make_mock_tk()
with ( with (
patch("python_pkg.wake_alarm._alarm.tk", mock), patch("wake_alarm._alarm.tk", mock),
patch( patch(
"python_pkg.wake_alarm._alarm.GateRoot", "wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value, return_value=mock.Tk.return_value,
), ),
): ):
@ -53,17 +53,17 @@ def _block_real_tk() -> Generator[MagicMock]:
def _block_extra_devices() -> Generator[MagicMock]: def _block_extra_devices() -> Generator[MagicMock]:
"""Prevent real subprocess calls for extra ALSA devices and hardware.""" """Prevent real subprocess calls for extra ALSA devices and hardware."""
with ( with (
patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock, patch("wake_alarm._alarm._play_on_extra_devices") as mock,
patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False), patch("wake_alarm._alarm._max_fans", return_value=False),
patch("python_pkg.wake_alarm._alarm._restore_fans"), patch("wake_alarm._alarm._restore_fans"),
patch("python_pkg.wake_alarm._alarm._set_max_brightness"), patch("wake_alarm._alarm._set_max_brightness"),
patch("python_pkg.wake_alarm._alarm._wake_display"), patch("wake_alarm._alarm._wake_display"),
patch("python_pkg.wake_alarm._alarm._restore_display"), patch("wake_alarm._alarm._restore_display"),
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"), patch("wake_alarm._alarm._warn_if_no_real_sink"),
patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None), patch("wake_alarm._alarm._activate_alarm_audio", return_value=None),
patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"), patch("wake_alarm._alarm._restore_alarm_audio"),
patch("python_pkg.wake_alarm._alarm.turn_on_plug"), patch("wake_alarm._alarm.turn_on_plug"),
patch("python_pkg.wake_alarm._alarm.turn_off_plug"), patch("wake_alarm._alarm.turn_off_plug"),
): ):
yield mock yield mock
@ -73,9 +73,9 @@ def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module.""" """Provide explicit access to the mocked tk module."""
mock = _make_mock_tk() mock = _make_mock_tk()
with ( with (
patch("python_pkg.wake_alarm._alarm.tk", mock), patch("wake_alarm._alarm.tk", mock),
patch( patch(
"python_pkg.wake_alarm._alarm.GateRoot", "wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value, return_value=mock.Tk.return_value,
), ),
): ):
@ -106,7 +106,7 @@ class TestBeepLoopPhases:
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm._beep_medium", "wake_alarm._alarm._beep_medium",
side_effect=stop_after_one, side_effect=stop_after_one,
) as mock_beep, ) as mock_beep,
): ):
@ -135,7 +135,7 @@ class TestBeepLoopPhases:
with ( with (
patch( patch(
"python_pkg.wake_alarm._alarm._beep_loud", "wake_alarm._alarm._beep_loud",
side_effect=stop_after_one, side_effect=stop_after_one,
) as mock_beep, ) as mock_beep,
): ):
@ -220,7 +220,7 @@ class TestFlashChallenge:
mock_tk_module: MagicMock, mock_tk_module: MagicMock,
) -> None: ) -> None:
"""_flash_tick counts down per second and hides the code at zero.""" """_flash_tick counts down per second and hides the code at zero."""
from python_pkg.wake_alarm._alarm import _Challenge from wake_alarm._alarm import _Challenge
alarm = WakeAlarm(demo_mode=True) alarm = WakeAlarm(demo_mode=True)
alarm._progress.current_challenge = _Challenge( alarm._progress.current_challenge = _Challenge(
@ -269,7 +269,7 @@ class TestFlashChallenge:
mock_tk_module: MagicMock, mock_tk_module: MagicMock,
) -> None: ) -> None:
"""Wrong flash answer restores the code and restarts the countdown.""" """Wrong flash answer restores the code and restarts the countdown."""
from python_pkg.wake_alarm._alarm import _Challenge from wake_alarm._alarm import _Challenge
alarm = WakeAlarm(demo_mode=True) alarm = WakeAlarm(demo_mode=True)
alarm._progress.current_challenge = _Challenge( alarm._progress.current_challenge = _Challenge(
@ -294,7 +294,7 @@ class TestFlashChallenge:
mock_tk_module: MagicMock, mock_tk_module: MagicMock,
) -> None: ) -> None:
"""When the next-round challenge is flash, the countdown starts immediately.""" """When the next-round challenge is flash, the countdown starts immediately."""
from python_pkg.wake_alarm._alarm import _Challenge from wake_alarm._alarm import _Challenge
alarm = WakeAlarm(demo_mode=True) alarm = WakeAlarm(demo_mode=True)
alarm._progress.current_challenge = _Challenge( alarm._progress.current_challenge = _Challenge(
@ -306,9 +306,7 @@ class TestFlashChallenge:
mock_entry = mock_tk_module.Entry.return_value mock_entry = mock_tk_module.Entry.return_value
mock_entry.get.return_value = "4" mock_entry.get.return_value = "4"
with patch( with patch("wake_alarm._alarm._make_challenge", return_value=next_flash):
"python_pkg.wake_alarm._alarm._make_challenge", return_value=next_flash
):
alarm._on_submit() alarm._on_submit()
assert alarm._progress.current_challenge.kind == "flash" assert alarm._progress.current_challenge.kind == "flash"
@ -330,7 +328,7 @@ class TestDismissWithoutSkip:
alarm._view.container.winfo_children.return_value = [mock_widget] alarm._view.container.winfo_children.return_value = [mock_widget]
with patch( with patch(
"python_pkg.wake_alarm._alarm.save_wake_state", "wake_alarm._alarm.save_wake_state",
) as mock_save: ) as mock_save:
alarm._dismiss_alarm(earned_skip=False) alarm._dismiss_alarm(earned_skip=False)

View File

@ -11,7 +11,7 @@ import pytest
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
from python_pkg.wake_alarm._alarm import WakeAlarm from wake_alarm._alarm import WakeAlarm
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers (duplicated from part 1 so this file is self-contained) # Helpers (duplicated from part 1 so this file is self-contained)
@ -38,9 +38,9 @@ def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests.""" """Prevent any real Tk windows in tests."""
mock = _make_mock_tk() mock = _make_mock_tk()
with ( with (
patch("python_pkg.wake_alarm._alarm.tk", mock), patch("wake_alarm._alarm.tk", mock),
patch( patch(
"python_pkg.wake_alarm._alarm.GateRoot", "wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value, return_value=mock.Tk.return_value,
), ),
): ):
@ -51,17 +51,17 @@ def _block_real_tk() -> Generator[MagicMock]:
def _block_extra_devices() -> Generator[MagicMock]: def _block_extra_devices() -> Generator[MagicMock]:
"""Prevent real subprocess.Popen calls for extra ALSA devices.""" """Prevent real subprocess.Popen calls for extra ALSA devices."""
with ( with (
patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock, patch("wake_alarm._alarm._play_on_extra_devices") as mock,
patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False), patch("wake_alarm._alarm._max_fans", return_value=False),
patch("python_pkg.wake_alarm._alarm._restore_fans"), patch("wake_alarm._alarm._restore_fans"),
patch("python_pkg.wake_alarm._alarm._set_max_brightness"), patch("wake_alarm._alarm._set_max_brightness"),
patch("python_pkg.wake_alarm._alarm._wake_display"), patch("wake_alarm._alarm._wake_display"),
patch("python_pkg.wake_alarm._alarm._restore_display"), patch("wake_alarm._alarm._restore_display"),
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"), patch("wake_alarm._alarm._warn_if_no_real_sink"),
patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None), patch("wake_alarm._alarm._activate_alarm_audio", return_value=None),
patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"), patch("wake_alarm._alarm._restore_alarm_audio"),
patch("python_pkg.wake_alarm._alarm.turn_on_plug"), patch("wake_alarm._alarm.turn_on_plug"),
patch("python_pkg.wake_alarm._alarm.turn_off_plug"), patch("wake_alarm._alarm.turn_off_plug"),
): ):
yield mock yield mock
@ -71,9 +71,9 @@ def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module.""" """Provide explicit access to the mocked tk module."""
mock = _make_mock_tk() mock = _make_mock_tk()
with ( with (
patch("python_pkg.wake_alarm._alarm.tk", mock), patch("wake_alarm._alarm.tk", mock),
patch( patch(
"python_pkg.wake_alarm._alarm.GateRoot", "wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value, return_value=mock.Tk.return_value,
), ),
): ):
@ -133,7 +133,7 @@ class TestClose:
del mock_tk_module del mock_tk_module
alarm = WakeAlarm(demo_mode=True) alarm = WakeAlarm(demo_mode=True)
alarm._hardware.fan_state = True alarm._hardware.fan_state = True
with patch("python_pkg.wake_alarm._alarm._restore_fans") as mock_restore: with patch("wake_alarm._alarm._restore_fans") as mock_restore:
alarm.on_close() alarm.on_close()
mock_restore.assert_called_once_with(active=True) mock_restore.assert_called_once_with(active=True)
alarm._stop_beep.set() alarm._stop_beep.set()
@ -147,7 +147,7 @@ class TestClose:
alarm = WakeAlarm(demo_mode=True) alarm = WakeAlarm(demo_mode=True)
alarm._hardware.audio_restore = "jbl_sink" alarm._hardware.audio_restore = "jbl_sink"
with patch( with patch(
"python_pkg.wake_alarm._alarm._restore_alarm_audio", "wake_alarm._alarm._restore_alarm_audio",
) as mock_restore: ) as mock_restore:
alarm.on_close() alarm.on_close()
mock_restore.assert_called_once_with("jbl_sink") mock_restore.assert_called_once_with("jbl_sink")

View File

@ -5,8 +5,8 @@ from __future__ import annotations
import subprocess import subprocess
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from python_pkg.wake_alarm._alarm import _parse_args from wake_alarm._alarm import _parse_args
from python_pkg.wake_alarm._audio import ( from wake_alarm._audio import (
_activate_alarm_audio, _activate_alarm_audio,
_alarm_sink_present, _alarm_sink_present,
_current_default_sink, _current_default_sink,
@ -22,11 +22,11 @@ class TestWarnIfNoRealSink:
"""No pactl on PATH → warns and returns.""" """No pactl on PATH → warns and returns."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value=None, return_value=None,
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
) as mock_run, ) as mock_run,
): ):
_warn_if_no_real_sink() _warn_if_no_real_sink()
@ -38,14 +38,14 @@ class TestWarnIfNoRealSink:
result.stdout = b"4319\tauto_null\tPipeWire\tfloat32le 2ch 48000Hz\tIDLE\n" result.stdout = b"4319\tauto_null\tPipeWire\tfloat32le 2ch 48000Hz\tIDLE\n"
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl", return_value="/usr/bin/pactl",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
return_value=result, return_value=result,
), ),
patch("python_pkg.wake_alarm._audio._logger") as mock_log, patch("wake_alarm._audio._logger") as mock_log,
): ):
_warn_if_no_real_sink() _warn_if_no_real_sink()
mock_log.warning.assert_called() mock_log.warning.assert_called()
@ -56,14 +56,14 @@ class TestWarnIfNoRealSink:
result.stdout = b"1\talsa_output.pci-0000_01_00.1.hdmi-stereo\tPipeWire\t-\t-\n" result.stdout = b"1\talsa_output.pci-0000_01_00.1.hdmi-stereo\tPipeWire\t-\t-\n"
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl", return_value="/usr/bin/pactl",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
return_value=result, return_value=result,
), ),
patch("python_pkg.wake_alarm._audio._logger") as mock_log, patch("wake_alarm._audio._logger") as mock_log,
): ):
_warn_if_no_real_sink() _warn_if_no_real_sink()
mock_log.info.assert_called() mock_log.info.assert_called()
@ -73,11 +73,11 @@ class TestWarnIfNoRealSink:
"""OSError/TimeoutExpired running pactl → warning, no raise.""" """OSError/TimeoutExpired running pactl → warning, no raise."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl", return_value="/usr/bin/pactl",
), ),
patch( patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=subprocess.TimeoutExpired("pactl", 5), side_effect=subprocess.TimeoutExpired("pactl", 5),
), ),
): ):
@ -89,11 +89,11 @@ class TestAlarmSinkPresent:
def test_true_when_sink_listed(self) -> None: def test_true_when_sink_listed(self) -> None:
"""Returns True when the alarm sink name appears in pactl output.""" """Returns True when the alarm sink name appears in pactl output."""
from python_pkg.wake_alarm._constants import ALARM_AUDIO_SINK from wake_alarm._constants import ALARM_AUDIO_SINK
proc = MagicMock(stdout=ALARM_AUDIO_SINK.encode() + b"\tPipeWire\n") proc = MagicMock(stdout=ALARM_AUDIO_SINK.encode() + b"\tPipeWire\n")
with patch( with patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
return_value=proc, return_value=proc,
): ):
assert _alarm_sink_present("/usr/bin/pactl") is True assert _alarm_sink_present("/usr/bin/pactl") is True
@ -102,7 +102,7 @@ class TestAlarmSinkPresent:
"""Returns False when the alarm sink is not in pactl output.""" """Returns False when the alarm sink is not in pactl output."""
proc = MagicMock(stdout=b"auto_null\tPipeWire\n") proc = MagicMock(stdout=b"auto_null\tPipeWire\n")
with patch( with patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
return_value=proc, return_value=proc,
): ):
assert _alarm_sink_present("/usr/bin/pactl") is False assert _alarm_sink_present("/usr/bin/pactl") is False
@ -110,7 +110,7 @@ class TestAlarmSinkPresent:
def test_false_on_subprocess_error(self) -> None: def test_false_on_subprocess_error(self) -> None:
"""OSError while listing sinks → False, no raise.""" """OSError while listing sinks → False, no raise."""
with patch( with patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=OSError("boom"), side_effect=OSError("boom"),
): ):
assert _alarm_sink_present("/usr/bin/pactl") is False assert _alarm_sink_present("/usr/bin/pactl") is False
@ -123,7 +123,7 @@ class TestCurrentDefaultSink:
"""Returns the trimmed default sink name.""" """Returns the trimmed default sink name."""
proc = MagicMock(stdout=b"jbl_sink\n") proc = MagicMock(stdout=b"jbl_sink\n")
with patch( with patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
return_value=proc, return_value=proc,
): ):
assert _current_default_sink("/usr/bin/pactl") == "jbl_sink" assert _current_default_sink("/usr/bin/pactl") == "jbl_sink"
@ -132,7 +132,7 @@ class TestCurrentDefaultSink:
"""Empty output → None.""" """Empty output → None."""
proc = MagicMock(stdout=b"\n") proc = MagicMock(stdout=b"\n")
with patch( with patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
return_value=proc, return_value=proc,
): ):
assert _current_default_sink("/usr/bin/pactl") is None assert _current_default_sink("/usr/bin/pactl") is None
@ -140,7 +140,7 @@ class TestCurrentDefaultSink:
def test_returns_none_on_error(self) -> None: def test_returns_none_on_error(self) -> None:
"""TimeoutExpired → None, no raise.""" """TimeoutExpired → None, no raise."""
with patch( with patch(
"python_pkg.wake_alarm._audio.subprocess.run", "wake_alarm._audio.subprocess.run",
side_effect=subprocess.TimeoutExpired("pactl", 3), side_effect=subprocess.TimeoutExpired("pactl", 3),
): ):
assert _current_default_sink("/usr/bin/pactl") is None assert _current_default_sink("/usr/bin/pactl") is None
@ -152,8 +152,8 @@ class TestActivateAlarmAudio:
def test_returns_none_when_pactl_missing(self) -> None: def test_returns_none_when_pactl_missing(self) -> None:
"""No pactl on PATH → returns None without touching audio.""" """No pactl on PATH → returns None without touching audio."""
with ( with (
patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None), patch("wake_alarm._audio.shutil.which", return_value=None),
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run, patch("wake_alarm._audio.subprocess.run") as mock_run,
): ):
assert _activate_alarm_audio() is None assert _activate_alarm_audio() is None
mock_run.assert_not_called() mock_run.assert_not_called()
@ -162,23 +162,23 @@ class TestActivateAlarmAudio:
"""Sink present → routes audio there and returns prior default sink.""" """Sink present → routes audio there and returns prior default sink."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl", return_value="/usr/bin/pactl",
), ),
patch( patch(
"python_pkg.wake_alarm._audio._alarm_sink_present", "wake_alarm._audio._alarm_sink_present",
return_value=True, return_value=True,
), ),
patch( patch(
"python_pkg.wake_alarm._audio._current_default_sink", "wake_alarm._audio._current_default_sink",
return_value="jbl_sink", return_value="jbl_sink",
), ),
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run, patch("wake_alarm._audio.subprocess.run") as mock_run,
): ):
result = _activate_alarm_audio() result = _activate_alarm_audio()
assert result == "jbl_sink" assert result == "jbl_sink"
cmds = [call.args[0] for call in mock_run.call_args_list] cmds = [call.args[0] for call in mock_run.call_args_list]
from python_pkg.wake_alarm._constants import ( from wake_alarm._constants import (
ALARM_AUDIO_CARD, ALARM_AUDIO_CARD,
ALARM_AUDIO_PROFILE, ALARM_AUDIO_PROFILE,
ALARM_AUDIO_SINK, ALARM_AUDIO_SINK,
@ -196,15 +196,15 @@ class TestActivateAlarmAudio:
"""Sink never shows up → returns None after polling (no raise).""" """Sink never shows up → returns None after polling (no raise)."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl", return_value="/usr/bin/pactl",
), ),
patch( patch(
"python_pkg.wake_alarm._audio._alarm_sink_present", "wake_alarm._audio._alarm_sink_present",
return_value=False, return_value=False,
), ),
patch("python_pkg.wake_alarm._audio.time.sleep") as mock_sleep, patch("wake_alarm._audio.time.sleep") as mock_sleep,
patch("python_pkg.wake_alarm._audio.subprocess.run"), patch("wake_alarm._audio.subprocess.run"),
): ):
assert _activate_alarm_audio() is None assert _activate_alarm_audio() is None
mock_sleep.assert_called() mock_sleep.assert_called()
@ -213,19 +213,19 @@ class TestActivateAlarmAudio:
"""Sink absent then present → sleeps once, then routes audio.""" """Sink absent then present → sleeps once, then routes audio."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl", return_value="/usr/bin/pactl",
), ),
patch( patch(
"python_pkg.wake_alarm._audio._alarm_sink_present", "wake_alarm._audio._alarm_sink_present",
side_effect=[False, True], side_effect=[False, True],
), ),
patch( patch(
"python_pkg.wake_alarm._audio._current_default_sink", "wake_alarm._audio._current_default_sink",
return_value="old", return_value="old",
), ),
patch("python_pkg.wake_alarm._audio.time.sleep") as mock_sleep, patch("wake_alarm._audio.time.sleep") as mock_sleep,
patch("python_pkg.wake_alarm._audio.subprocess.run"), patch("wake_alarm._audio.subprocess.run"),
): ):
assert _activate_alarm_audio() == "old" assert _activate_alarm_audio() == "old"
mock_sleep.assert_called_once() mock_sleep.assert_called_once()
@ -236,15 +236,15 @@ class TestRestoreAlarmAudio:
def test_none_is_noop(self) -> None: def test_none_is_noop(self) -> None:
"""None default → does nothing, no pactl lookup.""" """None default → does nothing, no pactl lookup."""
with patch("python_pkg.wake_alarm._audio.shutil.which") as mock_which: with patch("wake_alarm._audio.shutil.which") as mock_which:
_restore_alarm_audio(None) _restore_alarm_audio(None)
mock_which.assert_not_called() mock_which.assert_not_called()
def test_no_pactl_returns_silently(self) -> None: def test_no_pactl_returns_silently(self) -> None:
"""Default present but pactl missing → no raise, no run.""" """Default present but pactl missing → no raise, no run."""
with ( with (
patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None), patch("wake_alarm._audio.shutil.which", return_value=None),
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run, patch("wake_alarm._audio.subprocess.run") as mock_run,
): ):
_restore_alarm_audio("jbl_sink") _restore_alarm_audio("jbl_sink")
mock_run.assert_not_called() mock_run.assert_not_called()
@ -253,10 +253,10 @@ class TestRestoreAlarmAudio:
"""Calls set-default-sink with the captured prior default.""" """Calls set-default-sink with the captured prior default."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._audio.shutil.which", "wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl", return_value="/usr/bin/pactl",
), ),
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run, patch("wake_alarm._audio.subprocess.run") as mock_run,
): ):
_restore_alarm_audio("jbl_sink") _restore_alarm_audio("jbl_sink")
cmds = [call.args[0] for call in mock_run.call_args_list] cmds = [call.args[0] for call in mock_run.call_args_list]

View File

@ -9,8 +9,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from python_pkg.wake_alarm import _smart_plug from wake_alarm import _smart_plug
from python_pkg.wake_alarm._smart_plug import ( from wake_alarm._smart_plug import (
_connect, _connect,
_load_config, _load_config,
_run, _run,

View File

@ -8,7 +8,7 @@ from unittest.mock import patch
import pytest import pytest
from python_pkg.wake_alarm._state import ( from wake_alarm._state import (
_today_str, _today_str,
has_workout_skip_today, has_workout_skip_today,
load_wake_state, load_wake_state,
@ -31,7 +31,7 @@ def wake_state_file(tmp_path: Path) -> Path:
def _patch_wake_state_file(wake_state_file: Path) -> None: def _patch_wake_state_file(wake_state_file: Path) -> None:
"""Redirect WAKE_STATE_FILE to tmp_path for all tests.""" """Redirect WAKE_STATE_FILE to tmp_path for all tests."""
with patch( with patch(
"python_pkg.wake_alarm._state.WAKE_STATE_FILE", "wake_alarm._state.WAKE_STATE_FILE",
wake_state_file, wake_state_file,
): ):
yield yield
@ -54,7 +54,7 @@ class TestSaveWakeState:
def test_saves_with_hmac(self, wake_state_file: Path) -> None: def test_saves_with_hmac(self, wake_state_file: Path) -> None:
"""Save state with HMAC signature when key is available.""" """Save state with HMAC signature when key is available."""
with patch( with patch(
"python_pkg.wake_alarm._state.compute_entry_hmac", "wake_alarm._state.compute_entry_hmac",
return_value="fakesig", return_value="fakesig",
): ):
result = save_wake_state( result = save_wake_state(
@ -72,7 +72,7 @@ class TestSaveWakeState:
def test_saves_without_hmac(self, wake_state_file: Path) -> None: def test_saves_without_hmac(self, wake_state_file: Path) -> None:
"""Save unsigned state when HMAC key is unavailable.""" """Save unsigned state when HMAC key is unavailable."""
with patch( with patch(
"python_pkg.wake_alarm._state.compute_entry_hmac", "wake_alarm._state.compute_entry_hmac",
return_value=None, return_value=None,
): ):
result = save_wake_state( result = save_wake_state(
@ -89,11 +89,11 @@ class TestSaveWakeState:
"""Return False when file cannot be written.""" """Return False when file cannot be written."""
with ( with (
patch( patch(
"python_pkg.wake_alarm._state.compute_entry_hmac", "wake_alarm._state.compute_entry_hmac",
return_value="sig", return_value="sig",
), ),
patch( patch(
"python_pkg.wake_alarm._state.WAKE_STATE_FILE", "wake_alarm._state.WAKE_STATE_FILE",
wake_state_file / "nonexistent_dir" / "file.json", wake_state_file / "nonexistent_dir" / "file.json",
), ),
): ):
@ -147,7 +147,7 @@ class TestLoadWakeState:
} }
wake_state_file.write_text(json.dumps(state)) wake_state_file.write_text(json.dumps(state))
with patch( with patch(
"python_pkg.wake_alarm._state.verify_entry_hmac", "wake_alarm._state.verify_entry_hmac",
return_value=False, return_value=False,
): ):
assert load_wake_state() is None assert load_wake_state() is None
@ -165,7 +165,7 @@ class TestLoadWakeState:
} }
wake_state_file.write_text(json.dumps(state)) wake_state_file.write_text(json.dumps(state))
with patch( with patch(
"python_pkg.wake_alarm._state.verify_entry_hmac", "wake_alarm._state.verify_entry_hmac",
return_value=True, return_value=True,
): ):
result = load_wake_state() result = load_wake_state()
@ -194,7 +194,7 @@ class TestHasWorkoutSkipToday:
} }
wake_state_file.write_text(json.dumps(state)) wake_state_file.write_text(json.dumps(state))
with patch( with patch(
"python_pkg.wake_alarm._state.verify_entry_hmac", "wake_alarm._state.verify_entry_hmac",
return_value=True, return_value=True,
): ):
assert has_workout_skip_today() is True assert has_workout_skip_today() is True
@ -212,7 +212,7 @@ class TestHasWorkoutSkipToday:
} }
wake_state_file.write_text(json.dumps(state)) wake_state_file.write_text(json.dumps(state))
with patch( with patch(
"python_pkg.wake_alarm._state.verify_entry_hmac", "wake_alarm._state.verify_entry_hmac",
return_value=True, return_value=True,
): ):
assert has_workout_skip_today() is False assert has_workout_skip_today() is False
@ -238,7 +238,7 @@ class TestWasAlarmDismissedToday:
} }
wake_state_file.write_text(json.dumps(state)) wake_state_file.write_text(json.dumps(state))
with patch( with patch(
"python_pkg.wake_alarm._state.verify_entry_hmac", "wake_alarm._state.verify_entry_hmac",
return_value=True, return_value=True,
): ):
assert was_alarm_dismissed_today() is True assert was_alarm_dismissed_today() is True
@ -256,7 +256,7 @@ class TestWasAlarmDismissedToday:
} }
wake_state_file.write_text(json.dumps(state)) wake_state_file.write_text(json.dumps(state))
with patch( with patch(
"python_pkg.wake_alarm._state.verify_entry_hmac", "wake_alarm._state.verify_entry_hmac",
return_value=True, return_value=True,
): ):
assert was_alarm_dismissed_today() is False assert was_alarm_dismissed_today() is False
@ -268,7 +268,7 @@ class TestWasWorkoutLoggedToday:
def test_returns_false_when_file_missing(self, tmp_path: Path) -> None: def test_returns_false_when_file_missing(self, tmp_path: Path) -> None:
"""Return False when the workout log file does not exist.""" """Return False when the workout log file does not exist."""
with patch( with patch(
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE", "wake_alarm._state.WORKOUT_LOG_FILE",
tmp_path / "workout_log.json", tmp_path / "workout_log.json",
): ):
assert was_workout_logged_today() is False assert was_workout_logged_today() is False
@ -278,7 +278,7 @@ class TestWasWorkoutLoggedToday:
log_file = tmp_path / "workout_log.json" log_file = tmp_path / "workout_log.json"
log_file.write_text("not json {{{") log_file.write_text("not json {{{")
with patch( with patch(
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE", "wake_alarm._state.WORKOUT_LOG_FILE",
log_file, log_file,
): ):
assert was_workout_logged_today() is False assert was_workout_logged_today() is False
@ -288,7 +288,7 @@ class TestWasWorkoutLoggedToday:
log_file = tmp_path / "workout_log.json" log_file = tmp_path / "workout_log.json"
log_file.write_text(json.dumps([1, 2, 3])) log_file.write_text(json.dumps([1, 2, 3]))
with patch( with patch(
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE", "wake_alarm._state.WORKOUT_LOG_FILE",
log_file, log_file,
): ):
assert was_workout_logged_today() is False assert was_workout_logged_today() is False
@ -298,7 +298,7 @@ class TestWasWorkoutLoggedToday:
log_file = tmp_path / "workout_log.json" log_file = tmp_path / "workout_log.json"
log_file.write_text(json.dumps({"1999-01-01": {"type": "old"}})) log_file.write_text(json.dumps({"1999-01-01": {"type": "old"}}))
with patch( with patch(
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE", "wake_alarm._state.WORKOUT_LOG_FILE",
log_file, log_file,
): ):
assert was_workout_logged_today() is False assert was_workout_logged_today() is False
@ -308,7 +308,7 @@ class TestWasWorkoutLoggedToday:
log_file = tmp_path / "workout_log.json" log_file = tmp_path / "workout_log.json"
log_file.write_text(json.dumps({_today_str(): {"type": "phone_verified"}})) log_file.write_text(json.dumps({_today_str(): {"type": "phone_verified"}}))
with patch( with patch(
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE", "wake_alarm._state.WORKOUT_LOG_FILE",
log_file, log_file,
): ):
assert was_workout_logged_today() is True assert was_workout_logged_today() is True

View File

@ -1,20 +0,0 @@
[Unit]
Description=Weekend Wake Alarm
After=graphical-session.target
[Service]
Type=simple
# DISPLAY/PYTHONPATH mirror workout-locker.service: without DISPLAY the alarm
# crashes on cold boot with "no display name and no $DISPLAY" (Tk can't open
# the X server) before systemd retries. The short sleep lets the X session
# export DISPLAY into the user environment first.
Environment=DISPLAY=:0
Environment=PYTHONPATH=%h/testsAndMisc
ExecStartPre=/bin/sleep 1
ExecStart=/usr/bin/python -m python_pkg.wake_alarm._alarm --production
WorkingDirectory=%h/testsAndMisc
Restart=on-failure
RestartSec=10
[Install]
WantedBy=graphical-session.target

View File

@ -1,6 +0,0 @@
{
"date": "2026-06-14",
"dismissed_at": "2026-06-14T05:01:28.589654+00:00",
"skip_workout": true,
"hmac": "b472bf9b0874ff3f6f460cace7965d53cdfce823ee6f2d1f91914e43f003e92b"
}