mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 12:03:08 +02:00
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:
parent
2de8f5d122
commit
843f5e0221
19
.github/workflows/pre-commit.yml
vendored
Normal file
19
.github/workflows/pre-commit.yml
vendored
Normal 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
28
.github/workflows/python-tests.yml
vendored
Normal 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
16
.gitignore
vendored
Normal 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
142
.pre-commit-config.yaml
Normal 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
81
CLAUDE.md
Normal 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
37
README.md
Normal 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.
|
||||
@ -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
|
||||
@ -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())
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
23
diet_guard/_coerce.py
Normal file
23
diet_guard/_coerce.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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),
|
||||
),
|
||||
):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
172
pyproject.toml
Normal file
172
pyproject.toml
Normal 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
16
requirements.txt
Normal 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
26
scripts/check_file_length.py
Executable 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())
|
||||
Loading…
Reference in New Issue
Block a user