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
#
# What it does:
# 1. Copies wake-alarm.service to ~/.config/systemd/user/
# 2. Enables and starts the service
# 3. Installs the systemd-sleep hook (restarts alarm after hibernate resume)
# 4. Adds a sudoers entry for passwordless rtcwake
# 5. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights
# 6. Installs fan-control script so alarm can max fans on wake
# 7. Installs python-kasa (AUR) so the alarm can toggle a Tapo P110 smart plug
# 1. Installs wake_alarm + dependencies for /usr/bin/python
# 2. Installs system dependencies (alsa-utils, ddcutil)
# 3. Copies wake-alarm.service to ~/.config/systemd/user/ and enables it
# 4. Installs the systemd-sleep hook (restarts alarm after hibernate resume)
# 5. Adds a sudoers entry for passwordless rtcwake
# 6. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights
# 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
# Split declare/assign so the command-substitution exit code is not masked (SC2155).
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR
readonly REPO_DIR="$SCRIPT_DIR"
SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service"
SLEEP_HOOK_SRC="$SCRIPT_DIR/sleep-hook.sh"
SHUTDOWN_WRAPPER_SRC="$SCRIPT_DIR/shutdown-wrapper.sh"
@ -28,8 +33,15 @@ RTCWAKE_BIN="/usr/sbin/rtcwake"
echo "=== Weekend Wake Alarm Installer ==="
# 0. Install system dependencies
echo "[0/7] Checking system dependencies..."
# 1. Install this package + its dependencies into system Python -------------
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
echo " Installing alsa-utils (required for speaker-test)..."
sudo pacman -S --noconfirm alsa-utils
@ -37,26 +49,23 @@ else
echo " alsa-utils already installed"
fi
# 1. Install systemd user service
echo "[1/7] Installing systemd user service..."
# 3. Install systemd user service
echo "[3/9] Installing systemd user service..."
mkdir -p "$SYSTEMD_USER_DIR"
cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service"
systemctl --user daemon-reload
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
echo " Service enabled (will start on next boot)"
# 3. Install systemd-sleep hook (restarts alarm after hibernate resume)
echo "[3/7] Installing systemd-sleep hook..."
# 4. Install systemd-sleep hook (restarts alarm after hibernate resume)
echo "[4/9] Installing systemd-sleep hook..."
sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST"
sudo chmod 0755 "$SLEEP_HOOK_DST"
echo " Installed to $SLEEP_HOOK_DST"
# 4. Add sudoers entry for rtcwake (requires root)
echo "[4/7] Setting up sudoers for rtcwake..."
# 5. Add sudoers entry for rtcwake (requires root)
echo "[5/9] Setting up sudoers for rtcwake..."
SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $RTCWAKE_BIN"
if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$SUDOERS_LINE" "$SUDOERS_FILE"; then
echo " Sudoers entry already exists"
@ -67,15 +76,15 @@ else
echo " Added: $SUDOERS_LINE"
fi
# 5. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown)
echo "[5/7] Installing shutdown wrapper..."
# 6. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown)
echo "[6/9] Installing shutdown wrapper..."
sudo cp "$SHUTDOWN_WRAPPER_SRC" "$SHUTDOWN_WRAPPER_DST"
sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST"
echo " Installed to $SHUTDOWN_WRAPPER_DST"
echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights."
# 6. Install fan-control script and its sudoers entry
echo "[6/7] Installing fan-control script..."
# 7. Install fan-control script and its sudoers entry
echo "[7/9] Installing fan-control script..."
sudo cp "$FANS_SCRIPT_SRC" "$FANS_SCRIPT_DST"
sudo chmod 0755 "$FANS_SCRIPT_DST"
FANS_SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $FANS_SCRIPT_DST"
@ -88,8 +97,8 @@ else
echo " Added fan sudoers entry"
fi
# 7. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control
echo "[7/8] Installing python-kasa (AUR)..."
# 8. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control
echo "[8/9] Installing python-kasa (AUR)..."
if python -c 'import kasa' 2>/dev/null; then
echo " python-kasa already installed"
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."
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
# 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
echo " ddcutil already installed"
else
@ -128,4 +137,4 @@ echo "=== Installation complete ==="
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 "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
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.
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 python_pkg.shared.logging_setup import configure_logging
from python_pkg.wake_alarm._alarm_display import _restore_display, _wake_display
from python_pkg.wake_alarm._audio import (
from wake_alarm._alarm_display import _restore_display, _wake_display
from wake_alarm._audio import (
_activate_alarm_audio,
_beep_loud,
_beep_medium,
@ -34,11 +33,11 @@ from python_pkg.wake_alarm._audio import (
_set_max_brightness,
_warn_if_no_real_sink,
)
from python_pkg.wake_alarm._challenges import (
from wake_alarm._challenges import (
_Challenge,
_make_challenge,
)
from python_pkg.wake_alarm._constants import (
from wake_alarm._constants import (
ALARM_DAYS,
DISMISS_CODE_REFRESH_SECONDS,
DISMISS_FLASH_SECONDS,
@ -51,8 +50,9 @@ from python_pkg.wake_alarm._constants import (
PHASE_SOFT_END,
SOFT_BEEP_INTERVAL,
)
from python_pkg.wake_alarm._smart_plug import turn_off_plug, turn_on_plug
from python_pkg.wake_alarm._state import (
from wake_alarm._logging_setup import configure_logging
from wake_alarm._smart_plug import turn_off_plug, turn_on_plug
from wake_alarm._state import (
save_wake_state,
was_alarm_dismissed_today,
was_workout_logged_today,

View File

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

View File

@ -10,7 +10,7 @@ from __future__ import annotations
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:
# 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
from typing import TYPE_CHECKING
from python_pkg.wake_alarm._constants import (
from wake_alarm._constants import (
TAPO_CONFIG_FILE,
TAPO_TIMEOUT_SECONDS,
)

View File

@ -11,7 +11,7 @@ from gatelock.log_integrity import (
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__)

View File

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

View File

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

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from unittest.mock import patch
from python_pkg.wake_alarm._challenges import (
from wake_alarm._challenges import (
_DISMISS_CHARS,
_Challenge,
_make_challenge,
@ -12,7 +12,7 @@ from python_pkg.wake_alarm._challenges import (
_make_math_challenge,
_make_sort_challenge,
)
from python_pkg.wake_alarm._constants import DISMISS_FLASH_SECONDS
from wake_alarm._constants import DISMISS_FLASH_SECONDS
class TestMakeMathChallenge:
@ -25,9 +25,9 @@ class TestMakeMathChallenge:
def test_answer_is_correct_for_addition(self) -> None:
"""Stored answer is numerically correct for addition."""
with (
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="+"),
patch("wake_alarm._challenges.secrets.choice", return_value="+"),
patch(
"python_pkg.wake_alarm._challenges.secrets.randbelow",
"wake_alarm._challenges.secrets.randbelow",
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:
"""Stored answer is numerically correct for subtraction."""
with (
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="-"),
patch("wake_alarm._challenges.secrets.choice", return_value="-"),
patch(
"python_pkg.wake_alarm._challenges.secrets.randbelow",
"wake_alarm._challenges.secrets.randbelow",
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:
"""Stored answer is numerically correct for multiplication."""
with (
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="*"),
patch("wake_alarm._challenges.secrets.choice", return_value="*"),
patch(
"python_pkg.wake_alarm._challenges.secrets.randbelow",
"wake_alarm._challenges.secrets.randbelow",
side_effect=[3, 4], # 12+3=15, 3+4=7
),
):

View File

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

View File

@ -11,7 +11,7 @@ import pytest
if TYPE_CHECKING:
from collections.abc import Generator
from python_pkg.wake_alarm._alarm import (
from wake_alarm._alarm import (
WakeAlarm,
main,
)
@ -41,9 +41,9 @@ def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests."""
mock = _make_mock_tk()
with (
patch("python_pkg.wake_alarm._alarm.tk", mock),
patch("wake_alarm._alarm.tk", mock),
patch(
"python_pkg.wake_alarm._alarm.GateRoot",
"wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value,
),
):
@ -54,17 +54,17 @@ def _block_real_tk() -> Generator[MagicMock]:
def _block_extra_devices() -> Generator[MagicMock]:
"""Prevent real subprocess.Popen calls for extra ALSA devices."""
with (
patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock,
patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False),
patch("python_pkg.wake_alarm._alarm._restore_fans"),
patch("python_pkg.wake_alarm._alarm._set_max_brightness"),
patch("python_pkg.wake_alarm._alarm._wake_display"),
patch("python_pkg.wake_alarm._alarm._restore_display"),
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"),
patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None),
patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"),
patch("python_pkg.wake_alarm._alarm.turn_on_plug"),
patch("python_pkg.wake_alarm._alarm.turn_off_plug"),
patch("wake_alarm._alarm._play_on_extra_devices") as mock,
patch("wake_alarm._alarm._max_fans", return_value=False),
patch("wake_alarm._alarm._restore_fans"),
patch("wake_alarm._alarm._set_max_brightness"),
patch("wake_alarm._alarm._wake_display"),
patch("wake_alarm._alarm._restore_display"),
patch("wake_alarm._alarm._warn_if_no_real_sink"),
patch("wake_alarm._alarm._activate_alarm_audio", return_value=None),
patch("wake_alarm._alarm._restore_alarm_audio"),
patch("wake_alarm._alarm.turn_on_plug"),
patch("wake_alarm._alarm.turn_off_plug"),
):
yield mock
@ -74,9 +74,9 @@ def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module."""
mock = _make_mock_tk()
with (
patch("python_pkg.wake_alarm._alarm.tk", mock),
patch("wake_alarm._alarm.tk", mock),
patch(
"python_pkg.wake_alarm._alarm.GateRoot",
"wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value,
),
):
@ -137,13 +137,13 @@ class TestWakeAlarmDismiss:
mock_tk_module: MagicMock,
) -> None:
"""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)
mock_entry = mock_tk_module.Entry.return_value
with patch(
"python_pkg.wake_alarm._alarm.save_wake_state",
"wake_alarm._alarm.save_wake_state",
) as mock_save:
for _ in range(DISMISS_ROUNDS_REQUIRED):
mock_entry.get.return_value = alarm._progress.current_challenge.answer
@ -174,15 +174,13 @@ class TestWakeAlarmDismiss:
mock_tk_module: MagicMock,
) -> None:
"""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)
mock_entry = mock_tk_module.Entry.return_value
mock_entry.get.return_value = alarm._progress.current_challenge.answer
next_math = _Challenge(kind="math", display="2 + 2 = ?", answer="4", hint="x")
with patch(
"python_pkg.wake_alarm._alarm._make_challenge", return_value=next_math
):
with patch("wake_alarm._alarm._make_challenge", return_value=next_math):
alarm._on_submit()
assert alarm._progress.current_challenge.kind == "math"
@ -194,7 +192,7 @@ class TestWakeAlarmDismiss:
mock_tk_module: MagicMock,
) -> None:
"""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)
# 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)
with patch(
"python_pkg.wake_alarm._alarm.save_wake_state",
"wake_alarm._alarm.save_wake_state",
) as mock_save:
alarm._on_skip_window_expired()
@ -251,14 +249,14 @@ class TestWakeAlarmDismiss:
mock_tk_module: MagicMock,
) -> None:
"""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._progress.skip_earnable = False
mock_entry = mock_tk_module.Entry.return_value
with patch(
"python_pkg.wake_alarm._alarm.save_wake_state",
"wake_alarm._alarm.save_wake_state",
) as mock_save:
for _ in range(DISMISS_ROUNDS_REQUIRED):
mock_entry.get.return_value = alarm._progress.current_challenge.answer
@ -276,10 +274,10 @@ class TestMain:
"""main() returns early when not an alarm day."""
with (
patch(
"python_pkg.wake_alarm._alarm._should_run_alarm",
"wake_alarm._alarm._should_run_alarm",
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"]
main() # Should just return without error
@ -292,11 +290,11 @@ class TestMain:
del mock_tk_module
with (
patch(
"python_pkg.wake_alarm._alarm._should_run_alarm",
"wake_alarm._alarm._should_run_alarm",
return_value=True,
),
patch(
"python_pkg.wake_alarm._alarm.sys",
"wake_alarm._alarm.sys",
) as mock_sys,
patch.object(WakeAlarm, "run") as mock_run,
patch.object(WakeAlarm, "__init__", return_value=None),
@ -313,10 +311,10 @@ class TestMain:
del mock_tk_module
with (
patch(
"python_pkg.wake_alarm._alarm._should_run_alarm",
"wake_alarm._alarm._should_run_alarm",
return_value=False,
) 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, "__init__", return_value=None),
):
@ -377,7 +375,7 @@ class TestBeepLoop:
alarm._stop_beep.set()
# Loop should exit immediately
with patch(
"python_pkg.wake_alarm._alarm._beep_soft",
"wake_alarm._alarm._beep_soft",
):
alarm._beep_loop()
alarm._stop_beep.set()

View File

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

View File

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

View File

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

View File

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