diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..98911e8 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -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 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..66e5931 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22de16e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..85371e4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be39b13 --- /dev/null +++ b/CLAUDE.md @@ -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 "`, 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`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..901d7c8 --- /dev/null +++ b/README.md @@ -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. diff --git a/wake_alarm/install.sh b/install.sh similarity index 66% rename from wake_alarm/install.sh rename to install.sh index fe4b3fc..28dd3b1 100755 --- a/wake_alarm/install.sh +++ b/install.sh @@ -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" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d13fdec --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..277d333 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/check_file_length.py b/scripts/check_file_length.py new file mode 100755 index 0000000..d30d747 --- /dev/null +++ b/scripts/check_file_length.py @@ -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()) diff --git a/wake_alarm/shutdown-wrapper.sh b/shutdown-wrapper.sh similarity index 93% rename from wake_alarm/shutdown-wrapper.sh rename to shutdown-wrapper.sh index 3022e5e..307e1af 100755 --- a/wake_alarm/shutdown-wrapper.sh +++ b/shutdown-wrapper.sh @@ -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 diff --git a/wake_alarm/sleep-hook.sh b/sleep-hook.sh similarity index 100% rename from wake_alarm/sleep-hook.sh rename to sleep-hook.sh diff --git a/wake_alarm/wake-alarm-fans.sh b/wake-alarm-fans.sh similarity index 100% rename from wake_alarm/wake-alarm-fans.sh rename to wake-alarm-fans.sh diff --git a/wake-alarm.service b/wake-alarm.service new file mode 100644 index 0000000..e0a2873 --- /dev/null +++ b/wake-alarm.service @@ -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 diff --git a/wake_alarm/_alarm.py b/wake_alarm/_alarm.py index c74df62..ee93de1 100644 --- a/wake_alarm/_alarm.py +++ b/wake_alarm/_alarm.py @@ -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, diff --git a/wake_alarm/_audio.py b/wake_alarm/_audio.py index 54c977a..808aa1a 100644 --- a/wake_alarm/_audio.py +++ b/wake_alarm/_audio.py @@ -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, diff --git a/wake_alarm/_challenges.py b/wake_alarm/_challenges.py index 88cfe93..cee4fd7 100644 --- a/wake_alarm/_challenges.py +++ b/wake_alarm/_challenges.py @@ -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 diff --git a/wake_alarm/_logging_setup.py b/wake_alarm/_logging_setup.py new file mode 100644 index 0000000..d7b4ea0 --- /dev/null +++ b/wake_alarm/_logging_setup.py @@ -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", + ) diff --git a/wake_alarm/_smart_plug.py b/wake_alarm/_smart_plug.py index 271c588..93dff93 100644 --- a/wake_alarm/_smart_plug.py +++ b/wake_alarm/_smart_plug.py @@ -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, ) diff --git a/wake_alarm/_state.py b/wake_alarm/_state.py index 0066e90..75459e2 100644 --- a/wake_alarm/_state.py +++ b/wake_alarm/_state.py @@ -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__) diff --git a/wake_alarm/tests/test_alarm.py b/wake_alarm/tests/test_alarm.py index a63ecd3..ffd8928 100644 --- a/wake_alarm/tests/test_alarm.py +++ b/wake_alarm/tests/test_alarm.py @@ -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 diff --git a/wake_alarm/tests/test_alarm_audio.py b/wake_alarm/tests/test_alarm_audio.py index dc37875..bec9f7a 100644 --- a/wake_alarm/tests/test_alarm_audio.py +++ b/wake_alarm/tests/test_alarm_audio.py @@ -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() diff --git a/wake_alarm/tests/test_alarm_challenges.py b/wake_alarm/tests/test_alarm_challenges.py index d574268..eac6755 100644 --- a/wake_alarm/tests/test_alarm_challenges.py +++ b/wake_alarm/tests/test_alarm_challenges.py @@ -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 ), ): diff --git a/wake_alarm/tests/test_alarm_display.py b/wake_alarm/tests/test_alarm_display.py index a71f74c..426f348 100644 --- a/wake_alarm/tests/test_alarm_display.py +++ b/wake_alarm/tests/test_alarm_display.py @@ -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( diff --git a/wake_alarm/tests/test_alarm_part2.py b/wake_alarm/tests/test_alarm_part2.py index 81b60c8..77983cd 100644 --- a/wake_alarm/tests/test_alarm_part2.py +++ b/wake_alarm/tests/test_alarm_part2.py @@ -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() diff --git a/wake_alarm/tests/test_alarm_part3.py b/wake_alarm/tests/test_alarm_part3.py index f2772f4..b23e0fc 100644 --- a/wake_alarm/tests/test_alarm_part3.py +++ b/wake_alarm/tests/test_alarm_part3.py @@ -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) diff --git a/wake_alarm/tests/test_alarm_part4.py b/wake_alarm/tests/test_alarm_part4.py index 31e52a5..20df5b8 100644 --- a/wake_alarm/tests/test_alarm_part4.py +++ b/wake_alarm/tests/test_alarm_part4.py @@ -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") diff --git a/wake_alarm/tests/test_alarm_sinks.py b/wake_alarm/tests/test_alarm_sinks.py index 0efb964..ba6909c 100644 --- a/wake_alarm/tests/test_alarm_sinks.py +++ b/wake_alarm/tests/test_alarm_sinks.py @@ -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] diff --git a/wake_alarm/tests/test_smart_plug.py b/wake_alarm/tests/test_smart_plug.py index c7f85fe..1c61b7f 100644 --- a/wake_alarm/tests/test_smart_plug.py +++ b/wake_alarm/tests/test_smart_plug.py @@ -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, diff --git a/wake_alarm/tests/test_state.py b/wake_alarm/tests/test_state.py index 566f851..eb48fbd 100644 --- a/wake_alarm/tests/test_state.py +++ b/wake_alarm/tests/test_state.py @@ -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 diff --git a/wake_alarm/wake-alarm.service b/wake_alarm/wake-alarm.service deleted file mode 100644 index fe1c045..0000000 --- a/wake_alarm/wake-alarm.service +++ /dev/null @@ -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 diff --git a/wake_alarm/wake_state.json b/wake_alarm/wake_state.json deleted file mode 100644 index ff7d63a..0000000 --- a/wake_alarm/wake_state.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "date": "2026-06-14", - "dismissed_at": "2026-06-14T05:01:28.589654+00:00", - "skip_workout": true, - "hmac": "b472bf9b0874ff3f6f460cace7965d53cdfce823ee6f2d1f91914e43f003e92b" -}