Extract diet_guard from testsAndMisc as a standalone repo

Rewrites python_pkg.diet_guard imports to diet_guard, vendors the
shared as_float coercion helper, drops the monorepo PYTHONPATH from
install.sh and the systemd unit (package is now pip-installed), and
scaffolds standalone lint/test config matching testsAndMisc's real
enforced bar (pylint --fail-under=10 with tests excluded and the
use-implicit-booleaness/consider-using-with disables, mypy's actual
disabled-error-code set, ruff ALL, bandit, 100% branch coverage).
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-22 12:18:39 +02:00
parent 2de8f5d122
commit 843f5e0221
45 changed files with 729 additions and 168 deletions

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

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

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

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

16
.gitignore vendored Normal file
View File

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

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

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

81
CLAUDE.md Normal file
View File

@ -0,0 +1,81 @@
# CLAUDE.md — diet_guard
## What this does
A log-to-unlock gate: every ~30 minutes, `diet-guard-gate.timer` runs
`diet-guard-gate.service`, which checks whether a meal slot (08:00, 12:00,
16:00, 20:00) has elapsed without a logged meal. If so, it opens a fullscreen
Tk window that blocks the desktop until the user logs what they ate (with
autocomplete from a local food-name "bank", optionally seeded from Open Food
Facts via `requests`). It also tracks a daily calorie/macro budget, sealed at
init time and tamper-resistant via `chattr +i`.
See `docs/design.md` for the original feature spec (meal-slot timing logic,
the Tue/Wed/Thu "filled most of the day" catch-up rule, multi-item meals).
## Scheduling
`diet-guard-gate.timer` — wall-clock `OnCalendar=*-*-* *:00/30:00`,
`Persistent=true`. Deliberately wall-clock rather than boot-relative: an
earlier boot-relative timer interacted badly with fullscreen games grabbing
keyboard/mouse input around the same point in their session, so this is
pinned to the clock instead of "N minutes after boot/login."
`diet-guard-gate.service` is `Type=oneshot`, fires every tick, and exits 0
immediately if no lock is due — cheap enough to run that often. It needs
`DISPLAY`/`XAUTHORITY` because it opens a Tk window; see the inline comments
in the unit file for why `XAUTHORITY` is pinned explicitly (a `Persistent=true`
catch-up run at session start can beat the display manager writing
`~/.Xauthority`) and why a real fix lives in Python (`wait_for_display()`)
rather than in the unit file.
## Production dependency installation — read this before adding any dependency
`diet-guard-gate.service` runs `/usr/bin/python` directly — **not** a venv.
Any new non-stdlib dependency (this package itself, `gatelock`, `requests`,
anything added later) must be installed into system Python's *user*
site-packages, the same place `python-kasa` already lives:
```bash
/usr/bin/python3 -m pip install --user --break-system-packages -e .
```
`install.sh` already does this. **If you add a dependency and only install it
into a dev venv, the production service will silently fail with
`ModuleNotFoundError` on its next tick** — this exact gap caused a 3-day
diet_guard production outage (2026-06-19 to 2026-06-22) when `gatelock` was
added but only `pip install`-ed into `.venv`. Always verify against
`/usr/bin/python3 -c "import <new_dep>"`, not just the dev venv, before
considering a dependency change done.
## Operational gotchas
- **The budget file is sealed immutable.** `~/.local/share/diet_guard/.budget`
gets `chattr +i` after `init` (see `install.sh` step 5). This is the actual
tamper-resistance mechanism — the budget can't be casually edited to "make
room" once locked. To intentionally change it: `sudo chattr -i` the file,
re-run `python -m diet_guard init`, then re-lock.
- **Biometrics are used once and discarded.** `init` asks for biometrics to
compute the daily budget, then the only persisted output is the computed
budget number — never the biometrics themselves.
- **State lives entirely under `~/.local/share/diet_guard/`** — no
cross-repo file coupling (unlike wake_alarm, which reads
`~/screen-locker/screen_locker/workout_log.json`). Safe to reason about in
isolation.
## Commands
- Run tests: `python -m pytest diet_guard/tests/ --cov=diet_guard --cov-branch --cov-fail-under=100`
- Lint: `pre-commit run --all-files`
- Test the lock manually (safe, closeable): `python -m diet_guard gate --demo`
- Install for production: `bash install.sh`
## Do NOT
- Don't relax the meal-slot/macro logic without re-reading `docs/design.md`
the Tue/Wed/Thu catch-up rule and multi-item meal summing are deliberate,
not accidental complexity.
- Don't add a dependency without doing the production install-path check
above.
- Don't remove the `chattr +i` step from `install.sh` — it's the actual
enforcement mechanism, not a formality.

37
README.md Normal file
View File

@ -0,0 +1,37 @@
# diet_guard
A log-to-unlock gate: locks the desktop until a meal is logged once a meal
slot (08:00 / 12:00 / 16:00 / 20:00) has elapsed without one, and tracks a
sealed daily calorie/macro budget.
## Install
```bash
bash install.sh
```
This installs the package + dependencies into system Python's user
site-packages (the systemd service runs `/usr/bin/python` directly, not a
venv — see `CLAUDE.md`), installs the systemd user timer, seals your daily
budget, and locks the budget file immutable.
## Usage
```bash
python -m diet_guard init # one-time: compute and seal today's budget
python -m diet_guard gate --demo # test the lock window (safe, closeable)
```
The timer runs the gate automatically every ~30 minutes; no manual
invocation is needed once installed.
## Development
```bash
python -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/pre-commit install && .venv/bin/pre-commit install --hook-type pre-push
.venv/bin/python -m pytest diet_guard/tests/ --cov=diet_guard --cov-branch --cov-fail-under=100
```
See `CLAUDE.md` for scheduling details and production deployment gotchas,
and `docs/design.md` for the original feature spec.

View File

@ -4,10 +4,12 @@ After=graphical-session.target
[Service] [Service]
Type=oneshot Type=oneshot
# DISPLAY/PYTHONPATH mirror wake-alarm.service: the gate opens a Tk window when a # DISPLAY mirrors wake-alarm.service: the gate opens a Tk window when a lock
# lock is due, so without DISPLAY it would crash with "no display name and no # is due, so without DISPLAY it would crash with "no display name and no
# $DISPLAY" before it could even check. The command self-checks gate_is_due() and # $DISPLAY" before it could even check. The command self-checks gate_is_due()
# exits 0 when no lock is needed, so running it every ~30 min is cheap. # and exits 0 when no lock is needed, so running it every ~30 min is cheap.
# No PYTHONPATH needed: diet_guard is pip-installed (see install.sh / README),
# so /usr/bin/python finds it via user site-packages.
# #
# XAUTHORITY pins the X auth cookie path explicitly. It is belt-and-suspenders, # XAUTHORITY pins the X auth cookie path explicitly. It is belt-and-suspenders,
# not the fix: when this unit fires at SESSION START (Persistent=true catch-up), # not the fix: when this unit fires at SESSION START (Persistent=true catch-up),
@ -19,7 +21,5 @@ Type=oneshot
# brief head start; the Python wait is what makes it reliable. # brief head start; the Python wait is what makes it reliable.
Environment=DISPLAY=:0 Environment=DISPLAY=:0
Environment=XAUTHORITY=%h/.Xauthority Environment=XAUTHORITY=%h/.Xauthority
Environment=PYTHONPATH=%h/testsAndMisc
ExecStartPre=/bin/sleep 1 ExecStartPre=/bin/sleep 1
ExecStart=/usr/bin/python -m python_pkg.diet_guard gate ExecStart=/usr/bin/python -m diet_guard gate
WorkingDirectory=%h/testsAndMisc

View File

@ -1,10 +1,10 @@
"""Module entry point: ``python -m python_pkg.diet_guard``.""" """Module entry point: ``python -m diet_guard``."""
from __future__ import annotations from __future__ import annotations
import sys import sys
from python_pkg.diet_guard._cli import main from diet_guard._cli import main
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@ -32,7 +32,7 @@ import logging
from gatelock.log_integrity import compute_entry_hmac from gatelock.log_integrity import compute_entry_hmac
from python_pkg.diet_guard._constants import BUDGET_FILE from diet_guard._constants import BUDGET_FILE
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)

View File

@ -1,12 +1,12 @@
"""Command-line interface for diet_guard. """Command-line interface for diet_guard.
Examples: Examples:
python -m python_pkg.diet_guard init python -m diet_guard init
python -m python_pkg.diet_guard ate "big mac" python -m diet_guard ate "big mac"
python -m python_pkg.diet_guard ate "two slices of pizza" --grams 240 python -m diet_guard ate "two slices of pizza" --grams 240
python -m python_pkg.diet_guard ate "protein shake" --kcal 180 python -m diet_guard ate "protein shake" --kcal 180
python -m python_pkg.diet_guard status python -m diet_guard status
python -m python_pkg.diet_guard undo python -m diet_guard undo
The daily budget lives outside the repo (so it is never exposed online) but is The daily budget lives outside the repo (so it is never exposed online) but is
shown freely on this machine: ``status`` and each log print how many calories shown freely on this machine: ``status`` and each log print how many calories
@ -19,7 +19,7 @@ import argparse
from dataclasses import dataclass from dataclasses import dataclass
import sys import sys
from python_pkg.diet_guard._budget import ( from diet_guard._budget import (
Biometrics, Biometrics,
BudgetLockedError, BudgetLockedError,
BudgetNotInitializedError, BudgetNotInitializedError,
@ -31,21 +31,21 @@ from python_pkg.diet_guard._budget import (
seal_budget, seal_budget,
unlock_command, unlock_command,
) )
from python_pkg.diet_guard._foodbank import remember_food from diet_guard._foodbank import remember_food
from python_pkg.diet_guard._gate import due_slots, gate_is_due from diet_guard._gate import due_slots, gate_is_due
from python_pkg.diet_guard._gatelock import ( from diet_guard._gatelock import (
MealGate, MealGate,
acquire_gate_lock, acquire_gate_lock,
release_gate_lock, release_gate_lock,
) )
from python_pkg.diet_guard._gatelock_support import wait_for_display from diet_guard._gatelock_support import wait_for_display
from python_pkg.diet_guard._portions import ( from diet_guard._portions import (
DEFAULT_ITEM_GRAMS, DEFAULT_ITEM_GRAMS,
estimate_unit_grams, estimate_unit_grams,
) )
from python_pkg.diet_guard._resolve import ManualMacros, resolve_nutrition from diet_guard._resolve import ManualMacros, resolve_nutrition
from python_pkg.diet_guard._slots import current_slot, day_slots, slot_label from diet_guard._slots import current_slot, day_slots, slot_label
from python_pkg.diet_guard._state import ( from diet_guard._state import (
entry_kcal, entry_kcal,
log_meal, log_meal,
logged_slots_today, logged_slots_today,
@ -210,8 +210,7 @@ def _print_summary() -> None:
budget = daily_budget() budget = daily_budget()
except BudgetNotInitializedError: except BudgetNotInitializedError:
_emit( _emit(
f"today: {total:g} kcal " f"today: {total:g} kcal (budget not set - run: python -m diet_guard init)",
"(budget not set - run: python -m python_pkg.diet_guard init)",
) )
return return
except BudgetSealBrokenError: except BudgetSealBrokenError:

23
diet_guard/_coerce.py Normal file
View File

@ -0,0 +1,23 @@
"""Small value-coercion helpers for diet_guard."""
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

View File

@ -9,7 +9,7 @@ from pathlib import Path
# phone_focus_mode (which live only in the git-ignored config_secrets.sh on the # phone_focus_mode (which live only in the git-ignored config_secrets.sh on the
# device, never in committed source), the real budget is computed once from # device, never in committed source), the real budget is computed once from
# biometrics at ``init`` time and sealed into BUDGET_FILE below. It is read via # biometrics at ``init`` time and sealed into BUDGET_FILE below. It is read via
# python_pkg.diet_guard._budget.daily_budget() for over/under decisions only and # diet_guard._budget.daily_budget() for over/under decisions only and
# is never printed -- see _budget.py for the full threat model. # is never printed -- see _budget.py for the full threat model.
# #
# Fraction of the budget at which status flips from "on track" to "approaching # Fraction of the budget at which status flips from "on track" to "approaching

View File

@ -18,7 +18,7 @@ import logging
import requests import requests
from python_pkg.diet_guard._constants import ( from diet_guard._constants import (
DEFAULT_PORTION_GRAMS, DEFAULT_PORTION_GRAMS,
OFF_PAGE_SIZE, OFF_PAGE_SIZE,
OFF_SEARCH_URL, OFF_SEARCH_URL,

View File

@ -2,7 +2,7 @@
Every food the user logs is remembered here with its full macros, keyed by a Every food the user logs is remembered here with its full macros, keyed by a
normalized name. The gate's autocomplete searches *only* this corpus -- never normalized name. The gate's autocomplete searches *only* this corpus -- never
Open Food Facts. OFF (in :mod:`python_pkg.diet_guard._estimator`) is used only Open Food Facts. OFF (in :mod:`diet_guard._estimator`) is used only
to *fill in* the macros of a brand-new food the first time it is entered; from to *fill in* the macros of a brand-new food the first time it is entered; from
then on the food is served from the bank, so search quality improves with use then on the food is served from the bank, so search quality improves with use
and works fully offline. and works fully offline.
@ -21,11 +21,11 @@ import logging
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python_pkg.diet_guard._constants import FOOD_BANK_FILE from diet_guard._coerce import as_float
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._constants import FOOD_BANK_FILE
from python_pkg.diet_guard._fuzzy import match_score from diet_guard._estimator import Nutrition
from python_pkg.diet_guard._meal import MealItem, meal_total from diet_guard._fuzzy import match_score
from python_pkg.shared.coerce import as_float from diet_guard._meal import MealItem, meal_total
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence from collections.abc import Sequence

View File

@ -3,8 +3,8 @@
This module is GUI-free and side-effect-free so the lock/no-lock decision can This module is GUI-free and side-effect-free so the lock/no-lock decision can
be verified headlessly: the fullscreen window in ``_gatelock.py`` is only a be verified headlessly: the fullscreen window in ``_gatelock.py`` is only a
thin shell around :func:`gate_is_due` and :func:`due_slots`. It composes the thin shell around :func:`gate_is_due` and :func:`due_slots`. It composes the
pure slot arithmetic in :mod:`python_pkg.diet_guard._slots` with the logged-slot pure slot arithmetic in :mod:`diet_guard._slots` with the logged-slot
state in :mod:`python_pkg.diet_guard._state`; ``now`` is injectable so the state in :mod:`diet_guard._state`; ``now`` is injectable so the
time-of-day rules stay deterministically testable. time-of-day rules stay deterministically testable.
The gate fires when any *elapsed* meal slot for today carries no logged meal. The gate fires when any *elapsed* meal slot for today carries no logged meal.
@ -17,8 +17,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python_pkg.diet_guard._slots import missing_slots, slot_label from diet_guard._slots import missing_slots, slot_label
from python_pkg.diet_guard._state import logged_slots_today, now_local from diet_guard._state import logged_slots_today, now_local
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime

View File

@ -47,18 +47,18 @@ from typing import TYPE_CHECKING
from gatelock import GateRoot, LockConfig, LockWindow from gatelock import GateRoot, LockConfig, LockWindow
from python_pkg.diet_guard._constants import GATE_LOCK_FILE from diet_guard._constants import GATE_LOCK_FILE
from python_pkg.diet_guard._gate import due_slots from diet_guard._gate import due_slots
from python_pkg.diet_guard._gatelock_core import _GateState from diet_guard._gatelock_core import _GateState
from python_pkg.diet_guard._gatelock_mealflow import _GateMealFlow from diet_guard._gatelock_mealflow import _GateMealFlow
from python_pkg.diet_guard._gatelock_ui import ( from diet_guard._gatelock_ui import (
BG, BG,
GateCallbacks, GateCallbacks,
build_layout, build_layout,
make_vars, make_vars,
) )
from python_pkg.diet_guard._slots import current_slot, day_slots from diet_guard._slots import current_slot, day_slots
from python_pkg.diet_guard._state import now_local from diet_guard._state import now_local
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import TextIO from typing import TextIO

View File

@ -15,7 +15,7 @@ import logging
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python_pkg.diet_guard._gatelock_ui import ( from diet_guard._gatelock_ui import (
BASIS_PREFIX_GRAMS, BASIS_PREFIX_GRAMS,
BASIS_PREFIX_ITEMS, BASIS_PREFIX_ITEMS,
DEFAULT_PER_GRAMS, DEFAULT_PER_GRAMS,
@ -23,16 +23,16 @@ from python_pkg.diet_guard._gatelock_ui import (
GateVars, GateVars,
GateWidgets, GateWidgets,
) )
from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
from python_pkg.diet_guard._slots import slot_label from diet_guard._slots import slot_label
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from gatelock import GateRoot from gatelock import GateRoot
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._estimator import Nutrition
from python_pkg.diet_guard._meal import MealItem from diet_guard._meal import MealItem
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@ -75,7 +75,7 @@ class _GateCore:
"""Leaf widget/field helpers shared by every MealGate mixin. """Leaf widget/field helpers shared by every MealGate mixin.
Declares the attributes that Declares the attributes that
:class:`~python_pkg.diet_guard._gatelock.MealGate` sets up in ``__init__`` :class:`~diet_guard._gatelock.MealGate` sets up in ``__init__``
and ``_build`` so subclasses can reference them without tripping pylint's and ``_build`` so subclasses can reference them without tripping pylint's
no-member check. no-member check.
""" """

View File

@ -2,7 +2,7 @@
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
limit. ``_GateMealFlow`` extends limit. ``_GateMealFlow`` extends
:class:`~python_pkg.diet_guard._gatelock_nutrition._GateNutrition` with the :class:`~diet_guard._gatelock_nutrition._GateNutrition` with the
submit/lookup/log flow for single foods and multi-item meals, the per-slot submit/lookup/log flow for single foods and multi-item meals, the per-slot
input reset, and the running calorie/macro dashboard. input reset, and the running calorie/macro dashboard.
""" """
@ -13,14 +13,14 @@ import contextlib
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python_pkg.diet_guard._budget import BudgetError, daily_budget, protein_target_g from diet_guard._budget import BudgetError, daily_budget, protein_target_g
from python_pkg.diet_guard._foodbank import remember_food, remember_meal from diet_guard._foodbank import remember_food, remember_meal
from python_pkg.diet_guard._gatelock_nutrition import _GateNutrition from diet_guard._gatelock_nutrition import _GateNutrition
from python_pkg.diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS from diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS
from python_pkg.diet_guard._meal import MealItem, meal_total from diet_guard._meal import MealItem, meal_total
from python_pkg.diet_guard._resolve import lookup_candidates from diet_guard._resolve import lookup_candidates
from python_pkg.diet_guard._slots import slot_label from diet_guard._slots import slot_label
from python_pkg.diet_guard._state import ( from diet_guard._state import (
entry_kcal, entry_kcal,
log_meal, log_meal,
today_entries, today_entries,
@ -29,7 +29,7 @@ from python_pkg.diet_guard._state import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._estimator import Nutrition
# How long the "unlocking..." confirmation lingers before the window tears down. # How long the "unlocking..." confirmation lingers before the window tears down.
_UNLOCK_DELAY_MS = 1200 _UNLOCK_DELAY_MS = 1200
@ -214,7 +214,7 @@ class _GateMealFlow(_GateNutrition):
"""Log the accumulated multi-item meal for the current slot and advance. """Log the accumulated multi-item meal for the current slot and advance.
Each component and the summed composite are banked (see Each component and the summed composite are banked (see
:func:`python_pkg.diet_guard._foodbank.remember_meal`), and the slot is :func:`diet_guard._foodbank.remember_meal`), and the slot is
satisfied by the summed total under the meal's name. satisfied by the summed total under the meal's name.
""" """
name = self._meal_name() or _DEFAULT_MEAL_NAME name = self._meal_name() or _DEFAULT_MEAL_NAME

View File

@ -2,7 +2,7 @@
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
limit. ``_GateNutrition`` extends limit. ``_GateNutrition`` extends
:class:`~python_pkg.diet_guard._gatelock_core._GateCore` with the :class:`~diet_guard._gatelock_core._GateCore` with the
"reference -> total" nutrition maths -- the label macros describe one basis "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 (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 what gets logged -- plus the live preview/projection and the
@ -14,17 +14,17 @@ from __future__ import annotations
import tkinter as tk import tkinter as tk
from python_pkg.diet_guard._budget import BudgetError, daily_budget from diet_guard._budget import BudgetError, daily_budget
from python_pkg.diet_guard._estimator import Nutrition, scale_nutrition from diet_guard._estimator import Nutrition, scale_nutrition
from python_pkg.diet_guard._gatelock_core import _GateCore from diet_guard._gatelock_core import _GateCore
from python_pkg.diet_guard._gatelock_ui import ( from diet_guard._gatelock_ui import (
DEFAULT_PER_GRAMS, DEFAULT_PER_GRAMS,
SUGGESTION_ROWS, SUGGESTION_ROWS,
UNIT_ITEMS, UNIT_ITEMS,
) )
from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
from python_pkg.diet_guard._resolve import suggest_foods from diet_guard._resolve import suggest_foods
from python_pkg.diet_guard._state import today_total_kcal from diet_guard._state import today_total_kcal
def _format_preview(nutrition: Nutrition) -> str: def _format_preview(nutrition: Nutrition) -> str:

View File

@ -4,7 +4,7 @@ A meal is a named group of individually-macroed items -- e.g. a dinner of
salad + chicken + rice, each entered with its own calories and macros. The salad + chicken + rice, each entered with its own calories and macros. The
meal's nutrition is the sum of its items. Both the individual items and the meal's nutrition is the sum of its items. Both the individual items and the
composite meal are saved to the food bank (see composite meal are saved to the food bank (see
:func:`python_pkg.diet_guard._foodbank.remember_meal`), so next time each item :func:`diet_guard._foodbank.remember_meal`), so next time each item
autocompletes on its own and the whole meal can be picked as one summed entry. autocompletes on its own and the whole meal can be picked as one summed entry.
This module is deliberately pure (no I/O): the sum is a total function of its This module is deliberately pure (no I/O): the sum is a total function of its
@ -17,7 +17,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._estimator import Nutrition
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence from collections.abc import Sequence
@ -52,7 +52,7 @@ def meal_total(items: Sequence[MealItem]) -> Nutrition:
items: The meal's components. items: The meal's components.
Returns: Returns:
A :class:`~python_pkg.diet_guard._estimator.Nutrition` whose fields are A :class:`~diet_guard._estimator.Nutrition` whose fields are
the per-item sums. the per-item sums.
""" """
return Nutrition( return Nutrition(

View File

@ -10,7 +10,7 @@ Two problems this solves, both seen in real use:
So this module gives diet_guard, for each common countable food, the typical So this module gives diet_guard, for each common countable food, the typical
mass of one piece and its macros per 100 g. It is consulted *before* Open Food mass of one piece and its macros per 100 g. It is consulted *before* Open Food
Facts (see :mod:`python_pkg.diet_guard._resolve`), so a bare staple resolves Facts (see :mod:`diet_guard._resolve`), so a bare staple resolves
locally and sensibly, and a count multiplies cleanly into grams. locally and sensibly, and a count multiplies cleanly into grams.
The numbers are deliberately round "good enough" averages (USDA ballpark); the The numbers are deliberately round "good enough" averages (USDA ballpark); the
@ -22,8 +22,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._estimator import Nutrition
from python_pkg.diet_guard._fuzzy import match_score from diet_guard._fuzzy import match_score
# Same close-match bar the food bank uses, so matching feels consistent. # Same close-match bar the food bank uses, so matching feels consistent.
_MATCH_THRESHOLD = 0.6 _MATCH_THRESHOLD = 0.6
@ -130,7 +130,7 @@ def staple_nutrition(description: str) -> Nutrition | None:
The grams are fixed at 100 so the result is a clean reference basis the The grams are fixed at 100 so the result is a clean reference basis the
caller can rescale to the actual amount eaten via caller can rescale to the actual amount eaten via
:func:`python_pkg.diet_guard._estimator.scale_nutrition`. :func:`diet_guard._estimator.scale_nutrition`.
Args: Args:
description: Free-text food name. description: Free-text food name.

View File

@ -19,15 +19,15 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from python_pkg.diet_guard._estimator import ( from diet_guard._estimator import (
Nutrition, Nutrition,
estimate_off, estimate_off,
manual, manual,
off_candidates, off_candidates,
scale_nutrition, scale_nutrition,
) )
from python_pkg.diet_guard._foodbank import lookup_food, search_foods from diet_guard._foodbank import lookup_food, search_foods
from python_pkg.diet_guard._portions import staple_nutrition, suggest_staples from diet_guard._portions import staple_nutrition, suggest_staples
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@ -5,8 +5,8 @@ function of its ``now`` argument and the configured slot constants, so the
fiddly time-of-day edges (07:59 vs 08:00, the 20:00->22:00 tail, the midnight fiddly time-of-day edges (07:59 vs 08:00, the 20:00->22:00 tail, the midnight
reset) are exhaustively unit-testable without mocking the filesystem or the reset) are exhaustively unit-testable without mocking the filesystem or the
wall clock. The stateful "which slots have I actually logged?" question lives wall clock. The stateful "which slots have I actually logged?" question lives
in :mod:`python_pkg.diet_guard._state`; the two are composed in in :mod:`diet_guard._state`; the two are composed in
:mod:`python_pkg.diet_guard._gate`. :mod:`diet_guard._gate`.
A "slot" is simply the integer hour at which a meal checkpoint opens (08, 12, A "slot" is simply the integer hour at which a meal checkpoint opens (08, 12,
16, 20). A slot is *elapsed* once its hour has arrived and we are still inside 16, 20). A slot is *elapsed* once its hour has arrived and we are still inside
@ -18,7 +18,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python_pkg.diet_guard._constants import ( from diet_guard._constants import (
GATE_DAY_START_HOUR, GATE_DAY_START_HOUR,
GATE_EATING_END_HOUR, GATE_EATING_END_HOUR,
GATE_SLOT_INTERVAL_HOURS, GATE_SLOT_INTERVAL_HOURS,

View File

@ -20,12 +20,12 @@ from gatelock.log_integrity import (
verify_entry_hmac, verify_entry_hmac,
) )
from python_pkg.diet_guard._budget import daily_budget from diet_guard._budget import daily_budget
from python_pkg.diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE from diet_guard._coerce import as_float
from python_pkg.shared.coerce import as_float from diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE
if TYPE_CHECKING: if TYPE_CHECKING:
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._estimator import Nutrition
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)

View File

@ -12,7 +12,7 @@ Three safety nets run for every test:
``setxkbmap`` against the live X session. ``setxkbmap`` against the live X session.
The ``gate`` fixture and its supporting fakes (``FakeEntry``, ``_FAKE_TK``, ...) The ``gate`` fixture and its supporting fakes (``FakeEntry``, ``_FAKE_TK``, ...)
build a demo :class:`~python_pkg.diet_guard._gatelock.MealGate` whose widgets build a demo :class:`~diet_guard._gatelock.MealGate` whose widgets
are functional in-memory stand-ins, shared by ``test_gatelock.py`` and are functional in-memory stand-ins, shared by ``test_gatelock.py`` and
``test_gatelock_mealflow.py``. ``test_gatelock_mealflow.py``.
""" """
@ -26,15 +26,15 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from python_pkg.diet_guard import ( from diet_guard import (
_gatelock, _gatelock,
_gatelock_core, _gatelock_core,
_gatelock_mealflow, _gatelock_mealflow,
_gatelock_nutrition, _gatelock_nutrition,
_gatelock_ui, _gatelock_ui,
) )
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._estimator import Nutrition
from python_pkg.diet_guard._gatelock import MealGate from diet_guard._gatelock import MealGate
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
@ -46,19 +46,19 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]:
"""Redirect all on-disk diet_guard state into a temp dir.""" """Redirect all on-disk diet_guard state into a temp dir."""
with ( with (
patch( patch(
"python_pkg.diet_guard._budget.BUDGET_FILE", "diet_guard._budget.BUDGET_FILE",
tmp_path / ".budget", tmp_path / ".budget",
), ),
patch( patch(
"python_pkg.diet_guard._state.FOOD_LOG_FILE", "diet_guard._state.FOOD_LOG_FILE",
tmp_path / "food_log.json", tmp_path / "food_log.json",
), ),
patch( patch(
"python_pkg.diet_guard._foodbank.FOOD_BANK_FILE", "diet_guard._foodbank.FOOD_BANK_FILE",
tmp_path / "food_bank.json", tmp_path / "food_bank.json",
), ),
patch( patch(
"python_pkg.diet_guard._gatelock.GATE_LOCK_FILE", "diet_guard._gatelock.GATE_LOCK_FILE",
tmp_path / ".gate.lock", tmp_path / ".gate.lock",
), ),
): ):
@ -69,8 +69,8 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]:
def _block_real_tk() -> Iterator[None]: def _block_real_tk() -> Iterator[None]:
"""Replace tk + the window class in _gatelock so no real window can open.""" """Replace tk + the window class in _gatelock so no real window can open."""
with ( with (
patch("python_pkg.diet_guard._gatelock.tk", MagicMock()), patch("diet_guard._gatelock.tk", MagicMock()),
patch("python_pkg.diet_guard._gatelock.GateRoot", MagicMock()), patch("diet_guard._gatelock.GateRoot", MagicMock()),
): ):
yield yield

View File

@ -10,8 +10,8 @@ from unittest.mock import patch
import pytest import pytest
from python_pkg.diet_guard import _budget from diet_guard import _budget
from python_pkg.diet_guard._budget import ( from diet_guard._budget import (
Biometrics, Biometrics,
BudgetLockedError, BudgetLockedError,
BudgetNotInitializedError, BudgetNotInitializedError,

View File

@ -11,15 +11,15 @@ import io
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from python_pkg.diet_guard import _cli from diet_guard import _cli
from python_pkg.diet_guard._budget import ( from diet_guard._budget import (
BudgetLockedError, BudgetLockedError,
BudgetNotInitializedError, BudgetNotInitializedError,
BudgetSealBrokenError, BudgetSealBrokenError,
seal_budget, seal_budget,
) )
from python_pkg.diet_guard._cli import _eaten_grams, _Portion, main from diet_guard._cli import _eaten_grams, _Portion, main
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._estimator import Nutrition
if TYPE_CHECKING: if TYPE_CHECKING:
import pytest import pytest

View File

@ -10,9 +10,9 @@ from unittest.mock import MagicMock, patch
import requests import requests
from python_pkg.diet_guard import _estimator from diet_guard import _estimator
from python_pkg.diet_guard._constants import DEFAULT_PORTION_GRAMS from diet_guard._constants import DEFAULT_PORTION_GRAMS
from python_pkg.diet_guard._estimator import ( from diet_guard._estimator import (
Nutrition, Nutrition,
estimate, estimate,
estimate_off, estimate_off,

View File

@ -10,15 +10,15 @@ import json
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from python_pkg.diet_guard import _foodbank from diet_guard import _foodbank
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._estimator import Nutrition
from python_pkg.diet_guard._foodbank import ( from diet_guard._foodbank import (
lookup_food, lookup_food,
remember_food, remember_food,
remember_meal, remember_meal,
search_foods, search_foods,
) )
from python_pkg.diet_guard._meal import MealItem from diet_guard._meal import MealItem
_NUT = Nutrition( _NUT = Nutrition(
kcal=250, kcal=250,

View File

@ -6,7 +6,7 @@ the degenerate empty-input branch that falls back to a whole-string ratio.
from __future__ import annotations from __future__ import annotations
from python_pkg.diet_guard._fuzzy import match_score, token_score from diet_guard._fuzzy import match_score, token_score
class TestTokenScore: class TestTokenScore:

View File

@ -9,7 +9,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from unittest.mock import patch from unittest.mock import patch
from python_pkg.diet_guard._gate import due_slots, gate_is_due, gate_message from diet_guard._gate import due_slots, gate_is_due, gate_message
def _at(hour: int) -> datetime: def _at(hour: int) -> datetime:
@ -20,7 +20,7 @@ def _at(hour: int) -> datetime:
def _logged(slots: set[int]) -> object: def _logged(slots: set[int]) -> object:
"""Patch the logged-slots source so the decision is deterministic.""" """Patch the logged-slots source so the decision is deterministic."""
return patch( return patch(
"python_pkg.diet_guard._gate.logged_slots_today", "diet_guard._gate.logged_slots_today",
return_value=slots, return_value=slots,
) )
@ -38,7 +38,7 @@ class TestDueSlots:
with ( with (
_logged(set()), _logged(set()),
patch( patch(
"python_pkg.diet_guard._gate.now_local", "diet_guard._gate.now_local",
return_value=_at(9), return_value=_at(9),
), ),
): ):

View File

@ -15,24 +15,24 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from python_pkg.diet_guard import ( from diet_guard import (
_gatelock, _gatelock,
_gatelock_support, _gatelock_support,
_gatelock_ui, _gatelock_ui,
) )
from python_pkg.diet_guard._budget import seal_budget from diet_guard._budget import seal_budget
from python_pkg.diet_guard._gatelock import ( from diet_guard._gatelock import (
MealGate, MealGate,
_pending_slots, _pending_slots,
acquire_gate_lock, acquire_gate_lock,
release_gate_lock, release_gate_lock,
) )
from python_pkg.diet_guard._gatelock_core import _safe_float from diet_guard._gatelock_core import _safe_float
from python_pkg.diet_guard._gatelock_nutrition import _format_preview from diet_guard._gatelock_nutrition import _format_preview
from python_pkg.diet_guard._gatelock_support import wait_for_display from diet_guard._gatelock_support import wait_for_display
from python_pkg.diet_guard._gatelock_ui import DEFAULT_PER_GRAMS from diet_guard._gatelock_ui import DEFAULT_PER_GRAMS
from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS from diet_guard._portions import DEFAULT_ITEM_GRAMS
from python_pkg.diet_guard.tests.conftest import _FAKE_TK, _FakeTclError, _nutrition from diet_guard.tests.conftest import _FAKE_TK, _FakeTclError, _nutrition
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Module-level helpers # Module-level helpers

View File

@ -12,14 +12,14 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import patch from unittest.mock import patch
from python_pkg.diet_guard import _gatelock_mealflow from diet_guard import _gatelock_mealflow
from python_pkg.diet_guard._budget import seal_budget from diet_guard._budget import seal_budget
from python_pkg.diet_guard._meal import MealItem from diet_guard._meal import MealItem
from python_pkg.diet_guard._state import log_meal from diet_guard._state import log_meal
from python_pkg.diet_guard.tests.conftest import _nutrition from diet_guard.tests.conftest import _nutrition
if TYPE_CHECKING: if TYPE_CHECKING:
from python_pkg.diet_guard._gatelock import MealGate from diet_guard._gatelock import MealGate
class TestReferenceModel: class TestReferenceModel:

View File

@ -10,12 +10,12 @@ import importlib
def test_main_module_imports() -> None: def test_main_module_imports() -> None:
"""The ``python -m python_pkg.diet_guard`` entry module imports cleanly.""" """The ``python -m diet_guard`` entry module imports cleanly."""
module = importlib.import_module("python_pkg.diet_guard.__main__") module = importlib.import_module("diet_guard.__main__")
assert hasattr(module, "main") assert hasattr(module, "main")
def test_package_imports() -> None: def test_package_imports() -> None:
"""The package itself imports without side effects.""" """The package itself imports without side effects."""
package = importlib.import_module("python_pkg.diet_guard") package = importlib.import_module("diet_guard")
assert package is not None assert package is not None

View File

@ -2,8 +2,8 @@
from __future__ import annotations from __future__ import annotations
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._estimator import Nutrition
from python_pkg.diet_guard._meal import MEAL_SOURCE, MealItem, meal_total from diet_guard._meal import MEAL_SOURCE, MealItem, meal_total
def _item( def _item(

View File

@ -6,7 +6,7 @@ Nutrition / suggestion builders.
from __future__ import annotations from __future__ import annotations
from python_pkg.diet_guard._portions import ( from diet_guard._portions import (
estimate_unit_grams, estimate_unit_grams,
staple_nutrition, staple_nutrition,
suggest_staples, suggest_staples,

View File

@ -8,9 +8,9 @@ from __future__ import annotations
from unittest.mock import patch from unittest.mock import patch
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._estimator import Nutrition
from python_pkg.diet_guard._foodbank import remember_food from diet_guard._foodbank import remember_food
from python_pkg.diet_guard._resolve import ( from diet_guard._resolve import (
ManualMacros, ManualMacros,
lookup_candidates, lookup_candidates,
resolve_nutrition, resolve_nutrition,
@ -82,7 +82,7 @@ class TestResolveBankAndStaple:
def test_off_fallback(self) -> None: def test_off_fallback(self) -> None:
"""An unknown, non-staple food falls through to Open Food Facts.""" """An unknown, non-staple food falls through to Open Food Facts."""
with patch( with patch(
"python_pkg.diet_guard._resolve.estimate_off", "diet_guard._resolve.estimate_off",
return_value=_OFF, return_value=_OFF,
): ):
result = resolve_nutrition("exotic dish") result = resolve_nutrition("exotic dish")
@ -117,7 +117,7 @@ class TestLookupCandidates:
def test_off_candidates(self) -> None: def test_off_candidates(self) -> None:
"""An unknown food returns the OFF alternatives, labelled by source.""" """An unknown food returns the OFF alternatives, labelled by source."""
with patch( with patch(
"python_pkg.diet_guard._resolve.off_candidates", "diet_guard._resolve.off_candidates",
return_value=[_OFF], return_value=[_OFF],
): ):
candidates = lookup_candidates("exotic dish") candidates = lookup_candidates("exotic dish")

View File

@ -8,7 +8,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from python_pkg.diet_guard._slots import ( from diet_guard._slots import (
current_slot, current_slot,
day_slots, day_slots,
elapsed_slots, elapsed_slots,

View File

@ -12,10 +12,10 @@ from unittest.mock import patch
import pytest import pytest
from python_pkg.diet_guard import _state from diet_guard import _state
from python_pkg.diet_guard._budget import BudgetNotInitializedError, seal_budget from diet_guard._budget import BudgetNotInitializedError, seal_budget
from python_pkg.diet_guard._estimator import Nutrition from diet_guard._estimator import Nutrition
from python_pkg.diet_guard._state import ( from diet_guard._state import (
consumption_band, consumption_band,
entry_kcal, entry_kcal,
load_log, load_log,

View File

@ -6,9 +6,13 @@
# #
# What it does: # What it does:
# 1. Ensures system deps (setxkbmap for VT-disable, requests for OFF lookups) # 1. Ensures system deps (setxkbmap for VT-disable, requests for OFF lookups)
# 2. Installs + enables the systemd user timer that fires the gate every ~30m # 2. pip-installs this package + gatelock into system Python's user
# 3. Seals your daily budget from biometrics (only if not already sealed) # site-packages (the systemd service runs /usr/bin/python directly, not
# 4. Locks the budget file immutable with `chattr +i` (the real tamper gate) # a venv, so the package must live where that interpreter can find it —
# see CLAUDE.md's "Production dependency installation" section)
# 3. Installs + enables the systemd user timer that fires the gate every ~30m
# 4. Seals your daily budget from biometrics (only if not already sealed)
# 5. Locks the budget file immutable with `chattr +i` (the real tamper gate)
# ============================================================================ # ============================================================================
set -euo pipefail set -euo pipefail
@ -16,9 +20,7 @@ set -euo pipefail
# Split declare/assign so the command-substitution exit code is not masked (SC2155). # Split declare/assign so the command-substitution exit code is not masked (SC2155).
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR readonly SCRIPT_DIR
# python_pkg/diet_guard -> repo root (two levels up). readonly REPO_DIR="$SCRIPT_DIR"
REPO_DIR="$(readlink -f "$SCRIPT_DIR/../..")"
readonly REPO_DIR
readonly SERVICE_SRC="$SCRIPT_DIR/diet-guard-gate.service" readonly SERVICE_SRC="$SCRIPT_DIR/diet-guard-gate.service"
readonly TIMER_SRC="$SCRIPT_DIR/diet-guard-gate.timer" readonly TIMER_SRC="$SCRIPT_DIR/diet-guard-gate.timer"
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user" readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
@ -28,22 +30,23 @@ readonly BUDGET_FILE="$DATA_DIR/.budget"
echo "=== Diet Guard Installer ===" echo "=== Diet Guard Installer ==="
# 1. System dependencies ------------------------------------------------------ # 1. System dependencies ------------------------------------------------------
echo "[1/4] Checking system dependencies..." echo "[1/5] Checking system dependencies..."
if ! command -v setxkbmap &>/dev/null; then if ! command -v setxkbmap &>/dev/null; then
echo " Installing xorg-setxkbmap (gate disables VT switching while locked)..." echo " Installing xorg-setxkbmap (gate disables VT switching while locked)..."
sudo pacman -S --noconfirm xorg-setxkbmap sudo pacman -S --noconfirm xorg-setxkbmap
else else
echo " setxkbmap present" echo " setxkbmap present"
fi fi
if ! python -c 'import requests' 2>/dev/null; then
echo " Installing python-requests (Open Food Facts lookups)..."
sudo pacman -S --noconfirm python-requests
else
echo " python-requests present"
fi
# 2. systemd user timer + service -------------------------------------------- # 2. Install this package + its dependencies into system Python -------------
echo "[2/4] Installing systemd user timer + service..." echo "[2/5] Installing diet_guard + dependencies for /usr/bin/python..."
/usr/bin/python3 -m pip install --user --break-system-packages -e "$REPO_DIR"
echo " Installed. Verifying import..."
/usr/bin/python3 -c "import diet_guard; import gatelock" \
&& echo " diet_guard and gatelock import cleanly from the system interpreter."
# 3. systemd user timer + service --------------------------------------------
echo "[3/5] Installing systemd user timer + service..."
mkdir -p "$SYSTEMD_USER_DIR" mkdir -p "$SYSTEMD_USER_DIR"
cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.service" cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.service"
cp "$TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.timer" cp "$TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.timer"
@ -51,17 +54,17 @@ systemctl --user daemon-reload
systemctl --user enable --now diet-guard-gate.timer systemctl --user enable --now diet-guard-gate.timer
echo " Timer enabled and started (fires the gate every ~30 min)." echo " Timer enabled and started (fires the gate every ~30 min)."
# 3. Seal the daily budget (hidden) ------------------------------------------ # 4. Seal the daily budget (hidden) ------------------------------------------
echo "[3/4] Sealing your daily budget..." echo "[4/5] Sealing your daily budget..."
if [[ -e "$BUDGET_FILE" ]]; then if [[ -e "$BUDGET_FILE" ]]; then
echo " Budget already sealed at $BUDGET_FILE - skipping init." echo " Budget already sealed at $BUDGET_FILE - skipping init."
else else
echo " Enter your biometrics (used once then discarded; the value is hidden):" echo " Enter your biometrics (used once then discarded; the value is hidden):"
(cd "$REPO_DIR" && python -m python_pkg.diet_guard init) python -m diet_guard init
fi fi
# 4. Lock the budget immutable (the real tamper friction) -------------------- # 5. Lock the budget immutable (the real tamper friction) --------------------
echo "[4/4] Locking the budget file (chattr +i)..." echo "[5/5] Locking the budget file (chattr +i)..."
read -r attrs _ <<<"$(lsattr -d "$BUDGET_FILE" 2>/dev/null || true)" read -r attrs _ <<<"$(lsattr -d "$BUDGET_FILE" 2>/dev/null || true)"
if [[ "$attrs" == *i* ]]; then if [[ "$attrs" == *i* ]]; then
echo " Already immutable." echo " Already immutable."
@ -73,5 +76,4 @@ fi
echo "=== Installation complete ===" echo "=== Installation complete ==="
echo "The gate checks every ~30 min (08:00-22:00) and locks until you log a meal" echo "The gate checks every ~30 min (08:00-22:00) and locks until you log a meal"
echo "once you have gone 5h without logging." echo "once you have gone 5h without logging."
echo "Test the lock now (safe, closeable): \ echo "Test the lock now (safe, closeable): python -m diet_guard gate --demo"
cd $REPO_DIR && python -m python_pkg.diet_guard gate --demo"

172
pyproject.toml Normal file
View File

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

16
requirements.txt Normal file
View File

@ -0,0 +1,16 @@
# Diet Guard — runtime + development dependencies
# Runtime: tkinter/json/hmac/fcntl (stdlib) plus gatelock and requests below.
bandit>=1.7.0
codespell>=2.2.0
coverage>=7.4.0
gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0
mypy>=1.8.0
pre-commit>=3.6.0
pylint>=3.0.0
pytest>=8.0.0
pytest-cov>=4.1.0
pytest-randomly>=3.15.0
pytest-sugar>=1.0.0
pytest-xdist>=3.5.0
requests>=2.31.0
ruff>=0.8.0

26
scripts/check_file_length.py Executable file
View File

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