diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 8fbf5de..6e2fc92 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -7,6 +7,8 @@ on: - "python_pkg/lichess_bot/**" - "python_pkg/**" - "tests/**" + - "linux_configuration/scripts/periodic_background/system-maintenance/bin/**" + - "linux_configuration/tests/**" - "requirements.txt" pull_request: branches: [main] @@ -14,6 +16,8 @@ on: - "python_pkg/lichess_bot/**" - "python_pkg/**" - "tests/**" + - "linux_configuration/scripts/periodic_background/system-maintenance/bin/**" + - "linux_configuration/tests/**" - "requirements.txt" jobs: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9587eb2..e0e2ef1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -191,14 +191,18 @@ repos: stages: [pre-commit] args: - --rcfile=pyproject.toml - - --fail-under=8.0 + - --fail-under=10 - --jobs=4 additional_dependencies: - pytest - python-chess - requests - pygame - exclude: ^(Bash/|\.venv/) + - pillow + # Test suites and conftest fixtures are linted separately; they use + # patterns (protected-access, missing docstrings, fixture shadowing) + # that don't belong in the source-code 10/10 gate. + exclude: ^(Bash/|\.venv/)|(^|/)(tests/|conftest\.py) # =========================================================================== # BANDIT - Security linter (per-commit on changed files only) diff --git a/docs/superpowers/contracts/file-length-split-2026-06-14.json b/docs/superpowers/contracts/file-length-split-2026-06-14.json new file mode 100644 index 0000000..e709c7e --- /dev/null +++ b/docs/superpowers/contracts/file-length-split-2026-06-14.json @@ -0,0 +1,16 @@ +{ + "title": "Split 6 oversized files to satisfy the 500-line file-length test", + "objective": "Fix python_pkg/tests/test_file_length.py::test_all_python_files_are_at_most_500_lines by splitting diet_guard/_gatelock.py + test_gatelock.py, wake_alarm/_alarm.py + test_alarm.py, and the usage_report.py + _usage_report_parsing.py pair into focused modules, each <= 500 lines, while preserving behaviour, 100% branch coverage on python_pkg, and pylint 10.00/10 on touched non-test modules.", + "acceptance_criteria": [ + "All Python source files repo-wide are <= 500 lines", + "python_pkg/tests/test_file_length.py passes", + "Full repo pytest passes with 100% branch coverage on python_pkg", + "pylint 10.00/10 on all touched non-test modules", + "usage_report.py CLI still produces a full Markdown report" + ], + "out_of_scope": [ + "Changing diet_guard/wake_alarm/usage_report runtime behaviour", + "The pre-existing _AlarmProgress/_AlarmView/_AlarmHardware field-grouping refactor (unrelated, already in the working tree)" + ], + "verifier": "python3 -m pytest -q && python3 -m pylint " +} diff --git a/docs/superpowers/contracts/pylint10-r0801-schema-validation-2026-06.json b/docs/superpowers/contracts/pylint10-r0801-schema-validation-2026-06.json new file mode 100644 index 0000000..a8f9e63 --- /dev/null +++ b/docs/superpowers/contracts/pylint10-r0801-schema-validation-2026-06.json @@ -0,0 +1,18 @@ +{ + "title": "Dedupe validate_contract/validate_evidence to reach pylint 10/10", + "objective": "validate_contract.py and validate_evidence.py shared near-identical required-key/string-list/CLI-dispatch logic, triggering 3x pylint R0801 duplicate-code findings. This was the last of 16 findings blocking the repo-wide pylint pre-commit hook from reaching --fail-under=10. Extract the shared logic into a new _schema_validation.py helper module so both scripts import it, eliminating the duplication while preserving identical CLI behaviour (success/error messages, exit codes).", + "acceptance_criteria": [ + "meta/scripts/_schema_validation.py provides is_nonempty_str, load_and_check_required, check_string_lists, run_cli", + "validate_contract.py and validate_evidence.py import and use these helpers instead of duplicating the logic", + "pylint --rcfile=meta/pyproject.toml on the 3 files scores 10.00/10 with zero duplicate-code findings", + "pre-commit run pylint --all-files passes repo-wide (the overarching --fail-under=10 goal)", + "validate_contract.py and validate_evidence.py produce identical stdout/stderr/exit codes for: valid artifact, missing file, missing argv", + "linux_configuration/tests/test_validate_contract.py and test_validate_evidence.py pass unchanged" + ], + "out_of_scope": [ + "Any other staged/unstaged changes already present in the working tree (dwm setup, gaming scripts, transcription tools, usage-report edits, etc.)", + "python_pkg/tests/test_file_length.py 500-line violations (pre-existing, separate issue)", + "Changing error-message wording in _check_required_keys/_check_strings/_check_intent/_check_verification/_check_phrases" + ], + "verifier": "pylint --rcfile=meta/pyproject.toml meta/scripts/_schema_validation.py meta/scripts/validate_contract.py meta/scripts/validate_evidence.py; pre-commit run pylint --all-files; python3 -m pytest linux_configuration/tests/test_validate_contract.py linux_configuration/tests/test_validate_evidence.py -q" +} diff --git a/docs/superpowers/evidence/file-length-split-2026-06-14.json b/docs/superpowers/evidence/file-length-split-2026-06-14.json new file mode 100644 index 0000000..6d5b018 --- /dev/null +++ b/docs/superpowers/evidence/file-length-split-2026-06-14.json @@ -0,0 +1,56 @@ +{ + "intent": "Fix the repo-wide 500-line file length test by splitting 6 oversized files into focused modules, preserving behaviour, coverage, and lint scores.", + "scope": [ + "python_pkg/diet_guard/_gatelock.py (991 lines)", + "python_pkg/diet_guard/tests/test_gatelock.py (922 lines)", + "python_pkg/wake_alarm/_alarm.py (561 lines)", + "python_pkg/wake_alarm/tests/test_alarm.py (581 lines)", + "linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_parsing.py (545 lines)", + "linux_configuration/scripts/periodic_background/system-maintenance/bin/usage_report.py (733 lines)" + ], + "changes": [ + "Split _gatelock.py into _gatelock_core.py, _gatelock_window.py, _gatelock_nutrition.py, _gatelock_mealflow.py, _gatelock.py (mixin/composition split for MealGate)", + "Split test_gatelock.py into test_gatelock.py + test_gatelock_mealflow.py + conftest.py (shared fixtures/fakes)", + "Split _alarm.py into _alarm.py + _alarm_display.py (ddcutil/display helpers)", + "Split test_alarm.py into test_alarm.py + test_alarm_display.py, retargeting shutil/subprocess patches to _alarm_display", + "Split usage_report.py + _usage_report_parsing.py into 4 modules: usage_report.py, _usage_report_parsing.py, _usage_report_render.py (markdown rendering), _usage_report_pmon.py (GPU pmon aggregation); updated linux_configuration/tests to match", + "Installed missing python-kasa dependency into .venv (declared in meta/requirements.txt but absent after the python3.13->3.14 venv upgrade), fixing 8 pre-existing test_smart_plug.py failures and the resulting <100% coverage gap" + ], + "verification": [ + { + "command": "python3 -m pytest -q", + "result": "pass", + "evidence": "968 passed, TOTAL coverage 100.00% (python_pkg)" + }, + { + "command": "python3 -m pytest python_pkg/tests/test_file_length.py -q", + "result": "pass", + "evidence": "1 passed - all *.py files now <= 500 lines" + }, + { + "command": "python3 -m pylint python_pkg/diet_guard/_gatelock*.py python_pkg/wake_alarm/_alarm.py python_pkg/wake_alarm/_alarm_display.py", + "result": "pass", + "evidence": "10.00/10" + }, + { + "command": "python3 -m pylint --rcfile=pyproject.toml linux_configuration/.../bin/usage_report.py _usage_report_parsing.py _usage_report_render.py _usage_report_pmon.py", + "result": "pass", + "evidence": "10.00/10" + }, + { + "command": "python3 usage_report.py --since 20260613 --quiet --no-clipboard --no-update-state", + "result": "pass", + "evidence": "Produced full Markdown report (host profile, CPU/RAM/GPU tables, methodology)" + }, + { + "command": "python3 -m python_pkg.wake_alarm._alarm --help", + "result": "pass", + "evidence": "CLI argument parsing and module imports (incl. _alarm_display) resolve correctly" + } + ], + "risks": [ + "mypy reports 5 pre-existing tkinter-stub errors in _gatelock_window.py/_gatelock_nutrition.py; confirmed identical on the pre-split file at commit 31992b2, not a regression, not flagged by pre-commit's pinned mypy 1.13.0", + "patch() targets for relocated helpers (display, pmon, render) were retargeted to their new modules; verified via full test run" + ], + "rollback": ["git revert the commit", "Re-run python3 -m pytest -q to confirm 100% coverage on the reverted tree"] +} diff --git a/docs/superpowers/evidence/pylint10-r0801-schema-validation-2026-06.json b/docs/superpowers/evidence/pylint10-r0801-schema-validation-2026-06.json new file mode 100644 index 0000000..b173cff --- /dev/null +++ b/docs/superpowers/evidence/pylint10-r0801-schema-validation-2026-06.json @@ -0,0 +1,50 @@ +{ + "intent": "Eliminate the last 3 pylint R0801 duplicate-code findings (between validate_contract.py and validate_evidence.py) so the repo-wide pylint pre-commit hook passes at --fail-under=10, completing the multi-session pylint-10/10 goal.", + "scope": [ + "meta/scripts/_schema_validation.py (new)", + "meta/scripts/validate_contract.py", + "meta/scripts/validate_evidence.py", + "Non-goal: changing CLI output wording, exit codes, or any other staged work in the tree" + ], + "changes": [ + "Added meta/scripts/_schema_validation.py with is_nonempty_str, load_and_check_required, check_string_lists, run_cli shared helpers", + "validate_contract.py: removed duplicated key/list/CLI logic, now calls load_and_check_required(path, _check_required_keys) and run_cli(...)", + "validate_evidence.py: removed duplicated key/list/CLI logic, now calls load_and_check_required(path, _check_required_keys) and run_cli(...)", + "Moved Path import to TYPE_CHECKING-only in both scripts (annotation-only after refactor)", + "Added shebang + chmod +x to _schema_validation.py to satisfy ruff INP001/EXE001 conventions used by sibling files in meta/scripts/" + ], + "verification": [ + { + "command": "pylint --rcfile=meta/pyproject.toml meta/scripts/_schema_validation.py meta/scripts/validate_contract.py meta/scripts/validate_evidence.py", + "result": "pass", + "evidence": "Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)" + }, + { + "command": "pylint --disable=all --enable=duplicate-code --rcfile=meta/pyproject.toml meta/scripts/*.py", + "result": "pass", + "evidence": "No R0801 findings reported; score 10.00/10" + }, + { + "command": "pre-commit run pylint --all-files", + "result": "pass", + "evidence": "pylint...................................................................Passed" + }, + { + "command": "python3 -m pytest linux_configuration/tests/test_validate_contract.py linux_configuration/tests/test_validate_evidence.py -p no:cacheprovider -q", + "result": "pass", + "evidence": "27 passed in 0.36s" + }, + { + "command": "python3 meta/scripts/validate_contract.py docs/superpowers/contracts/agent-automation-bootstrap.json; python3 meta/scripts/validate_evidence.py docs/superpowers/evidence/template.json; python3 meta/scripts/validate_evidence.py; python3 meta/scripts/validate_contract.py /nonexistent.json", + "result": "pass", + "evidence": "Manual run: 'contract schema OK' / 'schema OK' (exit 0); usage message on stderr (exit 2); 'cannot read file (...)' on stderr (exit 1) -- identical to pre-refactor behaviour" + } + ], + "risks": [ + "New module _schema_validation.py is a private dependency shared by two CLI entry points used by pre-commit hooks (check_agent_contract.sh, check_ai_evidence.sh) -- if it fails to import, both hooks break" + ], + "rollback": [ + "git checkout -- meta/scripts/validate_contract.py meta/scripts/validate_evidence.py && rm meta/scripts/_schema_validation.py", + "Re-run pre-commit run pylint --all-files to confirm prior state (will show R0801 findings again, pre-existing pre-this-change state was already 10.00/10 minus this fix)" + ] +} diff --git a/linux_configuration/dwm/README.md b/linux_configuration/dwm/README.md new file mode 100644 index 0000000..3f23c33 --- /dev/null +++ b/linux_configuration/dwm/README.md @@ -0,0 +1,63 @@ +# dwm (i3-like, compiled from source) + +Versioned customisation for [suckless dwm](https://dwm.suckless.org/). This +directory is the **source of truth**; the installer +[`../scripts/single_use/features/setup_dwm.sh`](../scripts/single_use/features/setup_dwm.sh) +clones upstream `dwm` master, copies these files on top, applies the two +`dwm.c` patches, and builds. Nothing here is a full fork of dwm — only the +files we actually change live in the repo, so upstream stays bleeding-edge. + +## How a build works + +`setup_dwm.sh` runs, in order: + +1. `git clone` / `git reset --hard origin/master` into `~/.local/src/dwm` + (always the latest upstream). +2. Copy `config.h` over the clone. +3. `heal_config` — auto-merge any new upstream scalar knob (e.g. `refreshrate`) + that the current `dwm.c` needs but our `config.h` predates. +4. Apply `patches/focus-on-click.patch` and + `patches/fullscreen-pointer-confine.patch` to `dwm.c` — done with `perl` + rewrites (robust against upstream line shifts), not `git apply`. The + `.patch` files are the human-readable record of exactly those changes. +5. `make && sudo make install`. +6. Compile `pointer-confine.c` and install `bin/*` to `/usr/local/bin`. + +Because the clone is reset every run, the patches are **re-applied each build**, +which is what keeps "always latest master" working. + +## Files + +| Path | What it is | +| --- | --- | +| `config.h` | dwm config: Mod4, Dracula colours, 10 tags, bottom bar, vim-style focus/move, multi-monitor keys, media keys, `movestack`/`togglefullscr` defined inline so `dwm.c` needs no extra patch for them. | +| `pointer-confine.c` | Standalone helper: traps the X pointer on the current monitor with XFixes pointer barriers until killed. Used for fullscreen gaming so the cursor can't slide onto the other screen. | +| `patches/focus-on-click.patch` | No-ops `enternotify`/`motionnotify` so the pointer never changes focus or switches monitors — focus only changes on click or via keys. | +| `patches/fullscreen-pointer-confine.patch` | Hooks `setfullscreen`/`unmanage` to start/stop `pconfine-auto` so the cursor-lock turns on automatically when a window goes fullscreen. | +| `bin/dwm-session` | lightdm session launcher (autostart + `dwmstatus` + `exec dwm`). | +| `bin/dwmstatus` | Status feeder: CPU/GPU/board temps, RAM, load, volume, clock → root window name. `dwmstatus once` prints the line without `xsetroot`. | +| `bin/dwm-rebuild` | Recompile `~/.local/src/dwm` in place (quick local rebuild). | +| `bin/switch-wm` | Flip the lightdm boot session between `i3` and `dwm`. | +| `bin/pconfine-auto` | `on`/`off` single-instance control of the `pointer-confine` daemon (called by the dwm hooks and the panic key). | + +## Customising + +- **Permanent change:** edit the file here (e.g. `config.h`), then re-run + `setup_dwm.sh`. Log out / back in to apply (dwm has no live restart). +- **Quick experiment:** edit `~/.local/src/dwm/config.h` directly and run + `dwm-rebuild` — but note the next `setup_dwm.sh` run overwrites it from here. + +## Notable bindings (Mod = Super) + +- `Mod+Return` terminal, `Mod+d` dmenu, `Mod+f` fullscreen, `Mod+Shift+q` kill, + `Mod+Shift+e` exit, `Mod+Shift+r` recompile. +- Multi-monitor: `Mod+,`/`Mod+.` (or `Mod+Ctrl+←/→`) focus the other screen; + `Mod+Shift+,`/`Mod+Shift+.` (or `Mod+Ctrl+Shift+←/→`) throw the window there. +- `Mod+Shift+p` force-releases the fullscreen cursor-lock if a barrier sticks. + +## Dependencies + +Build/runtime: `libx11 libxft libxinerama gcc make dmenu terminator`, plus +`xorg-xsetroot` for the status bar and `libxfixes` for `pointer-confine` +(present as a dep of Xorg). Install missing ones interactively — this system's +`pacman` is a wrapper that deadlocks when driven non-interactively. diff --git a/linux_configuration/dwm/bin/dwm-rebuild b/linux_configuration/dwm/bin/dwm-rebuild new file mode 100755 index 0000000..e86f152 --- /dev/null +++ b/linux_configuration/dwm/bin/dwm-rebuild @@ -0,0 +1,9 @@ +#!/bin/sh +# Recompile dwm after editing ~/.local/src/dwm/config.h. Log out and back in +# (lightdm) to apply — dwm has no in-place restart without the restart patch. +set -eu +cd "$HOME/.local/src/dwm" || { echo "dwm source not found"; exit 1; } +sudo make clean install +printf '\n>>> dwm rebuilt. Log out and back in to apply the new config. <<<\n' +printf 'Press Enter to close…' +read -r _ diff --git a/linux_configuration/dwm/bin/dwmstatus b/linux_configuration/dwm/bin/dwmstatus new file mode 100755 index 0000000..81c2ba9 --- /dev/null +++ b/linux_configuration/dwm/bin/dwmstatus @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# dwm status feeder — sets the root window name, which dwm paints in the bar. +# Run `dwmstatus once` to print the line a single time (no xsetroot required). +set -u + +# Echo the primary temperature (temp1_input, whole °C) of the first hwmon chip +# whose name matches the given ERE. Echoes nothing if no such chip/sensor exists. +read_temp() { + local want=$1 d n milli + for d in /sys/class/hwmon/hwmon*/; do + [[ -r ${d}name ]] || continue + read -r n < "${d}name" + [[ $n =~ $want ]] || continue + [[ -r ${d}temp1_input ]] || return 0 + read -r milli < "${d}temp1_input" + printf '%d' "$((milli / 1000))" + return 0 + done +} + +# Format kibibytes as a compact GiB/MiB string (e.g. 11.2GiB). +fmt_kib() { + local mib=$(( $1 / 1024 )) + if ((mib >= 1024)); then + printf '%d.%dGiB' "$((mib / 1024))" "$((((mib % 1024) * 10) / 1024))" + else + printf '%dMiB' "$mib" + fi +} + +# Assemble the whole status line into $_status. +build_status() { + local cpu gpu mb load total avail raw mute vol now temps="" + + cpu=$(read_temp 'k10temp|coretemp') + gpu=$(read_temp 'amdgpu|nouveau|radeon') + mb=$(read_temp 'nct[0-9]|it87|f71') + [[ -n $cpu ]] && temps+="CPU ${cpu}C " + [[ -n $gpu ]] && temps+="GPU ${gpu}C " + [[ -n $mb ]] && temps+="MB ${mb}C " + + read -r load _ < /proc/loadavg + + total=0 avail=0 + while IFS=' :' read -r key value _; do + case $key in + MemTotal) total=$value ;; + MemAvailable) avail=$value ;; + esac + done < /proc/meminfo + + vol="?" + raw=$(pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null) || raw="" + [[ $raw =~ ([0-9]+)% ]] && vol="${BASH_REMATCH[1]}%" + mute=$(pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null) || mute="" + [[ $mute == *yes ]] && vol="mute" + + printf -v now '%(%Y-%m-%d %H:%M)T' -1 + + _status=" ${temps}RAM $(fmt_kib "$((total - avail))")/$(fmt_kib "$total") load ${load} vol ${vol} ${now} " +} + +if [[ ${1:-} == once ]]; then + build_status + printf '%s\n' "$_status" + exit 0 +fi + +# Need xsetroot to actually paint the bar; without it the bar just stays empty. +if ! command -v xsetroot >/dev/null 2>&1; then + printf 'dwmstatus: xorg-xsetroot not installed; run: sudo pacman -S xorg-xsetroot\n' >&2 + exit 0 +fi + +while :; do + build_status + xsetroot -name "$_status" + sleep 5 +done diff --git a/linux_configuration/dwm/bin/pconfine-auto b/linux_configuration/dwm/bin/pconfine-auto new file mode 100755 index 0000000..0173122 --- /dev/null +++ b/linux_configuration/dwm/bin/pconfine-auto @@ -0,0 +1,20 @@ +#!/bin/sh +# pconfine-auto on|off — single-instance control of the pointer-confine +# daemon, called by dwm's setfullscreen()/unmanage() hooks and the Mod+Shift+p +# panic key. `on` locks the cursor to the current monitor; `off` releases it. +set -u +case "${1:-}" in + on) + # start only if not already running, and only if the helper exists + pgrep -x pointer-confine >/dev/null 2>&1 && exit 0 + command -v pointer-confine >/dev/null 2>&1 || exit 0 + setsid pointer-confine >/dev/null 2>&1 & # new session: outlives the hook + ;; + off) + pkill -x pointer-confine 2>/dev/null || true + ;; + *) + echo "usage: ${0##*/} on|off" >&2 + exit 1 + ;; +esac diff --git a/linux_configuration/dwm/bin/switch-wm b/linux_configuration/dwm/bin/switch-wm new file mode 100755 index 0000000..af66b4d --- /dev/null +++ b/linux_configuration/dwm/bin/switch-wm @@ -0,0 +1,41 @@ +#!/bin/bash +# switch-wm — choose which window manager lightdm boots you into. +# Flips the autologin + default session between i3 and dwm in both lightdm +# config files (main lightdm.conf overrides the conf.d drop-in, so keep both in +# sync). Reboot or log out to apply. +# switch-wm show the current boot session +# switch-wm dwm boot into dwm next time +# switch-wm i3 boot into i3 next time +# Recovery: if a session misbehaves at boot, go to a TTY (Ctrl+Alt+F3), +# log in, run `switch-wm i3`, then `reboot`. +set -euo pipefail + +readonly CONFS=( + /etc/lightdm/lightdm.conf + /etc/lightdm/lightdm.conf.d/50-autologin.conf +) + +show_current() { + grep -hE '^autologin-session=' /etc/lightdm/lightdm.conf 2>/dev/null \ + | tail -1 | cut -d= -f2 +} + +target="${1:-}" +case "$target" in + "") echo "boot session: $(show_current)"; exit 0 ;; + i3|dwm) ;; + *) echo "Usage: $(basename "$0") {i3|dwm} (no arg = show current)" >&2; exit 1 ;; +esac + +if [[ ! -f "/usr/share/xsessions/${target}.desktop" ]]; then + echo "Error: /usr/share/xsessions/${target}.desktop not found — is '${target}' installed?" >&2 + exit 1 +fi + +for f in "${CONFS[@]}"; do + [[ -f "$f" ]] || continue + sudo sed -i -E \ + "s/^(autologin-session=).*/\1${target}/; s/^(user-session=).*/\1${target}/" "$f" +done + +echo "Boot session set to '${target}'. Reboot (or log out) to apply." diff --git a/linux_configuration/dwm/config.h b/linux_configuration/dwm/config.h new file mode 100644 index 0000000..50a53ad --- /dev/null +++ b/linux_configuration/dwm/config.h @@ -0,0 +1,249 @@ +/* dwm config.h — generated by setup_dwm.sh to mirror an i3 setup. + * + * NOTE ON THE FOCUS MODEL: dwm is master/stack, not a tree like i3, so there is + * no true 2-D directional focus. The closest familiar mapping is used: + * Mod+j / Mod+k -> focus next / previous window in the stack + * Mod+Shift+j / +k -> move the focused window down / up the stack + * Mod+h / Mod+l -> shrink / grow the master area (i3's resize mode) + * Mod+Return -> open terminator (i3: same) + * Mod+Shift+Return -> promote window to master (zoom; dwm idiom, no i3 eq) + * Everything that carries muscle memory (Mod4, Mod+d dmenu, Mod+1..0 tags, + * Mod+Shift+q kill, Mod+Shift+e exit, Mod+f fullscreen) is mirrored exactly. + */ + +#include + +/* ---- appearance --------------------------------------------------------- */ +static const unsigned int borderpx = 2; /* border pixel of windows */ +static const unsigned int snap = 32; /* snap pixel */ +static const int showbar = 1; /* 0 means no bar */ +static const int topbar = 0; /* 0 = bottom bar (matches the request) */ +static const char *fonts[] = { "monospace:size=10" }; + +/* Dracula palette, matching the i3 config */ +static const char col_bg[] = "#282A36"; /* background */ +static const char col_fg[] = "#F8F8F2"; /* foreground (active) */ +static const char col_inactive[] = "#BFBFBF"; /* foreground (idle) */ +static const char col_accent[] = "#6272A4"; /* focused accent */ +static const char *colors[][3] = { + /* fg bg border */ + [SchemeNorm] = { col_inactive, col_bg, col_bg }, + [SchemeSel] = { col_fg, col_accent, col_accent }, +}; + +/* ---- tagging (10 tags == i3's 10 workspaces) ---------------------------- */ +static const char *tags[] = { "1", "2", "3", "4", "5", "6", "7", "8", "9", "10" }; + +static const Rule rules[] = { + /* class instance title tags mask isfloating monitor */ + /* dwm needs >=1 rule (zero-length arrays are illegal in C). Gimp is the + * canonical harmless example — add your own with `xprop` to find a class. */ + { "Gimp", NULL, NULL, 0, 1, -1 }, +}; + +/* ---- layout(s) ---------------------------------------------------------- */ +static const float mfact = 0.55; /* factor of master area size [0.05..0.95] */ +static const int nmaster = 1; /* number of clients in master area */ +static const int resizehints = 1; /* 1 means respect size hints in tiled resizals */ +static const int lockfullscreen = 1; /* 1 will force focus on the fullscreen window */ + +static const Layout layouts[] = { + /* symbol arrange function */ + { "[]=", tile }, /* first entry is default */ + { "><>", NULL }, /* no layout function means floating behaviour */ + { "[M]", monocle }, +}; + +/* ---- key definitions ---------------------------------------------------- */ +#define MODKEY Mod4Mask /* Super, matching the i3 `set $mod Mod4` */ +#define TAGKEYS(KEY,TAG) \ + { MODKEY, KEY, view, {.ui = 1 << TAG} }, \ + { MODKEY|ControlMask, KEY, toggleview, {.ui = 1 << TAG} }, \ + { MODKEY|ShiftMask, KEY, tag, {.ui = 1 << TAG} }, \ + { MODKEY|ControlMask|ShiftMask, KEY, toggletag, {.ui = 1 << TAG} }, + +/* helper for commands that need a shell (pipes, &&, env vars) */ +#define SHCMD(cmd) { .v = (const char*[]){ "/bin/sh", "-c", cmd, NULL } } + +/* ---- extra behaviours (defined here so dwm.c stays untouched) ----------- */ + +/* movestack: shift the focused window up/down the client stack (i3-like move) */ +static void +movestack(const Arg *arg) +{ + Client *c = NULL, *p = NULL, *pc = NULL, *i; + + if (arg->i > 0) { + /* find the client after selmon->sel */ + for (c = selmon->sel->next; c && (!ISVISIBLE(c) || c->isfloating); c = c->next); + if (!c) + for (c = selmon->clients; c && (!ISVISIBLE(c) || c->isfloating); c = c->next); + } else { + /* find the client before selmon->sel */ + for (i = selmon->clients; i != selmon->sel; i = i->next) + if (ISVISIBLE(i) && !i->isfloating) + c = i; + if (!c) + for (; i; i = i->next) + if (ISVISIBLE(i) && !i->isfloating) + c = i; + } + /* find the client before selmon->sel and c */ + for (i = selmon->clients; i && (!p || !pc); i = i->next) { + if (i->next == selmon->sel) + p = i; + if (i->next == c) + pc = i; + } + + /* swap c and selmon->sel in the selmon->clients list */ + if (c && c != selmon->sel) { + Client *temp = selmon->sel->next == c ? selmon->sel : selmon->sel->next; + selmon->sel->next = c->next == selmon->sel ? c : c->next; + c->next = temp; + + if (p && p != c) + p->next = c; + if (pc && pc != selmon->sel) + pc->next = selmon->sel; + + if (selmon->sel == selmon->clients) + selmon->clients = c; + else if (c == selmon->clients) + selmon->clients = selmon->sel; + + arrange(selmon); + } +} + +/* togglefullscr: true fullscreen for the focused window (i3's Mod+f) */ +static void +togglefullscr(const Arg *arg) +{ + (void)arg; + if (selmon->sel) + setfullscreen(selmon->sel, !selmon->sel->isfullscreen); +} + +/* ---- spawn commands ----------------------------------------------------- */ +static char dmenumon[2] = "0"; /* component of dmenucmd, manipulated in spawn() */ +static const char *dmenucmd[] = { + "dmenu_run", "-m", dmenumon, + "-nf", "#F8F8F2", "-nb", "#282A36", "-sb", "#6272A4", "-sf", "#F8F8F2", + "-fn", "monospace-10", "-p", "dmenu%", NULL +}; +static const char *termcmd[] = { "terminator", NULL }; +static const char *rebuildcmd[] = { "terminator", "-T", "dwm-rebuild", "-e", "dwm-rebuild", NULL }; +/* panic key: force-release the auto fullscreen pointer-lock if it ever sticks */ +static const char *pconfoffcmd[] = { "pconfine-auto", "off", NULL }; + +static const Key keys[] = { + /* modifier key function argument */ + { MODKEY, XK_d, spawn, {.v = dmenucmd } }, + { MODKEY, XK_Return, spawn, {.v = termcmd } }, + + /* focus the stack (see header note on the master/stack model) */ + { MODKEY, XK_j, focusstack, {.i = +1 } }, + { MODKEY, XK_k, focusstack, {.i = -1 } }, + { MODKEY, XK_Down, focusstack, {.i = +1 } }, + { MODKEY, XK_Up, focusstack, {.i = -1 } }, + + /* move the focused window within the stack (i3-like Mod+Shift+dir) */ + { MODKEY|ShiftMask, XK_j, movestack, {.i = +1 } }, + { MODKEY|ShiftMask, XK_k, movestack, {.i = -1 } }, + { MODKEY|ShiftMask, XK_Down, movestack, {.i = +1 } }, + { MODKEY|ShiftMask, XK_Up, movestack, {.i = -1 } }, + + /* resize the master area (i3's resize mode, without the mode) */ + { MODKEY, XK_h, setmfact, {.f = -0.05 } }, + { MODKEY, XK_l, setmfact, {.f = +0.05 } }, + { MODKEY, XK_Left, setmfact, {.f = -0.05 } }, + { MODKEY, XK_Right, setmfact, {.f = +0.05 } }, + + /* number of windows in the master area */ + { MODKEY, XK_i, incnmaster, {.i = +1 } }, + { MODKEY|ShiftMask, XK_i, incnmaster, {.i = -1 } }, + + /* promote focused window to master (dwm zoom) */ + { MODKEY|ShiftMask, XK_Return, zoom, {0} }, + + /* fullscreen + floating (mirror i3's Mod+f and Mod+Shift+space) */ + { MODKEY, XK_f, togglefullscr, {0} }, + { MODKEY|ShiftMask, XK_space, togglefloating, {0} }, + + /* layouts: tile / monocle. i3 mnemonics mapped to the nearest dwm layout. */ + { MODKEY, XK_t, setlayout, {.v = &layouts[0]} }, /* tiling */ + { MODKEY, XK_e, setlayout, {.v = &layouts[0]} }, /* ~ toggle split -> tiling */ + { MODKEY, XK_w, setlayout, {.v = &layouts[2]} }, /* ~ tabbed -> monocle */ + { MODKEY, XK_s, setlayout, {.v = &layouts[2]} }, /* ~ stacking -> monocle */ + { MODKEY, XK_space, setlayout, {0} }, /* ~ focus mode_toggle -> prev layout */ + + { MODKEY, XK_b, togglebar, {0} }, + + /* ---- multi-monitor: the i3 "move a window to the other screen" workflow -- + * dwm differs from i3 here: there is ONE shared set of tags (1-10), and each + * monitor independently shows one of them — instead of i3's per-output + * workspaces. You don't pick "the workspace that lives on screen 2"; you just + * move focus (or the window) to the other screen directly, which is one key: + * focus other screen : Mod+, Mod+. (or Mod+Ctrl+Left / Right) + * throw window there : Mod+Shift+, Mod+Shift+. (or Mod+Ctrl+Shift+L/R) + * Arrow variants are added because comma/period are easy to forget. */ + { MODKEY, XK_comma, focusmon, {.i = -1 } }, + { MODKEY, XK_period, focusmon, {.i = +1 } }, + { MODKEY|ControlMask, XK_Left, focusmon, {.i = -1 } }, + { MODKEY|ControlMask, XK_Right, focusmon, {.i = +1 } }, + { MODKEY|ShiftMask, XK_comma, tagmon, {.i = -1 } }, + { MODKEY|ShiftMask, XK_period, tagmon, {.i = +1 } }, + { MODKEY|ControlMask|ShiftMask, XK_Left, tagmon, {.i = -1 } }, + { MODKEY|ControlMask|ShiftMask, XK_Right, tagmon, {.i = +1 } }, + + /* microphone mute (i3's Mod+m). Uses volume_control.sh micmute — same pactl + * backend as the media keys, with a notification and NO pacman side-effects. + * (toggle_mic.sh is deliberately avoided here: it runs `sudo pacman -S dunst` + * if dunst is missing, which deadlocks pacman when spawned without a tty.) */ + { MODKEY, XK_m, spawn, SHCMD("$HOME/testsAndMisc/linux_configuration/scripts/single_use/utils/volume_control.sh micmute") }, + + /* media keys — routed through volume_control.sh, same as the (now-fixed) i3 config */ + { 0, XF86XK_AudioRaiseVolume, spawn, SHCMD("$HOME/testsAndMisc/linux_configuration/scripts/single_use/utils/volume_control.sh up") }, + { 0, XF86XK_AudioLowerVolume, spawn, SHCMD("$HOME/testsAndMisc/linux_configuration/scripts/single_use/utils/volume_control.sh down") }, + { 0, XF86XK_AudioMute, spawn, SHCMD("$HOME/testsAndMisc/linux_configuration/scripts/single_use/utils/volume_control.sh mute") }, + { 0, XF86XK_AudioMicMute, spawn, SHCMD("$HOME/testsAndMisc/linux_configuration/scripts/single_use/utils/volume_control.sh micmute") }, + + /* tags 1-10 (i3 workspaces 1-10) */ + TAGKEYS( XK_1, 0) + TAGKEYS( XK_2, 1) + TAGKEYS( XK_3, 2) + TAGKEYS( XK_4, 3) + TAGKEYS( XK_5, 4) + TAGKEYS( XK_6, 5) + TAGKEYS( XK_7, 6) + TAGKEYS( XK_8, 7) + TAGKEYS( XK_9, 8) + TAGKEYS( XK_0, 9) + + /* kill window / exit session (mirror i3 Mod+Shift+q and Mod+Shift+e) */ + { MODKEY|ShiftMask, XK_q, killclient, {0} }, + { MODKEY|ShiftMask, XK_e, quit, {0} }, + + /* recompile from source (i3's Mod+Shift+r restart, dwm-style) */ + { MODKEY|ShiftMask, XK_r, spawn, {.v = rebuildcmd } }, + + /* panic: force-release the automatic fullscreen pointer-lock (pointer-confine) */ + { MODKEY|ShiftMask, XK_p, spawn, {.v = pconfoffcmd } }, +}; + +/* ---- mouse: Mod+drag to move/resize floats (i3's floating_modifier) ----- */ +static const Button buttons[] = { + /* click event mask button function argument */ + { ClkLtSymbol, 0, Button1, setlayout, {0} }, + { ClkLtSymbol, 0, Button3, setlayout, {.v = &layouts[2]} }, + { ClkWinTitle, 0, Button2, zoom, {0} }, + { ClkStatusText, 0, Button2, spawn, {.v = termcmd } }, + { ClkClientWin, MODKEY, Button1, movemouse, {0} }, + { ClkClientWin, MODKEY, Button2, togglefloating, {0} }, + { ClkClientWin, MODKEY, Button3, resizemouse, {0} }, + { ClkTagBar, 0, Button1, view, {0} }, + { ClkTagBar, 0, Button3, toggleview, {0} }, + { ClkTagBar, MODKEY, Button1, tag, {0} }, + { ClkTagBar, MODKEY, Button3, toggletag, {0} }, +}; diff --git a/linux_configuration/dwm/patches/focus-on-click.patch b/linux_configuration/dwm/patches/focus-on-click.patch new file mode 100644 index 0000000..1774430 --- /dev/null +++ b/linux_configuration/dwm/patches/focus-on-click.patch @@ -0,0 +1,46 @@ +--- a/dwm.c ++++ b/dwm.c +@@ -759,20 +759,8 @@ + void + enternotify(XEvent *e) + { +- Client *c; +- Monitor *m; +- XCrossingEvent *ev = &e->xcrossing; +- +- if ((ev->mode != NotifyNormal || ev->detail == NotifyInferior) && ev->window != root) +- return; +- c = wintoclient(ev->window); +- m = c ? c->mon : wintomon(ev->window); +- if (m != selmon) { +- unfocus(selmon->sel, 1); +- selmon = m; +- } else if (!c || c == selmon->sel) +- return; +- focus(c); ++ /* focusonclick: pointer never changes focus; use a click or Mod+keys. */ ++ (void)e; + } + + void +@@ -1128,18 +1116,8 @@ + void + motionnotify(XEvent *e) + { +- static Monitor *mon = NULL; +- Monitor *m; +- XMotionEvent *ev = &e->xmotion; +- +- if (ev->window != root) +- return; +- if ((m = recttomon(ev->x_root, ev->y_root, 1, 1)) != mon && mon) { +- unfocus(selmon->sel, 1); +- selmon = m; +- focus(NULL); +- } +- mon = m; ++ /* focusonclick: keep the active monitor fixed when crossing screens. */ ++ (void)e; + } + + void diff --git a/linux_configuration/dwm/patches/fullscreen-pointer-confine.patch b/linux_configuration/dwm/patches/fullscreen-pointer-confine.patch new file mode 100644 index 0000000..c563e5d --- /dev/null +++ b/linux_configuration/dwm/patches/fullscreen-pointer-confine.patch @@ -0,0 +1,26 @@ +--- a/dwm.c ++++ b/dwm.c +@@ -1464,6 +1464,7 @@ + XChangeProperty(dpy, c->win, netatom[NetWMState], XA_ATOM, 32, + PropModeReplace, (unsigned char*)&netatom[NetWMFullscreen], 1); + c->isfullscreen = 1; ++ if (system("pconfine-auto on &")) {} + c->oldstate = c->isfloating; + c->oldbw = c->bw; + c->bw = 0; +@@ -1474,6 +1475,7 @@ + XChangeProperty(dpy, c->win, netatom[NetWMState], XA_ATOM, 32, + PropModeReplace, (unsigned char*)0, 0); + c->isfullscreen = 0; ++ if (system("pconfine-auto off &")) {} + c->isfloating = c->oldstate; + c->bw = c->oldbw; + c->x = c->oldx; +@@ -1758,6 +1760,7 @@ + { + Monitor *m = c->mon; + XWindowChanges wc; ++ if (c->isfullscreen) { if (system("pconfine-auto off &")) {} } + + detach(c); + detachstack(c); diff --git a/linux_configuration/dwm/pointer-confine.c b/linux_configuration/dwm/pointer-confine.c new file mode 100644 index 0000000..aaccdf8 --- /dev/null +++ b/linux_configuration/dwm/pointer-confine.c @@ -0,0 +1,88 @@ +/* pointer-confine.c — trap the X pointer on whichever monitor it is currently + * on, using XFixes pointer barriers, until this process is killed. Barriers are + * released automatically on exit (signal handler + X client disconnect). dwm's + * setfullscreen()/unmanage() hooks start this when a window goes fullscreen + * (games) and stop it when fullscreen ends, so the cursor cannot slide onto the + * other monitor mid-game. Barriers block only pointer *crossing* — they do not + * grab or redirect input, so the game's own mouse handling is unaffected. + * + * Build: cc pointer-confine.c -o pointer-confine -lX11 -lXfixes -lXinerama + */ +#include +#include +#include +#include +#include +#include + +static Display *dpy; +static PointerBarrier barrier[4]; +static int nbar; + +/* Drop the barriers and exit cleanly when dwm signals us (or on Ctrl-C). */ +static void +release(int sig) +{ + int i; + (void)sig; + for (i = 0; i < nbar; i++) + XFixesDestroyPointerBarrier(dpy, barrier[i]); + if (dpy) { + XFlush(dpy); + XCloseDisplay(dpy); + } + _exit(0); +} + +int +main(void) +{ + int evb, erb, n, i; + Window root, rr, cr; + int rx, ry, wx, wy; + unsigned int mask; + int mx = 0, my = 0, mw = 0, mh = 0, found = 0; + XineramaScreenInfo *si; + + if (!(dpy = XOpenDisplay(NULL))) + return 1; + if (!XFixesQueryExtension(dpy, &evb, &erb)) + return 1; /* no XFixes -> nothing we can do */ + root = DefaultRootWindow(dpy); + + /* Where is the pointer right now? */ + if (!XQueryPointer(dpy, root, &rr, &cr, &rx, &ry, &wx, &wy, &mask)) + return 1; + + /* Which Xinerama monitor contains it? */ + if (XineramaIsActive(dpy) && (si = XineramaQueryScreens(dpy, &n))) { + for (i = 0; i < n; i++) { + if (rx >= si[i].x_org && rx < si[i].x_org + si[i].width && + ry >= si[i].y_org && ry < si[i].y_org + si[i].height) { + mx = si[i].x_org; my = si[i].y_org; + mw = si[i].width; mh = si[i].height; found = 1; + break; + } + } + XFree(si); + } + if (!found) + return 0; /* single monitor -> nothing to confine */ + + /* Four barriers boxing the monitor. directions == 0 blocks crossing both + * ways, so the pointer is trapped inside [mx,mx+mw) x [my,my+mh). */ + barrier[0] = XFixesCreatePointerBarrier(dpy, root, mx, my, mx, my + mh, 0, 0, NULL); + barrier[1] = XFixesCreatePointerBarrier(dpy, root, mx + mw, my, mx + mw, my + mh, 0, 0, NULL); + barrier[2] = XFixesCreatePointerBarrier(dpy, root, mx, my, mx + mw, my, 0, 0, NULL); + barrier[3] = XFixesCreatePointerBarrier(dpy, root, mx, my + mh, mx + mw, my + mh, 0, 0, NULL); + nbar = 4; + XFlush(dpy); + + signal(SIGTERM, release); + signal(SIGINT, release); + signal(SIGHUP, release); + + for (;;) + pause(); /* hold the barriers until killed */ + return 0; +} diff --git a/linux_configuration/scripts/gaming/README.md b/linux_configuration/scripts/gaming/README.md new file mode 100644 index 0000000..1ee70f9 --- /dev/null +++ b/linux_configuration/scripts/gaming/README.md @@ -0,0 +1,59 @@ +## Dual Steam Accounts (same PC, two monitors) + +Runs a second Steam instance as `player2` on HDMI-0, alongside your main session on DP-0. +Both accounts play simultaneously with full GPU acceleration — no VT switching needed. + +### One-time setup (fresh install) + +**1. Create the player2 user:** +```bash +sudo useradd -m -G audio,video,input -s /bin/bash player2 +sudo passwd player2 +``` + +**2. Install dependencies:** +```bash +sudo pacman -S xorg-xhost +``` + +**3. Add passwordless sudoers rule** (allows launching Steam as player2 without a prompt): +```bash +echo "kuhy ALL=(player2) NOPASSWD: /usr/bin/steam" | sudo tee /etc/sudoers.d/player2-steam +sudo chmod 440 /etc/sudoers.d/player2-steam +``` + +**4. Symlink the script into PATH:** +```bash +sudo ln -sf ~/testsAndMisc/linux_configuration/scripts/gaming/start-player2.sh /usr/local/bin/start-player2 +``` + +**5. Start the getty on tty2** (needed if LightDM autologin is configured, otherwise tty2 has no login prompt): +```bash +sudo systemctl enable getty@tty2 +``` + +### Usage + +```bash +start-player2 +``` + +A Steam window opens as `player2`. Drag it to HDMI-0 and log into the second account. +Click whichever monitor you want to control — no switching needed. + +To stop: close the Steam window or `pkill -u player2 -f steam`. + +### How it works + +- Steam is launched as `player2` via `sudo -H -u player2 steam` on `DISPLAY=:0` (your main X session). +- Because `player2` has a separate home directory (`~/.local/share/Steam/`), the two Steam + instances don't conflict — different PID locks, different configs, different accounts. +- `xhost +local:` grants local users access to your X display. +- Full GPU acceleration since there is no nesting — both instances hit the hardware directly. + +### Monitor layout + +| Output | Resolution | Session | +|--------|------------|--------------| +| DP-0 | 3840×2160 | kuhy (main) | +| HDMI-0 | 2560×1440 | player2 | diff --git a/linux_configuration/scripts/gaming/start-player2.sh b/linux_configuration/scripts/gaming/start-player2.sh new file mode 100755 index 0000000..ab0aa16 --- /dev/null +++ b/linux_configuration/scripts/gaming/start-player2.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail + +# Kill any lingering player2 steam processes +pkill -u player2 -f steam 2>/dev/null || true +sleep 0.5 + +# Allow player2 to use the main X display +xhost +local: > /dev/null + +# Launch Steam as player2 on the same X display (:0) +DISPLAY=":0" sudo -H -u player2 steam & + +echo "player2 Steam launched. Move the window to your HDMI monitor." diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh index 27bbcd9..24465e7 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh @@ -210,15 +210,30 @@ ensure_periodic_maintenance() { echo -e "${YELLOW}Periodic maintenance services missing or inactive. Running setup...${NC}" >&2 - # Try to locate setup_periodic_system.sh + # Try to locate setup_periodic_system.sh. The installed wrapper lives in + # /usr/local/bin (so $self_dir won't contain it) and, for real transactions, + # runs as root under sudo (so $HOME points at /root). Resolve the invoking + # user's home via SUDO_USER and probe the known repo locations. local setup_script="" - local self_dir + local self_dir real_user real_home self_dir="$(dirname "$(readlink -f "$0")")" - if [[ -f "$self_dir/setup_periodic_system.sh" ]]; then - setup_script="$self_dir/setup_periodic_system.sh" - elif [[ -f "$HOME/linux-configuration/scripts/periodic_background/setup_periodic_system.sh" ]]; then - setup_script="$HOME/linux-configuration/scripts/periodic_background/setup_periodic_system.sh" - fi + real_user="${SUDO_USER:-${USER:-$(id -un)}}" + real_home="$(getent passwd "$real_user" 2>/dev/null | cut -d: -f6)" + [[ -z $real_home ]] && real_home="$HOME" + + local -a setup_candidates=( + "$self_dir/setup_periodic_system.sh" + "$real_home/testsAndMisc/linux_configuration/scripts/periodic_background/setup_periodic_system.sh" + "$real_home/linux_configuration/scripts/periodic_background/setup_periodic_system.sh" + "$real_home/linux-configuration/scripts/periodic_background/setup_periodic_system.sh" + ) + local candidate + for candidate in "${setup_candidates[@]}"; do + if [[ -f $candidate ]]; then + setup_script="$candidate" + break + fi + done if [[ -n $setup_script ]]; then if [[ $EUID -ne 0 ]]; then @@ -745,6 +760,25 @@ if [[ ${1:-} == "--makepkg-capped" ]]; then run_makepkg_capped "$@" fi +# --------------------------------------------------------------------------- +# Fast pass-through for unprivileged, sandboxed and read-only invocations. +# +# makepkg/yay invoke pacman dozens of times for dependency resolution and +# metadata (e.g. `pacman -T`, `-Qi`, `-Qq`) — as a non-root user and inside a +# fakeroot build sandbox. Policy enforcement, service checks and package +# cleanup only make sense for a genuine privileged transaction (root running +# -S/-U/-R/-Syu ...), so for everything else we exec the real pacman directly. +# This avoids the root-only policy-file read ("policy.sha256: Permission +# denied"), the D-Bus "Failed to connect to system scope bus" errors from +# systemctl inside the build sandbox, and the per-call log spam during builds. +# +# Note: inside fakeroot $EUID reports 0 (libfakeroot intercepts geteuid), so it +# is the FAKEROOTKEY check — not the EUID check — that catches in-sandbox calls. +# --------------------------------------------------------------------------- +if [[ $EUID -ne 0 || -n ${FAKEROOTKEY:-} ]] || ! needs_unlock "$@"; then + exec "$PACMAN_BIN" "$@" +fi + # CRITICAL: Verify policy file integrity before any operations if ! verify_policy_integrity; then exit 1 diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh index 5f1558b..73858cc 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh @@ -934,17 +934,23 @@ if [[ $should_shutdown == true ]]; then # with an RTC timer so the alarm fires 8 hours later. Hibernate is completely # silent and dark — ideal when the PC is in a bedroom. rtcwake -m disk saves # state to swap and powers off, then the RTC restores power at wake_epoch. + # + # NOTE the -i (--ignore-inhibitors): this is a digital-wellbeing *enforcement* + # shutdown and must be unbypassable. Without -i, any process holding a block + # inhibitor — a game, Steam, a video player, or our own controller idle-off + # watcher — silently denies the hibernate ("Operation denied due to active + # block inhibitor") and the PC stays up all night. -i overrides all locks. tomorrow_dow=\$(date -d "tomorrow" +%u) case "\$tomorrow_dow" in 1|5|6|7) wake_epoch=\$(( \$(printf '%(%s)T' -1) + 8 * 3600 )) logger -t day-specific-shutdown "Tomorrow is alarm day (dow=\$tomorrow_dow) — hibernating, RTC wake at epoch \$wake_epoch" /usr/bin/sudo /usr/sbin/rtcwake -m no -t "\$wake_epoch" - /usr/bin/systemctl hibernate + /usr/bin/systemctl hibernate -i ;; *) logger -t day-specific-shutdown "Tomorrow is not an alarm day — powering off normally" - /usr/bin/systemctl poweroff + /usr/bin/systemctl poweroff -i ;; esac else diff --git a/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_parsing.py b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_parsing.py index 25639f0..03dfb2c 100644 --- a/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_parsing.py +++ b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_parsing.py @@ -9,9 +9,6 @@ import shutil import subprocess from typing import TYPE_CHECKING -if TYPE_CHECKING: - from collections.abc import Iterator - from _usage_report_types import ( _MIN_SAMPLES_FOR_WINDOW, GpuAgg, @@ -22,6 +19,9 @@ from _usage_report_types import ( _Window, ) +if TYPE_CHECKING: + from collections.abc import Iterator + # atop parseable output layout (atop 2.x, same on Arch/Debian/Ubuntu): # 0 label, 1 host, 2 epoch, 3 YYYY/MM/DD, 4 HH:MM:SS, 5 interval_s, # then per-process fields starting at index 6. @@ -36,7 +36,6 @@ _PRC_MIN_LEN = 12 _PRM_PID_IDX = 6 _PRM_NAME_IDX = 7 _PRM_MIN_LEN = 12 -_PMON_MIN_FIELDS = 11 _CPU_RECORD_MIN_LEN = 5 _PAREN_PAIR_MIN = 2 _ATOP_AGG_CACHE_BIN = Path.home() / ".cache" / "usage_report" / "atop_agg" @@ -373,124 +372,6 @@ def _fold_pid_aggregates( return agg -def _pmon_fields(line: str) -> list[str] | None: - """Return stripped fields of a pmon data line, or None for headers/blanks.""" - s = line.strip() - if not s or s.startswith("#"): - return None - return s.split() - - -def _normalize_pmon_command(command_fields: list[str]) -> str: - """Normalize pmon command fields into a stable process-ish name. - - `nvidia-smi pmon -o DT` emits fixed numeric columns followed by a command - field that can include whitespace. We prefer the *first* non-option token - (usually executable) and normalize it to a basename. - """ - tokens = [token.strip().strip("\"'") for token in command_fields if token.strip()] - if not tokens: - return "unknown" - - selected = tokens[0] - if selected.startswith("-"): - for candidate in tokens[1:]: - if not candidate.startswith("-"): - selected = candidate - break - - name = Path(selected).name.strip(";,:") - if not name: - return "unknown" - return name - - -def _pid_comm_name(pid: int) -> str | None: - """Return `/proc//comm` basename when available.""" - try: - comm = Path(f"/proc/{pid}/comm").read_text(encoding="utf-8").strip() - except OSError: - return None - return Path(comm).name if comm else None - - -def _pmon_row_epoch(parts: list[str]) -> float | None: - """Local-time epoch of a pmon row from its `date`/`time` columns, or None. - - pmon timestamps are naive local time (`YYYYMMDD HH:MM:SS`); `.astimezone()` - attaches the local offset so the result is comparable to a `begin_epoch` - derived the same way. - """ - try: - stamp = _dt.datetime.strptime( - f"{parts[0]} {parts[1]}", - "%Y%m%d %H:%M:%S", - ).astimezone() - except (ValueError, IndexError): - return None - return stamp.timestamp() - - -def aggregate_pmon( - log: Path, - progress: _Progress, - begin_epoch: float | None = None, -) -> tuple[dict[str, GpuAgg], int]: - """Return `({program: GpuAgg}, sample_count)` from the pmon *log*. - - When *begin_epoch* is set, rows timestamped before it are skipped so the - first day of a "since last report" window starts at the previous run time. - """ - progress.start_stage("pmon log scan") - agg: dict[str, GpuAgg] = {} - samples = 0 - if not log.exists(): - progress.update(1.0) - return agg, 0 - total_bytes = max(log.stat().st_size, 1) - bytes_read = 0 - with log.open(encoding="utf-8") as fh: - for line in fh: - bytes_read += len(line) - progress.update(min(bytes_read / total_bytes, 0.99)) - parts = _pmon_fields(line) - if parts is None or len(parts) < _PMON_MIN_FIELDS: - continue - if begin_epoch is not None: - row_epoch = _pmon_row_epoch(parts) - if row_epoch is not None and row_epoch < begin_epoch: - continue - samples += _ingest_pmon_row(parts, agg) - progress.update(1.0) - return agg, samples - - -def _ingest_pmon_row(parts: list[str], agg: dict[str, GpuAgg]) -> int: - """Fold a single pmon data row into *agg*; return 1 if consumed else 0.""" - # pmon -o DT fields: - # date time gpu pid type sm mem enc dec jpg ofa command - try: - pid = int(parts[3]) - except ValueError: - return 0 - sm_raw = parts[5] - mem_raw = parts[6] - command_fields = parts[11:] - name = _normalize_pmon_command(command_fields) - if name == "unknown": - name = _pid_comm_name(pid) or "unknown" - sm = float(sm_raw) if sm_raw != "-" else 0.0 - mem = float(mem_raw) if mem_raw != "-" else 0.0 - entry = agg.setdefault(name, GpuAgg(name=name)) - entry.sm_pct_sum += sm - entry.mem_pct_sum += mem - entry.samples += 1 - entry.pid_set.add(pid) - entry.peak_sm_pct = max(entry.peak_sm_pct, sm) - entry.peak_mem_pct = max(entry.peak_mem_pct, mem) - return 1 - - def merge_proc_aggs(dst: dict[str, ProcAgg], src: dict[str, ProcAgg]) -> None: """Fold one day's CPU/RAM aggregates (*src*) into the running *dst*. diff --git a/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_pmon.py b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_pmon.py new file mode 100644 index 0000000..c697bbf --- /dev/null +++ b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_pmon.py @@ -0,0 +1,128 @@ +"""nvidia-smi pmon log parsing and aggregation helpers for usage_report.""" + +from __future__ import annotations + +import datetime as _dt +from pathlib import Path + +from _usage_report_types import GpuAgg, _Progress + +_PMON_MIN_FIELDS = 11 + + +def _pmon_fields(line: str) -> list[str] | None: + """Return stripped fields of a pmon data line, or None for headers/blanks.""" + s = line.strip() + if not s or s.startswith("#"): + return None + return s.split() + + +def _normalize_pmon_command(command_fields: list[str]) -> str: + """Normalize pmon command fields into a stable process-ish name. + + `nvidia-smi pmon -o DT` emits fixed numeric columns followed by a command + field that can include whitespace. We prefer the *first* non-option token + (usually executable) and normalize it to a basename. + """ + tokens = [token.strip().strip("\"'") for token in command_fields if token.strip()] + if not tokens: + return "unknown" + + selected = tokens[0] + if selected.startswith("-"): + for candidate in tokens[1:]: + if not candidate.startswith("-"): + selected = candidate + break + + name = Path(selected).name.strip(";,:") + if not name: + return "unknown" + return name + + +def _pid_comm_name(pid: int) -> str | None: + """Return `/proc//comm` basename when available.""" + try: + comm = Path(f"/proc/{pid}/comm").read_text(encoding="utf-8").strip() + except OSError: + return None + return Path(comm).name if comm else None + + +def _pmon_row_epoch(parts: list[str]) -> float | None: + """Local-time epoch of a pmon row from its `date`/`time` columns, or None. + + pmon timestamps are naive local time (`YYYYMMDD HH:MM:SS`); `.astimezone()` + attaches the local offset so the result is comparable to a `begin_epoch` + derived the same way. + """ + try: + stamp = _dt.datetime.strptime( + f"{parts[0]} {parts[1]}", + "%Y%m%d %H:%M:%S", + ).astimezone() + except (ValueError, IndexError): + return None + return stamp.timestamp() + + +def aggregate_pmon( + log: Path, + progress: _Progress, + begin_epoch: float | None = None, +) -> tuple[dict[str, GpuAgg], int]: + """Return `({program: GpuAgg}, sample_count)` from the pmon *log*. + + When *begin_epoch* is set, rows timestamped before it are skipped so the + first day of a "since last report" window starts at the previous run time. + """ + progress.start_stage("pmon log scan") + agg: dict[str, GpuAgg] = {} + samples = 0 + if not log.exists(): + progress.update(1.0) + return agg, 0 + total_bytes = max(log.stat().st_size, 1) + bytes_read = 0 + with log.open(encoding="utf-8") as fh: + for line in fh: + bytes_read += len(line) + progress.update(min(bytes_read / total_bytes, 0.99)) + parts = _pmon_fields(line) + if parts is None or len(parts) < _PMON_MIN_FIELDS: + continue + if begin_epoch is not None: + row_epoch = _pmon_row_epoch(parts) + if row_epoch is not None and row_epoch < begin_epoch: + continue + samples += _ingest_pmon_row(parts, agg) + progress.update(1.0) + return agg, samples + + +def _ingest_pmon_row(parts: list[str], agg: dict[str, GpuAgg]) -> int: + """Fold a single pmon data row into *agg*; return 1 if consumed else 0.""" + # pmon -o DT fields: + # date time gpu pid type sm mem enc dec jpg ofa command + try: + pid = int(parts[3]) + except ValueError: + return 0 + sm_raw = parts[5] + mem_raw = parts[6] + command_fields = parts[11:] + name = _normalize_pmon_command(command_fields) + if name == "unknown": + name = _pid_comm_name(pid) or "unknown" + sm = float(sm_raw) if sm_raw != "-" else 0.0 + mem = float(mem_raw) if mem_raw != "-" else 0.0 + entry = agg.setdefault(name, GpuAgg(name=name)) + entry.sm_pct_sum += sm + entry.mem_pct_sum += mem + entry.samples += 1 + entry.pid_set.add(pid) + entry.peak_sm_pct = max(entry.peak_sm_pct, sm) + entry.peak_mem_pct = max(entry.peak_mem_pct, mem) + return 1 diff --git a/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_render.py b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_render.py new file mode 100644 index 0000000..29838b0 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/system-maintenance/bin/_usage_report_render.py @@ -0,0 +1,310 @@ +"""Markdown report rendering helpers for usage_report.""" + +from __future__ import annotations + +from collections import defaultdict +import datetime as _dt +import os +from pathlib import Path +import platform +import re +from typing import TYPE_CHECKING + +from _usage_report_parsing import _run +from _usage_report_types import _HZ, _PMON_INTERVAL_S, GpuAgg, ProcAgg, _Window + +if TYPE_CHECKING: + from collections.abc import Iterable + + from usage_report import _Aggregates + +_SEC_PER_HOUR = 3600 +_SEC_PER_MIN = 60 +_PAGE_KB = os.sysconf("SC_PAGESIZE") // 1024 if hasattr(os, "sysconf") else 4 +_RAM_BUCKET_MIB = 1 # dedupe rows whose peak RSS rounds to the same MiB +_MAX_SIBLINGS_SHOWN = 6 + + +def _host_profile() -> dict[str, str]: + """Collect a small bag of identifying facts about the host.""" + info: dict[str, str] = { + "hostname": platform.node(), + "kernel": platform.release(), + "cpus_online": str(os.cpu_count() or 0), + } + try: + with Path("/proc/cpuinfo").open(encoding="utf-8") as fh: + for line in fh: + if line.startswith("model name"): + info["cpu_model"] = line.split(":", 1)[1].strip() + break + except OSError: + pass + try: + with Path("/proc/meminfo").open(encoding="utf-8") as fh: + for line in fh: + if line.startswith("MemTotal:"): + kb = int(re.findall(r"\d+", line)[0]) + info["memory_total_gib"] = f"{kb / 1024 / 1024:.1f}" + break + except (OSError, IndexError, ValueError): + pass + gpu = _run( + [ + "nvidia-smi", + "--query-gpu=name,memory.total", + "--format=csv,noheader", + ], + ).strip() + if gpu: + info["gpu"] = gpu.replace("\n", "; ") + return info + + +def _md_escape(name: str) -> str: + """Escape characters that would break a Markdown table cell.""" + return name.replace("|", r"\|").replace("\n", " ") + + +def _fmt_h(seconds: float) -> str: + """Human-friendly duration: `"1h 23m"` / `"4m 12s"` / `"8.3s"`.""" + if seconds >= _SEC_PER_HOUR: + h = int(seconds // _SEC_PER_HOUR) + m = int((seconds % _SEC_PER_HOUR) // _SEC_PER_MIN) + return f"{h}h {m:02d}m" + if seconds >= _SEC_PER_MIN: + m = int(seconds // _SEC_PER_MIN) + s = int(seconds % _SEC_PER_MIN) + return f"{m}m {s:02d}s" + return f"{seconds:.1f}s" + + +def _cpu_table(aggs: Iterable[ProcAgg], window_s: int, top: int) -> list[str]: + ncpu = os.cpu_count() or 1 + header = ( + "| # | Program | CPU-seconds | Avg CPU% (of 1 core) |" + " Avg CPU% (of box) | Peak RSS | PIDs |" + ) + sep = ( + "|---|---------|------------:|---------------------:|" + "------------------:|---------:|-----:|" + ) + rows: list[str] = [header, sep] + top_items = sorted(aggs, key=lambda a: a.cpu_ticks, reverse=True)[:top] + for idx, item in enumerate(top_items, start=1): + single = (item.cpu_seconds / window_s * 100) if window_s else 0.0 + box = single / ncpu + rows.append( + "| " + f"{idx} | {_md_escape(item.name)} | " + f"{item.cpu_seconds:,.0f}s ({_fmt_h(item.cpu_seconds)}) | " + f"{single:.1f}% | {box:.1f}% | " + f"{item.peak_rss_mb:,.0f} MiB | {item.pid_count} |", + ) + return rows + + +def _dedupe_ram(aggs: Iterable[ProcAgg]) -> list[tuple[ProcAgg, list[str]]]: + """Group rows by peak-RSS bucket; keep the top-CPU row per bucket. + + Returns a list of `(representative, sibling_names)` ordered by peak RSS + descending. Siblings are the other names that shared the same RSS bucket + (likely threads of the same parent process). + """ + buckets: dict[int, list[ProcAgg]] = defaultdict(list) + for item in aggs: + if item.peak_rss_kb <= 0: + continue + key = round(item.peak_rss_kb / 1024 / _RAM_BUCKET_MIB) + buckets[key].append(item) + result: list[tuple[ProcAgg, list[str]]] = [] + for bucket in buckets.values(): + bucket.sort(key=lambda a: (a.cpu_ticks, a.pid_count), reverse=True) + rep = bucket[0] + siblings = [b.name for b in bucket[1:]] + result.append((rep, siblings)) + result.sort(key=lambda t: t[0].peak_rss_kb, reverse=True) + return result + + +def _ram_table(aggs: Iterable[ProcAgg], top: int) -> list[str]: + header = ( + "| # | Program | Peak RSS | Avg RSS | CPU-seconds | PIDs |" + " Sibling names (shared RSS) |" + ) + sep = ( + "|---|---------|---------:|--------:|------------:|-----:|" + "----------------------------|" + ) + rows: list[str] = [header, sep] + for idx, (item, siblings) in enumerate(_dedupe_ram(aggs)[:top], start=1): + if not siblings: + sib = "—" + else: + shown = ", ".join(_md_escape(s) for s in siblings[:_MAX_SIBLINGS_SHOWN]) + extra = ( + f" (+{len(siblings) - _MAX_SIBLINGS_SHOWN} more)" + if len(siblings) > _MAX_SIBLINGS_SHOWN + else "" + ) + sib = f"{shown}{extra}" + rows.append( + "| " + f"{idx} | {_md_escape(item.name)} | " + f"{item.peak_rss_mb:,.0f} MiB | " + f"{item.avg_rss_mb:,.0f} MiB | " + f"{item.cpu_seconds:,.0f}s | " + f"{item.pid_count} | {sib} |", + ) + return rows + + +def _gpu_table(aggs: dict[str, GpuAgg], total_samples: int, top: int) -> list[str]: + header = ( + "| # | Program | GPU SM-seconds | Avg SM% (when present) |" + " Peak SM% | Peak MEM% | Samples | PIDs |" + ) + sep = ( + "|---|---------|---------------:|-----------------------:|" + "---------:|----------:|--------:|-----:|" + ) + rows: list[str] = [header, sep] + top_items = sorted(aggs.values(), key=lambda a: a.gpu_seconds, reverse=True)[:top] + for idx, item in enumerate(top_items, start=1): + presence = (item.samples / total_samples * 100) if total_samples else 0.0 + rows.append( + "| " + f"{idx} | {_md_escape(item.name)} | " + f"{item.gpu_seconds:,.0f}s ({_fmt_h(item.gpu_seconds)}) | " + f"{item.avg_sm_pct:.1f}% | " + f"{item.peak_sm_pct:.0f}% | " + f"{item.peak_mem_pct:.0f}% | " + f"{item.samples} ({presence:.0f}%) | " + f"{item.pid_count} |", + ) + return rows + + +def _fingerprint_section() -> list[str]: + info = _host_profile() + return [ + "## Host", + "", + *[f"- **{k}**: {v}" for k, v in info.items()], + "", + ] + + +def _methodology_section( + atop_desc: str, + pmon_desc: str, + window: _Window, +) -> list[str]: + window_note = ( + f"- **Coverage window**: {_fmt_h(window.seconds)} " + f"(sum of per-day atop coverage from first to last sample; excludes " + f"any gap days where atop was not logging, and the final partial tick)." + ) + interval_note = ( + f"- **atop sample interval (observed)**: {window.interval_s}s" + if window.interval_s + else "- **atop sample interval**: only one sample so far; interval unknown." + ) + task_note = ( + "- atop's parseable output is **task-level** (threads get their own " + "rows keyed by `/proc//comm`); names like 'Main Thread' or " + "'dxvk-frame' are usually Wine/game worker threads of one parent." + ) + rss_note = ( + "- RSS is shared across threads of one process, so multiple rows " + "with identical 'Peak RSS' almost certainly belong to a single " + "parent. The RAM table dedupes by peak-RSS bucket and lists " + "sibling thread names under `(+ siblings)`." + ) + cpu_note = ( + "- **CPU-seconds** are computed per-PID as " + "`last_cumulative_ticks - first_cumulative_ticks` (or the cumulative " + "value itself for PIDs seen only once). They reflect CPU consumed " + "during the coverage window only, not since process start." + ) + gpu_note = ( + "- GPU SM-seconds = sum(sm% per sample) \u00d7 sample interval / 100; " + "single-GPU equivalent." + ) + prog_note = ( + "- 'Program' = executable/thread name; rows with the same name " + "are summed across their distinct PIDs." + ) + return [ + "## Methodology", + "", + f"- **atop log(s)**: {atop_desc}", + f"- **pmon log(s)**: {pmon_desc}", + f"- **HZ**: {_HZ} ticks/s; **page size**: {_PAGE_KB} KiB", + window_note, + interval_note, + cpu_note, + task_note, + rss_note, + gpu_note, + prog_note, + "", + ] + + +_LLM_PROMPT = [ + "> Below is aggregated resource usage for my Linux workstation over the", + "> reporting period shown above. Identify which programs are the biggest", + "> hogs, flag anything that looks abnormal for a typical developer/gaming", + "> setup, and suggest concrete optimisations (config tweaks, process limits,", + "> alternative tools). Be specific.", +] + + +def _render_report( + aggs: _Aggregates, + *, + top: int, + atop_desc: str, + pmon_desc: str, + period_line: str, +) -> str: + """Assemble the Markdown report from already-aggregated data.""" + window = aggs.window + gpu_section = ( + _gpu_table(aggs.gpu, aggs.gpu_samples, top) + if aggs.gpu + else ["_No GPU pmon data found._"] + ) + generated = _dt.datetime.now().astimezone().isoformat(timespec="seconds") + interval = f"{window.interval_s}s" if window.interval_s else "n/a (single sample)" + lines: list[str] = [ + "# System resource usage report", + "", + f"- **Generated**: {generated}", + period_line, + f"- **atop window**: {window.start} → {window.end}", + f"- **atop samples**: {window.distinct_samples} distinct " + f"timestamps (sample interval ≈ {interval})", + f"- **GPU pmon samples**: {aggs.gpu_samples} (≈{_PMON_INTERVAL_S}s each)", + "", + *_fingerprint_section(), + *_methodology_section(atop_desc, pmon_desc, window), + "## Top CPU consumers", + "", + *_cpu_table(aggs.cpu.values(), window.seconds, top), + "", + "## Top RAM consumers (by peak RSS, deduped by shared-memory bucket)", + "", + *_ram_table(aggs.cpu.values(), top), + "", + "## Top GPU consumers", + "", + *gpu_section, + "", + "## Suggested LLM prompt", + "", + *_LLM_PROMPT, + "", + ] + return "\n".join(lines) + "\n" diff --git a/linux_configuration/scripts/periodic_background/system-maintenance/bin/usage_report.py b/linux_configuration/scripts/periodic_background/system-maintenance/bin/usage_report.py index 2388ecb..207cc04 100755 --- a/linux_configuration/scripts/periodic_background/system-maintenance/bin/usage_report.py +++ b/linux_configuration/scripts/periodic_background/system-maintenance/bin/usage_report.py @@ -25,46 +25,28 @@ count, HZ, machine specs) so the LLM never has to guess context. from __future__ import annotations import argparse -from collections import defaultdict from dataclasses import dataclass import datetime as _dt import json -import os from pathlib import Path -import platform -import re import shutil import subprocess import sys -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Iterable from _usage_report_parsing import ( - _run, aggregate_atop, - aggregate_pmon, merge_gpu_aggs, merge_proc_aggs, merge_windows, ) -from _usage_report_types import ( - _HZ, - _PMON_INTERVAL_S, - GpuAgg, - ProcAgg, - _Progress, - _Window, -) +from _usage_report_pmon import aggregate_pmon +from _usage_report_render import _fmt_h, _render_report +from _usage_report_types import _PMON_INTERVAL_S, GpuAgg, ProcAgg, _Progress, _Window _ATOP_LOG_DIR = Path("/var/log/atop") _PMON_LOG_DIR = Path.home() / ".local/share/gpu-log" _DEFAULT_TOP = 15 -_PAGE_KB = os.sysconf("SC_PAGESIZE") // 1024 if hasattr(os, "sysconf") else 4 _SEC_PER_DAY = 86_400 -_SEC_PER_HOUR = 3600 -_SEC_PER_MIN = 60 # Persisted marker of when the last report was generated. Lives under # ~/.local/share (durable app state), not ~/.cache, so clearing caches does not @@ -73,237 +55,6 @@ _STATE_DIR = Path.home() / ".local/share/usage_report" _STATE_FILE = _STATE_DIR / "last_report.json" -def _host_profile() -> dict[str, str]: - """Collect a small bag of identifying facts about the host.""" - info: dict[str, str] = { - "hostname": platform.node(), - "kernel": platform.release(), - "cpus_online": str(os.cpu_count() or 0), - } - try: - with Path("/proc/cpuinfo").open(encoding="utf-8") as fh: - for line in fh: - if line.startswith("model name"): - info["cpu_model"] = line.split(":", 1)[1].strip() - break - except OSError: - pass - try: - with Path("/proc/meminfo").open(encoding="utf-8") as fh: - for line in fh: - if line.startswith("MemTotal:"): - kb = int(re.findall(r"\d+", line)[0]) - info["memory_total_gib"] = f"{kb / 1024 / 1024:.1f}" - break - except (OSError, IndexError, ValueError): - pass - gpu = _run( - [ - "nvidia-smi", - "--query-gpu=name,memory.total", - "--format=csv,noheader", - ], - ).strip() - if gpu: - info["gpu"] = gpu.replace("\n", "; ") - return info - - -def _md_escape(name: str) -> str: - """Escape characters that would break a Markdown table cell.""" - return name.replace("|", r"\|").replace("\n", " ") - - -def _fmt_h(seconds: float) -> str: - """Human-friendly duration: `"1h 23m"` / `"4m 12s"` / `"8.3s"`.""" - if seconds >= _SEC_PER_HOUR: - h = int(seconds // _SEC_PER_HOUR) - m = int((seconds % _SEC_PER_HOUR) // _SEC_PER_MIN) - return f"{h}h {m:02d}m" - if seconds >= _SEC_PER_MIN: - m = int(seconds // _SEC_PER_MIN) - s = int(seconds % _SEC_PER_MIN) - return f"{m}m {s:02d}s" - return f"{seconds:.1f}s" - - -def _cpu_table(aggs: Iterable[ProcAgg], window_s: int, top: int) -> list[str]: - ncpu = os.cpu_count() or 1 - header = ( - "| # | Program | CPU-seconds | Avg CPU% (of 1 core) |" - " Avg CPU% (of box) | Peak RSS | PIDs |" - ) - sep = ( - "|---|---------|------------:|---------------------:|" - "------------------:|---------:|-----:|" - ) - rows: list[str] = [header, sep] - top_items = sorted(aggs, key=lambda a: a.cpu_ticks, reverse=True)[:top] - for idx, item in enumerate(top_items, start=1): - single = (item.cpu_seconds / window_s * 100) if window_s else 0.0 - box = single / ncpu - rows.append( - "| " - f"{idx} | {_md_escape(item.name)} | " - f"{item.cpu_seconds:,.0f}s ({_fmt_h(item.cpu_seconds)}) | " - f"{single:.1f}% | {box:.1f}% | " - f"{item.peak_rss_mb:,.0f} MiB | {item.pid_count} |", - ) - return rows - - -_RAM_BUCKET_MIB = 1 # dedupe rows whose peak RSS rounds to the same MiB -_MAX_SIBLINGS_SHOWN = 6 - - -def _dedupe_ram(aggs: Iterable[ProcAgg]) -> list[tuple[ProcAgg, list[str]]]: - """Group rows by peak-RSS bucket; keep the top-CPU row per bucket. - - Returns a list of `(representative, sibling_names)` ordered by peak RSS - descending. Siblings are the other names that shared the same RSS bucket - (likely threads of the same parent process). - """ - buckets: dict[int, list[ProcAgg]] = defaultdict(list) - for item in aggs: - if item.peak_rss_kb <= 0: - continue - key = round(item.peak_rss_kb / 1024 / _RAM_BUCKET_MIB) - buckets[key].append(item) - result: list[tuple[ProcAgg, list[str]]] = [] - for bucket in buckets.values(): - bucket.sort(key=lambda a: (a.cpu_ticks, a.pid_count), reverse=True) - rep = bucket[0] - siblings = [b.name for b in bucket[1:]] - result.append((rep, siblings)) - result.sort(key=lambda t: t[0].peak_rss_kb, reverse=True) - return result - - -def _ram_table(aggs: Iterable[ProcAgg], top: int) -> list[str]: - header = ( - "| # | Program | Peak RSS | Avg RSS | CPU-seconds | PIDs |" - " Sibling names (shared RSS) |" - ) - sep = ( - "|---|---------|---------:|--------:|------------:|-----:|" - "----------------------------|" - ) - rows: list[str] = [header, sep] - for idx, (item, siblings) in enumerate(_dedupe_ram(aggs)[:top], start=1): - if not siblings: - sib = "\u2014" - else: - shown = ", ".join(_md_escape(s) for s in siblings[:_MAX_SIBLINGS_SHOWN]) - extra = ( - f" (+{len(siblings) - _MAX_SIBLINGS_SHOWN} more)" - if len(siblings) > _MAX_SIBLINGS_SHOWN - else "" - ) - sib = f"{shown}{extra}" - rows.append( - "| " - f"{idx} | {_md_escape(item.name)} | " - f"{item.peak_rss_mb:,.0f} MiB | " - f"{item.avg_rss_mb:,.0f} MiB | " - f"{item.cpu_seconds:,.0f}s | " - f"{item.pid_count} | {sib} |", - ) - return rows - - -def _gpu_table(aggs: dict[str, GpuAgg], total_samples: int, top: int) -> list[str]: - header = ( - "| # | Program | GPU SM-seconds | Avg SM% (when present) |" - " Peak SM% | Peak MEM% | Samples | PIDs |" - ) - sep = ( - "|---|---------|---------------:|-----------------------:|" - "---------:|----------:|--------:|-----:|" - ) - rows: list[str] = [header, sep] - top_items = sorted(aggs.values(), key=lambda a: a.gpu_seconds, reverse=True)[:top] - for idx, item in enumerate(top_items, start=1): - presence = (item.samples / total_samples * 100) if total_samples else 0.0 - rows.append( - "| " - f"{idx} | {_md_escape(item.name)} | " - f"{item.gpu_seconds:,.0f}s ({_fmt_h(item.gpu_seconds)}) | " - f"{item.avg_sm_pct:.1f}% | " - f"{item.peak_sm_pct:.0f}% | " - f"{item.peak_mem_pct:.0f}% | " - f"{item.samples} ({presence:.0f}%) | " - f"{item.pid_count} |", - ) - return rows - - -def _fingerprint_section() -> list[str]: - info = _host_profile() - return [ - "## Host", - "", - *[f"- **{k}**: {v}" for k, v in info.items()], - "", - ] - - -def _methodology_section( - atop_desc: str, - pmon_desc: str, - window: _Window, -) -> list[str]: - window_note = ( - f"- **Coverage window**: {_fmt_h(window.seconds)} " - f"(sum of per-day atop coverage from first to last sample; excludes " - f"any gap days where atop was not logging, and the final partial tick)." - ) - interval_note = ( - f"- **atop sample interval (observed)**: {window.interval_s}s" - if window.interval_s - else "- **atop sample interval**: only one sample so far; interval unknown." - ) - task_note = ( - "- atop's parseable output is **task-level** (threads get their own " - "rows keyed by `/proc//comm`); names like 'Main Thread' or " - "'dxvk-frame' are usually Wine/game worker threads of one parent." - ) - rss_note = ( - "- RSS is shared across threads of one process, so multiple rows " - "with identical 'Peak RSS' almost certainly belong to a single " - "parent. The RAM table dedupes by peak-RSS bucket and lists " - "sibling thread names under `(+ siblings)`." - ) - cpu_note = ( - "- **CPU-seconds** are computed per-PID as " - "`last_cumulative_ticks - first_cumulative_ticks` (or the cumulative " - "value itself for PIDs seen only once). They reflect CPU consumed " - "during the coverage window only, not since process start." - ) - gpu_note = ( - "- GPU SM-seconds = sum(sm% per sample) \u00d7 sample interval / 100; " - "single-GPU equivalent." - ) - prog_note = ( - "- 'Program' = executable/thread name; rows with the same name " - "are summed across their distinct PIDs." - ) - return [ - "## Methodology", - "", - f"- **atop log(s)**: {atop_desc}", - f"- **pmon log(s)**: {pmon_desc}", - f"- **HZ**: {_HZ} ticks/s; **page size**: {_PAGE_KB} KiB", - window_note, - interval_note, - cpu_note, - task_note, - rss_note, - gpu_note, - prog_note, - "", - ] - - def _compute_window(atop_log: Path, progress: _Progress) -> _Window: """Deprecated helper kept for backwards import compatibility. @@ -316,15 +67,6 @@ def _compute_window(atop_log: Path, progress: _Progress) -> _Window: return window -_LLM_PROMPT = [ - "> Below is aggregated resource usage for my Linux workstation over the", - "> reporting period shown above. Identify which programs are the biggest", - "> hogs, flag anything that looks abnormal for a typical developer/gaming", - "> setup, and suggest concrete optimisations (config tweaks, process limits,", - "> alternative tools). Be specific.", -] - - _REPORT_STAGES = 2 @@ -358,55 +100,6 @@ class _Aggregates: days_with_data: int -def _render_report( - aggs: _Aggregates, - *, - top: int, - atop_desc: str, - pmon_desc: str, - period_line: str, -) -> str: - """Assemble the Markdown report from already-aggregated data.""" - window = aggs.window - gpu_section = ( - _gpu_table(aggs.gpu, aggs.gpu_samples, top) - if aggs.gpu - else ["_No GPU pmon data found._"] - ) - generated = _dt.datetime.now().astimezone().isoformat(timespec="seconds") - interval = f"{window.interval_s}s" if window.interval_s else "n/a (single sample)" - lines: list[str] = [ - "# System resource usage report", - "", - f"- **Generated**: {generated}", - period_line, - f"- **atop window**: {window.start} \u2192 {window.end}", - f"- **atop samples**: {window.distinct_samples} distinct " - f"timestamps (sample interval \u2248 {interval})", - f"- **GPU pmon samples**: {aggs.gpu_samples} (\u2248{_PMON_INTERVAL_S}s each)", - "", - *_fingerprint_section(), - *_methodology_section(atop_desc, pmon_desc, window), - "## Top CPU consumers", - "", - *_cpu_table(aggs.cpu.values(), window.seconds, top), - "", - "## Top RAM consumers (by peak RSS, deduped by shared-memory bucket)", - "", - *_ram_table(aggs.cpu.values(), top), - "", - "## Top GPU consumers", - "", - *gpu_section, - "", - "## Suggested LLM prompt", - "", - *_LLM_PROMPT, - "", - ] - return "\n".join(lines) + "\n" - - def _aggregate_segments( segments: list[_Segment], progress: _Progress, diff --git a/linux_configuration/scripts/single_use/features/setup_dwm.sh b/linux_configuration/scripts/single_use/features/setup_dwm.sh new file mode 100755 index 0000000..e53fc9b --- /dev/null +++ b/linux_configuration/scripts/single_use/features/setup_dwm.sh @@ -0,0 +1,359 @@ +#!/bin/bash + +# ============================================================================ +# setup_dwm.sh — download, configure (i3-like), build & install suckless dwm +# ============================================================================ +# Installs dwm ALONGSIDE the existing i3 setup. i3 is never touched; dwm shows +# up as a separate session you can boot into (see switch-wm). +# +# SOURCE OF TRUTH: every customisation lives as a real, version-controlled file +# under linux_configuration/dwm/ and is COPIED onto a fresh upstream clone: +# dwm/config.h -> the i3-like config (keys, colours, rules) +# dwm/pointer-confine.c -> XFixes cursor-lock helper for fullscreen gaming +# dwm/bin/* -> dwm-session, dwmstatus, dwm-rebuild, switch-wm, +# pconfine-auto +# dwm/patches/*.patch -> human-readable form of the two dwm.c changes that +# this script applies with perl (focus-on-click + +# fullscreen pointer-confine) +# +# Bleeding edge: upstream master is cloned and `git reset --hard`'d on every run; +# our files are copied/applied on top, so `git pull && rebuild` keeps working. +# Edit the files in dwm/ and re-run this script to apply a permanent change. +# ============================================================================ + +set -euo pipefail + +readonly SRC_DIR="${HOME}/.local/src/dwm" +readonly DWM_REPO="https://git.suckless.org/dwm" +readonly XSESSION="/usr/share/xsessions/dwm.desktop" +readonly BIN_SESSION="/usr/local/bin/dwm-session" +readonly BIN_STATUS="/usr/local/bin/dwmstatus" +readonly BIN_REBUILD="/usr/local/bin/dwm-rebuild" +readonly BIN_SWITCH="/usr/local/bin/switch-wm" +readonly BIN_CONFINE="/usr/local/bin/pointer-confine" +readonly BIN_CONFINE_AUTO="/usr/local/bin/pconfine-auto" + +# Repo dir holding our versioned dwm source (resolved from this script's path, +# so it works regardless of the caller's CWD): features/ -> ... -> dwm/. +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_DWM_DIR="$(cd -- "${SCRIPT_DIR}/../../.." && pwd)/dwm" +readonly REPO_DWM_DIR + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!! \033[0m %s\n' "$*" >&2; } + +# --------------------------------------------------------------------------- +# 0. Confirm the versioned dwm source files are present in the repo before we +# touch anything. Fail fast and clearly if the checkout is incomplete. +# --------------------------------------------------------------------------- +validate_repo_files() { + local f missing=() + local need=( + "config.h" + "pointer-confine.c" + "bin/dwm-session" + "bin/dwmstatus" + "bin/dwm-rebuild" + "bin/switch-wm" + "bin/pconfine-auto" + ) + for f in "${need[@]}"; do + [[ -f "${REPO_DWM_DIR}/${f}" ]] || missing+=("${REPO_DWM_DIR}/${f}") + done + if ((${#missing[@]})); then + warn "Missing required dwm source files (is the repo checkout complete?):" + printf ' %s\n' "${missing[@]}" >&2 + exit 1 + fi + log "dwm source files present in repo: $REPO_DWM_DIR" +} + +# --------------------------------------------------------------------------- +# 1. Dependencies — DETECT ONLY, never auto-install. +# This system's `pacman` is a digital-wellbeing wrapper that deadlocks when +# driven non-interactively (stdin /dev/null + the /etc/hosts guard hooks +# re-enter pacman and futex-deadlock on db.lck). So we never run `pacman -S` +# from here. We only do a single read-only `pacman -Qq` (no db lock, no +# transaction hooks) to check what's present, and tell the user to install +# anything missing themselves, interactively, the way the wrapper expects. +# --------------------------------------------------------------------------- +install_deps() { + log "Checking dependencies (read-only; the pacman wrapper is NOT invoked for installs)…" + local required=(libx11 libxft libxinerama gcc make dmenu terminator) + local optional=(xorg-xsetroot) # status bar only — dwm runs fine without it + local installed missing_req=() missing_opt=() p + installed="$(pacman -Qq 2>/dev/null)" || installed="" + + for p in "${required[@]}"; do + grep -qxF "$p" <<<"$installed" || missing_req+=("$p") + done + for p in "${optional[@]}"; do + grep -qxF "$p" <<<"$installed" || missing_opt+=("$p") + done + + if ((${#missing_req[@]})); then + warn "Missing REQUIRED packages: ${missing_req[*]}" + warn "Install them yourself (interactively), then re-run this script:" + warn " sudo pacman -S ${missing_req[*]}" + exit 1 + fi + if ((${#missing_opt[@]})); then + warn "Optional status-bar package missing: ${missing_opt[*]} — dwm will still run." + warn "For the status bar, install it later in your terminal:" + warn " sudo pacman -S ${missing_opt[*]}" + fi + log "All required dependencies present." +} + +# --------------------------------------------------------------------------- +# 2. Fetch the LATEST dwm source (bleeding edge — always upstream master HEAD) +# into a persistent, user-owned location so it can be re-edited and +# recompiled at will. On re-run we hard-reset to origin/master to pull in +# upstream changes; our config.h is untracked, so the reset never touches it. +# --------------------------------------------------------------------------- +fetch_dwm() { + if [[ -d "$SRC_DIR/.git" ]]; then + log "Updating dwm to the latest upstream master (bleeding edge)…" + git -C "$SRC_DIR" fetch --quiet origin + # config.h is untracked, so a hard reset of tracked files preserves it. + git -C "$SRC_DIR" reset --hard --quiet origin/master + else + log "Cloning the latest dwm master into $SRC_DIR…" + mkdir -p "$(dirname "$SRC_DIR")" + git clone --quiet "$DWM_REPO" "$SRC_DIR" + fi + log "dwm source now at commit $(git -C "$SRC_DIR" rev-parse --short HEAD) ($(git -C "$SRC_DIR" log -1 --format=%cd --date=short))" +} + +# --------------------------------------------------------------------------- +# 3. Install our versioned config.h onto the fresh upstream clone. dwm.c stays +# pristine here — movestack()/togglefullscr() are defined inside config.h, so +# only the two intentional behaviour changes below patch dwm.c. +# --------------------------------------------------------------------------- +install_config() { + log "Installing config.h from the repo (${REPO_DWM_DIR}/config.h)…" + cp -- "${REPO_DWM_DIR}/config.h" "$SRC_DIR/config.h" +} + +# --------------------------------------------------------------------------- +# 3b. Auto-merge bleeding-edge config churn. When upstream adds a new config +# knob (e.g. `refreshrate`), dwm.c starts referencing a symbol our +# hand-written config.h doesn't define, breaking the build. To keep +# "always latest master" sustainable, copy across any scalar knob that the +# current dwm.c needs but our config.h lacks, using upstream's default. +# Only single-line scalars referenced by dwm.c are merged (arrays we own +# and unused symbols are left alone), so our customisations always win. +# --------------------------------------------------------------------------- +heal_config() { + local defh="$SRC_DIR/config.def.h" cfgh="$SRC_DIR/config.h" dwmc="$SRC_DIR/dwm.c" + [[ -f "$defh" && -f "$dwmc" ]] || return 0 + local line name added=0 + while IFS= read -r line; do + name="$(sed -nE 's/^.*[^A-Za-z0-9_]([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=.*/\1/p' <<<"$line")" + [[ -n "$name" ]] || continue + grep -qw "$name" "$cfgh" && continue # we already define it — keep ours + grep -qw "$name" "$dwmc" || continue # dwm.c doesn't need it — skip + printf '%s\n' "$line" >>"$cfgh" + warn "config.h: auto-merged new upstream knob '$name' (bleeding-edge churn)" + added=1 + done < <(grep -E '^[[:space:]]*static[[:space:]]+const[[:space:]]+[^={]*=[^;{]*;' "$defh") + ((added)) && log "Merged upstream config symbol(s) into config.h for this build." + return 0 +} + +# --------------------------------------------------------------------------- +# 3c. Focus-on-click: stop the pointer from changing focus / switching monitors. +# dwm defaults to focus-follows-mouse — and worse, crossing the screen +# boundary over EMPTY space (motionnotify) switches the active monitor, which +# yanks focus away from a fullscreen game on the other screen (no window edge +# to stop the pointer). We rewrite enternotify + motionnotify to no-ops so +# focus only changes on a CLICK or via the Mod+,/. (Mod+Ctrl+arrows) keys. +# Applied as an idempotent source rewrite after each reset so it survives the +# bleeding-edge `git pull`. perl -0777 slurps the file so the multi-line +# function bodies match at once (.*? stops at the first column-0 `}`, i.e. the +# function's own closing brace — nested if-block braces are tab-indented). +# The dwm/patches/focus-on-click.patch file is the human-readable equivalent. +# If upstream refactors these handlers the rewrite no-ops and we warn loudly +# rather than silently dropping the behaviour. +# --------------------------------------------------------------------------- +apply_focusonclick() { + local src="$SRC_DIR/dwm.c" + [[ -f "$src" ]] || return 0 + + perl -0777 -i -pe ' + s!\nenternotify\(XEvent \*e\)\n\{.*?\n\}\n!\nenternotify(XEvent *e)\n{\n\t/* focusonclick: pointer never changes focus; use a click or Mod+keys. */\n\t(void)e;\n}\n!s; + s!\nmotionnotify\(XEvent \*e\)\n\{.*?\n\}\n!\nmotionnotify(XEvent *e)\n{\n\t/* focusonclick: keep the active monitor fixed when crossing screens. */\n\t(void)e;\n}\n!s; + ' "$src" + + # Verify both rewrites landed; warn (never abort) if upstream changed shape. + local ok=1 + grep -q 'focusonclick: pointer never changes focus' "$src" || ok=0 + grep -q 'focusonclick: keep the active monitor fixed' "$src" || ok=0 + if ((ok)); then + log "Applied focus-on-click (pointer no longer changes focus or switches monitors)." + else + warn "focus-on-click rewrite did NOT match upstream dwm.c — pointer focus unchanged." + warn "enternotify/motionnotify were likely refactored upstream; update dwm/patches." + fi + return 0 +} + +# --------------------------------------------------------------------------- +# 3d. Auto pointer-confinement on fullscreen. dwm has no pointer barriers, so on +# a dual-monitor setup the cursor slides off a fullscreen game onto the other +# screen (there is no window edge to stop it). We hook setfullscreen() to +# start the `pointer-confine` helper (XFixes barriers) when a window goes +# fullscreen and stop it when fullscreen ends; unmanage() also stops it so a +# game that closes WHILE fullscreen can never leave the cursor trapped. The +# hook is a quick `if (system(...)) {}` — the `if` consumes system()'s result +# so -Wall stays warning-free; the trailing `&` returns to dwm immediately. +# Reapplied after each reset (idempotent via git) and self-verifying; the +# dwm/patches/fullscreen-pointer-confine.patch file mirrors it for reading. +# --------------------------------------------------------------------------- +apply_fullscreen_confine_hook() { + local src="$SRC_DIR/dwm.c" + [[ -f "$src" ]] || return 0 + + perl -0777 -i -pe ' + s!(\n\t\tc->isfullscreen = 1;\n)!$1\t\tif (system("pconfine-auto on &")) {}\n!; + s!(\n\t\tc->isfullscreen = 0;\n)!$1\t\tif (system("pconfine-auto off &")) {}\n!; + s!(\nunmanage\(Client \*c, int destroyed\)\n\{\n\tMonitor \*m = c->mon;\n\tXWindowChanges wc;\n)!$1\tif (c->isfullscreen) { if (system("pconfine-auto off &")) {} }\n!; + ' "$src" + + # Expect: 1 "on", 2 "off" (setfullscreen-leave + unmanage). Warn if not. + local on off + on=$(grep -c 'pconfine-auto on' "$src") + off=$(grep -c 'pconfine-auto off' "$src") + if [[ "$on" == 1 && "$off" == 2 ]]; then + log "Applied auto pointer-confinement hook (locks the cursor to a fullscreen window's screen)." + else + warn "pointer-confine hook only partially applied (on=$on off=$off, expected 1/2)." + warn "setfullscreen/unmanage were likely refactored upstream; update dwm/patches." + fi + return 0 +} + +# --------------------------------------------------------------------------- +# 4. Build & install (PREFIX defaults to /usr/local). +# --------------------------------------------------------------------------- +build_install() { + log "Compiling dwm…" + make -C "$SRC_DIR" clean >/dev/null + make -C "$SRC_DIR" 2>&1 | tail -15 + log "Installing dwm (sudo make install)…" + sudo make -C "$SRC_DIR" install 2>&1 | tail -8 +} + +# --------------------------------------------------------------------------- +# 4b. Build & install the pointer-confine helper (XFixes barriers) from the +# versioned dwm/pointer-confine.c. Standalone C so it stays out of dwm.c; +# dwm only spawns it via the setfullscreen() hook. If the X dev headers are +# missing it fails soft: warn and skip, leaving the rest of dwm fully working +# (fullscreen just won't auto-lock the cursor). +# --------------------------------------------------------------------------- +build_pointer_confine() { + log "Compiling pointer-confine from the repo (${REPO_DWM_DIR}/pointer-confine.c)…" + local bin + bin="$(mktemp)" + if cc -std=c99 -pedantic -Wall -O2 "${REPO_DWM_DIR}/pointer-confine.c" -o "$bin" \ + -lX11 -lXfixes -lXinerama 2>/tmp/pointer-confine-build.log; then + sudo install -m 755 "$bin" "$BIN_CONFINE" + log "Installed $BIN_CONFINE." + else + warn "pointer-confine failed to compile — fullscreen cursor-lock disabled (dwm itself is fine):" + sed 's/^/ /' /tmp/pointer-confine-build.log >&2 || true + fi + rm -f "$bin" +} + +# --------------------------------------------------------------------------- +# 5. Install the helper scripts (from the repo) and register the lightdm +# xsession. The scripts are the versioned files in dwm/bin/; we just place +# them on PATH with the right mode. +# --------------------------------------------------------------------------- +write_session_files() { + log "Installing helper scripts from the repo and the lightdm xsession entry…" + sudo install -m 755 "${REPO_DWM_DIR}/bin/dwm-session" "$BIN_SESSION" + sudo install -m 755 "${REPO_DWM_DIR}/bin/dwmstatus" "$BIN_STATUS" + sudo install -m 755 "${REPO_DWM_DIR}/bin/dwm-rebuild" "$BIN_REBUILD" + sudo install -m 755 "${REPO_DWM_DIR}/bin/switch-wm" "$BIN_SWITCH" + sudo install -m 755 "${REPO_DWM_DIR}/bin/pconfine-auto" "$BIN_CONFINE_AUTO" + + # --- xsession entry for lightdm (absolute Exec path) -------------------- + sudo tee "$XSESSION" >/dev/null <<'DESKTOP_EOF' +[Desktop Entry] +Name=dwm (i3-like) +Comment=dynamic window manager, compiled from source +Exec=/usr/local/bin/dwm-session +TryExec=/usr/local/bin/dwm +Type=Application +DesktopNames=dwm +DESKTOP_EOF +} + +# --------------------------------------------------------------------------- +# 6. Verify the build links and the session is registered. +# --------------------------------------------------------------------------- +verify() { + log "Verifying install…" + local ver + ver="$(dwm -v 2>&1)" || true # dwm -v prints version then exit(1) + log "dwm version: ${ver:-}" + command -v dwm >/dev/null && log "dwm binary: $(command -v dwm)" + [[ -f "$XSESSION" ]] && log "xsession registered: $XSESSION" +} + +print_summary() { + cat < boot dwm switch-wm i3 -> boot i3 switch-wm -> show + then reboot. Recovery if dwm misbehaves: TTY (Ctrl+Alt+F3) -> 'switch-wm i3' -> reboot. + + Key bindings (Mod = Super): + Mod+Return terminator Mod+d dmenu + Mod+j / Mod+k focus next / prev Mod+Shift+j/k move in stack + Mod+h / Mod+l shrink / grow master Mod+i/Shift+i +/- master count + Mod+1..0 view tag 1..10 Mod+Shift+1..0 send to tag + Mod+f fullscreen Mod+Shift+space toggle floating + Mod+t / Mod+w tiling / monocle Mod+Shift+Return promote to master + Mod+Shift+q kill window Mod+Shift+e exit dwm + Mod+m mic mute Mod+Shift+r recompile (dwm-rebuild) + + Two monitors (no i3-style per-output workspaces — see config.h note): + Mod+, / Mod+. focus the other screen (or Mod+Ctrl+Left/Right) + Mod+Shift+, / Mod+Shift+. throw window there (or Mod+Ctrl+Shift+L/R) + Focus-on-click is ON: the pointer no longer steals focus or switches monitors + when it crosses screens. Focus changes on click/keys. + Fullscreen cursor-lock is ON: when a window goes fullscreen (games), the cursor + is trapped on that screen (XFixes barriers) and released when fullscreen ends. + Stuck barrier? Mod+Shift+p force-releases it. + + Status bar (clock, temps, load, RAM, volume) needs xsetroot: + sudo pacman -S xorg-xsetroot # then log out / back in + Preview it now without the bar: dwmstatus once + + Customise (permanent): edit files in linux_configuration/dwm/ then re-run this + script. Quick experiment: edit ~/.local/src/dwm/config.h + run 'dwm-rebuild' + (re-running setup_dwm.sh overwrites it from the repo). +SUMMARY +} + +main() { + validate_repo_files + install_deps + fetch_dwm + install_config + heal_config + apply_focusonclick + apply_fullscreen_confine_hook + build_install + build_pointer_confine + write_session_files + verify + print_summary +} + +main "$@" diff --git a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/_transcribe_diarize.py b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/_transcribe_diarize.py index ed9e596..c323e76 100644 --- a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/_transcribe_diarize.py +++ b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/_transcribe_diarize.py @@ -214,8 +214,7 @@ def _ffmpeg_transcode_to_wav16_mono( with contextlib.suppress(OSError): Path(tmp_path).unlink() return None - else: - return tmp_path + return tmp_path def _cleanup_temp(path: str | None) -> None: @@ -263,10 +262,8 @@ def _load_audio( ) _cleanup_temp(alt) return None - else: - return wav, sr, alt - else: - return wav, sr, None + return wav, sr, alt + return wav, sr, None def _load_speaker_classifier( @@ -290,8 +287,7 @@ def _load_speaker_classifier( ) _cleanup_temp(temp_to_cleanup) return None - else: - return classifier + return classifier def _extract_embeddings( diff --git a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_fw.py b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_fw.py index 6305c6c..4469068 100755 --- a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_fw.py +++ b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_fw.py @@ -210,6 +210,30 @@ def _write_diarized_outputs( ) +def _load_whisper_model( + fw: types.ModuleType, + args: argparse.Namespace, + device: str, + compute_type: str, +) -> object: + """Resolve the model path, configure logging, and load the model.""" + model_path: str = args.model + if not Path(args.model).is_dir(): + model_path = download_model_with_progress(args.model) + + ct2_logger = logging.getLogger("faster_whisper") + ct2_logger.setLevel(logging.INFO) + + logger.info("Initializing model...") + model = fw.WhisperModel( + model_path, + device=device, + compute_type=compute_type, + ) + logger.info("Model loaded successfully.") + return model + + def main() -> int: """Run the main transcription pipeline.""" logging.basicConfig( @@ -247,20 +271,7 @@ def main() -> int: compute_type, ) - model_path: str = args.model - if not Path(args.model).is_dir(): - model_path = download_model_with_progress(args.model) - - ct2_logger = logging.getLogger("faster_whisper") - ct2_logger.setLevel(logging.INFO) - - logger.info("Initializing model...") - model = fw.WhisperModel( - model_path, - device=device, - compute_type=compute_type, - ) - logger.info("Model loaded successfully.") + model = _load_whisper_model(fw, args, device, compute_type) total_duration = get_media_duration(inp) if total_duration: diff --git a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_helpers.py b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_helpers.py index 0fc4c5c..47fdd5f 100755 --- a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_helpers.py +++ b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_helpers.py @@ -109,8 +109,7 @@ def generate_sine_wav( except OSError: logger.exception("Failed to generate WAV") return False - else: - return True + return True def prepare_model(model_name: str, model_dir: str) -> bool: @@ -151,8 +150,7 @@ def prepare_model(model_name: str, model_dir: str) -> bool: except (OSError, RuntimeError): logger.exception("Failed to prepare model") return False - else: - return True + return True def test_cuda() -> bool: @@ -172,8 +170,7 @@ def test_cuda() -> bool: except (OSError, RuntimeError): logger.exception("CUDA test failed") return False - else: - return True + return True def _handle_python_version() -> None: diff --git a/linux_configuration/scripts/single_use/utils/turn_off_auto_idle_screen_shutdown.sh b/linux_configuration/scripts/single_use/utils/turn_off_auto_idle_screen_shutdown.sh index 9d4b93d..7d8bcc8 100755 --- a/linux_configuration/scripts/single_use/utils/turn_off_auto_idle_screen_shutdown.sh +++ b/linux_configuration/scripts/single_use/utils/turn_off_auto_idle_screen_shutdown.sh @@ -12,7 +12,7 @@ # Optional persistence (requires sudo): # --persist-systemd -> Set IdleAction=ignore in /etc/systemd/logind.conf and restart logind # Optional activity watcher: -# --watch-controller -> Hold a systemd idle/sleep inhibitor while a game controller is connected (keeps the session awake, fork-free) +# --watch-controller -> Hold a systemd idle inhibitor while a game controller is connected (keeps the session awake, fork-free; does NOT block deliberate suspend/hibernate) # # Notes: # - This script focuses on keeping the screen on and unlocked. Use with care on shared systems. @@ -42,7 +42,7 @@ Disables idle detection, screen blanking, and auto-lock for the current session. Options: --persist-systemd Also set IdleAction=ignore in /etc/systemd/logind.conf (needs sudo) - --watch-controller Hold an idle/sleep inhibitor while a game controller is connected + --watch-controller Hold an idle inhibitor while a game controller is connected -h, --help Show this help and exit What this does: @@ -136,24 +136,30 @@ disable_tty_idle() { fi } -# PID of the single long-lived idle/sleep inhibitor we hold while a controller +# PID of the single long-lived idle inhibitor we hold while a controller # is connected. Empty when no inhibitor is active. inhibit_pid="" start_idle_inhibit() { - # Hold one systemd idle/sleep inhibitor for the whole time a controller is + # Hold one systemd idle inhibitor for the whole time a controller is # connected. This replaces the previous per-event fork storm (4 xset + an # xdotool + a dd read + a sleep on *every* joystick event, ~21 forks/s while - # gaming): a single long-lived process keeps logind from idling, suspending, - # or locking, while X11 blanking stays off thanks to the one-shot - # disable_x11_idle above. Idempotent — a live inhibitor is reused. + # gaming): a single long-lived process keeps logind from treating the session + # as idle (so it won't auto-suspend or lock), while X11 blanking stays off + # thanks to the one-shot disable_x11_idle above. Idempotent — a live inhibitor + # is reused. if [[ -n $inhibit_pid ]] && kill -0 "$inhibit_pid" 2> /dev/null; then return 0 fi - systemd-inhibit --what=idle:sleep --who="idle-off" \ + # NOTE: --what=idle only (NOT idle:sleep). An idle inhibitor already stops + # logind's idle-triggered auto-suspend/lock — which is all gaming needs — but + # a *sleep* inhibitor would also block *deliberate* suspend/hibernate, e.g. + # the scheduled digital-wellbeing day-specific-shutdown hibernate. Blocking + # sleep here once silently kept the PC running past every shutdown window. + systemd-inhibit --what=idle --who="idle-off" \ --why="game controller connected" sleep infinity & inhibit_pid=$! - log "Holding idle/sleep inhibitor (pid ${inhibit_pid}) while a controller is connected" + log "Holding idle inhibitor (pid ${inhibit_pid}) while a controller is connected" } stop_idle_inhibit() { @@ -163,7 +169,7 @@ stop_idle_inhibit() { kill "$inhibit_pid" 2> /dev/null || true wait "$inhibit_pid" 2> /dev/null || true inhibit_pid="" - log "Released idle/sleep inhibitor; normal idle behaviour resumes" + log "Released idle inhibitor; normal idle behaviour resumes" } controller_connected() { diff --git a/linux_configuration/scripts/single_use/utils/volume_control.sh b/linux_configuration/scripts/single_use/utils/volume_control.sh new file mode 100755 index 0000000..c933bfa --- /dev/null +++ b/linux_configuration/scripts/single_use/utils/volume_control.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# ============================================================================ +# volume_control.sh — adjust PulseAudio volume / mute via pactl +# ============================================================================ +# Single entry point for media-key bindings in i3 and dwm. Shows a transient +# desktop notification (best-effort). Replaces the long-missing ~/volume_control.sh +# that both window-manager configs referenced. +# +# Usage: volume_control.sh {up|down|mute|micmute} +# up raise output volume by VOLUME_STEP% (default 5) and unmute +# down lower output volume by VOLUME_STEP% and unmute +# mute toggle output (sink) mute +# micmute toggle input (source) mute +# ============================================================================ + +set -euo pipefail + +readonly SINK="@DEFAULT_SINK@" +readonly SOURCE="@DEFAULT_SOURCE@" +readonly STEP="${VOLUME_STEP:-5}" + +# Best-effort notification; never fail the binding if no daemon is running. +notify() { + command -v notify-send >/dev/null 2>&1 || return 0 + # Collapse repeated volume popups into one via a synchronous hint. + notify-send -t 1200 -h string:x-canonical-private-synchronous:volume \ + "$1" "$2" 2>/dev/null || true +} + +# First percentage pactl reports for the default sink (e.g. "45%"). +current_sink_volume() { + pactl get-sink-volume "$SINK" 2>/dev/null | grep -oE '[0-9]+%' | head -1 || true +} + +case "${1:-}" in + up) + pactl set-sink-mute "$SINK" 0 + pactl set-sink-volume "$SINK" "+${STEP}%" + notify "Volume" "$(current_sink_volume)" + ;; + down) + pactl set-sink-mute "$SINK" 0 + pactl set-sink-volume "$SINK" "-${STEP}%" + notify "Volume" "$(current_sink_volume)" + ;; + mute) + pactl set-sink-mute "$SINK" toggle + if pactl get-sink-mute "$SINK" 2>/dev/null | grep -q yes; then + notify "Volume" "Muted" + else + notify "Volume" "$(current_sink_volume)" + fi + ;; + micmute) + pactl set-source-mute "$SOURCE" toggle + if pactl get-source-mute "$SOURCE" 2>/dev/null | grep -q yes; then + notify "Microphone" "Muted" + else + notify "Microphone" "On" + fi + ;; + *) + echo "Usage: $(basename "$0") {up|down|mute|micmute}" >&2 + exit 1 + ;; +esac diff --git a/linux_configuration/tests/test_usage_report_pmon_names.py b/linux_configuration/tests/test_usage_report_pmon_names.py index eb22bcc..c6feb44 100644 --- a/linux_configuration/tests/test_usage_report_pmon_names.py +++ b/linux_configuration/tests/test_usage_report_pmon_names.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -import _usage_report_parsing as parsing +import _usage_report_pmon as pmon if TYPE_CHECKING: import pytest @@ -14,14 +14,14 @@ def test_normalize_pmon_command_prefers_first_executable_token() -> None: """The parser should keep executable-like token, not trailing args.""" tokens = ["code-insiders", "--type=", "gpu-process", "Not"] - assert parsing._normalize_pmon_command(tokens) == "code-insiders" + assert pmon._normalize_pmon_command(tokens) == "code-insiders" def test_normalize_pmon_command_skips_leading_option_tokens() -> None: """If the first token is an option, use the next non-option token.""" tokens = ["--type=", "code-insiders", "--flag"] - assert parsing._normalize_pmon_command(tokens) == "code-insiders" + assert pmon._normalize_pmon_command(tokens) == "code-insiders" def test_ingest_pmon_row_uses_command_field_start_not_last_token() -> None: @@ -44,7 +44,7 @@ def test_ingest_pmon_row_uses_command_field_start_not_last_token() -> None: ] agg: dict[str, object] = {} - consumed = parsing._ingest_pmon_row(row, agg) + consumed = pmon._ingest_pmon_row(row, agg) assert consumed == 1 assert "code-insiders" in agg @@ -69,8 +69,8 @@ def test_ingest_pmon_row_falls_back_to_proc_comm_on_unknown( ] agg: dict[str, object] = {} - monkeypatch.setattr(parsing, "_pid_comm_name", lambda _pid: "python") - consumed = parsing._ingest_pmon_row(row, agg) + monkeypatch.setattr(pmon, "_pid_comm_name", lambda _pid: "python") + consumed = pmon._ingest_pmon_row(row, agg) assert consumed == 1 assert "python" in agg diff --git a/linux_configuration/tests/test_usage_report_since.py b/linux_configuration/tests/test_usage_report_since.py index 8246402..dbb7c14 100644 --- a/linux_configuration/tests/test_usage_report_since.py +++ b/linux_configuration/tests/test_usage_report_since.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import TYPE_CHECKING import _usage_report_parsing as parsing +import _usage_report_pmon as pmon from _usage_report_types import GpuAgg, ProcAgg, _PidCpu, _Progress, _Window import usage_report @@ -177,13 +178,13 @@ def test_pmon_row_epoch_parses_valid_row() -> None: """A well-formed pmon row yields the matching local epoch.""" row = ["20260604", "10:30:00", "0", "100", "G", "5", "1"] - assert parsing._pmon_row_epoch(row) == _at(2026, 6, 4, 10, 30).timestamp() + assert pmon._pmon_row_epoch(row) == _at(2026, 6, 4, 10, 30).timestamp() def test_pmon_row_epoch_returns_none_on_bad_input() -> None: """Malformed or short rows return None rather than raising.""" - assert parsing._pmon_row_epoch([]) is None - assert parsing._pmon_row_epoch(["nope", "alsonope"]) is None + assert pmon._pmon_row_epoch([]) is None + assert pmon._pmon_row_epoch(["nope", "alsonope"]) is None def _write_pmon(path: Path) -> None: @@ -201,7 +202,7 @@ def test_aggregate_pmon_without_bound_keeps_all_rows(tmp_path: Path) -> None: log = tmp_path / "pmon.log" _write_pmon(log) - _, samples = parsing.aggregate_pmon(log, _Progress(enabled=False, total_stages=1)) + _, samples = pmon.aggregate_pmon(log, _Progress(enabled=False, total_stages=1)) assert samples == 2 @@ -212,7 +213,7 @@ def test_aggregate_pmon_filters_rows_before_begin(tmp_path: Path) -> None: _write_pmon(log) cutoff = _at(2026, 6, 4, 10, 30).timestamp() - agg, samples = parsing.aggregate_pmon( + agg, samples = pmon.aggregate_pmon( log, _Progress(enabled=False, total_stages=1), begin_epoch=cutoff, diff --git a/meta/lint_python.sh b/meta/lint_python.sh index 7de8f50..0ff30c0 100755 --- a/meta/lint_python.sh +++ b/meta/lint_python.sh @@ -224,7 +224,7 @@ fi # PYLINT - Comprehensive linting # ============================================================================== if check_tool pylint; then - run_tool "pylint" "pylint --rcfile=pyproject.toml --jobs=0 --fail-under=0 ${TARGET_FILES}" || OVERALL_STATUS=1 + run_tool "pylint" "pylint --rcfile=pyproject.toml --jobs=0 --fail-under=10 ${TARGET_FILES}" || OVERALL_STATUS=1 fi # ============================================================================== diff --git a/meta/pyproject.toml b/meta/pyproject.toml index 0951173..373004a 100644 --- a/meta/pyproject.toml +++ b/meta/pyproject.toml @@ -145,7 +145,7 @@ ignore_errors = true # bare name (the same dirs linux_configuration/tests/conftest.py adds to # sys.path at runtime) resolve under static analysis instead of raising E0401. # Paths are relative to the repo root, which is pre-commit's working directory. -init-hook = "import sys; sys.path[:0] = ['meta/scripts', 'phone_focus_mode', 'phone_focus_mode/lib', 'linux_configuration/scripts/single_use/utils', 'linux_configuration/scripts/periodic_background/system-maintenance/bin']" +init-hook = "import sys; sys.path[:0] = ['meta/scripts', 'python_pkg', 'phone_focus_mode', 'phone_focus_mode/lib', 'linux_configuration/scripts/single_use/utils', 'linux_configuration/scripts/periodic_background/system-maintenance/bin', 'linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools']" # Analyse import fallback blocks analyse-fallback-blocks = true # Pickle collected data for later comparisons @@ -154,8 +154,11 @@ persistent = true jobs = 0 # Minimum Python version py-version = "3.10" -# Ignore vendored directories -ignore = ["Bash", ".venv", "__pycache__"] +# Ignore vendored directories. "tests" and "conftest.py" are basename +# matches: test suites and pytest fixtures intentionally use patterns +# (protected-access, missing docstrings, fixture-arg shadowing) that don't +# apply to source code, so they are linted separately (not by this hook). +ignore = ["Bash", ".venv", "__pycache__", "tests", "conftest.py"] # Ignore patterns ignore-patterns = [".*\\.pyi$"] # Allow C extension modules to be introspected @@ -164,8 +167,22 @@ extension-pkg-allow-list = ["cv2", "pygame", "lxml"] [tool.pylint.messages_control] # Enable all checks by disabling disable enable = "all" -# No disabled checks - maximum strictness -disable = [] +# 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] # Mixins and single-entry-point classes may have zero public methods @@ -182,6 +199,9 @@ spelling-dict = "" [tool.pylint.typecheck] # cv2 (OpenCV) dynamically loads members from C extension at runtime. # 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 = [ "cv2.*", ".*\\.assert_called_once_with", @@ -192,6 +212,11 @@ generated-members = [ ".*\\.call_args", ".*\\.call_args_list", ".*\\.call_count", + ".*\\.setnchannels", + ".*\\.setsampwidth", + ".*\\.setframerate", + ".*\\.writeframes", + ".*\\.writeframesraw", ] # ============================================================================ diff --git a/meta/scripts/_schema_validation.py b/meta/scripts/_schema_validation.py new file mode 100755 index 0000000..297739a --- /dev/null +++ b/meta/scripts/_schema_validation.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Shared JSON-schema validation helpers for validate_contract/validate_evidence. + +Both CLI scripts validate a JSON artifact against a small required-field schema +and report problems via the same read/parse/dispatch shell. Factored out here so +the duplicated logic is defined once (see pylint duplicate-code). +""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + +def is_nonempty_str(value: object) -> bool: + """Return True if ``value`` is a string with non-whitespace content.""" + return isinstance(value, str) and bool(value.strip()) + + +def load_and_check_required( + path: Path, + check_required: Callable[[dict[str, object]], list[str]], +) -> tuple[dict[str, object] | None, str, list[str]]: + """Load ``path`` as JSON and verify its required top-level keys are present. + + Returns ``(data, text, [])`` once the file is valid JSON, is an object, and + ``check_required(data)`` reports no problems. Otherwise returns + ``(None, "", errors)`` with the relevant problem(s). ``text`` is the raw file + contents (useful for whole-text checks). + """ + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + return None, "", [f"cannot read file ({exc})"] + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + return None, "", [f"invalid JSON ({exc})"] + if not isinstance(data, dict): + return None, "", ["top-level JSON value must be an object"] + + errors = check_required(data) + if errors: # without the required keys present, the per-field checks are noise + return None, "", errors + return data, text, [] + + +def check_string_lists( + data: dict[str, object], + keys: Sequence[str], + item_noun: str, +) -> list[str]: + """Each field in ``keys`` must be a non-empty list of non-empty strings. + + ``item_noun`` (e.g. "items" or "entries") customizes the per-element message. + """ + errors: list[str] = [] + for key in keys: + value = data.get(key) + if not isinstance(value, list) or not value: + errors.append(f"{key} must be a non-empty list") + continue + if any(not is_nonempty_str(item) for item in value): + errors.append(f"{key} {item_noun} must be non-empty strings") + return errors + + +def run_cli( + argv: Sequence[str], + *, + usage: str, + validate: Callable[[Path], list[str]], + success_message: str, +) -> int: + """Validate the path named by ``argv[0]`` and report via stdout/stderr. + + Returns 2 if ``argv`` is empty (usage error), 1 if validation found + problems, or 0 if the artifact is valid. + """ + if not argv: + sys.stderr.write(f"{usage}\n") + return 2 + path = Path(argv[0]) + errors = validate(path) + if errors: + for error in errors: + sys.stderr.write(f"{path}: {error}\n") + return 1 + sys.stdout.write(f"{path}: {success_message}\n") + return 0 diff --git a/meta/scripts/optimize_vscode.py b/meta/scripts/optimize_vscode.py index 12ae154..80969d1 100755 --- a/meta/scripts/optimize_vscode.py +++ b/meta/scripts/optimize_vscode.py @@ -128,7 +128,7 @@ def _detect_cpu(hw: _Hw) -> None: def _detect_ram(hw: _Hw) -> None: try: - meminfo = Path("/proc/meminfo").read_text() + meminfo = Path("/proc/meminfo").read_text(encoding="utf-8") except OSError: return m = re.search(r"MemTotal:\s+(\d+)\s+kB", meminfo) @@ -167,7 +167,7 @@ def _detect_disk(hw: _Hw) -> None: rotational = Path(f"/sys/block/{base}/queue/rotational") if not rotational.exists(): return - if rotational.read_text().strip() == "1": + if rotational.read_text(encoding="utf-8").strip() == "1": hw.disk_type = "hdd" elif "nvme" in base: hw.disk_type = "nvme" diff --git a/meta/scripts/validate_contract.py b/meta/scripts/validate_contract.py index fab458d..2ad06ef 100755 --- a/meta/scripts/validate_contract.py +++ b/meta/scripts/validate_contract.py @@ -11,9 +11,18 @@ repository's Python tooling applies; see CLAUDE.md "Shell Style". from __future__ import annotations -import json -from pathlib import Path import sys +from typing import TYPE_CHECKING + +from _schema_validation import ( + check_string_lists, + is_nonempty_str, + load_and_check_required, + run_cli, +) + +if TYPE_CHECKING: + from pathlib import Path # Top-level keys every contract must define. _REQUIRED_KEYS = ( @@ -29,11 +38,6 @@ _STRING_KEYS = ("title", "objective", "verifier") _STRING_LIST_KEYS = ("acceptance_criteria", "out_of_scope") -def _is_nonempty_str(value: object) -> bool: - """Return True if ``value`` is a string with non-whitespace content.""" - return isinstance(value, str) and bool(value.strip()) - - def _check_required_keys(data: dict[str, object]) -> list[str]: """Report any required top-level keys that are absent.""" missing = [key for key in _REQUIRED_KEYS if key not in data] @@ -47,58 +51,28 @@ def _check_strings(data: dict[str, object]) -> list[str]: return [ f"{key} must be non-empty string" for key in _STRING_KEYS - if not _is_nonempty_str(data.get(key)) + if not is_nonempty_str(data.get(key)) ] -def _check_string_lists(data: dict[str, object]) -> list[str]: - """Each list field must be a non-empty list of non-empty strings.""" - errors: list[str] = [] - for key in _STRING_LIST_KEYS: - value = data.get(key) - if not isinstance(value, list) or not value: - errors.append(f"{key} must be a non-empty list") - continue - if any(not _is_nonempty_str(item) for item in value): - errors.append(f"{key} items must be non-empty strings") - return errors - - def validate(path: Path) -> list[str]: """Return a list of schema problems for ``path`` (empty when it is valid).""" - try: - text = path.read_text(encoding="utf-8") - except OSError as exc: - return [f"cannot read file ({exc})"] - try: - data = json.loads(text) - except json.JSONDecodeError as exc: - return [f"invalid JSON ({exc})"] - if not isinstance(data, dict): - return ["top-level JSON value must be an object"] - - errors = _check_required_keys(data) - if errors: # without the keys present, the per-field checks are noise + data, _text, errors = load_and_check_required(path, _check_required_keys) + if data is None: return errors errors += _check_strings(data) - errors += _check_string_lists(data) + errors += check_string_lists(data, _STRING_LIST_KEYS, "items") return errors def main() -> int: """Validate the contract named by ``argv[1]``; return a process exit code.""" - args = sys.argv[1:] - if not args: - sys.stderr.write("usage: validate_contract.py \n") - return 2 - path = Path(args[0]) - errors = validate(path) - if errors: - for error in errors: - sys.stderr.write(f"{path}: {error}\n") - return 1 - sys.stdout.write(f"{path}: contract schema OK\n") - return 0 + return run_cli( + sys.argv[1:], + usage="usage: validate_contract.py ", + validate=validate, + success_message="contract schema OK", + ) if __name__ == "__main__": diff --git a/meta/scripts/validate_evidence.py b/meta/scripts/validate_evidence.py index 2735518..7b8682c 100755 --- a/meta/scripts/validate_evidence.py +++ b/meta/scripts/validate_evidence.py @@ -11,9 +11,18 @@ repository's Python tooling applies; see CLAUDE.md "Shell Style". from __future__ import annotations -import json -from pathlib import Path import sys +from typing import TYPE_CHECKING + +from _schema_validation import ( + check_string_lists, + is_nonempty_str, + load_and_check_required, + run_cli, +) + +if TYPE_CHECKING: + from pathlib import Path # Top-level keys every evidence artifact must define. _REQUIRED_KEYS = ("intent", "scope", "changes", "verification", "risks", "rollback") @@ -25,11 +34,6 @@ _VERIFICATION_FIELDS = ("command", "result", "evidence") _BANNED_PHRASES = ("should work", "probably fine", "seems right") -def _is_nonempty_str(value: object) -> bool: - """Return True if ``value`` is a string with non-whitespace content.""" - return isinstance(value, str) and bool(value.strip()) - - def _check_required_keys(data: dict[str, object]) -> list[str]: """Report any required top-level keys that are absent.""" missing = [key for key in _REQUIRED_KEYS if key not in data] @@ -40,24 +44,11 @@ def _check_required_keys(data: dict[str, object]) -> list[str]: def _check_intent(data: dict[str, object]) -> list[str]: """The ``intent`` field must be a non-empty string.""" - if not _is_nonempty_str(data.get("intent")): + if not is_nonempty_str(data.get("intent")): return ["intent must be a non-empty string"] return [] -def _check_string_lists(data: dict[str, object]) -> list[str]: - """Each string-list field must be a non-empty list of non-empty strings.""" - errors: list[str] = [] - for key in _STRING_LIST_KEYS: - value = data.get(key) - if not isinstance(value, list) or not value: - errors.append(f"{key} must be a non-empty list") - continue - if any(not _is_nonempty_str(item) for item in value): - errors.append(f"{key} entries must be non-empty strings") - return errors - - def _check_verification(data: dict[str, object]) -> list[str]: """``verification`` must be a non-empty list of fully-populated objects.""" verification = data.get("verification") @@ -74,7 +65,7 @@ def _check_verification(data: dict[str, object]) -> list[str]: bad = [ field for field in _VERIFICATION_FIELDS - if field in item and not _is_nonempty_str(item[field]) + if field in item and not is_nonempty_str(item[field]) ] errors.extend( f"verification[{index}].{field} must be a non-empty string" for field in bad @@ -94,22 +85,11 @@ def _check_phrases(text: str) -> list[str]: def validate(path: Path) -> list[str]: """Return a list of schema problems for ``path`` (empty when it is valid).""" - try: - text = path.read_text(encoding="utf-8") - except OSError as exc: - return [f"cannot read file ({exc})"] - try: - data = json.loads(text) - except json.JSONDecodeError as exc: - return [f"invalid JSON ({exc})"] - if not isinstance(data, dict): - return ["top-level JSON value must be an object"] - - errors = _check_required_keys(data) - if errors: # without the keys present, the per-field checks are noise + data, text, errors = load_and_check_required(path, _check_required_keys) + if data is None: return errors errors += _check_intent(data) - errors += _check_string_lists(data) + errors += check_string_lists(data, _STRING_LIST_KEYS, "entries") errors += _check_verification(data) errors += _check_phrases(text) return errors @@ -117,18 +97,12 @@ def validate(path: Path) -> list[str]: def main() -> int: """Validate the artifact named by ``argv[1]``; return a process exit code.""" - args = sys.argv[1:] - if not args: - sys.stderr.write("usage: validate_evidence.py \n") - return 2 - path = Path(args[0]) - errors = validate(path) - if errors: - for error in errors: - sys.stderr.write(f"{path}: {error}\n") - return 1 - sys.stdout.write(f"{path}: schema OK\n") - return 0 + return run_cli( + sys.argv[1:], + usage="usage: validate_evidence.py ", + validate=validate, + success_message="schema OK", + ) if __name__ == "__main__": diff --git a/python_pkg/brother_printer/_query.py b/python_pkg/brother_printer/_query.py new file mode 100644 index 0000000..fd3bdd5 --- /dev/null +++ b/python_pkg/brother_printer/_query.py @@ -0,0 +1,63 @@ +"""Shared subprocess and CUPS-query helpers for the brother_printer package. + +Centralises the short, non-checking command invocation and the ``lpstat``-based +USB-info parsing that the CUPS, USB, and status modules all repeat, so the +subprocess boilerplate and URI parsing live in exactly one place. +""" + +from __future__ import annotations + +import logging +import subprocess +import urllib.parse + +logger = logging.getLogger(__name__) + + +def run_command_text(args: list[str], *, timeout: float = 5) -> str: + """Run ``args`` and return its captured stdout, or "" on any failure. + + A non-checking run with captured text output and a short timeout. Any + timeout, subprocess, or OS error is swallowed and reported as empty output, + so callers can split/scan the result unconditionally. + + Args: + args: The command and its arguments. + timeout: Seconds before the command is killed. + + Returns: + The command's standard output, or an empty string on any failure. + """ + try: + result = subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): + logger.debug("Command failed: %s", args, exc_info=True) + return "" + return result.stdout + + +def parse_cups_usb_uri(uri: str, info: dict[str, str]) -> None: + """Extract the product and serial from a CUPS ``usb://`` URI into ``info``.""" + parsed = urllib.parse.urlparse(uri) + info["product"] = urllib.parse.unquote(parsed.path.lstrip("/")) + query = urllib.parse.parse_qs(parsed.query) + if "serial" in query: + info["serial"] = query["serial"][0] + + +def printer_info_from_cups() -> dict[str, str]: + """Return the Brother printer's model/serial as parsed from ``lpstat -v``.""" + info: dict[str, str] = {"product": "", "serial": ""} + for line in run_command_text(["/usr/bin/lpstat", "-v"]).splitlines(): + if "Brother" in line: + for part in line.split(): + if part.startswith("usb://"): + parse_cups_usb_uri(part, info) + break + return info diff --git a/python_pkg/brother_printer/check_brother_printer.py b/python_pkg/brother_printer/check_brother_printer.py index ae67a27..332ea7e 100644 --- a/python_pkg/brother_printer/check_brother_printer.py +++ b/python_pkg/brother_printer/check_brother_printer.py @@ -31,9 +31,9 @@ import logging import os import re import shutil -import subprocess import sys +from python_pkg.brother_printer._query import run_command_text from python_pkg.brother_printer.constants import CYAN, RED, RESET, _out from python_pkg.brother_printer.cups_service import reset_consumable from python_pkg.brother_printer.display import ( @@ -54,22 +54,12 @@ def _discover_network_printer() -> str: lpstat_path = shutil.which("lpstat") if not lpstat_path: return "" - try: - r = subprocess.run( - [lpstat_path, "-v"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - match = re.search( - r"(?:ipp|socket|lpd|http)://" r"(\d+\.\d+\.\d+\.\d+)", - r.stdout, - ) - if match: - return match.group(1) - except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): - logger.debug("Failed to discover printer via CUPS", exc_info=True) + match = re.search( + r"(?:ipp|socket|lpd|http)://(\d+\.\d+\.\d+\.\d+)", + run_command_text([lpstat_path, "-v"]), + ) + if match: + return match.group(1) return "" diff --git a/python_pkg/brother_printer/cups_queue.py b/python_pkg/brother_printer/cups_queue.py index a19a5b8..48194ed 100644 --- a/python_pkg/brother_printer/cups_queue.py +++ b/python_pkg/brother_printer/cups_queue.py @@ -10,6 +10,7 @@ import sys import time from typing import TYPE_CHECKING +from python_pkg.brother_printer._query import run_command_text from python_pkg.brother_printer.constants import ( BOLD, CYAN, @@ -69,32 +70,14 @@ def get_cups_queue_status() -> CUPSQueueStatus: if not lpstat_path: return result - try: - r = subprocess.run( - [lpstat_path, "-p", printer_name], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - for line in r.stdout.splitlines(): - if "printer" in line.lower() and printer_name in line: - result.enabled, result.reason = _parse_lpstat_printer_line(line) - break - except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): - pass + status_lines = run_command_text([lpstat_path, "-p", printer_name]).splitlines() + for line in status_lines: + if "printer" in line.lower() and printer_name in line: + result.enabled, result.reason = _parse_lpstat_printer_line(line) + break - try: - r = subprocess.run( - [lpstat_path, "-o", printer_name], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - result.jobs = _parse_lpstat_jobs(r.stdout, printer_name) - except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): - pass + jobs_output = run_command_text([lpstat_path, "-o", printer_name]) + result.jobs = _parse_lpstat_jobs(jobs_output, printer_name) has_errors, last_error = _check_cups_backend_errors(printer_name) result.has_backend_errors = has_errors @@ -121,8 +104,7 @@ def _cups_enable_printer(printer_name: str) -> bool: except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as e: _out(f" {RED}Failed to enable printer: {e}{RESET}") return False - else: - return True + return True def _cups_cancel_all_jobs(printer_name: str) -> bool: @@ -140,8 +122,7 @@ def _cups_cancel_all_jobs(printer_name: str) -> bool: except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as e: _out(f" {RED}Failed to cancel jobs: {e}{RESET}") return False - else: - return True + return True def _cups_cancel_job(job_id: str) -> bool: @@ -157,8 +138,7 @@ def _cups_cancel_job(job_id: str) -> bool: ) except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): return False - else: - return True + return True def _cups_restart_service() -> bool: @@ -208,23 +188,13 @@ def _is_cups_printer_healthy(printer_name: str) -> bool: lpstat_path = shutil.which("lpstat") if not lpstat_path: return False - try: - r = subprocess.run( - [lpstat_path, "-p", printer_name], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - for line in r.stdout.splitlines(): - if ( - printer_name in line - and "idle" in line.lower() - and "enabled" in line.lower() - ): - return True - except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): - pass + for line in run_command_text([lpstat_path, "-p", printer_name]).splitlines(): + if ( + printer_name in line + and "idle" in line.lower() + and "enabled" in line.lower() + ): + return True return False diff --git a/python_pkg/brother_printer/cups_service.py b/python_pkg/brother_printer/cups_service.py index 00da3f6..13ca26e 100644 --- a/python_pkg/brother_printer/cups_service.py +++ b/python_pkg/brother_printer/cups_service.py @@ -11,8 +11,11 @@ import shutil import subprocess import time from typing import TYPE_CHECKING -import urllib.parse +from python_pkg.brother_printer._query import ( + printer_info_from_cups, + run_command_text, +) from python_pkg.brother_printer.constants import ( _CUPS_REASONS_TO_STATUS, _CUPS_STATE_TO_STATUS, @@ -63,11 +66,10 @@ def _get_pyusb_device_info() -> dict[str, str]: return {} except (ImportError, OSError, ValueError): return {} - else: - return { - "product": dev.product or "", - "serial": dev.serial_number or "", - } + return { + "product": dev.product or "", + "serial": dev.serial_number or "", + } # ── CUPS service control ──────────────────────────────────────────── @@ -310,21 +312,12 @@ def _get_cups_economode(printer_name: str) -> str: lpoptions_path = shutil.which("lpoptions") if not lpoptions_path: return "" - try: - r = subprocess.run( - [lpoptions_path, "-p", printer_name, "-l"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - for line in r.stdout.splitlines(): - if "conomode" in line.lower(): - match = re.search(r"\*(\w+)", line) - if match: - return "ON" if match.group(1).lower() == "true" else "OFF" - except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): - pass + command = [lpoptions_path, "-p", printer_name, "-l"] + for line in run_command_text(command).splitlines(): + if "conomode" in line.lower(): + match = re.search(r"\*(\w+)", line) + if match: + return "ON" if match.group(1).lower() == "true" else "OFF" return "" @@ -375,58 +368,17 @@ def find_cups_printer_name() -> str: lpstat_path = shutil.which("lpstat") if not lpstat_path: return "" - try: - r = subprocess.run( - [lpstat_path, "-v"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - for line in r.stdout.splitlines(): - if "brother" in line.lower(): - match = re.match(r"device for (\S+):", line) - if match: - return match.group(1) - except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): - pass + for line in run_command_text([lpstat_path, "-v"]).splitlines(): + if "brother" in line.lower(): + match = re.match(r"device for (\S+):", line) + if match: + return match.group(1) return "" # ── CUPS-based USB fallback query ──────────────────────────────────── -def _parse_cups_usb_uri(uri: str, info: dict[str, str]) -> None: - """Extract product and serial from a CUPS usb:// URI.""" - parsed = urllib.parse.urlparse(uri) - info["product"] = urllib.parse.unquote(parsed.path.lstrip("/")) - qs = urllib.parse.parse_qs(parsed.query) - if "serial" in qs: - info["serial"] = qs["serial"][0] - - -def _get_printer_info_from_cups() -> dict[str, str]: - """Get printer model/serial from lpstat.""" - info: dict[str, str] = {"product": "", "serial": ""} - try: - r = subprocess.run( - ["/usr/bin/lpstat", "-v"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - for line in r.stdout.splitlines(): - if "Brother" in line: - for part in line.split(): - if part.startswith("usb://"): - _parse_cups_usb_uri(part, info) - break - except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): - logger.debug("Failed to query CUPS for printer info", exc_info=True) - return info - - def query_usb_via_cups() -> USBResult: """Query USB printer status through CUPS when /dev/usb/lp* is unavailable.""" _ensure_cups_running() @@ -439,7 +391,7 @@ def query_usb_via_cups() -> USBResult: ) pyusb_info = _get_pyusb_device_info() - cups_info = _get_printer_info_from_cups() + cups_info = printer_info_from_cups() result = USBResult( device="cups", diff --git a/python_pkg/brother_printer/data_classes.py b/python_pkg/brother_printer/data_classes.py index cd034c8..0ed25a8 100644 --- a/python_pkg/brother_printer/data_classes.py +++ b/python_pkg/brother_printer/data_classes.py @@ -67,6 +67,19 @@ class USBResult: port_status: USBPortStatus | None = None +@dataclass +class SupplyReadings: + """Parallel SNMP supply tables (descriptions, capacities, current levels). + + The three lists are always populated and indexed together, so they travel + as one object rather than three loose fields on NetworkResult. + """ + + descriptions: list[str] = field(default_factory=list) + max_values: list[str] = field(default_factory=list) + levels: list[str] = field(default_factory=list) + + @dataclass class NetworkResult: """Result from an SNMP network query.""" @@ -79,9 +92,7 @@ class NetworkResult: device_status: str = "" display: str = "" page_count: str = "" - supply_descriptions: list[str] = field(default_factory=list) - supply_max: list[str] = field(default_factory=list) - supply_levels: list[str] = field(default_factory=list) + supplies: SupplyReadings = field(default_factory=SupplyReadings) error: str = "" @@ -90,7 +101,7 @@ class SupplyStatus: """Processed supply level info for display.""" color: str - bar: str + bar_text: str status_text: str warning: str needs_replacement: bool diff --git a/python_pkg/brother_printer/display.py b/python_pkg/brother_printer/display.py index 7d6c9aa..2ee3ec1 100644 --- a/python_pkg/brother_printer/display.py +++ b/python_pkg/brother_printer/display.py @@ -285,8 +285,8 @@ def _process_supply_item(desc: str, max_val: int, level: int) -> SupplyStatus: pct, status_text, color, warning, needs_replacement = _classify_supply_level( desc, max_val, level ) - bar = _format_supply_bar(pct) - return SupplyStatus(color, bar, status_text, warning, needs_replacement) + bar_text = _format_supply_bar(pct) + return SupplyStatus(color, bar_text, status_text, warning, needs_replacement) def _display_supply_warnings(*, needs_replacement: bool, warnings: list[str]) -> None: @@ -318,9 +318,9 @@ def _collect_supply_items( """Parse and collect supply items with their descriptions.""" items: list[SupplyStatus] = [] descs: list[str] = [] - for i, desc in enumerate(result.supply_descriptions): - max_val = _parse_supply_value(result.supply_max, i) - level = _parse_supply_value(result.supply_levels, i) + for i, desc in enumerate(result.supplies.descriptions): + max_val = _parse_supply_value(result.supplies.max_values, i) + level = _parse_supply_value(result.supplies.levels, i) items.append(_process_supply_item(desc, max_val, level)) descs.append(desc) return items, descs @@ -339,7 +339,7 @@ def _display_supply_levels(result: NetworkResult) -> None: for desc, item in zip(descs, items, strict=True): _out( f" {BOLD}{desc:<25}{RESET}" - f" {item.color}{item.bar} {item.status_text}{RESET}" + f" {item.color}{item.bar_text} {item.status_text}{RESET}" ) if item.needs_replacement: needs_replacement = True diff --git a/python_pkg/brother_printer/network_query.py b/python_pkg/brother_printer/network_query.py index 3d9cfad..422c2bf 100644 --- a/python_pkg/brother_printer/network_query.py +++ b/python_pkg/brother_printer/network_query.py @@ -5,7 +5,7 @@ from __future__ import annotations import shutil import subprocess -from python_pkg.brother_printer.data_classes import NetworkResult +from python_pkg.brother_printer.data_classes import NetworkResult, SupplyReadings def _snmpwalk_cmd( @@ -81,9 +81,11 @@ def _build_network_result(ip: str, community: str, timeout: int) -> NetworkResul device_status=" ".join(walk("1.3.6.1.2.1.25.3.2.1.5")[:1]) or "", display=" ".join(walk("1.3.6.1.2.1.43.16.5.1.2")[:3]) or "", page_count=" ".join(walk("1.3.6.1.2.1.43.10.2.1.4")[:1]) or "", - supply_descriptions=walk("1.3.6.1.2.1.43.11.1.1.6"), - supply_max=walk("1.3.6.1.2.1.43.11.1.1.8"), - supply_levels=walk("1.3.6.1.2.1.43.11.1.1.9"), + supplies=SupplyReadings( + descriptions=walk("1.3.6.1.2.1.43.11.1.1.6"), + max_values=walk("1.3.6.1.2.1.43.11.1.1.8"), + levels=walk("1.3.6.1.2.1.43.11.1.1.9"), + ), ) diff --git a/python_pkg/brother_printer/tests/test_check_brother_printer.py b/python_pkg/brother_printer/tests/test_check_brother_printer.py index dc64aa7..b00daac 100644 --- a/python_pkg/brother_printer/tests/test_check_brother_printer.py +++ b/python_pkg/brother_printer/tests/test_check_brother_printer.py @@ -25,7 +25,7 @@ class TestDiscoverNetworkPrinter: def test_no_lpstat(self, m: MagicMock) -> None: assert _discover_network_printer() == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") def test_found_ip(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( @@ -33,7 +33,7 @@ class TestDiscoverNetworkPrinter: ) assert _discover_network_printer() == "192.168.1.100" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") def test_socket(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( @@ -41,7 +41,7 @@ class TestDiscoverNetworkPrinter: ) assert _discover_network_printer() == "10.0.0.5" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") def test_no_match(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( @@ -49,13 +49,13 @@ class TestDiscoverNetworkPrinter: ) assert _discover_network_printer() == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") def test_timeout(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.side_effect = subprocess.TimeoutExpired("lpstat", 5) assert _discover_network_printer() == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") def test_oserror(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.side_effect = OSError("fail") diff --git a/python_pkg/brother_printer/tests/test_cups_service_part2.py b/python_pkg/brother_printer/tests/test_cups_service_part2.py index 5a04cc8..9daae1e 100644 --- a/python_pkg/brother_printer/tests/test_cups_service_part2.py +++ b/python_pkg/brother_printer/tests/test_cups_service_part2.py @@ -8,9 +8,7 @@ from unittest.mock import MagicMock, patch from python_pkg.brother_printer.cups_service import ( _cups_reasons_to_error, _get_cups_economode, - _get_printer_info_from_cups, _map_cups_to_status_code, - _parse_cups_usb_uri, _port_status_to_status_code, find_cups_printer_name, ) @@ -31,7 +29,7 @@ class TestGetCupsEconomode: def test_no_lpoptions(self, m: MagicMock) -> None: assert _get_cups_economode("Brother") == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions") def test_economode_on(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( @@ -39,7 +37,7 @@ class TestGetCupsEconomode: ) assert _get_cups_economode("Brother") == "ON" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions") def test_economode_off(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( @@ -47,7 +45,7 @@ class TestGetCupsEconomode: ) assert _get_cups_economode("Brother") == "OFF" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions") def test_no_economode_line(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( @@ -55,7 +53,7 @@ class TestGetCupsEconomode: ) assert _get_cups_economode("Brother") == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions") def test_economode_no_star_match(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( @@ -63,13 +61,13 @@ class TestGetCupsEconomode: ) assert _get_cups_economode("Brother") == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions") def test_timeout(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.side_effect = subprocess.TimeoutExpired("lpoptions", 5) assert _get_cups_economode("Brother") == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions") def test_oserror(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.side_effect = OSError("fail") @@ -191,7 +189,7 @@ class TestFindCupsPrinterName: def test_no_lpstat(self, m: MagicMock) -> None: assert find_cups_printer_name() == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") def test_found(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( @@ -199,13 +197,13 @@ class TestFindCupsPrinterName: ) assert find_cups_printer_name() == "BrotherHL1110" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") def test_no_brother(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(stdout="device for HP: ipp://hp.local\n") assert find_cups_printer_name() == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") def test_brother_no_match(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( @@ -213,73 +211,14 @@ class TestFindCupsPrinterName: ) assert find_cups_printer_name() == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") def test_timeout(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.side_effect = subprocess.TimeoutExpired("lpstat", 5) assert find_cups_printer_name() == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") def test_oserror(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.side_effect = OSError("fail") assert find_cups_printer_name() == "" - - -# ── _parse_cups_usb_uri ───────────────────────────────────────────── - - -class TestParseCupsUsbUri: - """Tests for _parse_cups_usb_uri.""" - - def test_full_uri(self) -> None: - info: dict[str, str] = {"product": "", "serial": ""} - _parse_cups_usb_uri("usb://Brother/HL-1110%20series?serial=ABC123", info) - assert info["product"] == "HL-1110 series" - assert info["serial"] == "ABC123" - - def test_no_serial(self) -> None: - info: dict[str, str] = {"product": "", "serial": ""} - _parse_cups_usb_uri("usb://Brother/HL-1110", info) - assert info["product"] == "HL-1110" - assert info["serial"] == "" - - -# ── _get_printer_info_from_cups ────────────────────────────────────── - - -class TestGetPrinterInfoFromCups: - """Tests for _get_printer_info_from_cups.""" - - @patch(f"{MOD}.subprocess.run") - def test_found(self, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock( - stdout="device for B: usb://Brother/HL-1110?serial=XYZ\n" - ) - result = _get_printer_info_from_cups() - assert result["product"] == "HL-1110" - assert result["serial"] == "XYZ" - - @patch(f"{MOD}.subprocess.run") - def test_no_brother(self, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock(stdout="device for HP: ipp://hp.local\n") - result = _get_printer_info_from_cups() - assert result["product"] == "" - - @patch(f"{MOD}.subprocess.run") - def test_brother_no_usb(self, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock(stdout="device for B: ipp://Brother.local\n") - result = _get_printer_info_from_cups() - assert result["product"] == "" - - @patch(f"{MOD}.subprocess.run") - def test_timeout(self, mock_run: MagicMock) -> None: - mock_run.side_effect = subprocess.TimeoutExpired("lpstat", 5) - result = _get_printer_info_from_cups() - assert result["product"] == "" - - @patch(f"{MOD}.subprocess.run") - def test_oserror(self, mock_run: MagicMock) -> None: - mock_run.side_effect = OSError("fail") - result = _get_printer_info_from_cups() - assert result["product"] == "" diff --git a/python_pkg/brother_printer/tests/test_cups_service_part3.py b/python_pkg/brother_printer/tests/test_cups_service_part3.py index 15f3655..fca9264 100644 --- a/python_pkg/brother_printer/tests/test_cups_service_part3.py +++ b/python_pkg/brother_printer/tests/test_cups_service_part3.py @@ -33,7 +33,7 @@ class TestQueryUsbViaCups: patch(f"{MOD}.find_cups_printer_name", return_value="Brother"), patch(f"{MOD}._get_pyusb_device_info", return_value={}), patch( - f"{MOD}._get_printer_info_from_cups", + f"{MOD}.printer_info_from_cups", return_value={"product": "HL-1110", "serial": "ABC"}, ), patch( @@ -58,7 +58,7 @@ class TestQueryUsbViaCups: patch(f"{MOD}.find_cups_printer_name", return_value="Brother"), patch(f"{MOD}._get_pyusb_device_info", return_value={}), patch( - f"{MOD}._get_printer_info_from_cups", + f"{MOD}.printer_info_from_cups", return_value={"product": "", "serial": ""}, ), patch( @@ -81,7 +81,7 @@ class TestQueryUsbViaCups: patch(f"{MOD}.find_cups_printer_name", return_value="Brother"), patch(f"{MOD}._get_pyusb_device_info", return_value={}), patch( - f"{MOD}._get_printer_info_from_cups", + f"{MOD}.printer_info_from_cups", return_value={"product": "", "serial": ""}, ), patch( @@ -112,7 +112,7 @@ class TestQueryUsbViaCups: patch(f"{MOD}.find_cups_printer_name", return_value="Brother"), patch(f"{MOD}._get_pyusb_device_info", return_value={}), patch( - f"{MOD}._get_printer_info_from_cups", + f"{MOD}.printer_info_from_cups", return_value={"product": "", "serial": ""}, ), patch( @@ -151,7 +151,7 @@ class TestQueryUsbViaCups: patch(f"{MOD}.find_cups_printer_name", return_value="Brother"), patch(f"{MOD}._get_pyusb_device_info", return_value={}), patch( - f"{MOD}._get_printer_info_from_cups", + f"{MOD}.printer_info_from_cups", return_value={"product": "", "serial": ""}, ), patch( @@ -190,7 +190,7 @@ class TestQueryUsbViaCups: patch(f"{MOD}.find_cups_printer_name", return_value="Brother"), patch(f"{MOD}._get_pyusb_device_info", return_value={}), patch( - f"{MOD}._get_printer_info_from_cups", + f"{MOD}.printer_info_from_cups", return_value={"product": "", "serial": ""}, ), patch( @@ -229,7 +229,7 @@ class TestQueryUsbViaCups: return_value={"product": "HL-1110", "serial": "SN1"}, ), patch( - f"{MOD}._get_printer_info_from_cups", + f"{MOD}.printer_info_from_cups", return_value={"product": "", "serial": ""}, ), patch( diff --git a/python_pkg/brother_printer/tests/test_data_classes.py b/python_pkg/brother_printer/tests/test_data_classes.py index 80d9de3..1bf2898 100644 --- a/python_pkg/brother_printer/tests/test_data_classes.py +++ b/python_pkg/brother_printer/tests/test_data_classes.py @@ -74,9 +74,9 @@ class TestNetworkResult: assert r.connection == "network" assert r.ip == "" assert r.product == "Unknown" - assert r.supply_descriptions == [] - assert r.supply_max == [] - assert r.supply_levels == [] + assert r.supplies.descriptions == [] + assert r.supplies.max_values == [] + assert r.supplies.levels == [] assert r.error == "" @@ -84,7 +84,7 @@ class TestSupplyStatus: def test_create(self) -> None: s = SupplyStatus( color="red", - bar="[###]", + bar_text="[###]", status_text="50%", warning="low", needs_replacement=True, diff --git a/python_pkg/brother_printer/tests/test_display.py b/python_pkg/brother_printer/tests/test_display.py index 057ed90..1ff2a59 100644 --- a/python_pkg/brother_printer/tests/test_display.py +++ b/python_pkg/brother_printer/tests/test_display.py @@ -10,6 +10,7 @@ import pytest from python_pkg.brother_printer.data_classes import ( NetworkResult, PageCountEstimate, + SupplyReadings, USBPortStatus, USBResult, ) @@ -399,9 +400,11 @@ class TestParseSupplyValue: class TestCollectSupplyItems: def test_collect(self) -> None: result = NetworkResult( - supply_descriptions=["Toner", "Drum"], - supply_max=["100", "200"], - supply_levels=["80", "150"], + supplies=SupplyReadings( + descriptions=["Toner", "Drum"], + max_values=["100", "200"], + levels=["80", "150"], + ), ) items, descs = _collect_supply_items(result) assert len(items) == 2 @@ -411,9 +414,11 @@ class TestCollectSupplyItems: class TestDisplaySupplyLevels: def test_with_items(self) -> None: result = NetworkResult( - supply_descriptions=["Toner"], - supply_max=["100"], - supply_levels=["80"], + supplies=SupplyReadings( + descriptions=["Toner"], + max_values=["100"], + levels=["80"], + ), ) with patch("sys.stdout", new_callable=StringIO) as out: _display_supply_levels(result) @@ -421,9 +426,11 @@ class TestDisplaySupplyLevels: def test_needs_replacement_and_warning(self) -> None: result = NetworkResult( - supply_descriptions=["Toner", "Drum"], - supply_max=["100", "100"], - supply_levels=["0", "15"], + supplies=SupplyReadings( + descriptions=["Toner", "Drum"], + max_values=["100", "100"], + levels=["0", "15"], + ), ) with patch("sys.stdout", new_callable=StringIO) as out: _display_supply_levels(result) diff --git a/python_pkg/brother_printer/tests/test_query.py b/python_pkg/brother_printer/tests/test_query.py new file mode 100644 index 0000000..5d3d052 --- /dev/null +++ b/python_pkg/brother_printer/tests/test_query.py @@ -0,0 +1,90 @@ +"""Tests for the shared brother_printer._query helpers.""" + +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock, patch + +from python_pkg.brother_printer._query import ( + parse_cups_usb_uri, + printer_info_from_cups, + run_command_text, +) + +MOD = "python_pkg.brother_printer._query" + + +class TestRunCommandText: + """The shared subprocess wrapper.""" + + @patch(f"{MOD}.subprocess.run") + def test_returns_stdout(self, mock_run: MagicMock) -> None: + """A successful run yields its captured stdout.""" + mock_run.return_value = MagicMock(stdout="line one\nline two\n") + assert run_command_text(["echo", "hi"]) == "line one\nline two\n" + + @patch(f"{MOD}.subprocess.run") + def test_timeout_is_empty(self, mock_run: MagicMock) -> None: + """A timeout is swallowed and reported as empty output.""" + mock_run.side_effect = subprocess.TimeoutExpired("cmd", 5) + assert run_command_text(["slow"]) == "" + + @patch(f"{MOD}.subprocess.run") + def test_oserror_is_empty(self, mock_run: MagicMock) -> None: + """An OS error (missing binary) is swallowed and reported as empty.""" + mock_run.side_effect = OSError("no such file") + assert run_command_text(["missing"]) == "" + + @patch(f"{MOD}.subprocess.run") + def test_subprocess_error_is_empty(self, mock_run: MagicMock) -> None: + """A generic subprocess error is swallowed and reported as empty.""" + mock_run.side_effect = subprocess.SubprocessError("boom") + assert run_command_text(["bad"]) == "" + + +class TestParseCupsUsbUri: + """Parsing product/serial out of a CUPS ``usb://`` URI.""" + + def test_full_uri(self) -> None: + """A URI with a serial fills both product and serial.""" + info: dict[str, str] = {"product": "", "serial": ""} + parse_cups_usb_uri("usb://Brother/HL-1110%20series?serial=ABC123", info) + assert info["product"] == "HL-1110 series" + assert info["serial"] == "ABC123" + + def test_no_serial(self) -> None: + """A URI without a serial leaves the serial empty.""" + info: dict[str, str] = {"product": "", "serial": ""} + parse_cups_usb_uri("usb://Brother/HL-1110", info) + assert info["product"] == "HL-1110" + assert info["serial"] == "" + + +class TestPrinterInfoFromCups: + """Resolving model/serial from ``lpstat -v`` output.""" + + @patch(f"{MOD}.run_command_text") + def test_found(self, mock_text: MagicMock) -> None: + """A Brother usb:// device line is parsed into product/serial.""" + mock_text.return_value = "device for B: usb://Brother/HL-1110?serial=XYZ\n" + result = printer_info_from_cups() + assert result["product"] == "HL-1110" + assert result["serial"] == "XYZ" + + @patch(f"{MOD}.run_command_text") + def test_no_brother(self, mock_text: MagicMock) -> None: + """A non-Brother line yields no product.""" + mock_text.return_value = "device for HP: ipp://hp.local\n" + assert printer_info_from_cups()["product"] == "" + + @patch(f"{MOD}.run_command_text") + def test_brother_no_usb(self, mock_text: MagicMock) -> None: + """A Brother line with no usb:// URI yields no product.""" + mock_text.return_value = "device for B: ipp://Brother.local\n" + assert printer_info_from_cups()["product"] == "" + + @patch(f"{MOD}.run_command_text") + def test_empty_output(self, mock_text: MagicMock) -> None: + """No output (command failed) yields no product.""" + mock_text.return_value = "" + assert printer_info_from_cups()["product"] == "" diff --git a/python_pkg/brother_printer/tests/test_usb_query.py b/python_pkg/brother_printer/tests/test_usb_query.py index 73b3643..8ca4267 100644 --- a/python_pkg/brother_printer/tests/test_usb_query.py +++ b/python_pkg/brother_printer/tests/test_usb_query.py @@ -8,7 +8,6 @@ from python_pkg.brother_printer.data_classes import USBResult from python_pkg.brother_printer.usb_query import ( _drain_buffer, _init_usb_result, - _parse_cups_usb_uri, _parse_status, _parse_variables, _read_nonblocking, @@ -17,7 +16,6 @@ from python_pkg.brother_printer.usb_query import ( _wait_for_pjl_response, find_brother_usb, find_usb_printer_dev, - get_printer_info_from_cups, pjl_query, query_usb_pjl, ) @@ -30,7 +28,7 @@ class TestFindBrotherUsb: def test_no_lsusb(self, m: MagicMock) -> None: assert find_brother_usb() == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb") def test_found(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( @@ -39,13 +37,13 @@ class TestFindBrotherUsb: result = find_brother_usb() assert "Brother" in result - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb") def test_not_found(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(stdout="Bus 001 Device 001: Hub\n") assert find_brother_usb() == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb") def test_line_with_colon_sep(self, w: MagicMock, mock_run: MagicMock) -> None: """Line contains 04f9: but no ': ' separator → returns full line.""" @@ -53,14 +51,14 @@ class TestFindBrotherUsb: result = find_brother_usb() assert result == "ID 04f9:0042" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb") def test_no_match(self, w: MagicMock, mock_run: MagicMock) -> None: """Line without 04f9: vendor id is ignored.""" mock_run.return_value = MagicMock(stdout="04f9 brother no colon\n") assert find_brother_usb() == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb") def test_timeout(self, w: MagicMock, mock_run: MagicMock) -> None: import subprocess @@ -68,7 +66,7 @@ class TestFindBrotherUsb: mock_run.side_effect = subprocess.TimeoutExpired("lsusb", 5) assert find_brother_usb() == "" - @patch(f"{MOD}.subprocess.run") + @patch("python_pkg.brother_printer._query.subprocess.run") @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb") def test_oserror(self, w: MagicMock, mock_run: MagicMock) -> None: mock_run.side_effect = OSError("fail") @@ -99,62 +97,6 @@ class TestFindUsbPrinterDev: assert result is None -class TestParseCupsUsbUri: - def test_basic_uri(self) -> None: - info: dict[str, str] = {"product": "", "serial": ""} - _parse_cups_usb_uri( - "usb://Brother/HL-1110%20series?serial=ABC123", - info, - ) - assert info["product"] == "HL-1110 series" - assert info["serial"] == "ABC123" - - def test_no_serial(self) -> None: - info: dict[str, str] = {"product": "", "serial": ""} - _parse_cups_usb_uri("usb://Brother/HL-1110%20series", info) - assert info["product"] == "HL-1110 series" - assert info["serial"] == "" - - -class TestGetPrinterInfoFromCups: - @patch(f"{MOD}.subprocess.run") - def test_found(self, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock( - stdout="device for Brother: usb://Brother/HL-1110?serial=SN1\n", - ) - info = get_printer_info_from_cups() - assert info["product"] == "HL-1110" - assert info["serial"] == "SN1" - - @patch(f"{MOD}.subprocess.run") - def test_no_brother(self, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock(stdout="device for HP: ipp://hp\n") - info = get_printer_info_from_cups() - assert info["product"] == "" - - @patch(f"{MOD}.subprocess.run") - def test_brother_no_usb_uri(self, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock( - stdout="device for Brother: ipp://1.2.3.4\n", - ) - info = get_printer_info_from_cups() - assert info["product"] == "" - - @patch(f"{MOD}.subprocess.run") - def test_timeout(self, mock_run: MagicMock) -> None: - import subprocess - - mock_run.side_effect = subprocess.TimeoutExpired("lpstat", 5) - info = get_printer_info_from_cups() - assert info == {"product": "", "serial": ""} - - @patch(f"{MOD}.subprocess.run") - def test_oserror(self, mock_run: MagicMock) -> None: - mock_run.side_effect = OSError("fail") - info = get_printer_info_from_cups() - assert info == {"product": "", "serial": ""} - - class TestDrainBuffer: @patch(f"{MOD}.os.read") @patch(f"{MOD}.fcntl.fcntl") @@ -403,7 +345,7 @@ class TestRunPjlQueries: class TestInitUsbResult: - @patch(f"{MOD}.get_printer_info_from_cups") + @patch(f"{MOD}.printer_info_from_cups") def test_from_cups(self, mock_cups: MagicMock) -> None: mock_cups.return_value = {"product": "HL-1110", "serial": "SN1"} result = _init_usb_result("/dev/usb/lp0") @@ -411,7 +353,7 @@ class TestInitUsbResult: assert result.product == "HL-1110" assert result.serial == "SN1" - @patch(f"{MOD}.get_printer_info_from_cups") + @patch(f"{MOD}.printer_info_from_cups") def test_no_product(self, mock_cups: MagicMock) -> None: mock_cups.return_value = {"product": "", "serial": ""} result = _init_usb_result("/dev/usb/lp0") diff --git a/python_pkg/brother_printer/usb_query.py b/python_pkg/brother_printer/usb_query.py index e914668..23983a2 100644 --- a/python_pkg/brother_printer/usb_query.py +++ b/python_pkg/brother_printer/usb_query.py @@ -5,21 +5,23 @@ from __future__ import annotations import contextlib import fcntl import importlib +import logging import os from pathlib import Path import select import shutil -import subprocess import time from typing import TYPE_CHECKING -import urllib.parse +from python_pkg.brother_printer._query import ( + printer_info_from_cups, + run_command_text, +) from python_pkg.brother_printer.data_classes import USBResult if TYPE_CHECKING: from collections.abc import Callable -import logging logger = logging.getLogger(__name__) @@ -31,19 +33,9 @@ def find_brother_usb() -> str: """Look for any Brother printer on USB via lsusb. Returns the info line.""" if not shutil.which("lsusb"): return "" - try: - r = subprocess.run( - ["/usr/bin/lsusb"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - for line in r.stdout.splitlines(): - if "04f9:" in line.lower(): - return line.split(": ", 1)[1] if ": " in line else line - except (subprocess.TimeoutExpired, OSError): - pass + for line in run_command_text(["/usr/bin/lsusb"]).splitlines(): + if "04f9:" in line.lower(): + return line.split(": ", 1)[1] if ": " in line else line return "" @@ -53,37 +45,6 @@ def find_usb_printer_dev() -> str | None: return str(devices[0]) if devices else None -def _parse_cups_usb_uri(uri: str, info: dict[str, str]) -> None: - """Extract product and serial from a CUPS usb:// URI.""" - parsed = urllib.parse.urlparse(uri) - info["product"] = urllib.parse.unquote(parsed.path.lstrip("/")) - qs = urllib.parse.parse_qs(parsed.query) - if "serial" in qs: - info["serial"] = qs["serial"][0] - - -def get_printer_info_from_cups() -> dict[str, str]: - """Get printer model/serial from lpstat.""" - info: dict[str, str] = {"product": "", "serial": ""} - try: - r = subprocess.run( - ["/usr/bin/lpstat", "-v"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - for line in r.stdout.splitlines(): - if "Brother" in line: - for part in line.split(): - if part.startswith("usb://"): - _parse_cups_usb_uri(part, info) - break - except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): - logger.debug("Failed to query CUPS for printer info", exc_info=True) - return info - - # ── PJL over USB ───────────────────────────────────────────────────── @@ -200,7 +161,7 @@ def _run_pjl_queries(fd: int, result: USBResult, max_retries: int) -> None: def _init_usb_result(dev_path: str) -> USBResult: """Create a USBResult with device info from CUPS.""" - cups_info = get_printer_info_from_cups() + cups_info = printer_info_from_cups() return USBResult( device=dev_path, product=cups_info.get("product") or "Brother Laser Printer", diff --git a/python_pkg/diet_guard/_cli.py b/python_pkg/diet_guard/_cli.py index 0ecf846..096d111 100644 --- a/python_pkg/diet_guard/_cli.py +++ b/python_pkg/diet_guard/_cli.py @@ -37,8 +37,8 @@ from python_pkg.diet_guard._gatelock import ( MealGate, acquire_gate_lock, release_gate_lock, - wait_for_display, ) +from python_pkg.diet_guard._gatelock_support import wait_for_display from python_pkg.diet_guard._portions import ( DEFAULT_ITEM_GRAMS, estimate_unit_grams, diff --git a/python_pkg/diet_guard/_foodbank.py b/python_pkg/diet_guard/_foodbank.py index 8ea3d22..1cc85aa 100644 --- a/python_pkg/diet_guard/_foodbank.py +++ b/python_pkg/diet_guard/_foodbank.py @@ -25,6 +25,7 @@ from python_pkg.diet_guard._constants import FOOD_BANK_FILE from python_pkg.diet_guard._estimator import Nutrition from python_pkg.diet_guard._fuzzy import match_score from python_pkg.diet_guard._meal import MealItem, meal_total +from python_pkg.shared.coerce import as_float if TYPE_CHECKING: from collections.abc import Sequence @@ -119,24 +120,15 @@ def _record_to_nutrition(record: BankRecord) -> Nutrition: The reconstructed Nutrition (source marked as the food bank). """ return Nutrition( - kcal=_as_float(record.get("kcal")), - protein_g=_as_float(record.get("protein_g")), - carbs_g=_as_float(record.get("carbs_g")), - fat_g=_as_float(record.get("fat_g")), - grams=_as_float(record.get("grams")), + kcal=as_float(record.get("kcal")), + protein_g=as_float(record.get("protein_g")), + carbs_g=as_float(record.get("carbs_g")), + fat_g=as_float(record.get("fat_g")), + grams=as_float(record.get("grams")), source="food bank", ) -def _as_float(value: object) -> float: - """Coerce a stored field to float, defaulting to 0.0 (bools rejected).""" - if isinstance(value, bool): - return 0.0 - if isinstance(value, (int, float)): - return float(value) - return 0.0 - - def remember_food(description: str, nutrition: Nutrition) -> None: """Record (or refresh) a food in the bank, bumping its use count. @@ -194,7 +186,7 @@ def _upsert( return bank = _read_bank() previous = bank.get(key, {}) - count = _as_float(previous.get("count")) + 1 + count = as_float(previous.get("count")) + 1 record: BankRecord = { "desc": description.strip(), "kcal": nutrition.kcal, @@ -256,7 +248,7 @@ def search_foods( score = match_score(normalized, key) if score < _FUZZY_THRESHOLD: continue - count = _as_float(record.get("count")) + count = as_float(record.get("count")) scored.append( (score, count, _display_name(record, key), _record_to_nutrition(record)), ) @@ -272,7 +264,7 @@ def _ranked_all( """Return all banked foods ranked by use count, most-logged first.""" ranked = sorted( bank.items(), - key=lambda item: _as_float(item[1].get("count")), + key=lambda item: as_float(item[1].get("count")), reverse=True, ) return [ diff --git a/python_pkg/diet_guard/_gatelock.py b/python_pkg/diet_guard/_gatelock.py index d40ba0c..323ab6b 100644 --- a/python_pkg/diet_guard/_gatelock.py +++ b/python_pkg/diet_guard/_gatelock.py @@ -30,157 +30,38 @@ source, and offers alternatives. A running dashboard makes the day's calories prominent, with macros and the protein target beneath. The unlock condition is *logging*, never *estimating correctly*: a manual calorie value always works offline, so a dead OFF endpoint can never trap you behind the lock. + +Building ``MealGate`` spans several sibling modules to keep each under the +repo's 500-line limit: :mod:`._gatelock_core` provides the shared leaf +widget/field helpers, root window, and state (``_GateCore``, ``_GateRoot``, +``_GateState``); :mod:`._gatelock_window` provides the fullscreen window setup, +input grab, and exit-path lifecycle (``_GateWindow``); +:mod:`._gatelock_nutrition` provides the reference->total nutrition maths and +food lookup (``_GateNutrition``); and :mod:`._gatelock_mealflow` provides the +submit/log flow and dashboard (``_GateMealFlow``). ``MealGate`` wires these +mixins together and owns construction, layout, and event binding. """ from __future__ import annotations -import atexit import contextlib import fcntl -import logging -import shutil -import signal -import subprocess import sys -import time import tkinter as tk from typing import TYPE_CHECKING -from python_pkg.diet_guard._budget import ( - BudgetError, - daily_budget, - protein_target_g, -) from python_pkg.diet_guard._constants import GATE_LOCK_FILE -from python_pkg.diet_guard._estimator import Nutrition, scale_nutrition -from python_pkg.diet_guard._foodbank import remember_food, remember_meal from python_pkg.diet_guard._gate import due_slots -from python_pkg.diet_guard._meal import MealItem, meal_total -from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams -from python_pkg.diet_guard._resolve import lookup_candidates, suggest_foods -from python_pkg.diet_guard._slots import current_slot, day_slots, slot_label -from python_pkg.diet_guard._state import ( - entry_kcal, - log_meal, - now_local, - today_entries, - today_total_kcal, - today_total_macros, -) +from python_pkg.diet_guard._gatelock_core import _GateRoot, _GateState +from python_pkg.diet_guard._gatelock_mealflow import _GateMealFlow +from python_pkg.diet_guard._gatelock_ui import GateCallbacks, build_layout, make_vars +from python_pkg.diet_guard._gatelock_window import _GateWindow +from python_pkg.diet_guard._slots import current_slot, day_slots +from python_pkg.diet_guard._state import now_local if TYPE_CHECKING: - from collections.abc import Callable - from types import FrameType, TracebackType from typing import TextIO -_logger = logging.getLogger(__name__) - -# Palette (mirrors the screen locker's dark, high-contrast lock aesthetic). -_BG = "#1a1a1a" -_FG = "#e0e0e0" -_ACCENT = "#00ff88" -_ERR = "#ff6666" -_FIELD_BG = "#2a2a2a" -_MUTED = "#9a9a9a" -# How long the "unlocking..." confirmation lingers before the window tears down. -_UNLOCK_DELAY_MS = 1200 -# Periodic no-op so the grabbed, event-starved loop keeps handing control back -# to Python, letting SIGTERM/SIGINT be serviced promptly. -_KEEPALIVE_MS = 250 -# A global input grab fails while another X client already holds one -- most -# often a FULLSCREEN GAME, which takes an exclusive keyboard/pointer grab. A -# single attempt then falls back to a *local* grab, which on an override-redirect -# window the WM refuses to focus means no keystroke ever reaches the field -- the -# "can't type anything" lock-trap. So the grab is retried for the window's whole -# life: the gate waits out the game and captures input the instant it is freed. -_GRAB_RETRY_MS = 200 -# How often (in attempts) to log that the grab is still blocked, so the journal -# shows the gate is alive and waiting rather than hung. ~every 5 s at 200 ms. -_GRAB_LOG_EVERY = 25 -# Number of food-bank / staple / OFF suggestions shown in the picker list. -_SUGGESTION_ROWS = 5 -# Grams a label's macros are assumed to describe when the "per" field is blank. -_DEFAULT_PER_GRAMS = 100.0 -# Unit-selector choices for how a portion is measured. -_UNIT_GRAMS = "grams" -_UNIT_ITEMS = "items" -# Per-basis label prefixes for the two measuring modes. -_BASIS_PREFIX_GRAMS = "Nutrition as on the label — per" -_BASIS_PREFIX_ITEMS = "Nutrition per 1 item ≈" -# How many recent meals the dashboard lists. -_DASHBOARD_ROWS = 5 -# ISO timestamp "YYYY-MM-DDTHH:MM:SS": HH:MM is characters 11..16. -_TIME_SLICE = slice(11, 16) -# Width a meal description is truncated to in the dashboard. -_DASH_DESC_WIDTH = 22 -# Fallback name for a multi-item meal when the user leaves the name field blank. -_DEFAULT_MEAL_NAME = "meal" -# -- display readiness (session-start race) --------------------------------- -# The gate's systemd timer fires the instant the user systemd instance starts -# (Persistent=true catch-up of the slot missed while the PC was off), which on a -# fresh login can BEAT the display manager writing ~/.Xauthority and the X server -# becoming reachable. That race -- not the slot logic -- is what silently -# dropped the session-start launch: _GateRoot() raised TclError ("couldn't -# connect to display") and the oneshot service died. So before building the -# window we poll the display until it is connectable; on timeout the gate exits -# cleanly and the next timer tick retries, instead of crashing. -_DISPLAY_WAIT_TIMEOUT_S = 60.0 -_DISPLAY_POLL_INTERVAL_S = 1.0 - - -def _display_is_ready() -> bool: - """Return True if a Tk root can connect to the X display right now. - - Builds and immediately destroys a throwaway, unmapped root -- the cheapest - way to ask "is DISPLAY reachable and authorized?" without opening a visible - window. A missing display or a not-yet-written X auth cookie raises - ``tk.TclError``, which is reported here as not-ready. - """ - try: - probe = tk.Tk() - except tk.TclError: - return False - probe.destroy() - return True - - -def wait_for_display( - *, - timeout_s: float = _DISPLAY_WAIT_TIMEOUT_S, - interval_s: float = _DISPLAY_POLL_INTERVAL_S, - sleep: Callable[[float], None] = time.sleep, - monotonic: Callable[[], float] = time.monotonic, -) -> bool: - """Block until the X display is connectable, or ``timeout_s`` elapses. - - Absorbs the session-start race in which the gate's timer fires before the - display manager has finished writing the X auth cookie (see the module - note). ``sleep`` and ``monotonic`` are injectable so the wait is tested - without real time passing. - - Args: - timeout_s: Total seconds to keep retrying before giving up. - interval_s: Seconds to wait between connection probes. - sleep: Sleep function (injected in tests). - monotonic: Monotonic clock (injected in tests). - - Returns: - True as soon as a probe connects; False if the deadline passes with the - display still unreachable (the caller should defer to the next tick). - """ - deadline = monotonic() + timeout_s - while True: - if _display_is_ready(): - return True - if monotonic() >= deadline: - _logger.warning( - "X display unreachable after %.0fs (session still settling?); " - "deferring the gate to the next timer tick", - timeout_s, - ) - return False - sleep(interval_s) - def _assert_not_under_pytest() -> None: """Raise if a real Tk gate is being built inside a pytest run. @@ -194,26 +75,6 @@ def _assert_not_under_pytest() -> None: raise RuntimeError(msg) -def _safe_float(raw: str) -> float | None: - """Return ``raw`` parsed as a float, or None if it is blank/non-numeric.""" - if not raw: - return None - try: - return float(raw) - except ValueError: - return None - - -def _format_preview(nutrition: Nutrition) -> str: - """Render the one-line "this is what will be logged" preview.""" - portion = f" · {nutrition.grams:g}g" if nutrition.grams else "" - return ( - f"→ {nutrition.kcal:g} kcal · " - f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}" - f"{portion} · {nutrition.source}" - ) - - def acquire_gate_lock() -> TextIO | None: """Acquire the gate's single-instance ``flock``. @@ -260,29 +121,7 @@ def _pending_slots(*, demo_mode: bool) -> list[int]: return [] -class _GateRoot(tk.Tk): - """Tk root that routes callback errors to a handler instead of crashing. - - Overriding ``report_callback_exception`` is the idiomatic, blind-except-free - way to guarantee that no exception raised inside a Tk callback escapes the - event loop -- essential while a global input grab is held. - """ - - on_callback_error: Callable[[], None] | None = None - - def report_callback_exception( - self, - exc: type[BaseException], - val: BaseException, - tb: TracebackType | None, - ) -> None: - """Log a callback error and notify the handler; never re-raise.""" - _logger.error("gate callback error", exc_info=(exc, val, tb)) - if self.on_callback_error is not None: - self.on_callback_error() - - -class MealGate: +class MealGate(_GateWindow, _GateMealFlow): """A fullscreen lock that dismisses only once every missing slot is logged.""" def __init__(self, *, demo_mode: bool = True) -> None: @@ -297,1038 +136,60 @@ class MealGate: self.demo_mode = demo_mode self._vt_disabled = False self._pending = _pending_slots(demo_mode=demo_mode) - # Provenance of the values currently in the reference fields ("manual", - # "food bank", "staple: apple", ...). Label only -- it never affects the - # maths, which read the fields directly -- so there is no second copy of - # the numbers to desync. Set when a food is picked/looked up; reset to - # "manual" the moment the user hand-edits a macro. - self._source = "manual" - # Suggestions currently listed, paired with their nutrition; the mode - # says whether picking one should also overwrite the description (bank - # entries are the user's own names) or only fill macros (OFF products). - self._suggestions: list[tuple[str, Nutrition]] = [] - self._suggestion_mode = "bank" - # The natural-basis nutrition of the food last picked or looked up (per - # 100 g for staples, per logged portion for banked foods). Kept so a - # grams<->items toggle can re-express it losslessly in the new basis; - # set to None the moment the user hand-edits a macro (then there is no - # clean reference to convert and the fields are cleared instead). - self._last_reference: Nutrition | None = None - # Components accumulated for a multi-item meal (salad + chicken + rice) - # before it is logged as one summed entry; empty for a single food. - self._meal_items: list[MealItem] = [] + # All mutable logical state (provenance, suggestions, meal-in-progress) + # lives in one bundle; see _GateState for the per-field rationale. + self._state = _GateState() self.root = _GateRoot() self.root.on_callback_error = self._handle_callback_error self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else "")) - self._status = tk.StringVar(master=self.root, value="") - self._slot_header = tk.StringVar(master=self.root, value="") - self._preview = tk.StringVar(master=self.root, value="") - self._projection = tk.StringVar(master=self.root, value="") - self._cal_headline = tk.StringVar(master=self.root, value="") - self._dashboard = tk.StringVar(master=self.root, value="") - self._meal_summary = tk.StringVar(master=self.root, value="") - self._unit = tk.StringVar(master=self.root, value=_UNIT_GRAMS) - self._desc_text: tk.Text - self._amount_entry: tk.Entry - self._per_entry: tk.Entry - self._basis_prefix: tk.Label - self._kcal_entry: tk.Entry - self._protein_entry: tk.Entry - self._carbs_entry: tk.Entry - self._fat_entry: tk.Entry - self._suggestion_box: tk.Listbox - self._meal_name_entry: tk.Entry - self._status_label: tk.Label + self._vars = make_vars(self.root) self._build() - # -- window mechanics (reused screen-locker pattern) -------------------- - - def _setup_window(self) -> None: - """Configure the lock window. - - Demo mode stays WM-managed so the window manager still grants it - keyboard focus -- and you can always close it -- making a usable, safe - sandbox. Only the real lock uses ``overrideredirect``, where the tiling - WM refuses focus and input is instead forced in by a global grab. - """ - screen_w = self.root.winfo_screenwidth() - screen_h = self.root.winfo_screenheight() - self.root.geometry(f"{screen_w}x{screen_h}+0+0") - self.root.attributes(topmost=True) - self.root.configure(bg=_BG, cursor="arrow") - if self.demo_mode: - self.root.attributes(fullscreen=True) - else: - self.root.overrideredirect(boolean=True) - self.root.attributes(fullscreen=True) - self._disable_vt_switching() - - def _disable_vt_switching(self) -> None: - """Block Ctrl+Alt+Fn TTY switching while the lock is up (best-effort).""" - setxkbmap = shutil.which("setxkbmap") - if setxkbmap is None: - _logger.warning("setxkbmap not found; VT switching stays enabled") - return - subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False) - self._vt_disabled = True - - def _restore_vt_switching(self) -> None: - """Re-enable VT switching; idempotent and safe to call on any exit.""" - if not self._vt_disabled: - return - setxkbmap = shutil.which("setxkbmap") - if setxkbmap is not None: - subprocess.run([setxkbmap, "-option", ""], check=False) - self._vt_disabled = False - - def _grab_input(self) -> None: - """Force input to the window, then focus the first field. - - Demo mode relies on normal WM focus (no grab), keeping the window an - escapable sandbox. The real lock forces *all* input here with a global - grab -- the only mechanism that reaches an overrideredirect window the - tiling WM will not focus. The grab is acquired with retries because it - commonly fails on the first attempt while the window is still mapping. - """ - self.root.update_idletasks() - self.root.focus_force() - if not self.demo_mode: - self._acquire_global_grab(attempt=1) - self.root.after(100, self._focus_first_field) - - def _acquire_global_grab(self, *, attempt: int) -> None: - """Acquire the global input grab, retrying until it succeeds. - - A successful global grab is the only way keystrokes reach the - override-redirect window the WM will not focus. When another client - (typically a fullscreen game) holds the grab, the attempt is rescheduled - indefinitely rather than conceding to an unusable local grab, so the gate - waits the other application out and captures input the moment it frees - the grab. On success, focus is forced onto the description field so the - first keystroke lands there. - - Args: - attempt: 1-based attempt counter, used only to throttle the log. - """ - try: - self.root.grab_set_global() - except tk.TclError: - if attempt % _GRAB_LOG_EVERY == 0: - _logger.warning( - "global grab still blocked after %d attempts (another app -- " - "e.g. a fullscreen game -- holds it); waiting for it to free", - attempt, - ) - self.root.after( - _GRAB_RETRY_MS, - lambda: self._acquire_global_grab(attempt=attempt + 1), - ) - return - with contextlib.suppress(tk.TclError): - self.root.focus_force() - self._focus_first_field() - - def _focus_first_field(self) -> None: - """Put keyboard focus on the description entry once it is mapped.""" - with contextlib.suppress(tk.TclError): - self._desc_text.focus_force() - - # -- UI construction ---------------------------------------------------- - def _build(self) -> None: - """Lay out the lock UI, seed the first slot prompt, and grab input.""" + """Lay out the UI, wire events, seed the first prompt, and grab input.""" self._setup_window() - frame = tk.Frame(self.root, bg=_BG) - frame.place(relx=0.5, rely=0.5, anchor="center") - - tk.Label( - frame, - text="🍽 Diet Gate", - font=("Arial", 30, "bold"), - bg=_BG, - fg=_ACCENT, - ).pack(pady=(0, 4)) - tk.Label( - frame, - textvariable=self._slot_header, - font=("Arial", 16, "bold"), - bg=_BG, - fg=_FG, - wraplength=900, - justify="center", - ).pack(pady=(0, 10)) - - self._build_desc(frame) - self._suggestion_box = self._build_suggestion_box(frame) - self._build_amount_row(frame) - self._build_macro_section(frame) - - for entry in (self._amount_entry, self._per_entry): - entry.bind("", self._on_return) - for entry in self._macro_entries(): - entry.bind("", self._on_return) - entry.bind("", self._on_macro_edit) - - tk.Label( - frame, - textvariable=self._projection, - font=("Arial", 13, "bold"), - bg=_BG, - fg=_FG, - wraplength=900, - justify="center", - ).pack(pady=(2, 2)) - - tk.Label( - frame, - textvariable=self._preview, - font=("Arial", 14, "bold"), - bg=_BG, - fg=_ACCENT, - wraplength=900, - justify="center", - ).pack(pady=(2, 6)) - - self._build_meal_controls(frame) - - tk.Button( - frame, - text="Log & Continue", - font=("Arial", 15, "bold"), - bg=_ACCENT, - fg="#003322", - activebackground="#00cc66", - cursor="hand2", - command=self._on_submit, - ).pack(pady=(4, 6)) - - self._status_label = tk.Label( - frame, - textvariable=self._status, - font=("Arial", 12), - bg=_BG, - fg=_FG, - wraplength=900, - justify="center", + callbacks = GateCallbacks( + on_unit_change=self._on_unit_change, + on_submit=self._on_submit, + on_close=self.close, + on_add_item=self._on_add_item, ) - self._status_label.pack() - - self._build_dashboard(frame) - - if self.demo_mode: - tk.Button( - self.root, - text="✕ Close Demo", - font=("Arial", 12), - bg="#ff4444", - fg="white", - command=self.close, - cursor="hand2", - ).place(x=10, y=10) - + self._widgets = build_layout( + self.root, + self._vars, + callbacks, + demo_mode=self.demo_mode, + ) + self._wire_events() self._relabel_basis() self._refresh_slot_header() self._refresh_dashboard() self._refresh_projection() self._grab_input() - self._desc_text.focus_set() + self._widgets.desc_text.focus_set() - def _build_desc(self, parent: tk.Frame) -> None: - """Build the wrapping, multi-line "what did you eat?" description box. + def _wire_events(self) -> None: + """Bind the live per-keystroke events to the freshly built widgets. - A multi-line ``Text`` (not an ``Entry``) so a long restaurant - description wraps onto a second line and stays fully visible, instead of - scrolling off the right edge where the end can no longer be read. + Construction-time commands (button and option-menu) are wired inside + ``build_layout``; the key bindings that drive lookup, scaling, and + submission are connected here, where the controller methods are in scope. """ - tk.Label( - parent, - text="What did you eat?", - font=("Arial", 12), - bg=_BG, - fg=_FG, - ).pack() - text = tk.Text( - parent, - font=("Arial", 15), - width=64, - height=2, - wrap="word", - bg=_FIELD_BG, - fg=_FG, - insertbackground=_FG, - highlightthickness=1, - highlightbackground=_MUTED, + widgets = self._widgets + widgets.desc_text.bind("", self._on_desc_keyrelease) + widgets.desc_text.bind("", self._on_desc_return) + widgets.suggestion_box.bind( + "<>", + self._on_suggestion_select, ) - text.pack(pady=(2, 6)) - text.bind("", self._on_desc_keyrelease) - text.bind("", self._on_desc_return) - self._desc_text = text - - def _get_desc(self) -> str: - """Return the description text, trimmed (a Text always trails a newline).""" - return self._desc_text.get("1.0", "end-1c").strip() - - def _set_desc(self, value: str) -> None: - """Replace the description box's contents with ``value``.""" - self._desc_text.delete("1.0", tk.END) - if value: - self._desc_text.insert("1.0", value) + for entry in (widgets.amount_entry, widgets.per_entry): + entry.bind("", self._on_amount_change) + entry.bind("", self._on_return) + for entry in self._macro_entries(): + entry.bind("", self._on_return) + entry.bind("", self._on_macro_edit) def _on_desc_return(self, _event: tk.Event[tk.Misc]) -> str: """Submit on Enter in the description box, suppressing the newline.""" self._on_submit() return "break" - - def _numeric_entry(self, parent: tk.Frame, *, width: int) -> tk.Entry: - """Return an entry that only accepts a number or a blank string.""" - vcmd = (self.root.register(self._is_numeric_or_blank), "%P") - return tk.Entry( - parent, - font=("Arial", 15), - width=width, - bg=_FIELD_BG, - fg=_FG, - insertbackground=_FG, - justify="center", - validate="key", - validatecommand=vcmd, - ) - - @staticmethod - def _is_numeric_or_blank(proposed: str) -> bool: - """Validate-on-key predicate: allow only a blank field or a number.""" - if proposed == "": - return True - try: - float(proposed) - except ValueError: - return False - return True - - def _build_suggestion_box(self, parent: tk.Frame) -> tk.Listbox: - """Build the food-bank / staple / OFF picker list and return it.""" - box = tk.Listbox( - parent, - font=("Arial", 12), - width=52, - height=_SUGGESTION_ROWS, - bg=_FIELD_BG, - fg=_FG, - selectbackground=_ACCENT, - selectforeground="#003322", - activestyle="none", - highlightthickness=0, - ) - box.bind("<>", self._on_suggestion_select) - box.pack(pady=(0, 8)) - return box - - def _build_amount_row(self, parent: tk.Frame) -> None: - """Build the centered "how much did you eat?" amount + unit row.""" - tk.Label( - parent, - text="How much did you eat?", - font=("Arial", 12), - bg=_BG, - fg=_FG, - ).pack() - row = tk.Frame(parent, bg=_BG) - row.pack(pady=(2, 6)) - self._amount_entry = self._numeric_entry(row, width=10) - self._amount_entry.pack(side="left", ipady=3) - self._amount_entry.bind("", self._on_amount_change) - unit_menu = tk.OptionMenu( - row, - self._unit, - _UNIT_GRAMS, - _UNIT_ITEMS, - command=self._on_unit_change, - ) - unit_menu.configure( - font=("Arial", 12), - bg=_FIELD_BG, - fg=_FG, - activebackground=_ACCENT, - highlightthickness=0, - ) - unit_menu.pack(side="left", padx=(8, 0)) - - def _build_macro_section(self, parent: tk.Frame) -> None: - """Build the per-basis field (grams or item weight) and macro row.""" - basis = tk.Frame(parent, bg=_BG) - basis.pack() - self._basis_prefix = tk.Label( - basis, - text=_BASIS_PREFIX_GRAMS, - font=("Arial", 12), - bg=_BG, - fg=_FG, - ) - self._basis_prefix.pack(side="left") - self._per_entry = self._numeric_entry(basis, width=5) - self._per_entry.insert(0, f"{_DEFAULT_PER_GRAMS:g}") - self._per_entry.pack(side="left", padx=4, ipady=2) - self._per_entry.bind("", self._on_amount_change) - tk.Label( - basis, - text="g (leave calories blank to look it up):", - font=("Arial", 12), - bg=_BG, - fg=_FG, - ).pack(side="left") - - row = tk.Frame(parent, bg=_BG) - row.pack(pady=(2, 6)) - self._kcal_entry = self._macro_cell(row, "kcal") - self._protein_entry = self._macro_cell(row, "P") - self._carbs_entry = self._macro_cell(row, "C") - self._fat_entry = self._macro_cell(row, "F") - - def _macro_cell(self, row: tk.Frame, label: str) -> tk.Entry: - """Pack one small labelled numeric entry into the macro row.""" - cell = tk.Frame(row, bg=_BG) - cell.pack(side="left", padx=6) - tk.Label(cell, text=label, font=("Arial", 11), bg=_BG, fg=_FG).pack() - entry = self._numeric_entry(cell, width=7) - entry.pack(ipady=3) - return entry - - def _macro_entries(self) -> tuple[tk.Entry, ...]: - """Return the four numeric entry widgets in (kcal, P, C, F) order.""" - return ( - self._kcal_entry, - self._protein_entry, - self._carbs_entry, - self._fat_entry, - ) - - def _build_dashboard(self, parent: tk.Frame) -> None: - """Build the running "how am I doing today" panel. - - The calorie line is large and prominent (the number the user steers by); - the meal list and macros sit beneath it in a smaller monospace block. - """ - tk.Label( - parent, - textvariable=self._cal_headline, - font=("Arial", 22, "bold"), - bg=_BG, - fg=_ACCENT, - ).pack(pady=(12, 0)) - tk.Label( - parent, - textvariable=self._dashboard, - font=("Courier", 11), - bg=_BG, - fg=_MUTED, - justify="left", - anchor="w", - wraplength=900, - ).pack(pady=(2, 0)) - - def _build_meal_controls(self, parent: tk.Frame) -> None: - """Build the optional multi-item meal row: name, add button, running sum. - - Logging stays one-tap for a single food; these controls only matter when - a meal has several separately-macroed parts (a dinner of salad + chicken - + rice). "Add item" banks the part onto the meal-in-progress and clears - the form for the next one; "Log & Continue" then logs the summed meal. - """ - row = tk.Frame(parent, bg=_BG) - row.pack(pady=(2, 2)) - tk.Label( - row, - text="Meal name (optional):", - font=("Arial", 11), - bg=_BG, - fg=_FG, - ).pack(side="left") - self._meal_name_entry = tk.Entry( - row, - font=("Arial", 13), - width=18, - bg=_FIELD_BG, - fg=_FG, - insertbackground=_FG, - ) - self._meal_name_entry.pack(side="left", padx=(6, 8), ipady=2) - tk.Button( - row, - text="+ Add item", - font=("Arial", 12, "bold"), - bg=_FIELD_BG, - fg=_ACCENT, - activebackground="#333333", - cursor="hand2", - command=self._on_add_item, - ).pack(side="left") - tk.Label( - parent, - textvariable=self._meal_summary, - font=("Arial", 11), - bg=_BG, - fg=_MUTED, - wraplength=900, - justify="center", - ).pack(pady=(0, 2)) - - # -- slot walk ---------------------------------------------------------- - - def _refresh_slot_header(self) -> None: - """Update the header to prompt for the slot now being collected.""" - total = len(self._pending) - if total == 0: - self._slot_header.set("All meals logged.") - return - slot = self._pending[0] - position = "" if total == 1 else f" (1 of {total} remaining)" - self._slot_header.set(f"Log your {slot_label(slot)} meal{position}") - - def _clear_food_inputs(self) -> None: - """Empty the food fields, picker, preview, and basis (keeps any meal).""" - self._set_desc("") - self._amount_entry.delete(0, tk.END) - self._unit.set(_UNIT_GRAMS) - self._relabel_basis() - self._reset_per_default() - for entry in self._macro_entries(): - entry.delete(0, tk.END) - self._suggestion_box.delete(0, tk.END) - self._suggestions = [] - self._source = "manual" - self._last_reference = None - self._preview.set("") - self._refresh_projection() - - def _clear_inputs(self) -> None: - """Empty the food fields and discard any in-progress meal (new slot).""" - self._clear_food_inputs() - self._meal_items = [] - self._meal_name_entry.delete(0, tk.END) - self._meal_summary.set("") - - def _reset_per_default(self) -> None: - """Set the "per" field to the basis default for the current unit.""" - self._per_entry.delete(0, tk.END) - if self._unit.get() == _UNIT_ITEMS: - grams = estimate_unit_grams(self._get_desc()) - self._per_entry.insert( - 0, f"{grams if grams is not None else DEFAULT_ITEM_GRAMS:g}" - ) - else: - self._per_entry.insert(0, f"{_DEFAULT_PER_GRAMS:g}") - - def _relabel_basis(self) -> None: - """Point the per-basis label at grams or per-item for the current unit.""" - items = self._unit.get() == _UNIT_ITEMS - self._basis_prefix.config( - text=_BASIS_PREFIX_ITEMS if items else _BASIS_PREFIX_GRAMS, - ) - - # -- field helpers ------------------------------------------------------ - - def _basis_grams(self) -> float: - """Return the grams the label macros describe (per 100 g or per item). - - Honours an explicit "per" value when the user has typed one; otherwise - falls back to one piece's weight in items mode, or 100 g in grams mode. - """ - typed = _safe_float(self._per_entry.get().strip()) - if typed is not None and typed > 0: - return typed - if self._unit.get() == _UNIT_ITEMS: - grams = estimate_unit_grams(self._get_desc()) - return grams if grams is not None else DEFAULT_ITEM_GRAMS - return _DEFAULT_PER_GRAMS - - def _eaten_grams(self) -> float | None: - """Return how many grams were eaten, or None if no amount is entered. - - In grams mode the amount *is* the grams; in items mode it is multiplied - by one piece's weight (the "per" field), so "5 apples" becomes a weight. - """ - amount = _safe_float(self._amount_entry.get().strip()) - if amount is None: - return None - if self._unit.get() == _UNIT_ITEMS: - return amount * self._basis_grams() - return amount - - def _macro_values(self) -> tuple[float | None, ...] | None: - """Return ``(kcal, P, C, F)`` floats/None, or None if any is non-numeric.""" - values: list[float | None] = [] - for entry in self._macro_entries(): - raw = entry.get().strip() - parsed = _safe_float(raw) - if raw and parsed is None: - return None - values.append(parsed) - return tuple(values) - - def _set_entry(self, entry: tk.Entry, value: str) -> None: - """Replace an entry's contents with ``value``.""" - entry.delete(0, tk.END) - entry.insert(0, value) - - def _fill_macro_fields(self, nutrition: Nutrition) -> None: - """Write a nutrition's macros into the kcal/P/C/F fields.""" - pairs = zip( - self._macro_entries(), - ( - nutrition.kcal, - nutrition.protein_g, - nutrition.carbs_g, - nutrition.fat_g, - ), - strict=True, - ) - for entry, value in pairs: - self._set_entry(entry, f"{value:g}") - - # -- the reference -> total model -------------------------------------- - - def _reference_nutrition(self) -> Nutrition | None: - """Return the label values as a Nutrition, or None if calories are blank. - - This is the *reference* (macros for one basis -- per 100 g, or per item), - not the total: how much was eaten scales it in :meth:`_current_nutrition`. - """ - values = self._macro_values() - if values is None or values[0] is None: - return None - return Nutrition( - kcal=values[0], - protein_g=values[1] or 0.0, - carbs_g=values[2] or 0.0, - fat_g=values[3] or 0.0, - grams=self._basis_grams(), - source=self._source, - ) - - def _current_nutrition(self) -> Nutrition | None: - """Return exactly what would be logged now, or None if not yet resolvable. - - The label reference scaled to the amount eaten. With no amount yet, the - reference itself stands in (one basis portion), so the preview is never - empty just because an amount has not been typed. - """ - reference = self._reference_nutrition() - if reference is None: - return None - eaten = self._eaten_grams() - return scale_nutrition(reference, eaten) if eaten is not None else reference - - def _refresh_preview(self) -> None: - """Recompute the preview line and the live calorie projection.""" - nutrition = self._current_nutrition() - self._preview.set(_format_preview(nutrition) if nutrition is not None else "") - self._refresh_projection() - - def _refresh_projection(self) -> None: - """Show consumed / budget / remaining, and what is left after this item. - - This answers, as the calories are typed, the four numbers the user asked - to see together: how much is already eaten today, the day's goal, how - much is left now, and how much would be left *after* logging the food - currently in the form. With no budget sealed it degrades to the running - total plus this item's calories, so it is always informative. - """ - consumed = today_total_kcal() - nutrition = self._current_nutrition() - this_kcal = nutrition.kcal if nutrition is not None else 0.0 - try: - budget = daily_budget() - except (BudgetError, OSError): - tail = f" · this item {this_kcal:g} kcal" if this_kcal else "" - self._projection.set(f"Consumed {consumed:g} kcal today{tail}") - return - left = round(budget - consumed, 1) - base = f"Consumed {consumed:g} / {budget:g} kcal · {left:g} left" - if this_kcal: - after = round(budget - consumed - this_kcal, 1) - self._projection.set(f"{base} → after this item: {after:g} left") - else: - self._projection.set(base) - - # -- autocomplete / lookup --------------------------------------------- - - def _on_desc_keyrelease(self, _event: tk.Event[tk.Misc]) -> None: - """Refresh suggestions; in items mode, show the piece's weight.""" - query = self._get_desc() - self._populate_suggestions(query) - # In items mode, surface a recognised piece's weight as it is typed, so - # "apple" visibly becomes "≈ 182 g" rather than a hidden assumption. - if self._unit.get() == _UNIT_ITEMS: - grams = estimate_unit_grams(query) - if grams is not None: - self._set_entry(self._per_entry, f"{grams:g}") - self._refresh_preview() - - def _populate_suggestions(self, query: str) -> None: - """Fill the picker with banked foods and matching staples for ``query``.""" - self._suggestion_mode = "bank" - self._suggestions = suggest_foods(query, limit=_SUGGESTION_ROWS) - self._suggestion_box.delete(0, tk.END) - for name, nutrition in self._suggestions: - self._suggestion_box.insert(tk.END, f"{name} ({nutrition.kcal:g} kcal)") - - def _show_candidates(self, candidates: list[tuple[str, Nutrition]]) -> None: - """Fill the picker with looked-up alternatives to choose from.""" - self._suggestion_mode = "candidates" - self._suggestions = candidates - self._suggestion_box.delete(0, tk.END) - for label, nutrition in candidates: - self._suggestion_box.insert( - tk.END, - f"{label} ({nutrition.kcal:g} kcal · {nutrition.grams:g}g)", - ) - - def _on_suggestion_select(self, _event: tk.Event[tk.Misc]) -> None: - """Fill the form from the picked suggestion.""" - selection = self._suggestion_box.curselection() - if not selection: - return - index = selection[0] - if index >= len(self._suggestions): - return - name, nutrition = self._suggestions[index] - # Banked/staple entries carry a name, so adopt it; OFF products only - # supply macros and must not overwrite what the user typed. - if self._suggestion_mode == "bank": - self._apply_reference(nutrition, name=name) - else: - self._apply_reference(nutrition) - - def _apply_reference( - self, nutrition: Nutrition, *, name: str | None = None - ) -> None: - """Adopt ``nutrition`` as the reference and mirror it into the fields. - - In grams mode the food's own weight is the "per" basis and its macros - fill the fields directly. In items mode the per-100 g reference is - converted to a single piece (its weight shown in "per"), so the macro - fields read *per item*. The amount eaten does the scaling either way. - """ - self._source = nutrition.source - self._last_reference = nutrition - if name is not None: - self._set_desc(name) - if self._unit.get() == _UNIT_ITEMS: - grams = estimate_unit_grams(self._get_desc()) - unit = grams if grams is not None else DEFAULT_ITEM_GRAMS - self._set_entry(self._per_entry, f"{unit:g}") - self._fill_macro_fields(scale_nutrition(nutrition, unit)) - else: - basis = nutrition.grams or _DEFAULT_PER_GRAMS - self._set_entry(self._per_entry, f"{basis:g}") - self._fill_macro_fields(nutrition) - # Default the eaten amount to one reference portion so a pick is - # immediately loggable (grams mode only -- items need a count). - if not self._amount_entry.get().strip() and nutrition.grams: - self._set_entry(self._amount_entry, f"{nutrition.grams:g}") - self._refresh_preview() - - # -- live recompute ----------------------------------------------------- - - def _on_amount_change(self, _event: tk.Event[tk.Misc]) -> None: - """Recompute the preview when the amount or basis changes. - - Crucially this does *not* rewrite the macro fields: those hold the label - reference, and only the previewed/logged total reflects the new amount. - """ - self._refresh_preview() - - def _on_unit_change(self, _value: str) -> None: - """Switch grams<->items, re-expressing the picked food in the new basis. - - The macro fields mean different things in each mode (per 100 g / per - portion vs per item). When a food was picked or looked up, its stored - reference is re-applied so toggling converts the values back and forth - losslessly. A hand-typed (manual) entry has no clean reference to - convert, so its fields are cleared to be re-entered in the new basis - rather than silently reinterpreted. - """ - self._relabel_basis() - self._amount_entry.delete(0, tk.END) - if self._last_reference is not None: - self._apply_reference(self._last_reference) - return - for entry in self._macro_entries(): - entry.delete(0, tk.END) - self._reset_per_default() - self._source = "manual" - self._refresh_preview() - - def _on_macro_edit(self, _event: tk.Event[tk.Misc]) -> None: - """A hand-edited macro becomes the manual reference from here on. - - Editing a macro by hand invalidates the picked food's stored reference: - the fields no longer match it, so a later unit toggle must not snap them - back to it. - """ - self._source = "manual" - self._last_reference = None - self._refresh_preview() - - # -- behaviour ---------------------------------------------------------- - - def _set_status(self, text: str, *, error: bool = False) -> None: - """Update the status line, red for errors.""" - self._status.set(text) - self._status_label.config(fg=_ERR if error else _FG) - - def _on_return(self, _event: tk.Event[tk.Misc]) -> None: - """Handle the Enter key in any entry field.""" - self._on_submit() - - def _on_submit(self) -> None: - """Validate, then look up, or log -- as a single food or a summed meal. - - With a meal in progress, an empty form finalizes the accumulated items, - and a completed form adds itself as the meal's last item before logging. - With no meal in progress this is the original single-food path. - """ - description = self._get_desc() - if not description: - if self._meal_items: - self._log_meal() - return - self._set_status("Type what you ate first.", error=True) - self._desc_text.focus_set() - return - - values = self._macro_values() - if values is None: - self._set_status("Macros must be numbers.", error=True) - self._kcal_entry.focus_set() - return - - if values[0] is None: - self._begin_lookup(description) - return - nutrition = self._current_nutrition() - if nutrition is None: - self._set_status("Enter the calories, then submit.", error=True) - self._kcal_entry.focus_set() - return - if self._meal_items: - self._meal_items.append(MealItem(description, nutrition)) - self._log_meal() - return - self._record(description, nutrition) - - def _begin_lookup(self, description: str) -> None: - """Step 1: look the food up, fill the label fields, offer alternatives. - - Nothing is logged here -- the user must see and confirm the filled - values (a second submit) before they are recorded. The food is looked - up at its natural basis (per 100 g / serving); the amount eaten scales - it, so the lookup never bakes in a portion. - """ - self._set_status("looking up…") - self.root.update_idletasks() - candidates = lookup_candidates(description) - if not candidates: - self._set_status( - "Couldn't look that up. Enter the calories yourself, then submit.", - error=True, - ) - self._kcal_entry.focus_set() - return - self._show_candidates(candidates) - self._apply_reference(candidates[0][1]) - source = candidates[0][1].source - tail = ( - "Review, or pick another below, then submit to log." - if len(candidates) > 1 - else "Review the values, then submit to log." - ) - self._set_status(f"Filled from {source}. {tail}") - - def _record(self, description: str, nutrition: Nutrition) -> None: - """Log and bank a single food for the current slot, then advance.""" - log_meal(description, nutrition, self._slot_for_log()) - remember_food(description, nutrition) - self._finish_slot(f"{nutrition.kcal:g} kcal ({nutrition.source})") - - def _meal_name(self) -> str: - """Return the trimmed meal name the user typed (empty if none).""" - return self._meal_name_entry.get().strip() - - def _refresh_meal_summary(self) -> None: - """Update the running "meal so far" line from the accumulated items.""" - if not self._meal_items: - self._meal_summary.set("") - return - total = meal_total(self._meal_items) - names = ", ".join(item.name for item in self._meal_items) - self._meal_summary.set( - f"Meal so far ({len(self._meal_items)}): {names} → " - f"{total.kcal:g} kcal · P{total.protein_g:g} " - f"C{total.carbs_g:g} F{total.fat_g:g}", - ) - - def _on_add_item(self) -> None: - """Add the current form as one component of a multi-part meal. - - Requires a name and resolved calories (a blank calorie field triggers a - lookup first, exactly like submitting). On success the item is appended - to the meal-in-progress, the running total updates, and the food fields - clear for the next item while the meal name is kept. - """ - description = self._get_desc() - if not description: - self._set_status("Type the item first, then add it.", error=True) - self._desc_text.focus_set() - return - values = self._macro_values() - if values is None: - self._set_status("Macros must be numbers.", error=True) - self._kcal_entry.focus_set() - return - if values[0] is None: - self._begin_lookup(description) - return - nutrition = self._current_nutrition() - if nutrition is None: - self._set_status("Enter the calories, then add the item.", error=True) - self._kcal_entry.focus_set() - return - self._meal_items.append(MealItem(description, nutrition)) - self._refresh_meal_summary() - self._clear_food_inputs() - self._set_status(f"Added {description}. Add another, or Log & Continue.") - self._desc_text.focus_set() - - def _slot_for_log(self) -> int | None: - """Return the slot to tag a log with -- None in demo (satisfies no slot). - - A synthetic demo slot must never satisfy a real checkpoint, so demo logs - are slot-less: they still bank the food and update the dashboard, but do - not silently stop the production gate from firing. - """ - return None if self.demo_mode else self._pending[0] - - def _log_meal(self) -> None: - """Log the accumulated multi-item meal for the current slot and advance. - - Each component and the summed composite are banked (see - :func:`python_pkg.diet_guard._foodbank.remember_meal`), and the slot is - satisfied by the summed total under the meal's name. - """ - name = self._meal_name() or _DEFAULT_MEAL_NAME - count = len(self._meal_items) - total = remember_meal(name, list(self._meal_items)) - log_meal(name, total, self._slot_for_log()) - self._meal_items = [] - self._finish_slot(f"{name}: {total.kcal:g} kcal ({count} items)") - - def _finish_slot(self, summary: str) -> None: - """Advance past the current slot after something was logged for it. - - Args: - summary: A short description of what was logged (calories/source, or - the meal name and item count), shown in the confirmation line. - """ - slot = self._pending[0] - self._pending.pop(0) - self._refresh_dashboard() - logged = f"Logged {slot_label(slot)}: {summary}" - if not self._pending: - self._unlock(logged) - return - self._clear_inputs() - self._refresh_slot_header() - self._set_status(f"{logged} — next meal, please.") - self._desc_text.focus_set() - - def _unlock(self, logged: str) -> None: - """Confirm the final log and tear the window down. - - Teardown is scheduled *before* the budget is looked up, so a broken - budget seal (which raises) can never re-trap the user at unlock time. - """ - self._set_status(f"{logged} — all meals logged, unlocking…") - self.root.after(_UNLOCK_DELAY_MS, self.close) - - # -- dashboard ---------------------------------------------------------- - - def _refresh_dashboard(self) -> None: - """Recompute the prominent calorie headline and the detail panel.""" - self._cal_headline.set(self._cal_headline_text()) - self._dashboard.set(self._dashboard_text()) - - def _cal_headline_text(self) -> str: - """Return the big calories-today line: consumed, target, and remaining.""" - consumed = today_total_kcal() - try: - budget = daily_budget() - except (BudgetError, OSError): - return f"{consumed:g} kcal today" - return ( - f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left" - ) - - def _dashboard_text(self) -> str: - """Build the detail panel: recent meals, then macros and protein.""" - lines = ["── Today ───────────────────────────────"] - entries = today_entries() - if entries: - for entry in entries[-_DASHBOARD_ROWS:]: - clock = str(entry.get("time", ""))[_TIME_SLICE] - desc = str(entry.get("desc", "?"))[:_DASH_DESC_WIDTH] - lines.append( - f" {clock:>5} {desc:<{_DASH_DESC_WIDTH}} " - f"{entry_kcal(entry):>5.0f} kcal", - ) - else: - lines.append(" (nothing logged yet today)") - protein, carbs, fat = today_total_macros() - lines.append(f" macros so far: P{protein:g} C{carbs:g} F{fat:g} g") - target = protein_target_g() - if target is not None: - left = round(target - protein, 1) - lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)") - return "\n".join(lines) - - def _handle_callback_error(self) -> None: - """Surface an unexpected callback error without dropping the grab.""" - self._set_status( - "Something went wrong. Enter the calories, then submit again.", - error=True, - ) - with contextlib.suppress(tk.TclError): - self._kcal_entry.focus_set() - - # -- lifecycle ---------------------------------------------------------- - - def _install_signal_handlers(self) -> None: - """Ensure VT switching is restored on crash or kill, not just close.""" - atexit.register(self._restore_vt_switching) - for sig in (signal.SIGTERM, signal.SIGINT): - with contextlib.suppress(ValueError): - signal.signal(sig, self._on_signal) - - def _on_signal(self, _signum: int, _frame: FrameType | None) -> None: - """Restore the keyboard escape, then exit, on SIGTERM/SIGINT.""" - self._restore_vt_switching() - raise SystemExit(0) - - def _keepalive(self) -> None: - """Re-arm a periodic no-op so pending signals get serviced promptly.""" - self.root.after(_KEEPALIVE_MS, self._keepalive) - - def close(self) -> None: - """Restore VT switching and destroy the window (no process exit).""" - self._restore_vt_switching() - with contextlib.suppress(tk.TclError): - self.root.destroy() - - def run(self) -> None: - """Run the Tk loop, restoring VT switching on every exit path.""" - self._install_signal_handlers() - self._keepalive() - try: - self.root.mainloop() - finally: - self._restore_vt_switching() diff --git a/python_pkg/diet_guard/_gatelock_core.py b/python_pkg/diet_guard/_gatelock_core.py new file mode 100644 index 0000000..e8f17e3 --- /dev/null +++ b/python_pkg/diet_guard/_gatelock_core.py @@ -0,0 +1,217 @@ +"""Shared base class, root window, and state for the MealGate gate. + +Split out of :mod:`._gatelock` to keep that module under the repo's 500-line +limit. ``_GateCore`` holds the leaf widget/field helpers that every other +gatelock mixin (`_gatelock_window`, `_gatelock_nutrition`, +`_gatelock_mealflow`) derives from, plus the small dataclass (`_GateState`) +and Tk root subclass (`_GateRoot`) that :mod:`._gatelock` itself depends on. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import logging +import tkinter as tk +from typing import TYPE_CHECKING + +from python_pkg.diet_guard._gatelock_ui import ( + BASIS_PREFIX_GRAMS, + BASIS_PREFIX_ITEMS, + DEFAULT_PER_GRAMS, + UNIT_ITEMS, + GateVars, + GateWidgets, +) +from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams +from python_pkg.diet_guard._slots import slot_label + +if TYPE_CHECKING: + from collections.abc import Callable + from types import TracebackType + + from python_pkg.diet_guard._estimator import Nutrition + from python_pkg.diet_guard._meal import MealItem + +_logger = logging.getLogger(__name__) + + +def _safe_float(raw: str) -> float | None: + """Return ``raw`` parsed as a float, or None if it is blank/non-numeric.""" + if not raw: + return None + try: + return float(raw) + except ValueError: + return None + + +class _GateRoot(tk.Tk): + """Tk root that routes callback errors to a handler instead of crashing. + + Overriding ``report_callback_exception`` is the idiomatic, blind-except-free + way to guarantee that no exception raised inside a Tk callback escapes the + event loop -- essential while a global input grab is held. + """ + + on_callback_error: Callable[[], None] | None = None + + def report_callback_exception( + self, + exc: type[BaseException], + val: BaseException, + tb: TracebackType | None, + ) -> None: + """Log a callback error and notify the handler; never re-raise.""" + _logger.error("gate callback error", exc_info=(exc, val, tb)) + if self.on_callback_error is not None: + self.on_callback_error() + + +@dataclass +class _GateState: + """Mutable logical state of the in-progress entry (no widget references). + + ``source`` is the provenance of the values in the reference fields + ("manual", "food bank", "staple: apple", ...). It is a label only -- the + maths read the fields directly -- so there is no second copy of the numbers + to desync; it resets to "manual" the moment a macro is hand-edited. + ``suggestions`` pairs each listed pick with its nutrition, and + ``suggestion_mode`` says whether picking one overwrites the description + (bank entries are the user's own names) or only fills macros (OFF products). + ``last_reference`` is the natural-basis nutrition of the food last picked + or looked up, kept so a grams<->items toggle can re-express it losslessly; + it is cleared the moment a macro is hand-edited. ``meal_items`` accumulates + the parts of a multi-item meal before they are logged as one summed entry. + """ + + source: str = "manual" + suggestions: list[tuple[str, Nutrition]] = field(default_factory=list) + suggestion_mode: str = "bank" + last_reference: Nutrition | None = None + meal_items: list[MealItem] = field(default_factory=list) + + +class _GateCore: + """Leaf widget/field helpers shared by every MealGate mixin. + + Declares the attributes that + :class:`~python_pkg.diet_guard._gatelock.MealGate` sets up in ``__init__`` + and ``_build`` so subclasses can reference them without tripping pylint's + no-member check. + """ + + root: _GateRoot + demo_mode: bool + _vt_disabled: bool + _pending: list[int] + _state: _GateState + _vars: GateVars + _widgets: GateWidgets + close: Callable[[], None] + + # -- description field --------------------------------------------------- + + def _get_desc(self) -> str: + """Return the description text, trimmed (a Text always trails a newline).""" + return self._widgets.desc_text.get("1.0", "end-1c").strip() + + def _set_desc(self, value: str) -> None: + """Replace the description box's contents with ``value``.""" + self._widgets.desc_text.delete("1.0", tk.END) + if value: + self._widgets.desc_text.insert("1.0", value) + + def _macro_entries(self) -> tuple[tk.Entry, ...]: + """Return the four numeric entry widgets in (kcal, P, C, F) order.""" + macros = self._widgets.macros + return (macros.kcal, macros.protein, macros.carbs, macros.fat) + + # -- slot walk -------------------------------------------------------------- + + def _refresh_slot_header(self) -> None: + """Update the header to prompt for the slot now being collected.""" + total = len(self._pending) + if total == 0: + self._vars.slot_header.set("All meals logged.") + return + slot = self._pending[0] + position = "" if total == 1 else f" (1 of {total} remaining)" + self._vars.slot_header.set(f"Log your {slot_label(slot)} meal{position}") + + def _reset_per_default(self) -> None: + """Set the "per" field to the basis default for the current unit.""" + self._widgets.per_entry.delete(0, tk.END) + if self._vars.unit.get() == UNIT_ITEMS: + grams = estimate_unit_grams(self._get_desc()) + self._widgets.per_entry.insert( + 0, f"{grams if grams is not None else DEFAULT_ITEM_GRAMS:g}" + ) + else: + self._widgets.per_entry.insert(0, f"{DEFAULT_PER_GRAMS:g}") + + def _relabel_basis(self) -> None: + """Point the per-basis label at grams or per-item for the current unit.""" + items = self._vars.unit.get() == UNIT_ITEMS + self._widgets.basis_prefix.config( + text=BASIS_PREFIX_ITEMS if items else BASIS_PREFIX_GRAMS, + ) + + # -- field helpers ------------------------------------------------------ + + def _basis_grams(self) -> float: + """Return the grams the label macros describe (per 100 g or per item). + + Honours an explicit "per" value when the user has typed one; otherwise + falls back to one piece's weight in items mode, or 100 g in grams mode. + """ + typed = _safe_float(self._widgets.per_entry.get().strip()) + if typed is not None and typed > 0: + return typed + if self._vars.unit.get() == UNIT_ITEMS: + grams = estimate_unit_grams(self._get_desc()) + return grams if grams is not None else DEFAULT_ITEM_GRAMS + return DEFAULT_PER_GRAMS + + def _eaten_grams(self) -> float | None: + """Return how many grams were eaten, or None if no amount is entered. + + In grams mode the amount *is* the grams; in items mode it is multiplied + by one piece's weight (the "per" field), so "5 apples" becomes a weight. + """ + amount = _safe_float(self._widgets.amount_entry.get().strip()) + if amount is None: + return None + if self._vars.unit.get() == UNIT_ITEMS: + return amount * self._basis_grams() + return amount + + def _macro_values(self) -> tuple[float | None, ...] | None: + """Return ``(kcal, P, C, F)`` floats/None, or None if any is non-numeric.""" + values: list[float | None] = [] + for entry in self._macro_entries(): + raw = entry.get().strip() + parsed = _safe_float(raw) + if raw and parsed is None: + return None + values.append(parsed) + return tuple(values) + + def _set_entry(self, entry: tk.Entry, value: str) -> None: + """Replace an entry's contents with ``value``.""" + entry.delete(0, tk.END) + entry.insert(0, value) + + def _fill_macro_fields(self, nutrition: Nutrition) -> None: + """Write a nutrition's macros into the kcal/P/C/F fields.""" + pairs = zip( + self._macro_entries(), + ( + nutrition.kcal, + nutrition.protein_g, + nutrition.carbs_g, + nutrition.fat_g, + ), + strict=True, + ) + for entry, value in pairs: + self._set_entry(entry, f"{value:g}") diff --git a/python_pkg/diet_guard/_gatelock_mealflow.py b/python_pkg/diet_guard/_gatelock_mealflow.py new file mode 100644 index 0000000..359646f --- /dev/null +++ b/python_pkg/diet_guard/_gatelock_mealflow.py @@ -0,0 +1,302 @@ +"""Submit/record/meal-building flow and dashboard for the MealGate gate. + +Split out of :mod:`._gatelock` to keep that module under the repo's 500-line +limit. ``_GateMealFlow`` extends +:class:`~python_pkg.diet_guard._gatelock_nutrition._GateNutrition` with the +submit/lookup/log flow for single foods and multi-item meals, the per-slot +input reset, and the running calorie/macro dashboard. +""" + +from __future__ import annotations + +import contextlib +import tkinter as tk +from typing import TYPE_CHECKING + +from python_pkg.diet_guard._budget import BudgetError, daily_budget, protein_target_g +from python_pkg.diet_guard._foodbank import remember_food, remember_meal +from python_pkg.diet_guard._gatelock_nutrition import _GateNutrition +from python_pkg.diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS +from python_pkg.diet_guard._meal import MealItem, meal_total +from python_pkg.diet_guard._resolve import lookup_candidates +from python_pkg.diet_guard._slots import slot_label +from python_pkg.diet_guard._state import ( + entry_kcal, + log_meal, + today_entries, + today_total_kcal, + today_total_macros, +) + +if TYPE_CHECKING: + from python_pkg.diet_guard._estimator import Nutrition + +# How long the "unlocking..." confirmation lingers before the window tears down. +_UNLOCK_DELAY_MS = 1200 +# How many recent meals the dashboard lists. +_DASHBOARD_ROWS = 5 +# ISO timestamp "YYYY-MM-DDTHH:MM:SS": HH:MM is characters 11..16. +_TIME_SLICE = slice(11, 16) +# Width a meal description is truncated to in the dashboard. +_DASH_DESC_WIDTH = 22 +# Fallback name for a multi-item meal when the user leaves the name field blank. +_DEFAULT_MEAL_NAME = "meal" + + +class _GateMealFlow(_GateNutrition): + """Submit/lookup/log flow for single foods and multi-item meals.""" + + # -- slot walk (meal-in-progress reset) ---------------------------------- + + def _clear_food_inputs(self) -> None: + """Empty the food fields, picker, preview, and basis (keeps any meal).""" + self._set_desc("") + self._widgets.amount_entry.delete(0, tk.END) + self._vars.unit.set(UNIT_GRAMS) + self._relabel_basis() + self._reset_per_default() + for entry in self._macro_entries(): + entry.delete(0, tk.END) + self._widgets.suggestion_box.delete(0, tk.END) + self._state.suggestions = [] + self._state.source = "manual" + self._state.last_reference = None + self._vars.preview.set("") + self._refresh_projection() + + def _clear_inputs(self) -> None: + """Empty the food fields and discard any in-progress meal (new slot).""" + self._clear_food_inputs() + self._state.meal_items = [] + self._widgets.meal_name_entry.delete(0, tk.END) + self._vars.meal_summary.set("") + + # -- behaviour ------------------------------------------------------------ + + def _set_status(self, text: str, *, error: bool = False) -> None: + """Update the status line, red for errors.""" + self._vars.status.set(text) + self._widgets.status_label.config(fg=ERR if error else FG) + + def _on_return(self, _event: tk.Event[tk.Misc]) -> None: + """Handle the Enter key in any entry field.""" + self._on_submit() + + def _on_submit(self) -> None: + """Validate, then look up, or log -- as a single food or a summed meal. + + With a meal in progress, an empty form finalizes the accumulated items, + and a completed form adds itself as the meal's last item before logging. + With no meal in progress this is the original single-food path. + """ + description = self._get_desc() + if not description: + if self._state.meal_items: + self._log_meal() + return + self._set_status("Type what you ate first.", error=True) + self._widgets.desc_text.focus_set() + return + + values = self._macro_values() + if values is None: + self._set_status("Macros must be numbers.", error=True) + self._widgets.macros.kcal.focus_set() + return + + if values[0] is None: + self._begin_lookup(description) + return + nutrition = self._current_nutrition() + if nutrition is None: + self._set_status("Enter the calories, then submit.", error=True) + self._widgets.macros.kcal.focus_set() + return + if self._state.meal_items: + self._state.meal_items.append(MealItem(description, nutrition)) + self._log_meal() + return + self._record(description, nutrition) + + def _begin_lookup(self, description: str) -> None: + """Step 1: look the food up, fill the label fields, offer alternatives. + + Nothing is logged here -- the user must see and confirm the filled + values (a second submit) before they are recorded. The food is looked + up at its natural basis (per 100 g / serving); the amount eaten scales + it, so the lookup never bakes in a portion. + """ + self._set_status("looking up…") + self.root.update_idletasks() + candidates = lookup_candidates(description) + if not candidates: + self._set_status( + "Couldn't look that up. Enter the calories yourself, then submit.", + error=True, + ) + self._widgets.macros.kcal.focus_set() + return + self._show_candidates(candidates) + self._apply_reference(candidates[0][1]) + source = candidates[0][1].source + tail = ( + "Review, or pick another below, then submit to log." + if len(candidates) > 1 + else "Review the values, then submit to log." + ) + self._set_status(f"Filled from {source}. {tail}") + + def _record(self, description: str, nutrition: Nutrition) -> None: + """Log and bank a single food for the current slot, then advance.""" + log_meal(description, nutrition, self._slot_for_log()) + remember_food(description, nutrition) + self._finish_slot(f"{nutrition.kcal:g} kcal ({nutrition.source})") + + def _meal_name(self) -> str: + """Return the trimmed meal name the user typed (empty if none).""" + return self._widgets.meal_name_entry.get().strip() + + def _refresh_meal_summary(self) -> None: + """Update the running "meal so far" line from the accumulated items.""" + if not self._state.meal_items: + self._vars.meal_summary.set("") + return + total = meal_total(self._state.meal_items) + names = ", ".join(item.name for item in self._state.meal_items) + self._vars.meal_summary.set( + f"Meal so far ({len(self._state.meal_items)}): {names} → " + f"{total.kcal:g} kcal · P{total.protein_g:g} " + f"C{total.carbs_g:g} F{total.fat_g:g}", + ) + + def _on_add_item(self) -> None: + """Add the current form as one component of a multi-part meal. + + Requires a name and resolved calories (a blank calorie field triggers a + lookup first, exactly like submitting). On success the item is appended + to the meal-in-progress, the running total updates, and the food fields + clear for the next item while the meal name is kept. + """ + description = self._get_desc() + if not description: + self._set_status("Type the item first, then add it.", error=True) + self._widgets.desc_text.focus_set() + return + values = self._macro_values() + if values is None: + self._set_status("Macros must be numbers.", error=True) + self._widgets.macros.kcal.focus_set() + return + if values[0] is None: + self._begin_lookup(description) + return + nutrition = self._current_nutrition() + if nutrition is None: + self._set_status("Enter the calories, then add the item.", error=True) + self._widgets.macros.kcal.focus_set() + return + self._state.meal_items.append(MealItem(description, nutrition)) + self._refresh_meal_summary() + self._clear_food_inputs() + self._set_status(f"Added {description}. Add another, or Log & Continue.") + self._widgets.desc_text.focus_set() + + def _slot_for_log(self) -> int | None: + """Return the slot to tag a log with -- None in demo (satisfies no slot). + + A synthetic demo slot must never satisfy a real checkpoint, so demo logs + are slot-less: they still bank the food and update the dashboard, but do + not silently stop the production gate from firing. + """ + return None if self.demo_mode else self._pending[0] + + def _log_meal(self) -> None: + """Log the accumulated multi-item meal for the current slot and advance. + + Each component and the summed composite are banked (see + :func:`python_pkg.diet_guard._foodbank.remember_meal`), and the slot is + satisfied by the summed total under the meal's name. + """ + name = self._meal_name() or _DEFAULT_MEAL_NAME + count = len(self._state.meal_items) + total = remember_meal(name, list(self._state.meal_items)) + log_meal(name, total, self._slot_for_log()) + self._state.meal_items = [] + self._finish_slot(f"{name}: {total.kcal:g} kcal ({count} items)") + + def _finish_slot(self, summary: str) -> None: + """Advance past the current slot after something was logged for it. + + Args: + summary: A short description of what was logged (calories/source, or + the meal name and item count), shown in the confirmation line. + """ + slot = self._pending[0] + self._pending.pop(0) + self._refresh_dashboard() + logged = f"Logged {slot_label(slot)}: {summary}" + if not self._pending: + self._unlock(logged) + return + self._clear_inputs() + self._refresh_slot_header() + self._set_status(f"{logged} — next meal, please.") + self._widgets.desc_text.focus_set() + + def _unlock(self, logged: str) -> None: + """Confirm the final log and tear the window down. + + Teardown is scheduled *before* the budget is looked up, so a broken + budget seal (which raises) can never re-trap the user at unlock time. + """ + self._set_status(f"{logged} — all meals logged, unlocking…") + self.root.after(_UNLOCK_DELAY_MS, self.close) + + # -- dashboard -------------------------------------------------------------- + + def _refresh_dashboard(self) -> None: + """Recompute the prominent calorie headline and the detail panel.""" + self._vars.cal_headline.set(self._cal_headline_text()) + self._vars.dashboard.set(self._dashboard_text()) + + def _cal_headline_text(self) -> str: + """Return the big calories-today line: consumed, target, and remaining.""" + consumed = today_total_kcal() + try: + budget = daily_budget() + except (BudgetError, OSError): + return f"{consumed:g} kcal today" + return ( + f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left" + ) + + def _dashboard_text(self) -> str: + """Build the detail panel: recent meals, then macros and protein.""" + lines = ["── Today ───────────────────────────────"] + entries = today_entries() + if entries: + for entry in entries[-_DASHBOARD_ROWS:]: + clock = str(entry.get("time", ""))[_TIME_SLICE] + desc = str(entry.get("desc", "?"))[:_DASH_DESC_WIDTH] + lines.append( + f" {clock:>5} {desc:<{_DASH_DESC_WIDTH}} " + f"{entry_kcal(entry):>5.0f} kcal", + ) + else: + lines.append(" (nothing logged yet today)") + protein, carbs, fat = today_total_macros() + lines.append(f" macros so far: P{protein:g} C{carbs:g} F{fat:g} g") + target = protein_target_g() + if target is not None: + left = round(target - protein, 1) + lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)") + return "\n".join(lines) + + def _handle_callback_error(self) -> None: + """Surface an unexpected callback error without dropping the grab.""" + self._set_status( + "Something went wrong. Enter the calories, then submit again.", + error=True, + ) + with contextlib.suppress(tk.TclError): + self._widgets.macros.kcal.focus_set() diff --git a/python_pkg/diet_guard/_gatelock_nutrition.py b/python_pkg/diet_guard/_gatelock_nutrition.py new file mode 100644 index 0000000..c25e219 --- /dev/null +++ b/python_pkg/diet_guard/_gatelock_nutrition.py @@ -0,0 +1,230 @@ +"""Reference-to-total nutrition model and food lookup for the MealGate gate. + +Split out of :mod:`._gatelock` to keep that module under the repo's 500-line +limit. ``_GateNutrition`` extends +:class:`~python_pkg.diet_guard._gatelock_core._GateCore` with the +"reference -> total" nutrition maths -- the label macros describe one basis +(per 100 g or per item), and how much was eaten scales that reference into +what gets logged -- plus the live preview/projection and the +autocomplete/lookup flow that fills the reference fields from banked foods, +staples, or Open Food Facts. +""" + +from __future__ import annotations + +import tkinter as tk + +from python_pkg.diet_guard._budget import BudgetError, daily_budget +from python_pkg.diet_guard._estimator import Nutrition, scale_nutrition +from python_pkg.diet_guard._gatelock_core import _GateCore +from python_pkg.diet_guard._gatelock_ui import ( + DEFAULT_PER_GRAMS, + SUGGESTION_ROWS, + UNIT_ITEMS, +) +from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams +from python_pkg.diet_guard._resolve import suggest_foods +from python_pkg.diet_guard._state import today_total_kcal + + +def _format_preview(nutrition: Nutrition) -> str: + """Render the one-line "this is what will be logged" preview.""" + portion = f" · {nutrition.grams:g}g" if nutrition.grams else "" + return ( + f"→ {nutrition.kcal:g} kcal · " + f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}" + f"{portion} · {nutrition.source}" + ) + + +class _GateNutrition(_GateCore): + """Reference->total nutrition maths, live preview, and food lookup.""" + + # -- the reference -> total model -------------------------------------- + + def _reference_nutrition(self) -> Nutrition | None: + """Return the label values as a Nutrition, or None if calories are blank. + + This is the *reference* (macros for one basis -- per 100 g, or per item), + not the total: how much was eaten scales it in :meth:`_current_nutrition`. + """ + values = self._macro_values() + if values is None or values[0] is None: + return None + return Nutrition( + kcal=values[0], + protein_g=values[1] or 0.0, + carbs_g=values[2] or 0.0, + fat_g=values[3] or 0.0, + grams=self._basis_grams(), + source=self._state.source, + ) + + def _current_nutrition(self) -> Nutrition | None: + """Return exactly what would be logged now, or None if not yet resolvable. + + The label reference scaled to the amount eaten. With no amount yet, the + reference itself stands in (one basis portion), so the preview is never + empty just because an amount has not been typed. + """ + reference = self._reference_nutrition() + if reference is None: + return None + eaten = self._eaten_grams() + return scale_nutrition(reference, eaten) if eaten is not None else reference + + def _refresh_preview(self) -> None: + """Recompute the preview line and the live calorie projection.""" + nutrition = self._current_nutrition() + self._vars.preview.set( + _format_preview(nutrition) if nutrition is not None else "" + ) + self._refresh_projection() + + def _refresh_projection(self) -> None: + """Show consumed / budget / remaining, and what is left after this item. + + This answers, as the calories are typed, the four numbers the user asked + to see together: how much is already eaten today, the day's goal, how + much is left now, and how much would be left *after* logging the food + currently in the form. With no budget sealed it degrades to the running + total plus this item's calories, so it is always informative. + """ + consumed = today_total_kcal() + nutrition = self._current_nutrition() + this_kcal = nutrition.kcal if nutrition is not None else 0.0 + try: + budget = daily_budget() + except (BudgetError, OSError): + tail = f" · this item {this_kcal:g} kcal" if this_kcal else "" + self._vars.projection.set(f"Consumed {consumed:g} kcal today{tail}") + return + left = round(budget - consumed, 1) + base = f"Consumed {consumed:g} / {budget:g} kcal · {left:g} left" + if this_kcal: + after = round(budget - consumed - this_kcal, 1) + self._vars.projection.set(f"{base} → after this item: {after:g} left") + else: + self._vars.projection.set(base) + + # -- autocomplete / lookup --------------------------------------------- + + def _on_desc_keyrelease(self, _event: tk.Event[tk.Misc]) -> None: + """Refresh suggestions; in items mode, show the piece's weight.""" + query = self._get_desc() + self._populate_suggestions(query) + # In items mode, surface a recognised piece's weight as it is typed, so + # "apple" visibly becomes "≈ 182 g" rather than a hidden assumption. + if self._vars.unit.get() == UNIT_ITEMS: + grams = estimate_unit_grams(query) + if grams is not None: + self._set_entry(self._widgets.per_entry, f"{grams:g}") + self._refresh_preview() + + def _populate_suggestions(self, query: str) -> None: + """Fill the picker with banked foods and matching staples for ``query``.""" + self._state.suggestion_mode = "bank" + self._state.suggestions = suggest_foods(query, limit=SUGGESTION_ROWS) + self._widgets.suggestion_box.delete(0, tk.END) + for name, nutrition in self._state.suggestions: + self._widgets.suggestion_box.insert( + tk.END, f"{name} ({nutrition.kcal:g} kcal)" + ) + + def _show_candidates(self, candidates: list[tuple[str, Nutrition]]) -> None: + """Fill the picker with looked-up alternatives to choose from.""" + self._state.suggestion_mode = "candidates" + self._state.suggestions = candidates + self._widgets.suggestion_box.delete(0, tk.END) + for label, nutrition in candidates: + self._widgets.suggestion_box.insert( + tk.END, + f"{label} ({nutrition.kcal:g} kcal · {nutrition.grams:g}g)", + ) + + def _on_suggestion_select(self, _event: tk.Event[tk.Misc]) -> None: + """Fill the form from the picked suggestion.""" + selection = self._widgets.suggestion_box.curselection() + if not selection: + return + index = selection[0] + if index >= len(self._state.suggestions): + return + name, nutrition = self._state.suggestions[index] + # Banked/staple entries carry a name, so adopt it; OFF products only + # supply macros and must not overwrite what the user typed. + if self._state.suggestion_mode == "bank": + self._apply_reference(nutrition, name=name) + else: + self._apply_reference(nutrition) + + def _apply_reference( + self, nutrition: Nutrition, *, name: str | None = None + ) -> None: + """Adopt ``nutrition`` as the reference and mirror it into the fields. + + In grams mode the food's own weight is the "per" basis and its macros + fill the fields directly. In items mode the per-100 g reference is + converted to a single piece (its weight shown in "per"), so the macro + fields read *per item*. The amount eaten does the scaling either way. + """ + self._state.source = nutrition.source + self._state.last_reference = nutrition + if name is not None: + self._set_desc(name) + if self._vars.unit.get() == UNIT_ITEMS: + grams = estimate_unit_grams(self._get_desc()) + unit = grams if grams is not None else DEFAULT_ITEM_GRAMS + self._set_entry(self._widgets.per_entry, f"{unit:g}") + self._fill_macro_fields(scale_nutrition(nutrition, unit)) + else: + basis = nutrition.grams or DEFAULT_PER_GRAMS + self._set_entry(self._widgets.per_entry, f"{basis:g}") + self._fill_macro_fields(nutrition) + # Default the eaten amount to one reference portion so a pick is + # immediately loggable (grams mode only -- items need a count). + if not self._widgets.amount_entry.get().strip() and nutrition.grams: + self._set_entry(self._widgets.amount_entry, f"{nutrition.grams:g}") + self._refresh_preview() + + # -- live recompute ----------------------------------------------------- + + def _on_amount_change(self, _event: tk.Event[tk.Misc]) -> None: + """Recompute the preview when the amount or basis changes. + + Crucially this does *not* rewrite the macro fields: those hold the label + reference, and only the previewed/logged total reflects the new amount. + """ + self._refresh_preview() + + def _on_unit_change(self, _value: str) -> None: + """Switch grams<->items, re-expressing the picked food in the new basis. + + The macro fields mean different things in each mode (per 100 g / per + portion vs per item). When a food was picked or looked up, its stored + reference is re-applied so toggling converts the values back and forth + losslessly. A hand-typed (manual) entry has no clean reference to + convert, so its fields are cleared to be re-entered in the new basis + rather than silently reinterpreted. + """ + self._relabel_basis() + self._widgets.amount_entry.delete(0, tk.END) + if self._state.last_reference is not None: + self._apply_reference(self._state.last_reference) + return + for entry in self._macro_entries(): + entry.delete(0, tk.END) + self._reset_per_default() + self._state.source = "manual" + self._refresh_preview() + + def _on_macro_edit(self, _event: tk.Event[tk.Misc]) -> None: + """A hand-edited macro becomes the manual reference from here on. + + Editing a macro by hand invalidates the picked food's stored reference: + the fields no longer match it, so a later unit toggle must not snap them + back to it. + """ + self._state.source = "manual" + self._state.last_reference = None + self._refresh_preview() diff --git a/python_pkg/diet_guard/_gatelock_support.py b/python_pkg/diet_guard/_gatelock_support.py new file mode 100644 index 0000000..f4c93d8 --- /dev/null +++ b/python_pkg/diet_guard/_gatelock_support.py @@ -0,0 +1,82 @@ +"""Session-start display-readiness probing for the diet_guard gate. + +Standalone infrastructure split out of :mod:`._gatelock` to keep that module +focused on the gate window itself. The gate's systemd timer fires the instant +the user systemd instance starts (``Persistent=true`` catch-up of the slot +missed while the PC was off), which on a fresh login can BEAT the display +manager writing ``~/.Xauthority`` and the X server becoming reachable. That +race -- not the slot logic -- silently dropped the session-start launch: the Tk +root raised ``TclError`` ("couldn't connect to display") and the oneshot +service died. So before building the window the launcher polls here until the +display is connectable; on timeout the gate exits cleanly and the next timer +tick retries, instead of crashing. +""" + +from __future__ import annotations + +import logging +import time +import tkinter as tk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + +_logger = logging.getLogger(__name__) + +_DISPLAY_WAIT_TIMEOUT_S = 60.0 +_DISPLAY_POLL_INTERVAL_S = 1.0 + + +def _display_is_ready() -> bool: + """Return True if a Tk root can connect to the X display right now. + + Builds and immediately destroys a throwaway, unmapped root -- the cheapest + way to ask "is DISPLAY reachable and authorized?" without opening a visible + window. A missing display or a not-yet-written X auth cookie raises + ``tk.TclError``, which is reported here as not-ready. + """ + try: + probe = tk.Tk() + except tk.TclError: + return False + probe.destroy() + return True + + +def wait_for_display( + *, + timeout_s: float = _DISPLAY_WAIT_TIMEOUT_S, + interval_s: float = _DISPLAY_POLL_INTERVAL_S, + sleep: Callable[[float], None] = time.sleep, + monotonic: Callable[[], float] = time.monotonic, +) -> bool: + """Block until the X display is connectable, or ``timeout_s`` elapses. + + Absorbs the session-start race in which the gate's timer fires before the + display manager has finished writing the X auth cookie (see the module + note). ``sleep`` and ``monotonic`` are injectable so the wait is tested + without real time passing. + + Args: + timeout_s: Total seconds to keep retrying before giving up. + interval_s: Seconds to wait between connection probes. + sleep: Sleep function (injected in tests). + monotonic: Monotonic clock (injected in tests). + + Returns: + True as soon as a probe connects; False if the deadline passes with the + display still unreachable (the caller should defer to the next tick). + """ + deadline = monotonic() + timeout_s + while True: + if _display_is_ready(): + return True + if monotonic() >= deadline: + _logger.warning( + "X display unreachable after %.0fs (session still settling?); " + "deferring the gate to the next timer tick", + timeout_s, + ) + return False + sleep(interval_s) diff --git a/python_pkg/diet_guard/_gatelock_ui.py b/python_pkg/diet_guard/_gatelock_ui.py new file mode 100644 index 0000000..ea3e5a8 --- /dev/null +++ b/python_pkg/diet_guard/_gatelock_ui.py @@ -0,0 +1,458 @@ +"""Widget construction for the diet_guard meal gate. + +This module owns the *view* half of the gate: the palette, the data bundles +that hold the live string variables and the interactive widgets, and the pure +functions that lay the window out. It deliberately knows nothing about slot +logic, nutrition maths, or logging -- the controller (:mod:`._gatelock`) keeps +all of that. Splitting the construction out keeps each file focused and within +a readable size; the controller imports :func:`build_layout` and wires events +to the widgets it gets back. + +The build functions take only public parameters (the root, the string-variable +bundle, and a small callbacks bundle) and return the populated widget bundle. +Event bindings that map to controller methods are left to the controller, so no +controller internals ever cross the module boundary. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import tkinter as tk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + +# Palette (mirrors the screen locker's dark, high-contrast lock aesthetic). +BG = "#1a1a1a" +FG = "#e0e0e0" +_ACCENT = "#00ff88" +ERR = "#ff6666" +_FIELD_BG = "#2a2a2a" +_MUTED = "#9a9a9a" +# Number of food-bank / staple / OFF suggestions shown in the picker list. +SUGGESTION_ROWS = 5 +# Grams a label's macros are assumed to describe when the "per" field is blank. +DEFAULT_PER_GRAMS = 100.0 +# Unit-selector choices for how a portion is measured. +UNIT_GRAMS = "grams" +UNIT_ITEMS = "items" +# Per-basis label prefixes for the two measuring modes. +BASIS_PREFIX_GRAMS = "Nutrition as on the label — per" +BASIS_PREFIX_ITEMS = "Nutrition per 1 item ≈" + + +@dataclass +class _MacroEntries: + """The four macro entry widgets, in (kcal, protein, carbs, fat) order.""" + + kcal: tk.Entry + protein: tk.Entry + carbs: tk.Entry + fat: tk.Entry + + +@dataclass +class GateVars: + """Tk string variables bound to the gate's live, auto-updating fields.""" + + status: tk.StringVar + slot_header: tk.StringVar + preview: tk.StringVar + projection: tk.StringVar + cal_headline: tk.StringVar + dashboard: tk.StringVar + meal_summary: tk.StringVar + unit: tk.StringVar + + +@dataclass +class GateWidgets: + """Interactive widgets the controller reads back after the UI is built.""" + + desc_text: tk.Text + amount_entry: tk.Entry + per_entry: tk.Entry + basis_prefix: tk.Label + macros: _MacroEntries + suggestion_box: tk.Listbox + meal_name_entry: tk.Entry + status_label: tk.Label + + +@dataclass +class GateCallbacks: + """Construction-time commands the widgets fire (not key/event bindings). + + These are the callbacks that must be supplied when a widget is created -- + option-menu and button commands. Per-keystroke event bindings are wired by + the controller after the layout is built, so they are not carried here. + """ + + on_unit_change: Callable[[str], None] + on_submit: Callable[[], None] + on_close: Callable[[], None] + on_add_item: Callable[[], None] + + +def make_vars(root: tk.Misc) -> GateVars: + """Create the gate's string variables, all mastered to ``root``.""" + return GateVars( + status=tk.StringVar(master=root, value=""), + slot_header=tk.StringVar(master=root, value=""), + preview=tk.StringVar(master=root, value=""), + projection=tk.StringVar(master=root, value=""), + cal_headline=tk.StringVar(master=root, value=""), + dashboard=tk.StringVar(master=root, value=""), + meal_summary=tk.StringVar(master=root, value=""), + unit=tk.StringVar(master=root, value=UNIT_GRAMS), + ) + + +def is_numeric_or_blank(proposed: str) -> bool: + """Validate-on-key predicate: allow only a blank field or a number.""" + if proposed == "": + return True + try: + float(proposed) + except ValueError: + return False + return True + + +def _numeric_entry(root: tk.Misc, parent: tk.Frame, *, width: int) -> tk.Entry: + """Return an entry that only accepts a number or a blank string.""" + vcmd = (root.register(is_numeric_or_blank), "%P") + return tk.Entry( + parent, + font=("Arial", 15), + width=width, + bg=_FIELD_BG, + fg=FG, + insertbackground=FG, + justify="center", + validate="key", + validatecommand=vcmd, + ) + + +def _macro_cell(root: tk.Misc, row: tk.Frame, label: str) -> tk.Entry: + """Pack one small labelled numeric entry into the macro row.""" + cell = tk.Frame(row, bg=BG) + cell.pack(side="left", padx=6) + tk.Label(cell, text=label, font=("Arial", 11), bg=BG, fg=FG).pack() + entry = _numeric_entry(root, cell, width=7) + entry.pack(ipady=3) + return entry + + +def _build_desc(parent: tk.Frame) -> tk.Text: + """Build and return the multi-line "what did you eat?" description box. + + A multi-line ``Text`` (not an ``Entry``) so a long restaurant description + wraps onto a second line and stays fully visible, instead of scrolling off + the right edge where the end can no longer be read. + """ + tk.Label( + parent, + text="What did you eat?", + font=("Arial", 12), + bg=BG, + fg=FG, + ).pack() + text = tk.Text( + parent, + font=("Arial", 15), + width=64, + height=2, + wrap="word", + bg=_FIELD_BG, + fg=FG, + insertbackground=FG, + highlightthickness=1, + highlightbackground=_MUTED, + ) + text.pack(pady=(2, 6)) + return text + + +def _build_suggestion_box(parent: tk.Frame) -> tk.Listbox: + """Build the food-bank / staple / OFF picker list and return it.""" + box = tk.Listbox( + parent, + font=("Arial", 12), + width=52, + height=SUGGESTION_ROWS, + bg=_FIELD_BG, + fg=FG, + selectbackground=_ACCENT, + selectforeground="#003322", + activestyle="none", + highlightthickness=0, + ) + box.pack(pady=(0, 8)) + return box + + +def _build_amount_row( + root: tk.Misc, + parent: tk.Frame, + unit_var: tk.StringVar, + on_unit_change: Callable[[str], None], +) -> tk.Entry: + """Build the "how much did you eat?" amount + unit row; return the entry.""" + tk.Label( + parent, + text="How much did you eat?", + font=("Arial", 12), + bg=BG, + fg=FG, + ).pack() + row = tk.Frame(parent, bg=BG) + row.pack(pady=(2, 6)) + amount_entry = _numeric_entry(root, row, width=10) + amount_entry.pack(side="left", ipady=3) + unit_menu = tk.OptionMenu( + row, + unit_var, + UNIT_GRAMS, + UNIT_ITEMS, + command=on_unit_change, + ) + unit_menu.configure( + font=("Arial", 12), + bg=_FIELD_BG, + fg=FG, + activebackground=_ACCENT, + highlightthickness=0, + ) + unit_menu.pack(side="left", padx=(8, 0)) + return amount_entry + + +def _build_macro_section( + root: tk.Misc, + parent: tk.Frame, +) -> tuple[tk.Label, tk.Entry, _MacroEntries]: + """Build the per-basis field and macro row. + + Returns the basis-prefix label, the "per" entry, and the four macro entries, + for the caller to store in the widget bundle. + """ + basis = tk.Frame(parent, bg=BG) + basis.pack() + basis_prefix = tk.Label( + basis, + text=BASIS_PREFIX_GRAMS, + font=("Arial", 12), + bg=BG, + fg=FG, + ) + basis_prefix.pack(side="left") + per_entry = _numeric_entry(root, basis, width=5) + per_entry.insert(0, f"{DEFAULT_PER_GRAMS:g}") + per_entry.pack(side="left", padx=4, ipady=2) + tk.Label( + basis, + text="g (leave calories blank to look it up):", + font=("Arial", 12), + bg=BG, + fg=FG, + ).pack(side="left") + + row = tk.Frame(parent, bg=BG) + row.pack(pady=(2, 6)) + macros = _MacroEntries( + kcal=_macro_cell(root, row, "kcal"), + protein=_macro_cell(root, row, "P"), + carbs=_macro_cell(root, row, "C"), + fat=_macro_cell(root, row, "F"), + ) + return basis_prefix, per_entry, macros + + +def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None: + """Build the running "how am I doing today" panel. + + The calorie line is large and prominent (the number the user steers by); the + meal list and macros sit beneath it in a smaller monospace block. + """ + tk.Label( + parent, + textvariable=vars_.cal_headline, + font=("Arial", 22, "bold"), + bg=BG, + fg=_ACCENT, + ).pack(pady=(12, 0)) + tk.Label( + parent, + textvariable=vars_.dashboard, + font=("Courier", 11), + bg=BG, + fg=_MUTED, + justify="left", + anchor="w", + wraplength=900, + ).pack(pady=(2, 0)) + + +def _build_meal_controls( + parent: tk.Frame, + vars_: GateVars, + on_add_item: Callable[[], None], +) -> tk.Entry: + """Build the optional multi-item meal row; return the meal-name entry. + + Logging stays one-tap for a single food; these controls only matter when a + meal has several separately-macroed parts (a dinner of salad + chicken + + rice). "Add item" banks the part onto the meal-in-progress and clears the + form for the next one; "Log & Continue" then logs the summed meal. + """ + row = tk.Frame(parent, bg=BG) + row.pack(pady=(2, 2)) + tk.Label( + row, + text="Meal name (optional):", + font=("Arial", 11), + bg=BG, + fg=FG, + ).pack(side="left") + meal_name_entry = tk.Entry( + row, + font=("Arial", 13), + width=18, + bg=_FIELD_BG, + fg=FG, + insertbackground=FG, + ) + meal_name_entry.pack(side="left", padx=(6, 8), ipady=2) + tk.Button( + row, + text="+ Add item", + font=("Arial", 12, "bold"), + bg=_FIELD_BG, + fg=_ACCENT, + activebackground="#333333", + cursor="hand2", + command=on_add_item, + ).pack(side="left") + tk.Label( + parent, + textvariable=vars_.meal_summary, + font=("Arial", 11), + bg=BG, + fg=_MUTED, + wraplength=900, + justify="center", + ).pack(pady=(0, 2)) + return meal_name_entry + + +def build_layout( + root: tk.Misc, + vars_: GateVars, + callbacks: GateCallbacks, + *, + demo_mode: bool, +) -> GateWidgets: + """Lay out the whole gate UI and return the widgets the controller drives. + + The controller calls this once (after configuring the window) and is then + responsible for binding per-keystroke events to the returned widgets. + """ + frame = tk.Frame(root, bg=BG) + frame.place(relx=0.5, rely=0.5, anchor="center") + + tk.Label( + frame, + text="🍽 Diet Gate", + font=("Arial", 30, "bold"), + bg=BG, + fg=_ACCENT, + ).pack(pady=(0, 4)) + tk.Label( + frame, + textvariable=vars_.slot_header, + font=("Arial", 16, "bold"), + bg=BG, + fg=FG, + wraplength=900, + justify="center", + ).pack(pady=(0, 10)) + + desc_text = _build_desc(frame) + suggestion_box = _build_suggestion_box(frame) + amount_entry = _build_amount_row( + root, + frame, + vars_.unit, + callbacks.on_unit_change, + ) + basis_prefix, per_entry, macros = _build_macro_section(root, frame) + + tk.Label( + frame, + textvariable=vars_.projection, + font=("Arial", 13, "bold"), + bg=BG, + fg=FG, + wraplength=900, + justify="center", + ).pack(pady=(2, 2)) + tk.Label( + frame, + textvariable=vars_.preview, + font=("Arial", 14, "bold"), + bg=BG, + fg=_ACCENT, + wraplength=900, + justify="center", + ).pack(pady=(2, 6)) + + meal_name_entry = _build_meal_controls(frame, vars_, callbacks.on_add_item) + + tk.Button( + frame, + text="Log & Continue", + font=("Arial", 15, "bold"), + bg=_ACCENT, + fg="#003322", + activebackground="#00cc66", + cursor="hand2", + command=callbacks.on_submit, + ).pack(pady=(4, 6)) + + status_label = tk.Label( + frame, + textvariable=vars_.status, + font=("Arial", 12), + bg=BG, + fg=FG, + wraplength=900, + justify="center", + ) + status_label.pack() + + _build_dashboard(frame, vars_) + + if demo_mode: + tk.Button( + root, + text="✕ Close Demo", + font=("Arial", 12), + bg="#ff4444", + fg="white", + command=callbacks.on_close, + cursor="hand2", + ).place(x=10, y=10) + + return GateWidgets( + desc_text=desc_text, + amount_entry=amount_entry, + per_entry=per_entry, + basis_prefix=basis_prefix, + macros=macros, + suggestion_box=suggestion_box, + meal_name_entry=meal_name_entry, + status_label=status_label, + ) diff --git a/python_pkg/diet_guard/_gatelock_window.py b/python_pkg/diet_guard/_gatelock_window.py new file mode 100644 index 0000000..34aa01a --- /dev/null +++ b/python_pkg/diet_guard/_gatelock_window.py @@ -0,0 +1,171 @@ +"""Window mechanics and process lifecycle for the MealGate gate. + +Split out of :mod:`._gatelock` to keep that module under the repo's 500-line +limit. ``_GateWindow`` extends +:class:`~python_pkg.diet_guard._gatelock_core._GateCore` with the +screen-locker-style window setup (fullscreen, VT-switch disable, global input +grab with retry) and the signal/atexit lifecycle that guarantees VT switching +is restored on every exit path. +""" + +from __future__ import annotations + +import atexit +import contextlib +import logging +import shutil +import signal +import subprocess +import tkinter as tk +from typing import TYPE_CHECKING + +from python_pkg.diet_guard._gatelock_core import _GateCore +from python_pkg.diet_guard._gatelock_ui import BG + +if TYPE_CHECKING: + from types import FrameType + +_logger = logging.getLogger(__name__) + +# Periodic no-op so the grabbed, event-starved loop keeps handing control back +# to Python, letting SIGTERM/SIGINT be serviced promptly. +_KEEPALIVE_MS = 250 +# A global input grab fails while another X client already holds one -- most +# often a FULLSCREEN GAME, which takes an exclusive keyboard/pointer grab. A +# single attempt then falls back to a *local* grab, which on an override-redirect +# window the WM refuses to focus means no keystroke ever reaches the field -- the +# "can't type anything" lock-trap. So the grab is retried for the window's whole +# life: the gate waits out the game and captures input the instant it is freed. +_GRAB_RETRY_MS = 200 +# How often (in attempts) to log that the grab is still blocked, so the journal +# shows the gate is alive and waiting rather than hung. ~every 5 s at 200 ms. +_GRAB_LOG_EVERY = 25 + + +class _GateWindow(_GateCore): + """Fullscreen window setup, input grab, and exit-path lifecycle.""" + + # -- window mechanics (reused screen-locker pattern) -------------------- + + def _setup_window(self) -> None: + """Configure the lock window. + + Demo mode stays WM-managed so the window manager still grants it + keyboard focus -- and you can always close it -- making a usable, safe + sandbox. Only the real lock uses ``overrideredirect``, where the tiling + WM refuses focus and input is instead forced in by a global grab. + """ + screen_w = self.root.winfo_screenwidth() + screen_h = self.root.winfo_screenheight() + self.root.geometry(f"{screen_w}x{screen_h}+0+0") + self.root.attributes(topmost=True) + self.root.configure(bg=BG, cursor="arrow") + if self.demo_mode: + self.root.attributes(fullscreen=True) + else: + self.root.overrideredirect(boolean=True) + self.root.attributes(fullscreen=True) + self._disable_vt_switching() + + def _disable_vt_switching(self) -> None: + """Block Ctrl+Alt+Fn TTY switching while the lock is up (best-effort).""" + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is None: + _logger.warning("setxkbmap not found; VT switching stays enabled") + return + subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False) + self._vt_disabled = True + + def _restore_vt_switching(self) -> None: + """Re-enable VT switching; idempotent and safe to call on any exit.""" + if not self._vt_disabled: + return + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is not None: + subprocess.run([setxkbmap, "-option", ""], check=False) + self._vt_disabled = False + + def _grab_input(self) -> None: + """Force input to the window, then focus the first field. + + Demo mode relies on normal WM focus (no grab), keeping the window an + escapable sandbox. The real lock forces *all* input here with a global + grab -- the only mechanism that reaches an overrideredirect window the + tiling WM will not focus. The grab is acquired with retries because it + commonly fails on the first attempt while the window is still mapping. + """ + self.root.update_idletasks() + self.root.focus_force() + if not self.demo_mode: + self._acquire_global_grab(attempt=1) + self.root.after(100, self._focus_first_field) + + def _acquire_global_grab(self, *, attempt: int) -> None: + """Acquire the global input grab, retrying until it succeeds. + + A successful global grab is the only way keystrokes reach the + override-redirect window the WM will not focus. When another client + (typically a fullscreen game) holds the grab, the attempt is rescheduled + indefinitely rather than conceding to an unusable local grab, so the gate + waits the other application out and captures input the moment it frees + the grab. On success, focus is forced onto the description field so the + first keystroke lands there. + + Args: + attempt: 1-based attempt counter, used only to throttle the log. + """ + try: + self.root.grab_set_global() + except tk.TclError: + if attempt % _GRAB_LOG_EVERY == 0: + _logger.warning( + "global grab still blocked after %d attempts (another app -- " + "e.g. a fullscreen game -- holds it); waiting for it to free", + attempt, + ) + self.root.after( + _GRAB_RETRY_MS, + lambda: self._acquire_global_grab(attempt=attempt + 1), + ) + return + with contextlib.suppress(tk.TclError): + self.root.focus_force() + self._focus_first_field() + + def _focus_first_field(self) -> None: + """Put keyboard focus on the description entry once it is mapped.""" + with contextlib.suppress(tk.TclError): + self._widgets.desc_text.focus_force() + + # -- lifecycle ------------------------------------------------------------ + + def _install_signal_handlers(self) -> None: + """Ensure VT switching is restored on crash or kill, not just close.""" + atexit.register(self._restore_vt_switching) + for sig in (signal.SIGTERM, signal.SIGINT): + with contextlib.suppress(ValueError): + signal.signal(sig, self._on_signal) + + def _on_signal(self, _signum: int, _frame: FrameType | None) -> None: + """Restore the keyboard escape, then exit, on SIGTERM/SIGINT.""" + self._restore_vt_switching() + raise SystemExit(0) + + def _keepalive(self) -> None: + """Re-arm a periodic no-op so pending signals get serviced promptly.""" + self.root.after(_KEEPALIVE_MS, self._keepalive) + + def close(self) -> None: + """Restore VT switching and destroy the window (no process exit).""" + self._restore_vt_switching() + with contextlib.suppress(tk.TclError): + self.root.destroy() + + def run(self) -> None: + """Run the Tk loop, restoring VT switching on every exit path.""" + self._install_signal_handlers() + self._keepalive() + try: + self.root.mainloop() + finally: + self._restore_vt_switching() diff --git a/python_pkg/diet_guard/_state.py b/python_pkg/diet_guard/_state.py index f29bd72..68dd910 100644 --- a/python_pkg/diet_guard/_state.py +++ b/python_pkg/diet_guard/_state.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING from python_pkg.diet_guard._budget import daily_budget from python_pkg.diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE +from python_pkg.shared.coerce import as_float from python_pkg.shared.log_integrity import ( compute_entry_hmac, verify_entry_hmac, @@ -58,12 +59,7 @@ def _entry_float(entry: dict[str, object], key: str) -> float: Returns: The field as a float, or 0.0 when absent or not a real number. """ - value = entry.get(key) - if isinstance(value, bool): - return 0.0 - if isinstance(value, (int, float)): - return float(value) - return 0.0 + return as_float(entry.get(key)) def entry_kcal(entry: dict[str, object]) -> float: diff --git a/python_pkg/diet_guard/tests/conftest.py b/python_pkg/diet_guard/tests/conftest.py index de83026..3ae64c3 100644 --- a/python_pkg/diet_guard/tests/conftest.py +++ b/python_pkg/diet_guard/tests/conftest.py @@ -7,15 +7,33 @@ Two safety nets run for every test: * ``_block_real_tk`` swaps ``tk`` and the ``_GateRoot`` window class inside ``_gatelock`` for mocks, so no test can open a real fullscreen window or grab the keyboard even if it forgets to. + +The ``gate`` fixture and its supporting fakes (``FakeEntry``, ``_FAKE_TK``, ...) +build a demo :class:`~python_pkg.diet_guard._gatelock.MealGate` whose widgets +are functional in-memory stand-ins, shared by ``test_gatelock.py`` and +``test_gatelock_mealflow.py``. """ from __future__ import annotations +from contextlib import ExitStack +from types import SimpleNamespace from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest +from python_pkg.diet_guard import ( + _gatelock, + _gatelock_core, + _gatelock_mealflow, + _gatelock_nutrition, + _gatelock_ui, + _gatelock_window, +) +from python_pkg.diet_guard._estimator import Nutrition +from python_pkg.diet_guard._gatelock import MealGate + if TYPE_CHECKING: from collections.abc import Iterator from pathlib import Path @@ -67,3 +85,161 @@ def _hmac_key(tmp_path: Path) -> Iterator[None]: key.write_bytes(b"diet-guard-test-key-0123456789ab") with patch("python_pkg.shared.log_integrity.HMAC_KEY_FILE", key): yield + + +# -------------------------------------------------------------------------- +# Gate fixture and its functional tk fakes +# -------------------------------------------------------------------------- +# +# A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a +# real, catchable ``TclError``) replaces the blanket MagicMock above for the +# duration of each gate test, so the window's *logic* runs for real against +# in-memory widgets without ever opening a window or grabbing the keyboard. + + +class _FakeTclError(Exception): + """Stand-in for ``tkinter.TclError`` (a real, catchable exception).""" + + +class FakeVar: + """A functional ``StringVar``: stores and returns a string.""" + + def __init__(self, master: object = None, value: str = "") -> None: + self._value = value + + def get(self) -> str: + return self._value + + def set(self, value: str) -> None: + self._value = value + + +class FakeEntry: + """A functional one-line entry (delete clears, insert appends).""" + + def __init__(self, *args: object, **kwargs: object) -> None: + self._value = "" + + def get(self) -> str: + return self._value + + def delete(self, first: object, last: object = None) -> None: + self._value = "" + + def insert(self, index: object, text: str) -> None: + self._value += text + + def pack(self, *args: object, **kwargs: object) -> FakeEntry: + return self + + def bind(self, *args: object, **kwargs: object) -> None: + pass + + def configure(self, *args: object, **kwargs: object) -> None: + pass + + config = configure + + def focus_set(self) -> None: + pass + + def focus_force(self) -> None: + pass + + +class FakeText(FakeEntry): + """A functional multi-line text box (``get`` ignores the index range).""" + + def get(self, start: object = None, end: object = None) -> str: + return self._value + + +class FakeListbox: + """A functional listbox tracking items and the current selection.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + self._items: list[str] = [] + self._sel: tuple[int, ...] = () + + def delete(self, first: object, last: object = None) -> None: + self._items = [] + + def insert(self, index: object, text: str) -> None: + self._items.append(text) + + def curselection(self) -> tuple[int, ...]: + return self._sel + + def selection_set(self, index: int) -> None: + self._sel = (index,) + + def selection_clear(self, first: object, last: object = None) -> None: + self._sel = () + + def pack(self, *args: object, **kwargs: object) -> FakeListbox: + return self + + def bind(self, *args: object, **kwargs: object) -> None: + pass + + +class FakeWidget: + """A generic no-op widget for Frame/Label/Button/OptionMenu.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def pack(self, *args: object, **kwargs: object) -> FakeWidget: + return self + + def place(self, *args: object, **kwargs: object) -> FakeWidget: + return self + + def configure(self, *args: object, **kwargs: object) -> FakeWidget: + return self + + config = configure + + def bind(self, *args: object, **kwargs: object) -> None: + pass + + +_FAKE_TK = SimpleNamespace( + END="end", + TclError=_FakeTclError, + StringVar=FakeVar, + Frame=FakeWidget, + Label=FakeWidget, + Button=FakeWidget, + OptionMenu=FakeWidget, + Entry=FakeEntry, + Text=FakeText, + Listbox=FakeListbox, + Event=object, +) + +# Every mixin module the gate window is built from imports ``tkinter`` +# independently; all of them must see the fake so ``tk.TclError`` etc. are the +# catchable ``_FakeTclError`` everywhere a test raises it. +_GATE_TK_MODULES = ( + _gatelock, + _gatelock_core, + _gatelock_window, + _gatelock_nutrition, + _gatelock_mealflow, + _gatelock_ui, +) + + +@pytest.fixture +def gate() -> Iterator[MealGate]: + """Build a demo gate whose widgets are functional fakes.""" + with ExitStack() as stack: + for module in _GATE_TK_MODULES: + stack.enter_context(patch.object(module, "tk", _FAKE_TK)) + yield MealGate(demo_mode=True) + + +def _nutrition(kcal: float = 100, grams: float = 100) -> Nutrition: + """A simple reference nutrition for driving the gate form.""" + return Nutrition(kcal, 10, 20, 5, grams, "food bank") diff --git a/python_pkg/diet_guard/tests/test_foodbank.py b/python_pkg/diet_guard/tests/test_foodbank.py index 9687688..ae13bd4 100644 --- a/python_pkg/diet_guard/tests/test_foodbank.py +++ b/python_pkg/diet_guard/tests/test_foodbank.py @@ -35,22 +35,6 @@ def _write_raw(bank: object) -> None: _foodbank.FOOD_BANK_FILE.write_text(json.dumps(bank), encoding="utf-8") -class TestAsFloat: - """Field coercion with the bool rejection.""" - - def test_bool_is_zero(self) -> None: - """A bool is not a real count/macro.""" - assert _foodbank._as_float(value=True) == 0.0 - - def test_number_passes(self) -> None: - """Ints and floats pass through.""" - assert _foodbank._as_float(7) == 7.0 - - def test_other_is_zero(self) -> None: - """A non-numeric value defaults to 0.0.""" - assert _foodbank._as_float("x") == 0.0 - - class TestRememberAndLookup: """Round-tripping foods through the bank.""" diff --git a/python_pkg/diet_guard/tests/test_gatelock.py b/python_pkg/diet_guard/tests/test_gatelock.py index 5573ccc..15ec60d 100644 --- a/python_pkg/diet_guard/tests/test_gatelock.py +++ b/python_pkg/diet_guard/tests/test_gatelock.py @@ -1,9 +1,9 @@ """Tests for _gatelock.py — the fullscreen log-to-unlock gate window. -A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a -real ``TclError``) replaces the conftest's blanket MagicMock for the duration of -each gate test, so the window's *logic* runs for real against in-memory widgets -without ever opening a window or grabbing the keyboard. +Window mechanics, construction, and the shared module-level helpers. The +nutrition/meal-flow tests live in :mod:`test_gatelock_mealflow`; the +functional fake ``tk`` widgets and the ``gate`` fixture live in +``conftest.py`` and are shared by both files. """ from __future__ import annotations @@ -13,159 +13,32 @@ from unittest.mock import MagicMock, patch import pytest -from python_pkg.diet_guard import _gatelock +from python_pkg.diet_guard import ( + _gatelock, + _gatelock_support, + _gatelock_ui, + _gatelock_window, +) from python_pkg.diet_guard._budget import seal_budget -from python_pkg.diet_guard._estimator import Nutrition from python_pkg.diet_guard._gatelock import ( MealGate, - _format_preview, _pending_slots, - _safe_float, acquire_gate_lock, release_gate_lock, - wait_for_display, ) -from python_pkg.diet_guard._meal import MealItem +from python_pkg.diet_guard._gatelock_core import _safe_float +from python_pkg.diet_guard._gatelock_nutrition import _format_preview +from python_pkg.diet_guard._gatelock_support import wait_for_display +from python_pkg.diet_guard._gatelock_ui import DEFAULT_PER_GRAMS +from python_pkg.diet_guard._gatelock_window import _GRAB_LOG_EVERY +from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS +from python_pkg.diet_guard.tests.conftest import _FAKE_TK, _FakeTclError, _nutrition # Captured before any autouse fixture patches the module attribute, so the real # class (not the conftest MagicMock) is available for its callback-error test. _REAL_GATE_ROOT = _gatelock._GateRoot -class _FakeTclError(Exception): - """Stand-in for ``tkinter.TclError`` (a real, catchable exception).""" - - -class FakeVar: - """A functional ``StringVar``: stores and returns a string.""" - - def __init__(self, master: object = None, value: str = "") -> None: - self._value = value - - def get(self) -> str: - return self._value - - def set(self, value: str) -> None: - self._value = value - - -class FakeEntry: - """A functional one-line entry (delete clears, insert appends).""" - - def __init__(self, *args: object, **kwargs: object) -> None: - self._value = "" - - def get(self) -> str: - return self._value - - def delete(self, first: object, last: object = None) -> None: - self._value = "" - - def insert(self, index: object, text: str) -> None: - self._value += text - - def pack(self, *args: object, **kwargs: object) -> FakeEntry: - return self - - def bind(self, *args: object, **kwargs: object) -> None: - pass - - def configure(self, *args: object, **kwargs: object) -> None: - pass - - config = configure - - def focus_set(self) -> None: - pass - - def focus_force(self) -> None: - pass - - -class FakeText(FakeEntry): - """A functional multi-line text box (``get`` ignores the index range).""" - - def get(self, start: object = None, end: object = None) -> str: - return self._value - - -class FakeListbox: - """A functional listbox tracking items and the current selection.""" - - def __init__(self, *args: object, **kwargs: object) -> None: - self._items: list[str] = [] - self._sel: tuple[int, ...] = () - - def delete(self, first: object, last: object = None) -> None: - self._items = [] - - def insert(self, index: object, text: str) -> None: - self._items.append(text) - - def curselection(self) -> tuple[int, ...]: - return self._sel - - def selection_set(self, index: int) -> None: - self._sel = (index,) - - def selection_clear(self, first: object, last: object = None) -> None: - self._sel = () - - def pack(self, *args: object, **kwargs: object) -> FakeListbox: - return self - - def bind(self, *args: object, **kwargs: object) -> None: - pass - - -class FakeWidget: - """A generic no-op widget for Frame/Label/Button/OptionMenu.""" - - def __init__(self, *args: object, **kwargs: object) -> None: - pass - - def pack(self, *args: object, **kwargs: object) -> FakeWidget: - return self - - def place(self, *args: object, **kwargs: object) -> FakeWidget: - return self - - def configure(self, *args: object, **kwargs: object) -> FakeWidget: - return self - - config = configure - - def bind(self, *args: object, **kwargs: object) -> None: - pass - - -_FAKE_TK = SimpleNamespace( - END="end", - TclError=_FakeTclError, - StringVar=FakeVar, - Frame=FakeWidget, - Label=FakeWidget, - Button=FakeWidget, - OptionMenu=FakeWidget, - Entry=FakeEntry, - Text=FakeText, - Listbox=FakeListbox, - Event=object, -) - - -@pytest.fixture -def gate() -> object: - """Build a demo gate whose widgets are functional fakes.""" - with patch.object(_gatelock, "tk", _FAKE_TK): - yield MealGate(demo_mode=True) - - -def _nutrition(kcal: float = 100, grams: float = 100) -> Nutrition: - """A simple reference nutrition for driving the form.""" - return Nutrition(kcal, 10, 20, 5, grams, "food bank") - - # -------------------------------------------------------------------------- # Module-level helpers # -------------------------------------------------------------------------- @@ -268,13 +141,13 @@ class TestConstruction: def test_demo_builds(self, gate: MealGate) -> None: """A demo gate constructs with a pending slot and grams basis.""" assert gate.demo_mode is True - assert gate._unit.get() == "grams" + assert gate._vars.unit.get() == "grams" def test_production_builds(self) -> None: """A production gate disables VT switching and grabs input.""" with ( patch.object(_gatelock, "tk", _FAKE_TK), - patch.object(_gatelock.shutil, "which", return_value=None), + patch.object(_gatelock_window.shutil, "which", return_value=None), ): gate = MealGate(demo_mode=False) assert gate.demo_mode is False @@ -288,11 +161,11 @@ class TestConstruction: class TestFormBasics: """Field helpers and the numeric validator.""" - def test_numeric_validator(self, gate: MealGate) -> None: + def test_numeric_validator(self) -> None: """Blank and numbers are allowed; words are not.""" - assert gate._is_numeric_or_blank("") - assert gate._is_numeric_or_blank("12.5") - assert not gate._is_numeric_or_blank("abc") + assert _gatelock_ui.is_numeric_or_blank("") + assert _gatelock_ui.is_numeric_or_blank("12.5") + assert not _gatelock_ui.is_numeric_or_blank("abc") def test_desc_get_set(self, gate: MealGate) -> None: """The description round-trips through its helpers, trimmed.""" @@ -308,264 +181,81 @@ class TestFormBasics: def test_macro_values_non_numeric(self, gate: MealGate) -> None: """A non-numeric macro field makes the whole read None.""" - gate._kcal_entry.insert(0, "abc") + gate._widgets.macros.kcal.insert(0, "abc") assert gate._macro_values() is None -class TestReferenceModel: - """The reference -> total nutrition computation.""" +class TestBasisAndAmount: + """Edge branches in the grams/items basis and amount maths.""" - def test_reference_none_without_calories(self, gate: MealGate) -> None: - """No calories typed means no reference yet.""" - assert gate._reference_nutrition() is None + def test_basis_typed_value(self, gate: MealGate) -> None: + """A typed per-value is honoured directly.""" + gate._set_entry(gate._widgets.per_entry, "50") + assert gate._basis_grams() == 50 - def test_current_is_reference_without_amount(self, gate: MealGate) -> None: - """With calories but no amount, the reference stands in as the total.""" - gate._kcal_entry.insert(0, "200") - current = gate._current_nutrition() - assert current is not None - assert current.kcal == 200 + def test_basis_items_known_staple(self, gate: MealGate) -> None: + """Items mode with a blank per falls back to the staple weight.""" + gate._widgets.per_entry.delete(0) + gate._vars.unit.set("items") + gate._set_desc("apple") + assert gate._basis_grams() == 182 - def test_current_scales_with_amount(self, gate: MealGate) -> None: - """Grams eaten scale the per-100 g reference into the total.""" - gate._kcal_entry.insert(0, "200") - gate._amount_entry.insert(0, "200") - current = gate._current_nutrition() - assert current is not None - assert current.kcal == 400 + def test_basis_items_unknown(self, gate: MealGate) -> None: + """An unknown item uses the default piece weight.""" + gate._widgets.per_entry.delete(0) + gate._vars.unit.set("items") + gate._set_desc("mystery") + assert gate._basis_grams() == DEFAULT_ITEM_GRAMS + def test_basis_grams_default(self, gate: MealGate) -> None: + """Grams mode with a blank per uses the per-100 g default.""" + gate._widgets.per_entry.delete(0) + assert gate._basis_grams() == DEFAULT_PER_GRAMS -class TestSuggestions: - """Autocomplete population and selection.""" + def test_eaten_grams_none(self, gate: MealGate) -> None: + """No amount typed yields no eaten weight.""" + assert gate._eaten_grams() is None - def test_keyrelease_items_mode_shows_weight(self, gate: MealGate) -> None: - """In items mode, typing a staple fills the per-item weight.""" - gate._unit.set("items") + def test_eaten_grams_items(self, gate: MealGate) -> None: + """Items mode multiplies the count by the per-item weight.""" + gate._vars.unit.set("items") + gate._set_desc("apple") + gate._set_entry(gate._widgets.per_entry, "182") + gate._set_entry(gate._widgets.amount_entry, "5") + assert gate._eaten_grams() == 5 * 182 + + def test_amount_change_refreshes(self, gate: MealGate) -> None: + """Changing the amount recomputes the preview.""" + gate._set_entry(gate._widgets.macros.kcal, "100") + gate._set_entry(gate._widgets.amount_entry, "200") + gate._on_amount_change(None) + assert gate._vars.preview.get() + + def test_projection_else_without_item(self, gate: MealGate) -> None: + """With a budget but no priced item, no after-this-item is shown.""" + seal_budget(2000) + gate._refresh_projection() + text = gate._vars.projection.get() + assert "left" in text + assert "after this item" not in text + + def test_keyrelease_grams_mode(self, gate: MealGate) -> None: + """In grams mode the per-item weight is not touched on keyrelease.""" + gate._vars.unit.set("grams") gate._set_desc("apple") gate._on_desc_keyrelease(None) - assert gate._per_entry.get() == "182" - def test_select_bank_fills_name_and_macros(self, gate: MealGate) -> None: - """Picking a banked suggestion adopts its name and macros.""" - gate._suggestions = [("apple pie", _nutrition(300, 120))] - gate._suggestion_mode = "bank" - gate._suggestion_box.selection_set(0) - gate._on_suggestion_select(None) - assert gate._get_desc() == "apple pie" - assert gate._kcal_entry.get() == "300" + def test_keyrelease_items_unknown(self, gate: MealGate) -> None: + """An unknown item in items mode leaves the per field unchanged.""" + gate._vars.unit.set("items") + gate._set_desc("zzzz") + gate._on_desc_keyrelease(None) - def test_select_candidate_keeps_description(self, gate: MealGate) -> None: - """An OFF candidate fills macros but not the typed description.""" - gate._set_desc("my dish") - gate._suggestions = [("openfoodfacts: X", _nutrition(250, 100))] - gate._suggestion_mode = "candidates" - gate._suggestion_box.selection_set(0) - gate._on_suggestion_select(None) - assert gate._get_desc() == "my dish" - - def test_select_no_selection(self, gate: MealGate) -> None: - """No selection is a no-op.""" - gate._on_suggestion_select(None) - - def test_select_out_of_range(self, gate: MealGate) -> None: - """A stale selection index beyond the list is ignored.""" - gate._suggestions = [] - gate._suggestion_box.selection_set(5) - gate._on_suggestion_select(None) - - -class TestUnitToggle: - """Switching the grams/items basis.""" - - def test_toggle_reconverts_picked_food(self, gate: MealGate) -> None: - """A picked food is re-expressed per item, then back per 100 g.""" - gate._apply_reference(_nutrition(52, 100), name="apple") - gate._unit.set("items") - gate._on_unit_change("items") - per_item = gate._kcal_entry.get() - gate._unit.set("grams") - gate._on_unit_change("grams") - assert gate._kcal_entry.get() == "52" - assert per_item != "52" - - def test_toggle_without_reference_clears(self, gate: MealGate) -> None: - """With no picked food, a toggle clears the macro fields.""" - gate._kcal_entry.insert(0, "123") - gate._last_reference = None - gate._unit.set("items") - gate._on_unit_change("items") - assert gate._kcal_entry.get() == "" - - def test_macro_edit_drops_reference(self, gate: MealGate) -> None: - """Hand-editing a macro invalidates the stored reference.""" - gate._last_reference = _nutrition() - gate._on_macro_edit(None) - assert gate._last_reference is None - - -class TestSubmit: - """The two-step submit (look up, then log).""" - - def test_empty_description(self, gate: MealGate) -> None: - """Submitting with no description prompts for one.""" - gate._on_submit() - assert "Type what you ate" in gate._status.get() - - def test_non_numeric_macros(self, gate: MealGate) -> None: - """Non-numeric macros are rejected before logging.""" - gate._set_desc("apple") - gate._kcal_entry.insert(0, "abc") - gate._on_submit() - assert "must be numbers" in gate._status.get() - - def test_blank_calories_triggers_lookup(self, gate: MealGate) -> None: - """A blank calorie field looks the food up rather than logging.""" - gate._set_desc("apple") - with patch.object(gate, "_begin_lookup") as lookup: - gate._on_submit() - lookup.assert_called_once() - - def test_defensive_none_nutrition(self, gate: MealGate) -> None: - """A calorie value but unresolvable nutrition prompts again (guard).""" - gate._set_desc("apple") - gate._kcal_entry.insert(0, "200") - with patch.object(gate, "_current_nutrition", return_value=None): - gate._on_submit() - assert "Enter the calories" in gate._status.get() - - def test_valid_submit_records(self, gate: MealGate) -> None: - """A described, priced meal is recorded.""" - gate._set_desc("apple") - gate._kcal_entry.insert(0, "95") - with patch.object(gate, "_record") as record: - gate._on_submit() - record.assert_called_once() - - def test_on_return_submits(self, gate: MealGate) -> None: - """Enter in a numeric field submits.""" - with patch.object(gate, "_on_submit") as submit: - gate._on_return(None) - submit.assert_called_once() - - -class TestLookup: - """Step one: filling the form from a lookup.""" - - def test_no_candidates(self, gate: MealGate) -> None: - """No match asks for a manual value.""" - gate._set_desc("nonsense") - with patch.object(_gatelock, "lookup_candidates", return_value=[]): - gate._begin_lookup("nonsense") - assert "Couldn't look that up" in gate._status.get() - - def test_single_candidate(self, gate: MealGate) -> None: - """A single match fills the fields and invites review.""" - with patch.object( - _gatelock, - "lookup_candidates", - return_value=[("apple", _nutrition(95, 100))], - ): - gate._begin_lookup("apple") - assert "Review the values" in gate._status.get() - - def test_multiple_candidates(self, gate: MealGate) -> None: - """Several matches invite picking another.""" - with patch.object( - _gatelock, - "lookup_candidates", - return_value=[ - ("a", _nutrition(95, 100)), - ("b", _nutrition(120, 100)), - ], - ): - gate._begin_lookup("apple") - assert "pick another" in gate._status.get() - - -class TestRecord: - """Logging a meal and advancing the slot walk.""" - - def test_demo_logs_without_slot(self, gate: MealGate) -> None: - """A demo record banks the food but tags no real slot.""" - gate._pending = [8] - with patch.object(_gatelock, "log_meal") as log: - gate._record("apple", _nutrition(95, 100)) - assert log.call_args.args[2] is None - - def test_last_slot_unlocks(self, gate: MealGate) -> None: - """Recording the final pending slot triggers the unlock.""" - gate._pending = [8] - with ( - patch.object(_gatelock, "log_meal"), - patch.object(_gatelock, "remember_food"), - patch.object(gate, "_unlock") as unlock, - ): - gate._record("apple", _nutrition(95, 100)) - unlock.assert_called_once() - - def test_more_slots_continue(self, gate: MealGate) -> None: - """With slots remaining, the form clears and prompts the next.""" - gate._pending = [8, 12] - with ( - patch.object(_gatelock, "log_meal"), - patch.object(_gatelock, "remember_food"), - ): - gate._record("apple", _nutrition(95, 100)) - assert gate._pending == [12] - assert "next meal" in gate._status.get() - - def test_unlock_schedules_close(self, gate: MealGate) -> None: - """Unlock sets the closing status and schedules teardown.""" - gate._unlock("logged X") - assert "unlocking" in gate._status.get() - - -class TestDashboard: - """The running calorie/macro panel.""" - - def test_headline_with_budget(self, gate: MealGate) -> None: - """A sealed budget shows consumed/target/remaining.""" - seal_budget(2000) - gate._refresh_dashboard() - assert "left" in gate._cal_headline.get() - - def test_headline_without_budget(self, gate: MealGate) -> None: - """With no budget, only today's total is shown.""" - gate._refresh_dashboard() - assert "kcal today" in gate._cal_headline.get() - - def test_dashboard_lists_entries(self, gate: MealGate) -> None: - """Logged entries appear in the detail panel.""" - seal_budget(2000, weight_kg=80) - _gatelock.log_meal("apple", _nutrition(95, 100), 8) - gate._refresh_dashboard() - text = gate._dashboard.get() - assert "apple" in text - assert "protein" in text - - def test_dashboard_empty(self, gate: MealGate) -> None: - """With nothing logged, the panel says so.""" - gate._refresh_dashboard() - assert "nothing logged yet" in gate._dashboard.get() - - def test_slot_header_variants(self, gate: MealGate) -> None: - """The header covers none / one / several pending slots.""" - gate._pending = [] - gate._refresh_slot_header() - assert "All meals logged" in gate._slot_header.get() - gate._pending = [8] - gate._refresh_slot_header() - assert "Log your" in gate._slot_header.get() - gate._pending = [8, 12] - gate._refresh_slot_header() - assert "remaining" in gate._slot_header.get() - - def test_projection_with_budget(self, gate: MealGate) -> None: - """The projection shows the after-this-item remaining when priced.""" - seal_budget(2000) - gate._kcal_entry.insert(0, "300") - gate._refresh_projection() - assert "after this item" in gate._projection.get() + def test_apply_reference_keeps_existing_amount(self, gate: MealGate) -> None: + """A grams-mode pick does not overwrite an amount already typed.""" + gate._set_entry(gate._widgets.amount_entry, "50") + gate._apply_reference(_nutrition(100, 100)) + assert gate._widgets.amount_entry.get() == "50" class TestWindowMechanics: @@ -573,15 +263,15 @@ class TestWindowMechanics: def test_disable_vt_no_tool(self, gate: MealGate) -> None: """A missing setxkbmap leaves VT switching enabled.""" - with patch.object(_gatelock.shutil, "which", return_value=None): + with patch.object(_gatelock_window.shutil, "which", return_value=None): gate._disable_vt_switching() assert gate._vt_disabled is False def test_disable_and_restore_vt(self, gate: MealGate) -> None: """With the tool present, VT switching toggles off then back on.""" with ( - patch.object(_gatelock.shutil, "which", return_value="/x/setxkbmap"), - patch.object(_gatelock.subprocess, "run") as run, + patch.object(_gatelock_window.shutil, "which", return_value="/x/setxkbmap"), + patch.object(_gatelock_window.subprocess, "run") as run, ): gate._disable_vt_switching() assert gate._vt_disabled is True @@ -603,7 +293,7 @@ class TestWindowMechanics: """A held grab reschedules another attempt instead of giving up.""" gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError) gate.root.after = MagicMock() - gate._acquire_global_grab(attempt=_gatelock._GRAB_LOG_EVERY) + gate._acquire_global_grab(attempt=_GRAB_LOG_EVERY) gate.root.after.assert_called_once() def test_focus_first_field(self, gate: MealGate) -> None: @@ -625,8 +315,8 @@ class TestWindowMechanics: """run wires handlers, starts the loop, and restores on exit.""" gate.root.mainloop = MagicMock() with ( - patch.object(_gatelock.signal, "signal"), - patch.object(_gatelock.atexit, "register"), + patch.object(_gatelock_window.signal, "signal"), + patch.object(_gatelock_window.atexit, "register"), ): gate.run() gate.root.mainloop.assert_called_once() @@ -640,12 +330,12 @@ class TestWindowMechanics: def test_callback_error_status(self, gate: MealGate) -> None: """An unexpected callback error surfaces a recoverable message.""" gate._handle_callback_error() - assert "went wrong" in gate._status.get() + assert "went wrong" in gate._vars.status.get() def test_restore_vt_without_tool(self, gate: MealGate) -> None: """Restoring when the tool has since vanished still clears the flag.""" gate._vt_disabled = True - with patch.object(_gatelock.shutil, "which", return_value=None): + with patch.object(_gatelock_window.shutil, "which", return_value=None): gate._restore_vt_switching() assert gate._vt_disabled is False @@ -657,87 +347,14 @@ class TestWindowMechanics: gate.root.after.assert_called_once() -class TestBasisAndAmount: - """Edge branches in the grams/items basis and amount maths.""" - - def test_basis_typed_value(self, gate: MealGate) -> None: - """A typed per-value is honoured directly.""" - gate._set_entry(gate._per_entry, "50") - assert gate._basis_grams() == 50 - - def test_basis_items_known_staple(self, gate: MealGate) -> None: - """Items mode with a blank per falls back to the staple weight.""" - gate._per_entry.delete(0) - gate._unit.set("items") - gate._set_desc("apple") - assert gate._basis_grams() == 182 - - def test_basis_items_unknown(self, gate: MealGate) -> None: - """An unknown item uses the default piece weight.""" - gate._per_entry.delete(0) - gate._unit.set("items") - gate._set_desc("mystery") - assert gate._basis_grams() == _gatelock.DEFAULT_ITEM_GRAMS - - def test_basis_grams_default(self, gate: MealGate) -> None: - """Grams mode with a blank per uses the per-100 g default.""" - gate._per_entry.delete(0) - assert gate._basis_grams() == _gatelock._DEFAULT_PER_GRAMS - - def test_eaten_grams_none(self, gate: MealGate) -> None: - """No amount typed yields no eaten weight.""" - assert gate._eaten_grams() is None - - def test_eaten_grams_items(self, gate: MealGate) -> None: - """Items mode multiplies the count by the per-item weight.""" - gate._unit.set("items") - gate._set_desc("apple") - gate._set_entry(gate._per_entry, "182") - gate._set_entry(gate._amount_entry, "5") - assert gate._eaten_grams() == 5 * 182 - - def test_amount_change_refreshes(self, gate: MealGate) -> None: - """Changing the amount recomputes the preview.""" - gate._set_entry(gate._kcal_entry, "100") - gate._set_entry(gate._amount_entry, "200") - gate._on_amount_change(None) - assert gate._preview.get() - - def test_projection_else_without_item(self, gate: MealGate) -> None: - """With a budget but no priced item, no after-this-item is shown.""" - seal_budget(2000) - gate._refresh_projection() - text = gate._projection.get() - assert "left" in text - assert "after this item" not in text - - def test_keyrelease_grams_mode(self, gate: MealGate) -> None: - """In grams mode the per-item weight is not touched on keyrelease.""" - gate._unit.set("grams") - gate._set_desc("apple") - gate._on_desc_keyrelease(None) - - def test_keyrelease_items_unknown(self, gate: MealGate) -> None: - """An unknown item in items mode leaves the per field unchanged.""" - gate._unit.set("items") - gate._set_desc("zzzz") - gate._on_desc_keyrelease(None) - - def test_apply_reference_keeps_existing_amount(self, gate: MealGate) -> None: - """A grams-mode pick does not overwrite an amount already typed.""" - gate._set_entry(gate._amount_entry, "50") - gate._apply_reference(_nutrition(100, 100)) - assert gate._amount_entry.get() == "50" - - class TestDisplayReadiness: """The session-start display wait that absorbs the X auth-cookie race.""" def test_ready_when_root_connects(self) -> None: """A Tk root that builds and destroys cleanly means the display is up.""" fake_tk = SimpleNamespace(Tk=MagicMock(), TclError=_FakeTclError) - with patch.object(_gatelock, "tk", fake_tk): - assert _gatelock._display_is_ready() is True + with patch.object(_gatelock_support, "tk", fake_tk): + assert _gatelock_support._display_is_ready() is True fake_tk.Tk.return_value.destroy.assert_called_once() def test_not_ready_on_tclerror(self) -> None: @@ -746,13 +363,13 @@ class TestDisplayReadiness: Tk=MagicMock(side_effect=_FakeTclError("no display")), TclError=_FakeTclError, ) - with patch.object(_gatelock, "tk", fake_tk): - assert _gatelock._display_is_ready() is False + with patch.object(_gatelock_support, "tk", fake_tk): + assert _gatelock_support._display_is_ready() is False def test_wait_returns_immediately_when_ready(self) -> None: """A display ready on the first probe returns at once and never sleeps.""" sleep = MagicMock() - with patch.object(_gatelock, "_display_is_ready", return_value=True): + with patch.object(_gatelock_support, "_display_is_ready", return_value=True): ready = wait_for_display(sleep=sleep, monotonic=MagicMock(return_value=0.0)) assert ready is True sleep.assert_not_called() @@ -761,7 +378,9 @@ class TestDisplayReadiness: """Not-ready then ready sleeps once between probes, then unblocks.""" sleep = MagicMock() monotonic = MagicMock(side_effect=[0.0, 0.0]) - with patch.object(_gatelock, "_display_is_ready", side_effect=[False, True]): + with patch.object( + _gatelock_support, "_display_is_ready", side_effect=[False, True] + ): assert wait_for_display(sleep=sleep, monotonic=monotonic) is True sleep.assert_called_once() @@ -769,149 +388,6 @@ class TestDisplayReadiness: """A display still down at the deadline gives up so the next tick retries.""" sleep = MagicMock() monotonic = MagicMock(side_effect=[0.0, 60.0]) - with patch.object(_gatelock, "_display_is_ready", return_value=False): + with patch.object(_gatelock_support, "_display_is_ready", return_value=False): assert wait_for_display(sleep=sleep, monotonic=monotonic) is False sleep.assert_not_called() - - -class TestMealFlow: - """Building and logging a multi-item composite meal.""" - - def test_meal_name_trimmed(self, gate: MealGate) -> None: - """The meal name is read back trimmed.""" - gate._meal_name_entry.insert(0, " dinner ") - assert gate._meal_name() == "dinner" - - def test_summary_empty_with_no_items(self, gate: MealGate) -> None: - """With no accumulated items the running summary is blank.""" - gate._refresh_meal_summary() - assert gate._meal_summary.get() == "" - - def test_summary_lists_items_and_total(self, gate: MealGate) -> None: - """The summary shows the item names and the running calorie total.""" - gate._meal_items = [ - MealItem("salad", _nutrition(80, 120)), - MealItem("chicken", _nutrition(330, 200)), - ] - gate._refresh_meal_summary() - summary = gate._meal_summary.get() - assert "salad, chicken" in summary - assert "410 kcal" in summary - - def test_add_item_requires_description(self, gate: MealGate) -> None: - """Adding with no description prompts for one.""" - gate._on_add_item() - assert "Type the item first" in gate._status.get() - - def test_add_item_rejects_non_numeric(self, gate: MealGate) -> None: - """Non-numeric macros are rejected before adding.""" - gate._set_desc("salad") - gate._kcal_entry.insert(0, "abc") - gate._on_add_item() - assert "must be numbers" in gate._status.get() - - def test_add_item_blank_calories_looks_up(self, gate: MealGate) -> None: - """A blank calorie field looks the item up rather than adding.""" - gate._set_desc("salad") - with patch.object(gate, "_begin_lookup") as lookup: - gate._on_add_item() - lookup.assert_called_once() - - def test_add_item_defensive_none_nutrition(self, gate: MealGate) -> None: - """A priced item that will not resolve prompts again (guard).""" - gate._set_desc("salad") - gate._kcal_entry.insert(0, "80") - with patch.object(gate, "_current_nutrition", return_value=None): - gate._on_add_item() - assert "add the item" in gate._status.get() - - def test_add_item_accumulates_and_clears(self, gate: MealGate) -> None: - """A valid item is appended, the form clears, the meal name is kept.""" - gate._meal_name_entry.insert(0, "dinner") - gate._set_desc("salad") - gate._kcal_entry.insert(0, "80") - gate._on_add_item() - assert len(gate._meal_items) == 1 - assert gate._meal_items[0].name == "salad" - assert gate._get_desc() == "" - assert gate._meal_name() == "dinner" - assert "Added salad" in gate._status.get() - - def test_submit_empty_form_logs_accumulated_meal(self, gate: MealGate) -> None: - """Submitting an empty form with items finalizes the meal.""" - gate._meal_items = [MealItem("salad", _nutrition(80, 120))] - with patch.object(gate, "_log_meal") as log_meal_: - gate._on_submit() - log_meal_.assert_called_once() - - def test_submit_completes_meal_with_final_item(self, gate: MealGate) -> None: - """A filled form plus existing items adds the form item, then logs.""" - gate._meal_items = [MealItem("salad", _nutrition(80, 120))] - gate._set_desc("rice") - gate._kcal_entry.insert(0, "260") - with patch.object(gate, "_log_meal") as log_meal_: - gate._on_submit() - assert len(gate._meal_items) == 2 - assert gate._meal_items[1].name == "rice" - log_meal_.assert_called_once() - - def test_log_meal_calls_remember_and_advances(self, gate: MealGate) -> None: - """Logging a meal banks it under the typed name and advances the slot.""" - gate._pending = [8, 12] - gate._meal_name_entry.insert(0, "dinner") - gate._meal_items = [ - MealItem("salad", _nutrition(80, 120)), - MealItem("chicken", _nutrition(330, 200)), - ] - with ( - patch.object( - _gatelock, "remember_meal", return_value=_nutrition(410, 320) - ) as remember, - patch.object(_gatelock, "log_meal") as log, - ): - gate._log_meal() - assert remember.call_args.args[0] == "dinner" - assert log.call_args.args[0] == "dinner" - assert gate._meal_items == [] - assert gate._pending == [12] - - def test_log_meal_uses_default_name(self, gate: MealGate) -> None: - """A blank meal name falls back to the default.""" - gate._pending = [8, 12] - gate._meal_items = [MealItem("soup", _nutrition(150, 300))] - with ( - patch.object( - _gatelock, "remember_meal", return_value=_nutrition(150, 300) - ) as remember, - patch.object(_gatelock, "log_meal"), - ): - gate._log_meal() - assert remember.call_args.args[0] == _gatelock._DEFAULT_MEAL_NAME - - def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None: - """A demo gate tags logs with no real slot.""" - gate._pending = [8] - assert gate._slot_for_log() is None - - def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None: - """A production gate tags logs with the current slot.""" - gate.demo_mode = False - gate._pending = [12] - assert gate._slot_for_log() == 12 - - def test_clear_inputs_discards_meal(self, gate: MealGate) -> None: - """Clearing between slots drops the in-progress meal and its name.""" - gate._meal_items = [MealItem("salad", _nutrition(80, 120))] - gate._meal_name_entry.insert(0, "dinner") - gate._meal_summary.set("something") - gate._clear_inputs() - assert gate._meal_items == [] - assert gate._meal_name() == "" - assert gate._meal_summary.get() == "" - - def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None: - """Finishing the final slot triggers unlock.""" - gate._pending = [20] - with patch.object(gate, "_unlock") as unlock: - gate._finish_slot("done") - unlock.assert_called_once() diff --git a/python_pkg/diet_guard/tests/test_gatelock_mealflow.py b/python_pkg/diet_guard/tests/test_gatelock_mealflow.py new file mode 100644 index 0000000..3ff9173 --- /dev/null +++ b/python_pkg/diet_guard/tests/test_gatelock_mealflow.py @@ -0,0 +1,425 @@ +"""Tests for the nutrition model, lookup, and meal-building flow of MealGate. + +Covers :mod:`._gatelock_nutrition` (reference -> total maths, suggestions, +unit toggling) and :mod:`._gatelock_mealflow` (submit/lookup/record, the +dashboard, and multi-item meals). The functional fake ``tk`` widgets and the +``gate`` fixture live in ``conftest.py`` and are shared with +:mod:`test_gatelock`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +from python_pkg.diet_guard import _gatelock_mealflow +from python_pkg.diet_guard._budget import seal_budget +from python_pkg.diet_guard._meal import MealItem +from python_pkg.diet_guard._state import log_meal +from python_pkg.diet_guard.tests.conftest import _nutrition + +if TYPE_CHECKING: + from python_pkg.diet_guard._gatelock import MealGate + + +class TestReferenceModel: + """The reference -> total nutrition computation.""" + + def test_reference_none_without_calories(self, gate: MealGate) -> None: + """No calories typed means no reference yet.""" + assert gate._reference_nutrition() is None + + def test_current_is_reference_without_amount(self, gate: MealGate) -> None: + """With calories but no amount, the reference stands in as the total.""" + gate._widgets.macros.kcal.insert(0, "200") + current = gate._current_nutrition() + assert current is not None + assert current.kcal == 200 + + def test_current_scales_with_amount(self, gate: MealGate) -> None: + """Grams eaten scale the per-100 g reference into the total.""" + gate._widgets.macros.kcal.insert(0, "200") + gate._widgets.amount_entry.insert(0, "200") + current = gate._current_nutrition() + assert current is not None + assert current.kcal == 400 + + +class TestSuggestions: + """Autocomplete population and selection.""" + + def test_keyrelease_items_mode_shows_weight(self, gate: MealGate) -> None: + """In items mode, typing a staple fills the per-item weight.""" + gate._vars.unit.set("items") + gate._set_desc("apple") + gate._on_desc_keyrelease(None) + assert gate._widgets.per_entry.get() == "182" + + def test_select_bank_fills_name_and_macros(self, gate: MealGate) -> None: + """Picking a banked suggestion adopts its name and macros.""" + gate._state.suggestions = [("apple pie", _nutrition(300, 120))] + gate._state.suggestion_mode = "bank" + gate._widgets.suggestion_box.selection_set(0) + gate._on_suggestion_select(None) + assert gate._get_desc() == "apple pie" + assert gate._widgets.macros.kcal.get() == "300" + + def test_select_candidate_keeps_description(self, gate: MealGate) -> None: + """An OFF candidate fills macros but not the typed description.""" + gate._set_desc("my dish") + gate._state.suggestions = [("openfoodfacts: X", _nutrition(250, 100))] + gate._state.suggestion_mode = "candidates" + gate._widgets.suggestion_box.selection_set(0) + gate._on_suggestion_select(None) + assert gate._get_desc() == "my dish" + + def test_select_no_selection(self, gate: MealGate) -> None: + """No selection is a no-op.""" + gate._on_suggestion_select(None) + + def test_select_out_of_range(self, gate: MealGate) -> None: + """A stale selection index beyond the list is ignored.""" + gate._state.suggestions = [] + gate._widgets.suggestion_box.selection_set(5) + gate._on_suggestion_select(None) + + +class TestUnitToggle: + """Switching the grams/items basis.""" + + def test_toggle_reconverts_picked_food(self, gate: MealGate) -> None: + """A picked food is re-expressed per item, then back per 100 g.""" + gate._apply_reference(_nutrition(52, 100), name="apple") + gate._vars.unit.set("items") + gate._on_unit_change("items") + per_item = gate._widgets.macros.kcal.get() + gate._vars.unit.set("grams") + gate._on_unit_change("grams") + assert gate._widgets.macros.kcal.get() == "52" + assert per_item != "52" + + def test_toggle_without_reference_clears(self, gate: MealGate) -> None: + """With no picked food, a toggle clears the macro fields.""" + gate._widgets.macros.kcal.insert(0, "123") + gate._state.last_reference = None + gate._vars.unit.set("items") + gate._on_unit_change("items") + assert gate._widgets.macros.kcal.get() == "" + + def test_macro_edit_drops_reference(self, gate: MealGate) -> None: + """Hand-editing a macro invalidates the stored reference.""" + gate._state.last_reference = _nutrition() + gate._on_macro_edit(None) + assert gate._state.last_reference is None + + +class TestSubmit: + """The two-step submit (look up, then log).""" + + def test_empty_description(self, gate: MealGate) -> None: + """Submitting with no description prompts for one.""" + gate._on_submit() + assert "Type what you ate" in gate._vars.status.get() + + def test_non_numeric_macros(self, gate: MealGate) -> None: + """Non-numeric macros are rejected before logging.""" + gate._set_desc("apple") + gate._widgets.macros.kcal.insert(0, "abc") + gate._on_submit() + assert "must be numbers" in gate._vars.status.get() + + def test_blank_calories_triggers_lookup(self, gate: MealGate) -> None: + """A blank calorie field looks the food up rather than logging.""" + gate._set_desc("apple") + with patch.object(gate, "_begin_lookup") as lookup: + gate._on_submit() + lookup.assert_called_once() + + def test_defensive_none_nutrition(self, gate: MealGate) -> None: + """A calorie value but unresolvable nutrition prompts again (guard).""" + gate._set_desc("apple") + gate._widgets.macros.kcal.insert(0, "200") + with patch.object(gate, "_current_nutrition", return_value=None): + gate._on_submit() + assert "Enter the calories" in gate._vars.status.get() + + def test_valid_submit_records(self, gate: MealGate) -> None: + """A described, priced meal is recorded.""" + gate._set_desc("apple") + gate._widgets.macros.kcal.insert(0, "95") + with patch.object(gate, "_record") as record: + gate._on_submit() + record.assert_called_once() + + def test_on_return_submits(self, gate: MealGate) -> None: + """Enter in a numeric field submits.""" + with patch.object(gate, "_on_submit") as submit: + gate._on_return(None) + submit.assert_called_once() + + +class TestLookup: + """Step one: filling the form from a lookup.""" + + def test_no_candidates(self, gate: MealGate) -> None: + """No match asks for a manual value.""" + gate._set_desc("nonsense") + with patch.object(_gatelock_mealflow, "lookup_candidates", return_value=[]): + gate._begin_lookup("nonsense") + assert "Couldn't look that up" in gate._vars.status.get() + + def test_single_candidate(self, gate: MealGate) -> None: + """A single match fills the fields and invites review.""" + with patch.object( + _gatelock_mealflow, + "lookup_candidates", + return_value=[("apple", _nutrition(95, 100))], + ): + gate._begin_lookup("apple") + assert "Review the values" in gate._vars.status.get() + + def test_multiple_candidates(self, gate: MealGate) -> None: + """Several matches invite picking another.""" + with patch.object( + _gatelock_mealflow, + "lookup_candidates", + return_value=[ + ("a", _nutrition(95, 100)), + ("b", _nutrition(120, 100)), + ], + ): + gate._begin_lookup("apple") + assert "pick another" in gate._vars.status.get() + + +class TestRecord: + """Logging a meal and advancing the slot walk.""" + + def test_demo_logs_without_slot(self, gate: MealGate) -> None: + """A demo record banks the food but tags no real slot.""" + gate._pending = [8] + with patch.object(_gatelock_mealflow, "log_meal") as log: + gate._record("apple", _nutrition(95, 100)) + assert log.call_args.args[2] is None + + def test_last_slot_unlocks(self, gate: MealGate) -> None: + """Recording the final pending slot triggers the unlock.""" + gate._pending = [8] + with ( + patch.object(_gatelock_mealflow, "log_meal"), + patch.object(_gatelock_mealflow, "remember_food"), + patch.object(gate, "_unlock") as unlock, + ): + gate._record("apple", _nutrition(95, 100)) + unlock.assert_called_once() + + def test_more_slots_continue(self, gate: MealGate) -> None: + """With slots remaining, the form clears and prompts the next.""" + gate._pending = [8, 12] + with ( + patch.object(_gatelock_mealflow, "log_meal"), + patch.object(_gatelock_mealflow, "remember_food"), + ): + gate._record("apple", _nutrition(95, 100)) + assert gate._pending == [12] + assert "next meal" in gate._vars.status.get() + + def test_unlock_schedules_close(self, gate: MealGate) -> None: + """Unlock sets the closing status and schedules teardown.""" + gate._unlock("logged X") + assert "unlocking" in gate._vars.status.get() + + +class TestDashboard: + """The running calorie/macro panel.""" + + def test_headline_with_budget(self, gate: MealGate) -> None: + """A sealed budget shows consumed/target/remaining.""" + seal_budget(2000) + gate._refresh_dashboard() + assert "left" in gate._vars.cal_headline.get() + + def test_headline_without_budget(self, gate: MealGate) -> None: + """With no budget, only today's total is shown.""" + gate._refresh_dashboard() + assert "kcal today" in gate._vars.cal_headline.get() + + def test_dashboard_lists_entries(self, gate: MealGate) -> None: + """Logged entries appear in the detail panel.""" + seal_budget(2000, weight_kg=80) + log_meal("apple", _nutrition(95, 100), 8) + gate._refresh_dashboard() + text = gate._vars.dashboard.get() + assert "apple" in text + assert "protein" in text + + def test_dashboard_empty(self, gate: MealGate) -> None: + """With nothing logged, the panel says so.""" + gate._refresh_dashboard() + assert "nothing logged yet" in gate._vars.dashboard.get() + + def test_slot_header_variants(self, gate: MealGate) -> None: + """The header covers none / one / several pending slots.""" + gate._pending = [] + gate._refresh_slot_header() + assert "All meals logged" in gate._vars.slot_header.get() + gate._pending = [8] + gate._refresh_slot_header() + assert "Log your" in gate._vars.slot_header.get() + gate._pending = [8, 12] + gate._refresh_slot_header() + assert "remaining" in gate._vars.slot_header.get() + + def test_projection_with_budget(self, gate: MealGate) -> None: + """The projection shows the after-this-item remaining when priced.""" + seal_budget(2000) + gate._widgets.macros.kcal.insert(0, "300") + gate._refresh_projection() + assert "after this item" in gate._vars.projection.get() + + +class TestMealFlow: + """Building and logging a multi-item composite meal.""" + + def test_meal_name_trimmed(self, gate: MealGate) -> None: + """The meal name is read back trimmed.""" + gate._widgets.meal_name_entry.insert(0, " dinner ") + assert gate._meal_name() == "dinner" + + def test_summary_empty_with_no_items(self, gate: MealGate) -> None: + """With no accumulated items the running summary is blank.""" + gate._refresh_meal_summary() + assert gate._vars.meal_summary.get() == "" + + def test_summary_lists_items_and_total(self, gate: MealGate) -> None: + """The summary shows the item names and the running calorie total.""" + gate._state.meal_items = [ + MealItem("salad", _nutrition(80, 120)), + MealItem("chicken", _nutrition(330, 200)), + ] + gate._refresh_meal_summary() + summary = gate._vars.meal_summary.get() + assert "salad, chicken" in summary + assert "410 kcal" in summary + + def test_add_item_requires_description(self, gate: MealGate) -> None: + """Adding with no description prompts for one.""" + gate._on_add_item() + assert "Type the item first" in gate._vars.status.get() + + def test_add_item_rejects_non_numeric(self, gate: MealGate) -> None: + """Non-numeric macros are rejected before adding.""" + gate._set_desc("salad") + gate._widgets.macros.kcal.insert(0, "abc") + gate._on_add_item() + assert "must be numbers" in gate._vars.status.get() + + def test_add_item_blank_calories_looks_up(self, gate: MealGate) -> None: + """A blank calorie field looks the item up rather than adding.""" + gate._set_desc("salad") + with patch.object(gate, "_begin_lookup") as lookup: + gate._on_add_item() + lookup.assert_called_once() + + def test_add_item_defensive_none_nutrition(self, gate: MealGate) -> None: + """A priced item that will not resolve prompts again (guard).""" + gate._set_desc("salad") + gate._widgets.macros.kcal.insert(0, "80") + with patch.object(gate, "_current_nutrition", return_value=None): + gate._on_add_item() + assert "add the item" in gate._vars.status.get() + + def test_add_item_accumulates_and_clears(self, gate: MealGate) -> None: + """A valid item is appended, the form clears, the meal name is kept.""" + gate._widgets.meal_name_entry.insert(0, "dinner") + gate._set_desc("salad") + gate._widgets.macros.kcal.insert(0, "80") + gate._on_add_item() + assert len(gate._state.meal_items) == 1 + assert gate._state.meal_items[0].name == "salad" + assert gate._get_desc() == "" + assert gate._meal_name() == "dinner" + assert "Added salad" in gate._vars.status.get() + + def test_submit_empty_form_logs_accumulated_meal(self, gate: MealGate) -> None: + """Submitting an empty form with items finalizes the meal.""" + gate._state.meal_items = [MealItem("salad", _nutrition(80, 120))] + with patch.object(gate, "_log_meal") as log_meal_: + gate._on_submit() + log_meal_.assert_called_once() + + def test_submit_completes_meal_with_final_item(self, gate: MealGate) -> None: + """A filled form plus existing items adds the form item, then logs.""" + gate._state.meal_items = [MealItem("salad", _nutrition(80, 120))] + gate._set_desc("rice") + gate._widgets.macros.kcal.insert(0, "260") + with patch.object(gate, "_log_meal") as log_meal_: + gate._on_submit() + assert len(gate._state.meal_items) == 2 + assert gate._state.meal_items[1].name == "rice" + log_meal_.assert_called_once() + + def test_log_meal_calls_remember_and_advances(self, gate: MealGate) -> None: + """Logging a meal banks it under the typed name and advances the slot.""" + gate._pending = [8, 12] + gate._widgets.meal_name_entry.insert(0, "dinner") + gate._state.meal_items = [ + MealItem("salad", _nutrition(80, 120)), + MealItem("chicken", _nutrition(330, 200)), + ] + with ( + patch.object( + _gatelock_mealflow, + "remember_meal", + return_value=_nutrition(410, 320), + ) as remember, + patch.object(_gatelock_mealflow, "log_meal") as log, + ): + gate._log_meal() + assert remember.call_args.args[0] == "dinner" + assert log.call_args.args[0] == "dinner" + assert gate._state.meal_items == [] + assert gate._pending == [12] + + def test_log_meal_uses_default_name(self, gate: MealGate) -> None: + """A blank meal name falls back to the default.""" + gate._pending = [8, 12] + gate._state.meal_items = [MealItem("soup", _nutrition(150, 300))] + with ( + patch.object( + _gatelock_mealflow, + "remember_meal", + return_value=_nutrition(150, 300), + ) as remember, + patch.object(_gatelock_mealflow, "log_meal"), + ): + gate._log_meal() + assert remember.call_args.args[0] == _gatelock_mealflow._DEFAULT_MEAL_NAME + + def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None: + """A demo gate tags logs with no real slot.""" + gate._pending = [8] + assert gate._slot_for_log() is None + + def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None: + """A production gate tags logs with the current slot.""" + gate.demo_mode = False + gate._pending = [12] + assert gate._slot_for_log() == 12 + + def test_clear_inputs_discards_meal(self, gate: MealGate) -> None: + """Clearing between slots drops the in-progress meal and its name.""" + gate._state.meal_items = [MealItem("salad", _nutrition(80, 120))] + gate._widgets.meal_name_entry.insert(0, "dinner") + gate._vars.meal_summary.set("something") + gate._clear_inputs() + assert gate._state.meal_items == [] + assert gate._meal_name() == "" + assert gate._vars.meal_summary.get() == "" + + def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None: + """Finishing the final slot triggers unlock.""" + gate._pending = [20] + with patch.object(gate, "_unlock") as unlock: + gate._finish_slot("done") + unlock.assert_called_once() diff --git a/python_pkg/morning_routine/_orchestrator.py b/python_pkg/morning_routine/_orchestrator.py index 082ccc4..7cfbb1c 100644 --- a/python_pkg/morning_routine/_orchestrator.py +++ b/python_pkg/morning_routine/_orchestrator.py @@ -25,6 +25,8 @@ import logging import subprocess import sys +from python_pkg.shared.logging_setup import configure_logging + _logger = logging.getLogger(__name__) # Modules invoked as ``python -m --production``. @@ -79,10 +81,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: def main() -> None: """Entry point: optionally run the alarm, then always run the workout lock.""" - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(name)s %(levelname)s %(message)s", - ) + configure_logging() args = _parse_args(sys.argv[1:]) # Alarm first so it owns the fullscreen and escalates until dismissed; only # then hand off to the workout lock. Running them in this order in a single diff --git a/python_pkg/shared/coerce.py b/python_pkg/shared/coerce.py new file mode 100644 index 0000000..89e5e90 --- /dev/null +++ b/python_pkg/shared/coerce.py @@ -0,0 +1,23 @@ +"""Small value-coercion helpers shared across python_pkg subpackages.""" + +from __future__ import annotations + + +def as_float(value: object) -> float: + """Coerce a stored field to ``float``, defaulting to 0.0. + + Booleans are rejected (they are an ``int`` subclass but never a real numeric + measurement here) and any non-numeric value yields 0.0, so callers reading + semi-structured log/bank data get a safe number without guarding each read. + + Args: + value: A value read back from a JSON-ish store. + + Returns: + The value as a float, or 0.0 when it is absent, a bool, or non-numeric. + """ + if isinstance(value, bool): + return 0.0 + if isinstance(value, (int, float)): + return float(value) + return 0.0 diff --git a/python_pkg/shared/logging_setup.py b/python_pkg/shared/logging_setup.py new file mode 100644 index 0000000..ae5a47c --- /dev/null +++ b/python_pkg/shared/logging_setup.py @@ -0,0 +1,17 @@ +"""Shared logging configuration for python_pkg entry points.""" + +from __future__ import annotations + +import logging + + +def configure_logging() -> None: + """Configure root logging with the standard daemon format and level. + + Centralises the ``basicConfig`` call shared by the package ``main`` entry + points so every daemon logs with an identical timestamped format. + """ + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(name)s %(levelname)s %(message)s", + ) diff --git a/python_pkg/shared/tests/test_coerce.py b/python_pkg/shared/tests/test_coerce.py new file mode 100644 index 0000000..c3519e4 --- /dev/null +++ b/python_pkg/shared/tests/test_coerce.py @@ -0,0 +1,26 @@ +"""Tests for the shared ``as_float`` coercion helper.""" + +from __future__ import annotations + +from python_pkg.shared.coerce import as_float + + +def test_bool_rejected() -> None: + """Booleans coerce to 0.0 despite subclassing ``int``.""" + true_value: bool = True + false_value: bool = False + assert as_float(true_value) == 0.0 + assert as_float(false_value) == 0.0 + + +def test_numeric_coerced() -> None: + """ints and floats coerce to a float value.""" + assert as_float(3) == 3.0 + assert as_float(2.5) == 2.5 + + +def test_non_numeric_is_zero() -> None: + """Strings, ``None`` and other types yield 0.0.""" + assert as_float("abc") == 0.0 + assert as_float(None) == 0.0 + assert as_float([1, 2]) == 0.0 diff --git a/python_pkg/shared/tests/test_logging_setup.py b/python_pkg/shared/tests/test_logging_setup.py new file mode 100644 index 0000000..f9f52f0 --- /dev/null +++ b/python_pkg/shared/tests/test_logging_setup.py @@ -0,0 +1,20 @@ +"""Tests for the shared logging configuration helper.""" + +from __future__ import annotations + +import logging +from unittest.mock import patch + +from python_pkg.shared.logging_setup import configure_logging + + +def test_configure_logging_uses_standard_format_and_level() -> None: + """``configure_logging`` delegates to ``basicConfig`` with INFO + format.""" + with patch( + "python_pkg.shared.logging_setup.logging.basicConfig", + ) as mock_basic_config: + configure_logging() + mock_basic_config.assert_called_once_with( + level=logging.INFO, + format="%(asctime)s %(name)s %(levelname)s %(message)s", + ) diff --git a/python_pkg/wake_alarm/_alarm.py b/python_pkg/wake_alarm/_alarm.py index 47834b6..0fb21e7 100644 --- a/python_pkg/wake_alarm/_alarm.py +++ b/python_pkg/wake_alarm/_alarm.py @@ -9,15 +9,16 @@ workout-free day via HMAC-signed wake state. from __future__ import annotations import argparse +from dataclasses import dataclass from datetime import datetime, timezone import logging -import shutil -import subprocess import sys import threading import time import tkinter as tk +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 ( _activate_alarm_audio, _beep_loud, @@ -40,6 +41,7 @@ from python_pkg.wake_alarm._constants import ( DISMISS_FLASH_SECONDS, DISMISS_ROUNDS_REQUIRED, DISMISS_WINDOW_MINUTES, + DISPLAY_WAKE_WAIT_SECONDS, LOUD_TOGGLE_INTERVAL, MEDIUM_BEEP_INTERVAL, PHASE_MEDIUM_END, @@ -50,6 +52,7 @@ from python_pkg.wake_alarm._smart_plug import turn_off_plug, turn_on_plug from python_pkg.wake_alarm._state import ( save_wake_state, was_alarm_dismissed_today, + was_workout_logged_today, ) _logger = logging.getLogger(__name__) @@ -60,31 +63,37 @@ def _is_alarm_day() -> bool: return datetime.now(tz=timezone.utc).weekday() in ALARM_DAYS -def _wake_display() -> None: - """Force the display on and disable screensaver during alarm.""" - xset = shutil.which("xset") - if xset is None: - _logger.warning("xset not on PATH; skipping display wake") - return - for cmd in ( - [xset, "dpms", "force", "on"], - [xset, "s", "off"], - ): - subprocess.run(cmd, check=False, capture_output=True, timeout=5) +@dataclass +class _AlarmView: + """The Tk widgets that make up the alarm's dismiss-challenge screen.""" + + container: tk.Frame + title_label: tk.Label + round_label: tk.Label + info_label: tk.Label + code_label: tk.Label + entry: tk.Entry + status_label: tk.Label + timer_label: tk.Label -def _restore_display() -> None: - """Re-enable screensaver after the alarm ends.""" - xset = shutil.which("xset") - if xset is None: - _logger.warning("xset not on PATH; skipping display restore") - return - subprocess.run( - [xset, "s", "on"], - check=False, - capture_output=True, - timeout=5, - ) +@dataclass +class _AlarmProgress: + """Mutable dismiss-challenge progress state.""" + + current_challenge: _Challenge + skip_earnable: bool = True + rounds_completed: int = 0 + flash_remaining: int = 0 + flash_on: bool = False + + +@dataclass +class _AlarmHardware: + """Hardware state captured at alarm start, restored when it closes.""" + + fan_state: bool + audio_restore: str | None class WakeAlarm: @@ -123,92 +132,103 @@ class WakeAlarm: self.root.focus_force() self.root.update_idletasks() - self._current_challenge: _Challenge = _make_challenge() - self._skip_earnable: bool = True - self._rounds_completed: int = 0 - self._flash_remaining: int = 0 - self._build_ui() - if self._current_challenge.kind == "flash": + self._progress = _AlarmProgress(current_challenge=_make_challenge()) + self._view = self._build_ui() + self._update_timer() + if self._progress.current_challenge.kind == "flash": self._start_flash_countdown() self._schedule_code_refresh() self._schedule_skip_window_close() self._start_beep_thread() - self._fan_state: bool = _max_fans() - self._audio_restore: str | None = _activate_alarm_audio() - self._flash_on: bool = False + self._hardware = _AlarmHardware( + fan_state=_max_fans(), + audio_restore=_activate_alarm_audio(), + ) self._start_screen_flash() - def _build_ui(self) -> None: - """Build the dismiss challenge UI.""" - self._container = tk.Frame(self.root, bg="#1a1a1a") - self._container.place(relx=0.5, rely=0.5, anchor="center") + def _build_ui(self) -> _AlarmView: + """Build the dismiss-challenge UI and return its widgets as a view.""" + challenge = self._progress.current_challenge - self._title_label = tk.Label( - self._container, + container = tk.Frame(self.root, bg="#1a1a1a") + container.place(relx=0.5, rely=0.5, anchor="center") + + title_label = tk.Label( + container, text="WAKE UP!", font=("Arial", 48, "bold"), fg="#ff4444", bg="#1a1a1a", ) - self._title_label.pack(pady=20) + title_label.pack(pady=20) - self._round_label = tk.Label( - self._container, + round_label = tk.Label( + container, text=f"Round 1 / {DISMISS_ROUNDS_REQUIRED}", font=("Arial", 24, "bold"), fg="#ffaa00", bg="#1a1a1a", ) - self._round_label.pack(pady=5) + round_label.pack(pady=5) - self._info_label = tk.Label( - self._container, - text=self._current_challenge.hint, + info_label = tk.Label( + container, + text=challenge.hint, font=("Arial", 18), fg="white", bg="#1a1a1a", ) - self._info_label.pack(pady=10) + info_label.pack(pady=10) # Math and sort use a smaller font because their display text is wider. - code_font_size = 48 if self._current_challenge.kind in ("math", "sort") else 72 - self._code_label = tk.Label( - self._container, - text=self._current_challenge.display, + code_font_size = 48 if challenge.kind in ("math", "sort") else 72 + code_label = tk.Label( + container, + text=challenge.display, font=("Courier", code_font_size, "bold"), fg="#00ff00", bg="#1a1a1a", ) - self._code_label.pack(pady=30) + code_label.pack(pady=30) - self._entry = tk.Entry( - self._container, + entry = tk.Entry( + container, font=("Courier", 36), justify="center", width=12, ) - self._entry.pack(pady=10) - self._entry.focus_set() - self._entry.bind("", self._on_submit) + entry.pack(pady=10) + entry.focus_set() + entry.bind("", self._on_submit) - self._status_label = tk.Label( - self._container, + status_label = tk.Label( + container, text="", font=("Arial", 18), fg="#ff4444", bg="#1a1a1a", ) - self._status_label.pack(pady=10) + status_label.pack(pady=10) - self._timer_label = tk.Label( - self._container, + timer_label = tk.Label( + container, text="", font=("Arial", 14), fg="#aaaaaa", bg="#1a1a1a", ) - self._timer_label.pack(pady=5) - self._update_timer() + timer_label.pack(pady=5) + + return _AlarmView( + container=container, + title_label=title_label, + round_label=round_label, + info_label=info_label, + code_label=code_label, + entry=entry, + status_label=status_label, + timer_label=timer_label, + ) def _on_submit(self, _event: object = None) -> None: """Handle challenge submission. @@ -218,57 +238,57 @@ class WakeAlarm: correct round generates a new random challenge so the user must stay awake and re-engage each time. """ - entered = self._entry.get().strip().upper() - if entered != self._current_challenge.answer: - self._status_label.configure(text="Wrong! Try again.") - self._entry.delete(0, tk.END) - if self._current_challenge.kind == "flash": - self._code_label.configure( - text=self._current_challenge.display, + entered = self._view.entry.get().strip().upper() + if entered != self._progress.current_challenge.answer: + self._view.status_label.configure(text="Wrong! Try again.") + self._view.entry.delete(0, tk.END) + if self._progress.current_challenge.kind == "flash": + self._view.code_label.configure( + text=self._progress.current_challenge.display, fg="#00ff00", ) self._start_flash_countdown() return - self._rounds_completed += 1 - if self._rounds_completed >= DISMISS_ROUNDS_REQUIRED: - self._dismiss_alarm(earned_skip=self._skip_earnable) + self._progress.rounds_completed += 1 + if self._progress.rounds_completed >= DISMISS_ROUNDS_REQUIRED: + self._dismiss_alarm(earned_skip=self._progress.skip_earnable) return - self._current_challenge = _make_challenge() - self._code_label.configure( - text=self._current_challenge.display, + self._progress.current_challenge = _make_challenge() + self._view.code_label.configure( + text=self._progress.current_challenge.display, fg="#00ff00", ) - self._info_label.configure(text=self._current_challenge.hint) - self._entry.delete(0, tk.END) - next_round = self._rounds_completed + 1 - self._round_label.configure( + self._view.info_label.configure(text=self._progress.current_challenge.hint) + self._view.entry.delete(0, tk.END) + next_round = self._progress.rounds_completed + 1 + self._view.round_label.configure( text=f"Round {next_round} / {DISMISS_ROUNDS_REQUIRED}", ) - self._status_label.configure( - text=f"Round {self._rounds_completed} done — keep going!", + self._view.status_label.configure( + text=f"Round {self._progress.rounds_completed} done — keep going!", ) - if self._current_challenge.kind == "flash": + if self._progress.current_challenge.kind == "flash": self._start_flash_countdown() def _start_flash_countdown(self) -> None: """Begin the flash countdown: show code then hide it.""" - self._flash_remaining = DISMISS_FLASH_SECONDS + self._progress.flash_remaining = DISMISS_FLASH_SECONDS self._flash_tick() def _flash_tick(self) -> None: """Decrement flash countdown; replace the displayed code with placeholders.""" if not self._active: return - if self._flash_remaining > 0: - self._status_label.configure( - text=f"Memorise! Hiding in {self._flash_remaining}s…", + if self._progress.flash_remaining > 0: + self._view.status_label.configure( + text=f"Memorise! Hiding in {self._progress.flash_remaining}s…", ) - self._flash_remaining -= 1 + self._progress.flash_remaining -= 1 self.root.after(1000, self._flash_tick) else: - hidden = "?" * len(self._current_challenge.display) - self._code_label.configure(text=hidden, fg="#555555") - self._status_label.configure(text="Now type the code from memory!") + hidden = "?" * len(self._progress.current_challenge.display) + self._view.code_label.configure(text=hidden, fg="#555555") + self._view.status_label.configure(text="Now type the code from memory!") def _dismiss_alarm(self, *, earned_skip: bool) -> None: """Dismiss the alarm and save state.""" @@ -278,7 +298,7 @@ class WakeAlarm: now_iso = datetime.now(tz=timezone.utc).isoformat() save_wake_state(dismissed_at=now_iso, skip_workout=earned_skip) - for widget in self._container.winfo_children(): + for widget in self._view.container.winfo_children(): widget.destroy() msg = ( @@ -289,7 +309,7 @@ class WakeAlarm: color = "#00ff00" if earned_skip else "#ffaa00" tk.Label( - self._container, + self._view.container, text=msg, font=("Arial", 36, "bold"), fg=color, @@ -301,8 +321,8 @@ class WakeAlarm: def _close(self) -> None: """Close the alarm window.""" self._stop_beep.set() - _restore_fans(active=self._fan_state) - _restore_alarm_audio(self._audio_restore) + _restore_fans(active=self._hardware.fan_state) + _restore_alarm_audio(self._hardware.audio_restore) _restore_display() turn_off_plug() self.root.destroy() @@ -315,14 +335,14 @@ class WakeAlarm: """ if not self._active: return - self._current_challenge = _make_challenge() - self._code_label.configure( - text=self._current_challenge.display, + self._progress.current_challenge = _make_challenge() + self._view.code_label.configure( + text=self._progress.current_challenge.display, fg="#00ff00", ) - self._info_label.configure(text=self._current_challenge.hint) - self._entry.delete(0, tk.END) - if self._current_challenge.kind == "flash": + self._view.info_label.configure(text=self._progress.current_challenge.hint) + self._view.entry.delete(0, tk.END) + if self._progress.current_challenge.kind == "flash": self._start_flash_countdown() ms = DISMISS_CODE_REFRESH_SECONDS * 1000 if not self.demo_mode else 10_000 self.root.after(ms, self._schedule_code_refresh) @@ -341,11 +361,11 @@ class WakeAlarm: """ if not self._active: return - self._skip_earnable = False - self._info_label.configure( + self._progress.skip_earnable = False + self._view.info_label.configure( text="Skip window closed - type the code to stop the alarm", ) - self._status_label.configure(text="No workout skip today.") + self._view.status_label.configure(text="No workout skip today.") _logger.info("Skip window expired - alarm continues until dismissed.") def _update_timer(self) -> None: @@ -355,14 +375,14 @@ class WakeAlarm: elapsed = time.monotonic() - self._alarm_start window = DISMISS_WINDOW_MINUTES * 60 if not self.demo_mode else 30 remaining = max(0, window - elapsed) - if self._skip_earnable and remaining > 0: + if self._progress.skip_earnable and remaining > 0: minutes = int(remaining) // 60 seconds = int(remaining) % 60 - self._timer_label.configure( + self._view.timer_label.configure( text=f"Skip window: {minutes:02d}:{seconds:02d}", ) else: - self._timer_label.configure( + self._view.timer_label.configure( text="No skip available - type the code to stop the alarm", ) self.root.after(1000, self._update_timer) @@ -383,8 +403,8 @@ class WakeAlarm: """Alternate background colour every 750 ms (below seizure-risk 3 Hz).""" if not self._active: return - self.root.configure(bg="#ff0000" if self._flash_on else "#1a1a1a") - self._flash_on = not self._flash_on + self.root.configure(bg="#ff0000" if self._progress.flash_on else "#1a1a1a") + self._progress.flash_on = not self._progress.flash_on self.root.after(750, self._flash_step) def _beep_loop(self) -> None: @@ -419,6 +439,9 @@ def _should_run_alarm() -> bool: if was_alarm_dismissed_today(): _logger.info("Alarm already dismissed today. Exiting.") return False + if was_workout_logged_today(): + _logger.info("Workout already logged today. Skipping alarm.") + return False return True @@ -445,10 +468,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: def main() -> None: """Entry point for the wake alarm daemon.""" - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(name)s %(levelname)s %(message)s", - ) + configure_logging() args = _parse_args(sys.argv[1:]) @@ -463,6 +483,10 @@ def main() -> None: ) _warn_if_no_real_sink() _wake_display() + # Wait for the G27Q to power on and enumerate its HDMI audio sink. + # Without this delay the sink often isn't visible yet when _activate_alarm_audio + # runs, making the alarm silent when the monitor was physically off at wake time. + time.sleep(DISPLAY_WAKE_WAIT_SECONDS) _set_max_brightness() turn_on_plug() alarm = WakeAlarm(demo_mode=args.demo) diff --git a/python_pkg/wake_alarm/_alarm_display.py b/python_pkg/wake_alarm/_alarm_display.py new file mode 100644 index 0000000..ea952de --- /dev/null +++ b/python_pkg/wake_alarm/_alarm_display.py @@ -0,0 +1,76 @@ +"""Display power and screensaver helpers for the wake alarm. + +Wakes monitors that may be physically powered off (via DDC/CI) or in DPMS +standby, and restores the screensaver once the alarm dismiss flow ends. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess + +_logger = logging.getLogger(__name__) + + +def _ddcutil_power_on() -> None: + """Power on all connected monitors via DDC/CI VCP code D6. + + This wakes monitors that were physically turned off with the power button + and therefore ignore DPMS signals. Falls back silently when ddcutil is + absent or returns an error (e.g. no i2c access yet). + """ + ddcutil = shutil.which("ddcutil") + if ddcutil is None: + _logger.warning("ddcutil not on PATH; skipping DDC/CI monitor power-on") + return + try: + result = subprocess.run( + [ddcutil, "setvcp", "D6", "01"], + check=False, + capture_output=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + _logger.warning("ddcutil setvcp failed", exc_info=True) + return + if result.returncode != 0: + _logger.warning( + "ddcutil setvcp D6 01 exited %d: %s", + result.returncode, + result.stderr.decode(errors="replace").strip()[:200], + ) + else: + _logger.info("DDC/CI monitor power-on sent") + + +def _wake_display() -> None: + """Force the display on and disable screensaver during alarm. + + Sends both a DDC/CI hard power-on (for monitors powered off via the + power button) and a DPMS force-on (for monitors in standby). + """ + _ddcutil_power_on() + xset = shutil.which("xset") + if xset is None: + _logger.warning("xset not on PATH; skipping DPMS display wake") + return + for cmd in ( + [xset, "dpms", "force", "on"], + [xset, "s", "off"], + ): + subprocess.run(cmd, check=False, capture_output=True, timeout=5) + + +def _restore_display() -> None: + """Re-enable screensaver after the alarm ends.""" + xset = shutil.which("xset") + if xset is None: + _logger.warning("xset not on PATH; skipping display restore") + return + subprocess.run( + [xset, "s", "on"], + check=False, + capture_output=True, + timeout=5, + ) diff --git a/python_pkg/wake_alarm/_constants.py b/python_pkg/wake_alarm/_constants.py index 7bae93f..7707432 100644 --- a/python_pkg/wake_alarm/_constants.py +++ b/python_pkg/wake_alarm/_constants.py @@ -54,9 +54,22 @@ ALARM_AUDIO_CARD: str = "alsa_card.pci-0000_01_00.1" ALARM_AUDIO_PROFILE: str = "output:hdmi-stereo" ALARM_AUDIO_SINK: str = "alsa_output.pci-0000_01_00.1.hdmi-stereo" # Seconds to wait for the HDMI sink to appear after forcing the profile on. -ALARM_AUDIO_SINK_WAIT_SECONDS: float = 6.0 +# The G27Q takes up to ~15 s to power on from a hard-off state and enumerate +# its HDMI audio; 6 s was too short when the monitor was physically off. +ALARM_AUDIO_SINK_WAIT_SECONDS: float = 20.0 # Poll interval while waiting for the sink. ALARM_AUDIO_SINK_POLL_SECONDS: float = 0.5 +# Seconds to pause after waking the display (xset dpms force on) before +# attempting audio setup. Gives the G27Q time to come out of power-off +# and re-enumerate its HDMI audio sink under PipeWire. +DISPLAY_WAKE_WAIT_SECONDS: float = 5.0 + +# Path to the workout log written by the companion screen_locker package. +# Dict keyed by YYYY-MM-DD date strings; presence of today's key means the +# workout was already completed and the alarm should not fire. +WORKOUT_LOG_FILE: Path = ( + Path.home() / "screen-locker" / "screen_locker" / "workout_log.json" +) # TP-Link Tapo P110 smart-plug config file (JSON). # Create with mode 0600 and these keys: host, email, password. diff --git a/python_pkg/wake_alarm/_state.py b/python_pkg/wake_alarm/_state.py index c1892cc..1ad4f3c 100644 --- a/python_pkg/wake_alarm/_state.py +++ b/python_pkg/wake_alarm/_state.py @@ -10,7 +10,7 @@ from python_pkg.shared.log_integrity import ( compute_entry_hmac, verify_entry_hmac, ) -from python_pkg.wake_alarm._constants import WAKE_STATE_FILE +from python_pkg.wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE _logger = logging.getLogger(__name__) @@ -103,3 +103,26 @@ def was_alarm_dismissed_today() -> bool: if state is None: return False return state.get("dismissed_at") is not None + + +def was_workout_logged_today() -> bool: + """Check if the workout was already logged today via the screen locker. + + Reads the companion screen_locker workout_log.json. The file is a + dict keyed by YYYY-MM-DD date strings; presence of today's key means + the workout was completed and the alarm is no longer needed. + + Returns: + True if today's workout entry exists, False on any error or absence. + """ + if not WORKOUT_LOG_FILE.exists(): + return False + try: + with WORKOUT_LOG_FILE.open() as f: + log = json.load(f) + except (OSError, json.JSONDecodeError): + _logger.warning("Cannot read workout log file %s", WORKOUT_LOG_FILE) + return False + if not isinstance(log, dict): + return False + return _today_str() in log diff --git a/python_pkg/wake_alarm/install.sh b/python_pkg/wake_alarm/install.sh index a325172..fe4b3fc 100755 --- a/python_pkg/wake_alarm/install.sh +++ b/python_pkg/wake_alarm/install.sh @@ -89,7 +89,7 @@ else fi # 7. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control -echo "[7/7] Installing python-kasa (AUR)..." +echo "[7/8] 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,6 +102,28 @@ 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 +# 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)..." +if command -v ddcutil &>/dev/null; then + echo " ddcutil already installed" +else + sudo pacman -S --noconfirm ddcutil + echo " ddcutil installed" +fi +# ddcutil needs access to /dev/i2c-* — add user to i2c group if it exists. +if getent group i2c &>/dev/null; then + if ! id -nG "$USER" | grep -qw i2c; then + sudo usermod -aG i2c "$USER" + echo " Added $USER to i2c group (re-login required for group to take effect)" + else + echo " $USER already in i2c group" + fi +else + echo " i2c group not found — ddcutil will run via sudo" +fi + 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." diff --git a/python_pkg/wake_alarm/tests/test_alarm.py b/python_pkg/wake_alarm/tests/test_alarm.py index 086b056..cee6576 100644 --- a/python_pkg/wake_alarm/tests/test_alarm.py +++ b/python_pkg/wake_alarm/tests/test_alarm.py @@ -15,9 +15,7 @@ if TYPE_CHECKING: from python_pkg.wake_alarm._alarm import ( _is_alarm_day, - _restore_display, _should_run_alarm, - _wake_display, ) from python_pkg.wake_alarm._audio import ( _beep_loud, @@ -249,51 +247,30 @@ class TestShouldRunAlarm: "python_pkg.wake_alarm._alarm.was_alarm_dismissed_today", return_value=False, ), + patch( + "python_pkg.wake_alarm._alarm.was_workout_logged_today", + return_value=False, + ), ): assert _should_run_alarm() is True - -class TestDisplayHelpers: - """Tests for _wake_display and _restore_display when xset is absent.""" - - def test_wake_display_skips_when_xset_missing(self) -> None: - """_wake_display does nothing when xset is not on PATH.""" + def test_returns_false_when_workout_already_logged(self) -> None: + """Return False when workout was already logged today.""" with ( patch( - "python_pkg.wake_alarm._alarm.shutil.which", - return_value=None, + "python_pkg.wake_alarm._alarm._is_alarm_day", + return_value=True, ), - patch("python_pkg.wake_alarm._alarm.subprocess.run") as mock_run, - ): - _wake_display() - mock_run.assert_not_called() - - def test_wake_display_runs_xset_commands(self) -> None: - """_wake_display runs xset dpms force on + xset s off.""" - with ( patch( - "python_pkg.wake_alarm._alarm.shutil.which", - return_value="/usr/bin/xset", + "python_pkg.wake_alarm._alarm.was_alarm_dismissed_today", + return_value=False, ), - patch("python_pkg.wake_alarm._alarm.subprocess.run") as mock_run, - ): - _wake_display() - assert mock_run.call_count == 2 - call_args = [call[0][0] for call in mock_run.call_args_list] - assert ["/usr/bin/xset", "dpms", "force", "on"] in call_args - assert ["/usr/bin/xset", "s", "off"] in call_args - - def test_restore_display_skips_when_xset_missing(self) -> None: - """_restore_display does nothing when xset is not on PATH.""" - with ( patch( - "python_pkg.wake_alarm._alarm.shutil.which", - return_value=None, + "python_pkg.wake_alarm._alarm.was_workout_logged_today", + return_value=True, ), - patch("python_pkg.wake_alarm._alarm.subprocess.run") as mock_run, ): - _restore_display() - mock_run.assert_not_called() + assert _should_run_alarm() is False class TestPlayOnExtraDevices: diff --git a/python_pkg/wake_alarm/tests/test_alarm_display.py b/python_pkg/wake_alarm/tests/test_alarm_display.py new file mode 100644 index 0000000..6dc4900 --- /dev/null +++ b/python_pkg/wake_alarm/tests/test_alarm_display.py @@ -0,0 +1,129 @@ +"""Tests for _alarm_display.py — DDC/CI and DPMS display power helpers.""" + +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock, patch + +from python_pkg.wake_alarm._alarm_display import ( + _ddcutil_power_on, + _restore_display, + _wake_display, +) + + +class TestDdcutilPowerOn: + """Tests for _ddcutil_power_on.""" + + def test_skips_when_ddcutil_missing(self) -> None: + """_ddcutil_power_on does nothing when ddcutil is not on PATH.""" + with ( + patch( + "python_pkg.wake_alarm._alarm_display.shutil.which", + return_value=None, + ), + patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, + ): + _ddcutil_power_on() + mock_run.assert_not_called() + + def test_runs_setvcp_when_ddcutil_present(self) -> None: + """_ddcutil_power_on sends setvcp D6 01 when ddcutil is found.""" + with ( + patch( + "python_pkg.wake_alarm._alarm_display.shutil.which", + return_value="/usr/bin/ddcutil", + ), + patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, + ): + _ddcutil_power_on() + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert cmd == ["/usr/bin/ddcutil", "setvcp", "D6", "01"] + + def test_logs_success_when_returncode_zero(self) -> None: + """_ddcutil_power_on logs success when setvcp returns 0.""" + with ( + patch( + "python_pkg.wake_alarm._alarm_display.shutil.which", + return_value="/usr/bin/ddcutil", + ), + patch( + "python_pkg.wake_alarm._alarm_display.subprocess.run", + return_value=MagicMock(returncode=0), + ), + ): + _ddcutil_power_on() + + def test_handles_timeout(self) -> None: + """_ddcutil_power_on does not raise on TimeoutExpired.""" + with ( + patch( + "python_pkg.wake_alarm._alarm_display.shutil.which", + return_value="/usr/bin/ddcutil", + ), + patch( + "python_pkg.wake_alarm._alarm_display.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="ddcutil", timeout=10), + ), + ): + _ddcutil_power_on() # must not raise + + def test_handles_oserror(self) -> None: + """_ddcutil_power_on does not raise on OSError.""" + with ( + patch( + "python_pkg.wake_alarm._alarm_display.shutil.which", + return_value="/usr/bin/ddcutil", + ), + patch( + "python_pkg.wake_alarm._alarm_display.subprocess.run", + side_effect=OSError("no device"), + ), + ): + _ddcutil_power_on() # must not raise + + +class TestDisplayHelpers: + """Tests for _wake_display and _restore_display when xset is absent.""" + + def test_wake_display_skips_when_xset_missing(self) -> None: + """_wake_display skips xset commands but still attempts ddcutil.""" + with ( + patch( + "python_pkg.wake_alarm._alarm_display.shutil.which", + return_value=None, + ), + patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, + ): + _wake_display() + mock_run.assert_not_called() + + def test_wake_display_runs_ddcutil_and_xset_commands(self) -> None: + """_wake_display runs ddcutil setvcp, xset dpms force on, xset s off.""" + with ( + patch( + "python_pkg.wake_alarm._alarm_display.shutil.which", + return_value="/usr/bin/xset", + ), + patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, + ): + _wake_display() + # 1 ddcutil setvcp call + 2 xset calls + assert mock_run.call_count == 3 + call_args = [call[0][0] for call in mock_run.call_args_list] + assert ["/usr/bin/xset", "setvcp", "D6", "01"] in call_args + assert ["/usr/bin/xset", "dpms", "force", "on"] in call_args + assert ["/usr/bin/xset", "s", "off"] in call_args + + def test_restore_display_skips_when_xset_missing(self) -> None: + """_restore_display does nothing when xset is not on PATH.""" + with ( + patch( + "python_pkg.wake_alarm._alarm_display.shutil.which", + return_value=None, + ), + patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run, + ): + _restore_display() + mock_run.assert_not_called() diff --git a/python_pkg/wake_alarm/tests/test_alarm_part2.py b/python_pkg/wake_alarm/tests/test_alarm_part2.py index 44ac32d..bbfd70d 100644 --- a/python_pkg/wake_alarm/tests/test_alarm_part2.py +++ b/python_pkg/wake_alarm/tests/test_alarm_part2.py @@ -133,7 +133,7 @@ class TestWakeAlarmDismiss: "python_pkg.wake_alarm._alarm.save_wake_state", ) as mock_save: for _ in range(DISMISS_ROUNDS_REQUIRED): - mock_entry.get.return_value = alarm._current_challenge.answer + mock_entry.get.return_value = alarm._progress.current_challenge.answer alarm._on_submit() assert alarm.dismissed is True @@ -148,12 +148,12 @@ class TestWakeAlarmDismiss: """A single correct entry is not enough — DISMISS_ROUNDS_REQUIRED is 2+.""" alarm = WakeAlarm(demo_mode=True) mock_entry = mock_tk_module.Entry.return_value - mock_entry.get.return_value = alarm._current_challenge.answer + mock_entry.get.return_value = alarm._progress.current_challenge.answer alarm._on_submit() assert alarm.dismissed is False - assert alarm._rounds_completed == 1 + assert alarm._progress.rounds_completed == 1 alarm._stop_beep.set() def test_first_round_correct_non_flash_next_no_countdown( @@ -165,14 +165,14 @@ class TestWakeAlarmDismiss: alarm = WakeAlarm(demo_mode=True) mock_entry = mock_tk_module.Entry.return_value - mock_entry.get.return_value = alarm._current_challenge.answer + mock_entry.get.return_value = alarm._progress.current_challenge.answer next_math = _Challenge(kind="math", display="2 + 2 = ?", answer="4", hint="x") with patch( "python_pkg.wake_alarm._alarm._make_challenge", return_value=next_math ): alarm._on_submit() - assert alarm._current_challenge.kind == "math" + assert alarm._progress.current_challenge.kind == "math" assert alarm.dismissed is False alarm._stop_beep.set() @@ -185,7 +185,7 @@ class TestWakeAlarmDismiss: alarm = WakeAlarm(demo_mode=True) # Use a pinned math challenge so the non-flash wrong-answer branch is covered. - alarm._current_challenge = _Challenge( + alarm._progress.current_challenge = _Challenge( kind="math", display="2 + 2 = ?", answer="4", hint="test" ) mock_entry = mock_tk_module.Entry.return_value @@ -210,12 +210,12 @@ class TestWakeAlarmDismiss: alarm._on_skip_window_expired() # Alarm stays active and audible; only the skip reward is gone. - assert alarm._skip_earnable is False + assert alarm._progress.skip_earnable is False assert alarm._active is True assert alarm.dismissed is False assert not alarm._stop_beep.is_set() mock_save.assert_not_called() - alarm._info_label.configure.assert_called() + alarm._view.info_label.configure.assert_called() alarm._stop_beep.set() def test_skip_window_expired_noop_if_not_active( @@ -230,7 +230,7 @@ class TestWakeAlarmDismiss: alarm._on_skip_window_expired() # skip_earnable stays at its initial True (method returned early). - assert alarm._skip_earnable is True + assert alarm._progress.skip_earnable is True alarm._stop_beep.set() def test_dismiss_after_skip_window_earns_no_skip( @@ -241,14 +241,14 @@ class TestWakeAlarmDismiss: from python_pkg.wake_alarm._constants import DISMISS_ROUNDS_REQUIRED alarm = WakeAlarm(demo_mode=True) - alarm._skip_earnable = False + alarm._progress.skip_earnable = False mock_entry = mock_tk_module.Entry.return_value with patch( "python_pkg.wake_alarm._alarm.save_wake_state", ) as mock_save: for _ in range(DISMISS_ROUNDS_REQUIRED): - mock_entry.get.return_value = alarm._current_challenge.answer + mock_entry.get.return_value = alarm._progress.current_challenge.answer alarm._on_submit() assert alarm.dismissed is True @@ -325,7 +325,7 @@ class TestCodeRefreshAndTimer: displays = set() for _ in range(50): alarm._schedule_code_refresh() - displays.add(alarm._current_challenge.display) + displays.add(alarm._progress.current_challenge.display) assert len(displays) > 1 alarm._stop_beep.set() @@ -336,9 +336,9 @@ class TestCodeRefreshAndTimer: """Code refresh is a no-op when alarm is no longer active.""" alarm = WakeAlarm(demo_mode=True) alarm._active = False - old_challenge = alarm._current_challenge + old_challenge = alarm._progress.current_challenge alarm._schedule_code_refresh() - assert alarm._current_challenge is old_challenge + assert alarm._progress.current_challenge is old_challenge alarm._stop_beep.set() def test_update_timer_noop_when_not_active( @@ -391,7 +391,7 @@ class TestClose: """_close calls _restore_fans with the saved fan state.""" del mock_tk_module alarm = WakeAlarm(demo_mode=True) - alarm._fan_state = True + alarm._hardware.fan_state = True with patch("python_pkg.wake_alarm._alarm._restore_fans") as mock_restore: alarm._close() mock_restore.assert_called_once_with(active=True) @@ -403,7 +403,7 @@ class TestClose: """_close restores the default sink captured at activation.""" del mock_tk_module alarm = WakeAlarm(demo_mode=True) - alarm._audio_restore = "jbl_sink" + alarm._hardware.audio_restore = "jbl_sink" with patch( "python_pkg.wake_alarm._alarm._restore_alarm_audio", ) as mock_restore: @@ -425,11 +425,11 @@ class TestScreenFlash: mock_root.configure.reset_mock() mock_root.after.reset_mock() - alarm._flash_on = False + alarm._progress.flash_on = False alarm._flash_step() mock_root.configure.assert_called_once_with(bg="#1a1a1a") - assert alarm._flash_on is True + assert alarm._progress.flash_on is True mock_root.after.assert_called_with(750, alarm._flash_step) alarm._stop_beep.set() @@ -443,11 +443,11 @@ class TestScreenFlash: mock_root.configure.reset_mock() mock_root.after.reset_mock() - alarm._flash_on = True + alarm._progress.flash_on = True alarm._flash_step() mock_root.configure.assert_called_once_with(bg="#ff0000") - assert alarm._flash_on is False + assert alarm._progress.flash_on is False mock_root.after.assert_called_with(750, alarm._flash_step) alarm._stop_beep.set() diff --git a/python_pkg/wake_alarm/tests/test_alarm_part3.py b/python_pkg/wake_alarm/tests/test_alarm_part3.py index fa54a14..157698e 100644 --- a/python_pkg/wake_alarm/tests/test_alarm_part3.py +++ b/python_pkg/wake_alarm/tests/test_alarm_part3.py @@ -157,7 +157,7 @@ class TestUpdateTimerActive: del mock_tk_module alarm = WakeAlarm(demo_mode=True) alarm._update_timer() - text = alarm._timer_label.configure.call_args[1]["text"] + text = alarm._view.timer_label.configure.call_args[1]["text"] assert text.startswith("Skip window:") alarm._stop_beep.set() @@ -173,7 +173,7 @@ class TestUpdateTimerActive: alarm._alarm_start = time_mod.monotonic() - 60 * 60 alarm.root.after.reset_mock() alarm._update_timer() - text = alarm._timer_label.configure.call_args[1]["text"] + text = alarm._view.timer_label.configure.call_args[1]["text"] assert "type the code" in text # The alarm keeps nagging: it always reschedules while active. alarm.root.after.assert_called_once() @@ -187,9 +187,9 @@ class TestUpdateTimerActive: del mock_tk_module alarm = WakeAlarm(demo_mode=True) alarm._active = False - alarm._timer_label.configure.reset_mock() + alarm._view.timer_label.configure.reset_mock() alarm._update_timer() - alarm._timer_label.configure.assert_not_called() + alarm._view.timer_label.configure.assert_not_called() alarm._stop_beep.set() @@ -204,27 +204,28 @@ class TestFlashChallenge: from python_pkg.wake_alarm._alarm import _Challenge alarm = WakeAlarm(demo_mode=True) - alarm._current_challenge = _Challenge( + alarm._progress.current_challenge = _Challenge( kind="flash", display="ABCDEFGH", answer="ABCDEFGH", hint="Memorise", ) - alarm._flash_remaining = 2 - alarm._status_label.configure.reset_mock() + alarm._progress.flash_remaining = 2 + alarm._view.status_label.configure.reset_mock() alarm._flash_tick() - assert alarm._flash_remaining == 1 - alarm._status_label.configure.assert_called() + assert alarm._progress.flash_remaining == 1 + alarm._view.status_label.configure.assert_called() alarm._flash_tick() - assert alarm._flash_remaining == 0 + assert alarm._progress.flash_remaining == 0 # Final tick hides the code. alarm._flash_tick() # _code_label and _status_label share the same mock; inspect all calls. all_texts = [ - c.kwargs.get("text", "") for c in alarm._code_label.configure.call_args_list + c.kwargs.get("text", "") + for c in alarm._view.code_label.configure.call_args_list ] assert any("?" in t for t in all_texts) alarm._stop_beep.set() @@ -236,12 +237,12 @@ class TestFlashChallenge: """_flash_tick returns immediately when the alarm is no longer active.""" alarm = WakeAlarm(demo_mode=True) alarm._active = False - alarm._flash_remaining = 3 - alarm._status_label.configure.reset_mock() + alarm._progress.flash_remaining = 3 + alarm._view.status_label.configure.reset_mock() alarm._flash_tick() - alarm._status_label.configure.assert_not_called() + alarm._view.status_label.configure.assert_not_called() alarm._stop_beep.set() def test_wrong_flash_answer_reshows_code( @@ -252,7 +253,7 @@ class TestFlashChallenge: from python_pkg.wake_alarm._alarm import _Challenge alarm = WakeAlarm(demo_mode=True) - alarm._current_challenge = _Challenge( + alarm._progress.current_challenge = _Challenge( kind="flash", display="TESTCODE", answer="TESTCODE", @@ -260,13 +261,13 @@ class TestFlashChallenge: ) mock_entry = mock_tk_module.Entry.return_value mock_entry.get.return_value = "WRONGCODE" - alarm._code_label.configure.reset_mock() + alarm._view.code_label.configure.reset_mock() alarm._on_submit() assert alarm.dismissed is False # Code label should be reconfigured (code shown again + countdown restarted). - alarm._code_label.configure.assert_called() + alarm._view.code_label.configure.assert_called() alarm._stop_beep.set() def test_next_round_flash_starts_countdown( @@ -277,7 +278,7 @@ class TestFlashChallenge: from python_pkg.wake_alarm._alarm import _Challenge alarm = WakeAlarm(demo_mode=True) - alarm._current_challenge = _Challenge( + alarm._progress.current_challenge = _Challenge( kind="math", display="2 + 2 = ?", answer="4", hint="test" ) next_flash = _Challenge( @@ -291,7 +292,7 @@ class TestFlashChallenge: ): alarm._on_submit() - assert alarm._current_challenge.kind == "flash" + assert alarm._progress.current_challenge.kind == "flash" assert alarm.dismissed is False alarm._stop_beep.set() @@ -307,7 +308,7 @@ class TestDismissWithoutSkip: del mock_tk_module alarm = WakeAlarm(demo_mode=True) mock_widget = MagicMock() - alarm._container.winfo_children.return_value = [mock_widget] + alarm._view.container.winfo_children.return_value = [mock_widget] with patch( "python_pkg.wake_alarm._alarm.save_wake_state", @@ -334,7 +335,7 @@ class TestSkipWindowExpiredMessage: alarm._on_skip_window_expired() - alarm._status_label.configure.assert_called_with( + alarm._view.status_label.configure.assert_called_with( text="No workout skip today.", ) alarm._stop_beep.set() diff --git a/python_pkg/wake_alarm/tests/test_state.py b/python_pkg/wake_alarm/tests/test_state.py index 56f32f9..566f851 100644 --- a/python_pkg/wake_alarm/tests/test_state.py +++ b/python_pkg/wake_alarm/tests/test_state.py @@ -14,6 +14,7 @@ from python_pkg.wake_alarm._state import ( load_wake_state, save_wake_state, was_alarm_dismissed_today, + was_workout_logged_today, ) if TYPE_CHECKING: @@ -259,3 +260,55 @@ class TestWasAlarmDismissedToday: return_value=True, ): assert was_alarm_dismissed_today() is False + + +class TestWasWorkoutLoggedToday: + """Tests for was_workout_logged_today.""" + + 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", + tmp_path / "workout_log.json", + ): + assert was_workout_logged_today() is False + + def test_returns_false_when_file_is_invalid_json(self, tmp_path: Path) -> None: + """Return False when the workout log contains invalid JSON.""" + log_file = tmp_path / "workout_log.json" + log_file.write_text("not json {{{") + with patch( + "python_pkg.wake_alarm._state.WORKOUT_LOG_FILE", + log_file, + ): + assert was_workout_logged_today() is False + + def test_returns_false_when_file_is_not_a_dict(self, tmp_path: Path) -> None: + """Return False when the workout log is not a JSON object.""" + 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", + log_file, + ): + assert was_workout_logged_today() is False + + def test_returns_false_when_today_absent(self, tmp_path: Path) -> None: + """Return False when the workout log has no entry for today.""" + 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", + log_file, + ): + assert was_workout_logged_today() is False + + def test_returns_true_when_today_present(self, tmp_path: Path) -> None: + """Return True when today's date key exists in the workout log.""" + 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", + log_file, + ): + assert was_workout_logged_today() is True diff --git a/python_pkg/wake_alarm/wake_state.json b/python_pkg/wake_alarm/wake_state.json index 94c00e5..ff7d63a 100644 --- a/python_pkg/wake_alarm/wake_state.json +++ b/python_pkg/wake_alarm/wake_state.json @@ -1,6 +1,6 @@ { - "date": "2026-05-25", - "dismissed_at": "2026-05-25T10:33:09.098156+00:00", + "date": "2026-06-14", + "dismissed_at": "2026-06-14T05:01:28.589654+00:00", "skip_workout": true, - "hmac": "49ae99880405c6e3f0b4948b07d398980e223a91d33dc7d0c7f0f9254463fa92" + "hmac": "b472bf9b0874ff3f6f460cace7965d53cdfce823ee6f2d1f91914e43f003e92b" }