diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..98911e8 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,19 @@ +name: pre-commit + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install -r requirements.txt + - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..66e5931 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,28 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests with coverage + run: python -m pytest -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22de16e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +.Python +build/ +dist/ +*.egg-info/ +.env +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.lcov +htmlcov/ +*.log +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d92038a --- /dev/null +++ b/.pre-commit-config.yaml @@ -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] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b5aad1c --- /dev/null +++ b/CLAUDE.md @@ -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 "`, 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..54acdf8 --- /dev/null +++ b/README.md @@ -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. diff --git a/diet_guard/diet-guard-gate.service b/diet-guard-gate.service similarity index 68% rename from diet_guard/diet-guard-gate.service rename to diet-guard-gate.service index 9132404..ee29192 100644 --- a/diet_guard/diet-guard-gate.service +++ b/diet-guard-gate.service @@ -4,10 +4,12 @@ After=graphical-session.target [Service] Type=oneshot -# DISPLAY/PYTHONPATH mirror wake-alarm.service: the gate opens a Tk window when a -# lock 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 -# exits 0 when no lock is needed, so running it every ~30 min is cheap. +# DISPLAY mirrors wake-alarm.service: the gate opens a Tk window when a lock +# 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 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, # 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. Environment=DISPLAY=:0 Environment=XAUTHORITY=%h/.Xauthority -Environment=PYTHONPATH=%h/testsAndMisc ExecStartPre=/bin/sleep 1 -ExecStart=/usr/bin/python -m python_pkg.diet_guard gate -WorkingDirectory=%h/testsAndMisc +ExecStart=/usr/bin/python -m diet_guard gate diff --git a/diet_guard/diet-guard-gate.timer b/diet-guard-gate.timer similarity index 100% rename from diet_guard/diet-guard-gate.timer rename to diet-guard-gate.timer diff --git a/diet_guard/__main__.py b/diet_guard/__main__.py index 04086d6..edf5be5 100644 --- a/diet_guard/__main__.py +++ b/diet_guard/__main__.py @@ -1,10 +1,10 @@ -"""Module entry point: ``python -m python_pkg.diet_guard``.""" +"""Module entry point: ``python -m diet_guard``.""" from __future__ import annotations import sys -from python_pkg.diet_guard._cli import main +from diet_guard._cli import main if __name__ == "__main__": sys.exit(main()) diff --git a/diet_guard/_budget.py b/diet_guard/_budget.py index 00825cc..1e4d1e9 100644 --- a/diet_guard/_budget.py +++ b/diet_guard/_budget.py @@ -32,7 +32,7 @@ import logging 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__) diff --git a/diet_guard/_cli.py b/diet_guard/_cli.py index 096d111..940c9c0 100644 --- a/diet_guard/_cli.py +++ b/diet_guard/_cli.py @@ -1,12 +1,12 @@ """Command-line interface for diet_guard. Examples: - python -m python_pkg.diet_guard init - python -m python_pkg.diet_guard ate "big mac" - python -m python_pkg.diet_guard ate "two slices of pizza" --grams 240 - python -m python_pkg.diet_guard ate "protein shake" --kcal 180 - python -m python_pkg.diet_guard status - python -m python_pkg.diet_guard undo + python -m diet_guard init + python -m diet_guard ate "big mac" + python -m diet_guard ate "two slices of pizza" --grams 240 + python -m diet_guard ate "protein shake" --kcal 180 + python -m diet_guard status + python -m diet_guard undo 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 @@ -19,7 +19,7 @@ import argparse from dataclasses import dataclass import sys -from python_pkg.diet_guard._budget import ( +from diet_guard._budget import ( Biometrics, BudgetLockedError, BudgetNotInitializedError, @@ -31,21 +31,21 @@ from python_pkg.diet_guard._budget import ( seal_budget, unlock_command, ) -from python_pkg.diet_guard._foodbank import remember_food -from python_pkg.diet_guard._gate import due_slots, gate_is_due -from python_pkg.diet_guard._gatelock import ( +from diet_guard._foodbank import remember_food +from diet_guard._gate import due_slots, gate_is_due +from diet_guard._gatelock import ( MealGate, acquire_gate_lock, release_gate_lock, ) -from python_pkg.diet_guard._gatelock_support import wait_for_display -from python_pkg.diet_guard._portions import ( +from diet_guard._gatelock_support import wait_for_display +from diet_guard._portions import ( DEFAULT_ITEM_GRAMS, estimate_unit_grams, ) -from python_pkg.diet_guard._resolve import ManualMacros, resolve_nutrition -from python_pkg.diet_guard._slots import current_slot, day_slots, slot_label -from python_pkg.diet_guard._state import ( +from diet_guard._resolve import ManualMacros, resolve_nutrition +from diet_guard._slots import current_slot, day_slots, slot_label +from diet_guard._state import ( entry_kcal, log_meal, logged_slots_today, @@ -210,8 +210,7 @@ def _print_summary() -> None: budget = daily_budget() except BudgetNotInitializedError: _emit( - f"today: {total:g} kcal " - "(budget not set - run: python -m python_pkg.diet_guard init)", + f"today: {total:g} kcal (budget not set - run: python -m diet_guard init)", ) return except BudgetSealBrokenError: diff --git a/diet_guard/_coerce.py b/diet_guard/_coerce.py new file mode 100644 index 0000000..839fff1 --- /dev/null +++ b/diet_guard/_coerce.py @@ -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 diff --git a/diet_guard/_constants.py b/diet_guard/_constants.py index 7bbc463..579f999 100644 --- a/diet_guard/_constants.py +++ b/diet_guard/_constants.py @@ -9,7 +9,7 @@ from pathlib import Path # 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 # 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. # # Fraction of the budget at which status flips from "on track" to "approaching diff --git a/diet_guard/_estimator.py b/diet_guard/_estimator.py index 2cbfbd7..53044e9 100644 --- a/diet_guard/_estimator.py +++ b/diet_guard/_estimator.py @@ -18,7 +18,7 @@ import logging import requests -from python_pkg.diet_guard._constants import ( +from diet_guard._constants import ( DEFAULT_PORTION_GRAMS, OFF_PAGE_SIZE, OFF_SEARCH_URL, diff --git a/diet_guard/_foodbank.py b/diet_guard/_foodbank.py index 1cc85aa..e95fc98 100644 --- a/diet_guard/_foodbank.py +++ b/diet_guard/_foodbank.py @@ -2,7 +2,7 @@ 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 -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 then on the food is served from the bank, so search quality improves with use and works fully offline. @@ -21,11 +21,11 @@ import logging import time from typing import TYPE_CHECKING -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 +from diet_guard._coerce import as_float +from diet_guard._constants import FOOD_BANK_FILE +from diet_guard._estimator import Nutrition +from diet_guard._fuzzy import match_score +from diet_guard._meal import MealItem, meal_total if TYPE_CHECKING: from collections.abc import Sequence diff --git a/diet_guard/_gate.py b/diet_guard/_gate.py index e323112..4021134 100644 --- a/diet_guard/_gate.py +++ b/diet_guard/_gate.py @@ -3,8 +3,8 @@ 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 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 -state in :mod:`python_pkg.diet_guard._state`; ``now`` is injectable so the +pure slot arithmetic in :mod:`diet_guard._slots` with the logged-slot +state in :mod:`diet_guard._state`; ``now`` is injectable so the time-of-day rules stay deterministically testable. 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 python_pkg.diet_guard._slots import missing_slots, slot_label -from python_pkg.diet_guard._state import logged_slots_today, now_local +from diet_guard._slots import missing_slots, slot_label +from diet_guard._state import logged_slots_today, now_local if TYPE_CHECKING: from datetime import datetime diff --git a/diet_guard/_gatelock.py b/diet_guard/_gatelock.py index a1cc541..0dbe8de 100644 --- a/diet_guard/_gatelock.py +++ b/diet_guard/_gatelock.py @@ -47,18 +47,18 @@ from typing import TYPE_CHECKING from gatelock import GateRoot, LockConfig, LockWindow -from python_pkg.diet_guard._constants import GATE_LOCK_FILE -from python_pkg.diet_guard._gate import due_slots -from python_pkg.diet_guard._gatelock_core import _GateState -from python_pkg.diet_guard._gatelock_mealflow import _GateMealFlow -from python_pkg.diet_guard._gatelock_ui import ( +from diet_guard._constants import GATE_LOCK_FILE +from diet_guard._gate import due_slots +from diet_guard._gatelock_core import _GateState +from diet_guard._gatelock_mealflow import _GateMealFlow +from diet_guard._gatelock_ui import ( BG, GateCallbacks, build_layout, make_vars, ) -from python_pkg.diet_guard._slots import current_slot, day_slots -from python_pkg.diet_guard._state import now_local +from diet_guard._slots import current_slot, day_slots +from diet_guard._state import now_local if TYPE_CHECKING: from typing import TextIO diff --git a/diet_guard/_gatelock_core.py b/diet_guard/_gatelock_core.py index 7815771..01a168e 100644 --- a/diet_guard/_gatelock_core.py +++ b/diet_guard/_gatelock_core.py @@ -15,7 +15,7 @@ import logging import tkinter as tk from typing import TYPE_CHECKING -from python_pkg.diet_guard._gatelock_ui import ( +from diet_guard._gatelock_ui import ( BASIS_PREFIX_GRAMS, BASIS_PREFIX_ITEMS, DEFAULT_PER_GRAMS, @@ -23,16 +23,16 @@ from python_pkg.diet_guard._gatelock_ui import ( GateVars, GateWidgets, ) -from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams -from python_pkg.diet_guard._slots import slot_label +from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams +from diet_guard._slots import slot_label if TYPE_CHECKING: from collections.abc import Callable from gatelock import GateRoot - from python_pkg.diet_guard._estimator import Nutrition - from python_pkg.diet_guard._meal import MealItem + from diet_guard._estimator import Nutrition + from diet_guard._meal import MealItem _logger = logging.getLogger(__name__) @@ -75,7 +75,7 @@ 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__`` + :class:`~diet_guard._gatelock.MealGate` sets up in ``__init__`` and ``_build`` so subclasses can reference them without tripping pylint's no-member check. """ diff --git a/diet_guard/_gatelock_mealflow.py b/diet_guard/_gatelock_mealflow.py index d5c1346..480ec82 100644 --- a/diet_guard/_gatelock_mealflow.py +++ b/diet_guard/_gatelock_mealflow.py @@ -2,7 +2,7 @@ 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 +:class:`~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. """ @@ -13,14 +13,14 @@ 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 ( +from diet_guard._budget import BudgetError, daily_budget, protein_target_g +from diet_guard._foodbank import remember_food, remember_meal +from diet_guard._gatelock_nutrition import _GateNutrition +from diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS +from diet_guard._meal import MealItem, meal_total +from diet_guard._resolve import lookup_candidates +from diet_guard._slots import slot_label +from diet_guard._state import ( entry_kcal, log_meal, today_entries, @@ -29,7 +29,7 @@ from python_pkg.diet_guard._state import ( ) 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. _UNLOCK_DELAY_MS = 1200 @@ -214,7 +214,7 @@ class _GateMealFlow(_GateNutrition): """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 + :func:`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 diff --git a/diet_guard/_gatelock_nutrition.py b/diet_guard/_gatelock_nutrition.py index c25e219..d2f472c 100644 --- a/diet_guard/_gatelock_nutrition.py +++ b/diet_guard/_gatelock_nutrition.py @@ -2,7 +2,7 @@ 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 +:class:`~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 @@ -14,17 +14,17 @@ 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 ( +from diet_guard._budget import BudgetError, daily_budget +from diet_guard._estimator import Nutrition, scale_nutrition +from diet_guard._gatelock_core import _GateCore +from 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 +from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams +from diet_guard._resolve import suggest_foods +from diet_guard._state import today_total_kcal def _format_preview(nutrition: Nutrition) -> str: diff --git a/diet_guard/_meal.py b/diet_guard/_meal.py index 39d92f7..6e9ddb9 100644 --- a/diet_guard/_meal.py +++ b/diet_guard/_meal.py @@ -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 meal's nutrition is the sum of its items. Both the individual items and the 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. 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 typing import TYPE_CHECKING -from python_pkg.diet_guard._estimator import Nutrition +from diet_guard._estimator import Nutrition if TYPE_CHECKING: from collections.abc import Sequence @@ -52,7 +52,7 @@ def meal_total(items: Sequence[MealItem]) -> Nutrition: items: The meal's components. 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. """ return Nutrition( diff --git a/diet_guard/_portions.py b/diet_guard/_portions.py index 61c1683..7fe63a9 100644 --- a/diet_guard/_portions.py +++ b/diet_guard/_portions.py @@ -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 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. The numbers are deliberately round "good enough" averages (USDA ballpark); the @@ -22,8 +22,8 @@ from __future__ import annotations from dataclasses import dataclass -from python_pkg.diet_guard._estimator import Nutrition -from python_pkg.diet_guard._fuzzy import match_score +from diet_guard._estimator import Nutrition +from diet_guard._fuzzy import match_score # Same close-match bar the food bank uses, so matching feels consistent. _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 caller can rescale to the actual amount eaten via - :func:`python_pkg.diet_guard._estimator.scale_nutrition`. + :func:`diet_guard._estimator.scale_nutrition`. Args: description: Free-text food name. diff --git a/diet_guard/_resolve.py b/diet_guard/_resolve.py index 72d61af..266d30b 100644 --- a/diet_guard/_resolve.py +++ b/diet_guard/_resolve.py @@ -19,15 +19,15 @@ from __future__ import annotations from dataclasses import dataclass -from python_pkg.diet_guard._estimator import ( +from diet_guard._estimator import ( Nutrition, estimate_off, manual, off_candidates, scale_nutrition, ) -from python_pkg.diet_guard._foodbank import lookup_food, search_foods -from python_pkg.diet_guard._portions import staple_nutrition, suggest_staples +from diet_guard._foodbank import lookup_food, search_foods +from diet_guard._portions import staple_nutrition, suggest_staples @dataclass(frozen=True) diff --git a/diet_guard/_slots.py b/diet_guard/_slots.py index 285d412..ea89143 100644 --- a/diet_guard/_slots.py +++ b/diet_guard/_slots.py @@ -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 reset) are exhaustively unit-testable without mocking the filesystem or the wall clock. The stateful "which slots have I actually logged?" question lives -in :mod:`python_pkg.diet_guard._state`; the two are composed in -:mod:`python_pkg.diet_guard._gate`. +in :mod:`diet_guard._state`; the two are composed in +:mod:`diet_guard._gate`. 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 @@ -18,7 +18,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from python_pkg.diet_guard._constants import ( +from diet_guard._constants import ( GATE_DAY_START_HOUR, GATE_EATING_END_HOUR, GATE_SLOT_INTERVAL_HOURS, diff --git a/diet_guard/_state.py b/diet_guard/_state.py index e4b205e..1b9fd8f 100644 --- a/diet_guard/_state.py +++ b/diet_guard/_state.py @@ -20,12 +20,12 @@ from gatelock.log_integrity import ( verify_entry_hmac, ) -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 diet_guard._budget import daily_budget +from diet_guard._coerce import as_float +from diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE if TYPE_CHECKING: - from python_pkg.diet_guard._estimator import Nutrition + from diet_guard._estimator import Nutrition _logger = logging.getLogger(__name__) diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index 41dcd00..6b8ce0a 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -12,7 +12,7 @@ Three safety nets run for every test: ``setxkbmap`` against the live X session. 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 ``test_gatelock_mealflow.py``. """ @@ -26,15 +26,15 @@ from unittest.mock import MagicMock, patch import pytest -from python_pkg.diet_guard import ( +from diet_guard import ( _gatelock, _gatelock_core, _gatelock_mealflow, _gatelock_nutrition, _gatelock_ui, ) -from python_pkg.diet_guard._estimator import Nutrition -from python_pkg.diet_guard._gatelock import MealGate +from diet_guard._estimator import Nutrition +from diet_guard._gatelock import MealGate if TYPE_CHECKING: 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.""" with ( patch( - "python_pkg.diet_guard._budget.BUDGET_FILE", + "diet_guard._budget.BUDGET_FILE", tmp_path / ".budget", ), patch( - "python_pkg.diet_guard._state.FOOD_LOG_FILE", + "diet_guard._state.FOOD_LOG_FILE", tmp_path / "food_log.json", ), patch( - "python_pkg.diet_guard._foodbank.FOOD_BANK_FILE", + "diet_guard._foodbank.FOOD_BANK_FILE", tmp_path / "food_bank.json", ), patch( - "python_pkg.diet_guard._gatelock.GATE_LOCK_FILE", + "diet_guard._gatelock.GATE_LOCK_FILE", tmp_path / ".gate.lock", ), ): @@ -69,8 +69,8 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]: def _block_real_tk() -> Iterator[None]: """Replace tk + the window class in _gatelock so no real window can open.""" with ( - patch("python_pkg.diet_guard._gatelock.tk", MagicMock()), - patch("python_pkg.diet_guard._gatelock.GateRoot", MagicMock()), + patch("diet_guard._gatelock.tk", MagicMock()), + patch("diet_guard._gatelock.GateRoot", MagicMock()), ): yield diff --git a/diet_guard/tests/test_budget.py b/diet_guard/tests/test_budget.py index 48f5ab9..3f8b19d 100644 --- a/diet_guard/tests/test_budget.py +++ b/diet_guard/tests/test_budget.py @@ -10,8 +10,8 @@ from unittest.mock import patch import pytest -from python_pkg.diet_guard import _budget -from python_pkg.diet_guard._budget import ( +from diet_guard import _budget +from diet_guard._budget import ( Biometrics, BudgetLockedError, BudgetNotInitializedError, diff --git a/diet_guard/tests/test_cli.py b/diet_guard/tests/test_cli.py index c2fb9d3..3cbffeb 100644 --- a/diet_guard/tests/test_cli.py +++ b/diet_guard/tests/test_cli.py @@ -11,15 +11,15 @@ import io from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from python_pkg.diet_guard import _cli -from python_pkg.diet_guard._budget import ( +from diet_guard import _cli +from diet_guard._budget import ( BudgetLockedError, BudgetNotInitializedError, BudgetSealBrokenError, seal_budget, ) -from python_pkg.diet_guard._cli import _eaten_grams, _Portion, main -from python_pkg.diet_guard._estimator import Nutrition +from diet_guard._cli import _eaten_grams, _Portion, main +from diet_guard._estimator import Nutrition if TYPE_CHECKING: import pytest diff --git a/diet_guard/tests/test_estimator.py b/diet_guard/tests/test_estimator.py index 999da00..776ef28 100644 --- a/diet_guard/tests/test_estimator.py +++ b/diet_guard/tests/test_estimator.py @@ -10,9 +10,9 @@ from unittest.mock import MagicMock, patch import requests -from python_pkg.diet_guard import _estimator -from python_pkg.diet_guard._constants import DEFAULT_PORTION_GRAMS -from python_pkg.diet_guard._estimator import ( +from diet_guard import _estimator +from diet_guard._constants import DEFAULT_PORTION_GRAMS +from diet_guard._estimator import ( Nutrition, estimate, estimate_off, diff --git a/diet_guard/tests/test_foodbank.py b/diet_guard/tests/test_foodbank.py index ae13bd4..81f41b7 100644 --- a/diet_guard/tests/test_foodbank.py +++ b/diet_guard/tests/test_foodbank.py @@ -10,15 +10,15 @@ import json from pathlib import Path from unittest.mock import patch -from python_pkg.diet_guard import _foodbank -from python_pkg.diet_guard._estimator import Nutrition -from python_pkg.diet_guard._foodbank import ( +from diet_guard import _foodbank +from diet_guard._estimator import Nutrition +from diet_guard._foodbank import ( lookup_food, remember_food, remember_meal, search_foods, ) -from python_pkg.diet_guard._meal import MealItem +from diet_guard._meal import MealItem _NUT = Nutrition( kcal=250, diff --git a/diet_guard/tests/test_fuzzy.py b/diet_guard/tests/test_fuzzy.py index cc04b3a..11208c0 100644 --- a/diet_guard/tests/test_fuzzy.py +++ b/diet_guard/tests/test_fuzzy.py @@ -6,7 +6,7 @@ the degenerate empty-input branch that falls back to a whole-string ratio. 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: diff --git a/diet_guard/tests/test_gate.py b/diet_guard/tests/test_gate.py index 8cb6b95..f20eed5 100644 --- a/diet_guard/tests/test_gate.py +++ b/diet_guard/tests/test_gate.py @@ -9,7 +9,7 @@ from __future__ import annotations from datetime import datetime, timezone 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: @@ -20,7 +20,7 @@ def _at(hour: int) -> datetime: def _logged(slots: set[int]) -> object: """Patch the logged-slots source so the decision is deterministic.""" return patch( - "python_pkg.diet_guard._gate.logged_slots_today", + "diet_guard._gate.logged_slots_today", return_value=slots, ) @@ -38,7 +38,7 @@ class TestDueSlots: with ( _logged(set()), patch( - "python_pkg.diet_guard._gate.now_local", + "diet_guard._gate.now_local", return_value=_at(9), ), ): diff --git a/diet_guard/tests/test_gatelock.py b/diet_guard/tests/test_gatelock.py index 4ad7a0c..128daf5 100644 --- a/diet_guard/tests/test_gatelock.py +++ b/diet_guard/tests/test_gatelock.py @@ -15,24 +15,24 @@ from unittest.mock import MagicMock, patch import pytest -from python_pkg.diet_guard import ( +from diet_guard import ( _gatelock, _gatelock_support, _gatelock_ui, ) -from python_pkg.diet_guard._budget import seal_budget -from python_pkg.diet_guard._gatelock import ( +from diet_guard._budget import seal_budget +from diet_guard._gatelock import ( MealGate, _pending_slots, acquire_gate_lock, release_gate_lock, ) -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._portions import DEFAULT_ITEM_GRAMS -from python_pkg.diet_guard.tests.conftest import _FAKE_TK, _FakeTclError, _nutrition +from diet_guard._gatelock_core import _safe_float +from diet_guard._gatelock_nutrition import _format_preview +from diet_guard._gatelock_support import wait_for_display +from diet_guard._gatelock_ui import DEFAULT_PER_GRAMS +from diet_guard._portions import DEFAULT_ITEM_GRAMS +from diet_guard.tests.conftest import _FAKE_TK, _FakeTclError, _nutrition # -------------------------------------------------------------------------- # Module-level helpers diff --git a/diet_guard/tests/test_gatelock_mealflow.py b/diet_guard/tests/test_gatelock_mealflow.py index 3ff9173..a89c23c 100644 --- a/diet_guard/tests/test_gatelock_mealflow.py +++ b/diet_guard/tests/test_gatelock_mealflow.py @@ -12,14 +12,14 @@ 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 +from diet_guard import _gatelock_mealflow +from diet_guard._budget import seal_budget +from diet_guard._meal import MealItem +from diet_guard._state import log_meal +from diet_guard.tests.conftest import _nutrition if TYPE_CHECKING: - from python_pkg.diet_guard._gatelock import MealGate + from diet_guard._gatelock import MealGate class TestReferenceModel: diff --git a/diet_guard/tests/test_main.py b/diet_guard/tests/test_main.py index 984f684..d4293aa 100644 --- a/diet_guard/tests/test_main.py +++ b/diet_guard/tests/test_main.py @@ -10,12 +10,12 @@ import importlib def test_main_module_imports() -> None: - """The ``python -m python_pkg.diet_guard`` entry module imports cleanly.""" - module = importlib.import_module("python_pkg.diet_guard.__main__") + """The ``python -m diet_guard`` entry module imports cleanly.""" + module = importlib.import_module("diet_guard.__main__") assert hasattr(module, "main") def test_package_imports() -> None: """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 diff --git a/diet_guard/tests/test_meal.py b/diet_guard/tests/test_meal.py index b013102..dcc0fb6 100644 --- a/diet_guard/tests/test_meal.py +++ b/diet_guard/tests/test_meal.py @@ -2,8 +2,8 @@ from __future__ import annotations -from python_pkg.diet_guard._estimator import Nutrition -from python_pkg.diet_guard._meal import MEAL_SOURCE, MealItem, meal_total +from diet_guard._estimator import Nutrition +from diet_guard._meal import MEAL_SOURCE, MealItem, meal_total def _item( diff --git a/diet_guard/tests/test_portions.py b/diet_guard/tests/test_portions.py index ce195db..b9e6797 100644 --- a/diet_guard/tests/test_portions.py +++ b/diet_guard/tests/test_portions.py @@ -6,7 +6,7 @@ Nutrition / suggestion builders. from __future__ import annotations -from python_pkg.diet_guard._portions import ( +from diet_guard._portions import ( estimate_unit_grams, staple_nutrition, suggest_staples, diff --git a/diet_guard/tests/test_resolve.py b/diet_guard/tests/test_resolve.py index b54613b..95b6823 100644 --- a/diet_guard/tests/test_resolve.py +++ b/diet_guard/tests/test_resolve.py @@ -8,9 +8,9 @@ from __future__ import annotations from unittest.mock import patch -from python_pkg.diet_guard._estimator import Nutrition -from python_pkg.diet_guard._foodbank import remember_food -from python_pkg.diet_guard._resolve import ( +from diet_guard._estimator import Nutrition +from diet_guard._foodbank import remember_food +from diet_guard._resolve import ( ManualMacros, lookup_candidates, resolve_nutrition, @@ -82,7 +82,7 @@ class TestResolveBankAndStaple: def test_off_fallback(self) -> None: """An unknown, non-staple food falls through to Open Food Facts.""" with patch( - "python_pkg.diet_guard._resolve.estimate_off", + "diet_guard._resolve.estimate_off", return_value=_OFF, ): result = resolve_nutrition("exotic dish") @@ -117,7 +117,7 @@ class TestLookupCandidates: def test_off_candidates(self) -> None: """An unknown food returns the OFF alternatives, labelled by source.""" with patch( - "python_pkg.diet_guard._resolve.off_candidates", + "diet_guard._resolve.off_candidates", return_value=[_OFF], ): candidates = lookup_candidates("exotic dish") diff --git a/diet_guard/tests/test_slots.py b/diet_guard/tests/test_slots.py index 4baf5f3..dc32986 100644 --- a/diet_guard/tests/test_slots.py +++ b/diet_guard/tests/test_slots.py @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import datetime, timezone -from python_pkg.diet_guard._slots import ( +from diet_guard._slots import ( current_slot, day_slots, elapsed_slots, diff --git a/diet_guard/tests/test_state.py b/diet_guard/tests/test_state.py index 8773aab..51c9d9c 100644 --- a/diet_guard/tests/test_state.py +++ b/diet_guard/tests/test_state.py @@ -12,10 +12,10 @@ from unittest.mock import patch import pytest -from python_pkg.diet_guard import _state -from python_pkg.diet_guard._budget import BudgetNotInitializedError, seal_budget -from python_pkg.diet_guard._estimator import Nutrition -from python_pkg.diet_guard._state import ( +from diet_guard import _state +from diet_guard._budget import BudgetNotInitializedError, seal_budget +from diet_guard._estimator import Nutrition +from diet_guard._state import ( consumption_band, entry_kcal, load_log, diff --git a/diet_guard/docs/design.md b/docs/design.md similarity index 100% rename from diet_guard/docs/design.md rename to docs/design.md diff --git a/diet_guard/install.sh b/install.sh similarity index 59% rename from diet_guard/install.sh rename to install.sh index f98ca42..d02661e 100755 --- a/diet_guard/install.sh +++ b/install.sh @@ -6,9 +6,13 @@ # # What it does: # 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 -# 3. Seals your daily budget from biometrics (only if not already sealed) -# 4. Locks the budget file immutable with `chattr +i` (the real tamper gate) +# 2. pip-installs this package + gatelock into system Python's user +# site-packages (the systemd service runs /usr/bin/python directly, not +# 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 @@ -16,9 +20,7 @@ set -euo pipefail # Split declare/assign so the command-substitution exit code is not masked (SC2155). SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" readonly SCRIPT_DIR -# python_pkg/diet_guard -> repo root (two levels up). -REPO_DIR="$(readlink -f "$SCRIPT_DIR/../..")" -readonly REPO_DIR +readonly REPO_DIR="$SCRIPT_DIR" readonly SERVICE_SRC="$SCRIPT_DIR/diet-guard-gate.service" readonly TIMER_SRC="$SCRIPT_DIR/diet-guard-gate.timer" readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user" @@ -28,22 +30,23 @@ readonly BUDGET_FILE="$DATA_DIR/.budget" echo "=== Diet Guard Installer ===" # 1. System dependencies ------------------------------------------------------ -echo "[1/4] Checking system dependencies..." +echo "[1/5] Checking system dependencies..." if ! command -v setxkbmap &>/dev/null; then echo " Installing xorg-setxkbmap (gate disables VT switching while locked)..." sudo pacman -S --noconfirm xorg-setxkbmap else echo " setxkbmap present" 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 -------------------------------------------- -echo "[2/4] Installing systemd user timer + service..." +# 2. Install this package + its dependencies into system Python ------------- +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" cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.service" 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 echo " Timer enabled and started (fires the gate every ~30 min)." -# 3. Seal the daily budget (hidden) ------------------------------------------ -echo "[3/4] Sealing your daily budget..." +# 4. Seal the daily budget (hidden) ------------------------------------------ +echo "[4/5] Sealing your daily budget..." if [[ -e "$BUDGET_FILE" ]]; then echo " Budget already sealed at $BUDGET_FILE - skipping init." else 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 -# 4. Lock the budget immutable (the real tamper friction) -------------------- -echo "[4/4] Locking the budget file (chattr +i)..." +# 5. Lock the budget immutable (the real tamper friction) -------------------- +echo "[5/5] Locking the budget file (chattr +i)..." read -r attrs _ <<<"$(lsattr -d "$BUDGET_FILE" 2>/dev/null || true)" if [[ "$attrs" == *i* ]]; then echo " Already immutable." @@ -73,5 +76,4 @@ fi echo "=== Installation complete ===" 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 "Test the lock now (safe, closeable): \ -cd $REPO_DIR && python -m python_pkg.diet_guard gate --demo" +echo "Test the lock now (safe, closeable): python -m diet_guard gate --demo" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a184c8b --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fd516f7 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/check_file_length.py b/scripts/check_file_length.py new file mode 100755 index 0000000..d30d747 --- /dev/null +++ b/scripts/check_file_length.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Pre-commit hook: fail if any file exceeds MAX_LINES lines.""" + +from pathlib import Path +import sys + +MAX_LINES = 500 + + +def main() -> int: + """Return 1 if any file exceeds the line limit, else 0.""" + failed = False + for filepath in sys.argv[1:]: + try: + with Path(filepath).open(encoding="utf-8", errors="replace") as fh: + count = sum(1 for _ in fh) + except OSError: + failed = True + continue + if count > MAX_LINES: + failed = True + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())