mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 13:43:30 +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]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
# DISPLAY/PYTHONPATH mirror wake-alarm.service: the gate opens a Tk window when a
|
# DISPLAY mirrors wake-alarm.service: the gate opens a Tk window when a lock
|
||||||
# lock is due, so without DISPLAY it would crash with "no display name and no
|
# is due, so without DISPLAY it would crash with "no display name and no
|
||||||
# $DISPLAY" before it could even check. The command self-checks gate_is_due() and
|
# $DISPLAY" before it could even check. The command self-checks gate_is_due()
|
||||||
# exits 0 when no lock is needed, so running it every ~30 min is cheap.
|
# and exits 0 when no lock is needed, so running it every ~30 min is cheap.
|
||||||
|
# No PYTHONPATH needed: diet_guard is pip-installed (see install.sh / README),
|
||||||
|
# so /usr/bin/python finds it via user site-packages.
|
||||||
#
|
#
|
||||||
# XAUTHORITY pins the X auth cookie path explicitly. It is belt-and-suspenders,
|
# XAUTHORITY pins the X auth cookie path explicitly. It is belt-and-suspenders,
|
||||||
# not the fix: when this unit fires at SESSION START (Persistent=true catch-up),
|
# not the fix: when this unit fires at SESSION START (Persistent=true catch-up),
|
||||||
@ -19,7 +21,5 @@ Type=oneshot
|
|||||||
# brief head start; the Python wait is what makes it reliable.
|
# brief head start; the Python wait is what makes it reliable.
|
||||||
Environment=DISPLAY=:0
|
Environment=DISPLAY=:0
|
||||||
Environment=XAUTHORITY=%h/.Xauthority
|
Environment=XAUTHORITY=%h/.Xauthority
|
||||||
Environment=PYTHONPATH=%h/testsAndMisc
|
|
||||||
ExecStartPre=/bin/sleep 1
|
ExecStartPre=/bin/sleep 1
|
||||||
ExecStart=/usr/bin/python -m python_pkg.diet_guard gate
|
ExecStart=/usr/bin/python -m diet_guard gate
|
||||||
WorkingDirectory=%h/testsAndMisc
|
|
||||||
@ -1,10 +1,10 @@
|
|||||||
"""Module entry point: ``python -m python_pkg.diet_guard``."""
|
"""Module entry point: ``python -m diet_guard``."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from python_pkg.diet_guard._cli import main
|
from diet_guard._cli import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import logging
|
|||||||
|
|
||||||
from gatelock.log_integrity import compute_entry_hmac
|
from gatelock.log_integrity import compute_entry_hmac
|
||||||
|
|
||||||
from python_pkg.diet_guard._constants import BUDGET_FILE
|
from diet_guard._constants import BUDGET_FILE
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
"""Command-line interface for diet_guard.
|
"""Command-line interface for diet_guard.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
python -m python_pkg.diet_guard init
|
python -m diet_guard init
|
||||||
python -m python_pkg.diet_guard ate "big mac"
|
python -m diet_guard ate "big mac"
|
||||||
python -m python_pkg.diet_guard ate "two slices of pizza" --grams 240
|
python -m diet_guard ate "two slices of pizza" --grams 240
|
||||||
python -m python_pkg.diet_guard ate "protein shake" --kcal 180
|
python -m diet_guard ate "protein shake" --kcal 180
|
||||||
python -m python_pkg.diet_guard status
|
python -m diet_guard status
|
||||||
python -m python_pkg.diet_guard undo
|
python -m diet_guard undo
|
||||||
|
|
||||||
The daily budget lives outside the repo (so it is never exposed online) but is
|
The daily budget lives outside the repo (so it is never exposed online) but is
|
||||||
shown freely on this machine: ``status`` and each log print how many calories
|
shown freely on this machine: ``status`` and each log print how many calories
|
||||||
@ -19,7 +19,7 @@ import argparse
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from python_pkg.diet_guard._budget import (
|
from diet_guard._budget import (
|
||||||
Biometrics,
|
Biometrics,
|
||||||
BudgetLockedError,
|
BudgetLockedError,
|
||||||
BudgetNotInitializedError,
|
BudgetNotInitializedError,
|
||||||
@ -31,21 +31,21 @@ from python_pkg.diet_guard._budget import (
|
|||||||
seal_budget,
|
seal_budget,
|
||||||
unlock_command,
|
unlock_command,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._foodbank import remember_food
|
from diet_guard._foodbank import remember_food
|
||||||
from python_pkg.diet_guard._gate import due_slots, gate_is_due
|
from diet_guard._gate import due_slots, gate_is_due
|
||||||
from python_pkg.diet_guard._gatelock import (
|
from diet_guard._gatelock import (
|
||||||
MealGate,
|
MealGate,
|
||||||
acquire_gate_lock,
|
acquire_gate_lock,
|
||||||
release_gate_lock,
|
release_gate_lock,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._gatelock_support import wait_for_display
|
from diet_guard._gatelock_support import wait_for_display
|
||||||
from python_pkg.diet_guard._portions import (
|
from diet_guard._portions import (
|
||||||
DEFAULT_ITEM_GRAMS,
|
DEFAULT_ITEM_GRAMS,
|
||||||
estimate_unit_grams,
|
estimate_unit_grams,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._resolve import ManualMacros, resolve_nutrition
|
from diet_guard._resolve import ManualMacros, resolve_nutrition
|
||||||
from python_pkg.diet_guard._slots import current_slot, day_slots, slot_label
|
from diet_guard._slots import current_slot, day_slots, slot_label
|
||||||
from python_pkg.diet_guard._state import (
|
from diet_guard._state import (
|
||||||
entry_kcal,
|
entry_kcal,
|
||||||
log_meal,
|
log_meal,
|
||||||
logged_slots_today,
|
logged_slots_today,
|
||||||
@ -210,8 +210,7 @@ def _print_summary() -> None:
|
|||||||
budget = daily_budget()
|
budget = daily_budget()
|
||||||
except BudgetNotInitializedError:
|
except BudgetNotInitializedError:
|
||||||
_emit(
|
_emit(
|
||||||
f"today: {total:g} kcal "
|
f"today: {total:g} kcal (budget not set - run: python -m diet_guard init)",
|
||||||
"(budget not set - run: python -m python_pkg.diet_guard init)",
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except BudgetSealBrokenError:
|
except BudgetSealBrokenError:
|
||||||
|
|||||||
23
diet_guard/_coerce.py
Normal file
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
|
# phone_focus_mode (which live only in the git-ignored config_secrets.sh on the
|
||||||
# device, never in committed source), the real budget is computed once from
|
# device, never in committed source), the real budget is computed once from
|
||||||
# biometrics at ``init`` time and sealed into BUDGET_FILE below. It is read via
|
# biometrics at ``init`` time and sealed into BUDGET_FILE below. It is read via
|
||||||
# python_pkg.diet_guard._budget.daily_budget() for over/under decisions only and
|
# diet_guard._budget.daily_budget() for over/under decisions only and
|
||||||
# is never printed -- see _budget.py for the full threat model.
|
# is never printed -- see _budget.py for the full threat model.
|
||||||
#
|
#
|
||||||
# Fraction of the budget at which status flips from "on track" to "approaching
|
# Fraction of the budget at which status flips from "on track" to "approaching
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import logging
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from python_pkg.diet_guard._constants import (
|
from diet_guard._constants import (
|
||||||
DEFAULT_PORTION_GRAMS,
|
DEFAULT_PORTION_GRAMS,
|
||||||
OFF_PAGE_SIZE,
|
OFF_PAGE_SIZE,
|
||||||
OFF_SEARCH_URL,
|
OFF_SEARCH_URL,
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Every food the user logs is remembered here with its full macros, keyed by a
|
Every food the user logs is remembered here with its full macros, keyed by a
|
||||||
normalized name. The gate's autocomplete searches *only* this corpus -- never
|
normalized name. The gate's autocomplete searches *only* this corpus -- never
|
||||||
Open Food Facts. OFF (in :mod:`python_pkg.diet_guard._estimator`) is used only
|
Open Food Facts. OFF (in :mod:`diet_guard._estimator`) is used only
|
||||||
to *fill in* the macros of a brand-new food the first time it is entered; from
|
to *fill in* the macros of a brand-new food the first time it is entered; from
|
||||||
then on the food is served from the bank, so search quality improves with use
|
then on the food is served from the bank, so search quality improves with use
|
||||||
and works fully offline.
|
and works fully offline.
|
||||||
@ -21,11 +21,11 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from python_pkg.diet_guard._constants import FOOD_BANK_FILE
|
from diet_guard._coerce import as_float
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._constants import FOOD_BANK_FILE
|
||||||
from python_pkg.diet_guard._fuzzy import match_score
|
from diet_guard._estimator import Nutrition
|
||||||
from python_pkg.diet_guard._meal import MealItem, meal_total
|
from diet_guard._fuzzy import match_score
|
||||||
from python_pkg.shared.coerce import as_float
|
from diet_guard._meal import MealItem, meal_total
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
This module is GUI-free and side-effect-free so the lock/no-lock decision can
|
This module is GUI-free and side-effect-free so the lock/no-lock decision can
|
||||||
be verified headlessly: the fullscreen window in ``_gatelock.py`` is only a
|
be verified headlessly: the fullscreen window in ``_gatelock.py`` is only a
|
||||||
thin shell around :func:`gate_is_due` and :func:`due_slots`. It composes the
|
thin shell around :func:`gate_is_due` and :func:`due_slots`. It composes the
|
||||||
pure slot arithmetic in :mod:`python_pkg.diet_guard._slots` with the logged-slot
|
pure slot arithmetic in :mod:`diet_guard._slots` with the logged-slot
|
||||||
state in :mod:`python_pkg.diet_guard._state`; ``now`` is injectable so the
|
state in :mod:`diet_guard._state`; ``now`` is injectable so the
|
||||||
time-of-day rules stay deterministically testable.
|
time-of-day rules stay deterministically testable.
|
||||||
|
|
||||||
The gate fires when any *elapsed* meal slot for today carries no logged meal.
|
The gate fires when any *elapsed* meal slot for today carries no logged meal.
|
||||||
@ -17,8 +17,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from python_pkg.diet_guard._slots import missing_slots, slot_label
|
from diet_guard._slots import missing_slots, slot_label
|
||||||
from python_pkg.diet_guard._state import logged_slots_today, now_local
|
from diet_guard._state import logged_slots_today, now_local
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|||||||
@ -47,18 +47,18 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from gatelock import GateRoot, LockConfig, LockWindow
|
from gatelock import GateRoot, LockConfig, LockWindow
|
||||||
|
|
||||||
from python_pkg.diet_guard._constants import GATE_LOCK_FILE
|
from diet_guard._constants import GATE_LOCK_FILE
|
||||||
from python_pkg.diet_guard._gate import due_slots
|
from diet_guard._gate import due_slots
|
||||||
from python_pkg.diet_guard._gatelock_core import _GateState
|
from diet_guard._gatelock_core import _GateState
|
||||||
from python_pkg.diet_guard._gatelock_mealflow import _GateMealFlow
|
from diet_guard._gatelock_mealflow import _GateMealFlow
|
||||||
from python_pkg.diet_guard._gatelock_ui import (
|
from diet_guard._gatelock_ui import (
|
||||||
BG,
|
BG,
|
||||||
GateCallbacks,
|
GateCallbacks,
|
||||||
build_layout,
|
build_layout,
|
||||||
make_vars,
|
make_vars,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._slots import current_slot, day_slots
|
from diet_guard._slots import current_slot, day_slots
|
||||||
from python_pkg.diet_guard._state import now_local
|
from diet_guard._state import now_local
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing import TextIO
|
from typing import TextIO
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import logging
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from python_pkg.diet_guard._gatelock_ui import (
|
from diet_guard._gatelock_ui import (
|
||||||
BASIS_PREFIX_GRAMS,
|
BASIS_PREFIX_GRAMS,
|
||||||
BASIS_PREFIX_ITEMS,
|
BASIS_PREFIX_ITEMS,
|
||||||
DEFAULT_PER_GRAMS,
|
DEFAULT_PER_GRAMS,
|
||||||
@ -23,16 +23,16 @@ from python_pkg.diet_guard._gatelock_ui import (
|
|||||||
GateVars,
|
GateVars,
|
||||||
GateWidgets,
|
GateWidgets,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
|
from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
|
||||||
from python_pkg.diet_guard._slots import slot_label
|
from diet_guard._slots import slot_label
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from gatelock import GateRoot
|
from gatelock import GateRoot
|
||||||
|
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
from python_pkg.diet_guard._meal import MealItem
|
from diet_guard._meal import MealItem
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -75,7 +75,7 @@ class _GateCore:
|
|||||||
"""Leaf widget/field helpers shared by every MealGate mixin.
|
"""Leaf widget/field helpers shared by every MealGate mixin.
|
||||||
|
|
||||||
Declares the attributes that
|
Declares the attributes that
|
||||||
:class:`~python_pkg.diet_guard._gatelock.MealGate` sets up in ``__init__``
|
:class:`~diet_guard._gatelock.MealGate` sets up in ``__init__``
|
||||||
and ``_build`` so subclasses can reference them without tripping pylint's
|
and ``_build`` so subclasses can reference them without tripping pylint's
|
||||||
no-member check.
|
no-member check.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
|
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
|
||||||
limit. ``_GateMealFlow`` extends
|
limit. ``_GateMealFlow`` extends
|
||||||
:class:`~python_pkg.diet_guard._gatelock_nutrition._GateNutrition` with the
|
:class:`~diet_guard._gatelock_nutrition._GateNutrition` with the
|
||||||
submit/lookup/log flow for single foods and multi-item meals, the per-slot
|
submit/lookup/log flow for single foods and multi-item meals, the per-slot
|
||||||
input reset, and the running calorie/macro dashboard.
|
input reset, and the running calorie/macro dashboard.
|
||||||
"""
|
"""
|
||||||
@ -13,14 +13,14 @@ import contextlib
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from python_pkg.diet_guard._budget import BudgetError, daily_budget, protein_target_g
|
from diet_guard._budget import BudgetError, daily_budget, protein_target_g
|
||||||
from python_pkg.diet_guard._foodbank import remember_food, remember_meal
|
from diet_guard._foodbank import remember_food, remember_meal
|
||||||
from python_pkg.diet_guard._gatelock_nutrition import _GateNutrition
|
from diet_guard._gatelock_nutrition import _GateNutrition
|
||||||
from python_pkg.diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS
|
from diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS
|
||||||
from python_pkg.diet_guard._meal import MealItem, meal_total
|
from diet_guard._meal import MealItem, meal_total
|
||||||
from python_pkg.diet_guard._resolve import lookup_candidates
|
from diet_guard._resolve import lookup_candidates
|
||||||
from python_pkg.diet_guard._slots import slot_label
|
from diet_guard._slots import slot_label
|
||||||
from python_pkg.diet_guard._state import (
|
from diet_guard._state import (
|
||||||
entry_kcal,
|
entry_kcal,
|
||||||
log_meal,
|
log_meal,
|
||||||
today_entries,
|
today_entries,
|
||||||
@ -29,7 +29,7 @@ from python_pkg.diet_guard._state import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
|
|
||||||
# How long the "unlocking..." confirmation lingers before the window tears down.
|
# How long the "unlocking..." confirmation lingers before the window tears down.
|
||||||
_UNLOCK_DELAY_MS = 1200
|
_UNLOCK_DELAY_MS = 1200
|
||||||
@ -214,7 +214,7 @@ class _GateMealFlow(_GateNutrition):
|
|||||||
"""Log the accumulated multi-item meal for the current slot and advance.
|
"""Log the accumulated multi-item meal for the current slot and advance.
|
||||||
|
|
||||||
Each component and the summed composite are banked (see
|
Each component and the summed composite are banked (see
|
||||||
:func:`python_pkg.diet_guard._foodbank.remember_meal`), and the slot is
|
:func:`diet_guard._foodbank.remember_meal`), and the slot is
|
||||||
satisfied by the summed total under the meal's name.
|
satisfied by the summed total under the meal's name.
|
||||||
"""
|
"""
|
||||||
name = self._meal_name() or _DEFAULT_MEAL_NAME
|
name = self._meal_name() or _DEFAULT_MEAL_NAME
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
|
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
|
||||||
limit. ``_GateNutrition`` extends
|
limit. ``_GateNutrition`` extends
|
||||||
:class:`~python_pkg.diet_guard._gatelock_core._GateCore` with the
|
:class:`~diet_guard._gatelock_core._GateCore` with the
|
||||||
"reference -> total" nutrition maths -- the label macros describe one basis
|
"reference -> total" nutrition maths -- the label macros describe one basis
|
||||||
(per 100 g or per item), and how much was eaten scales that reference into
|
(per 100 g or per item), and how much was eaten scales that reference into
|
||||||
what gets logged -- plus the live preview/projection and the
|
what gets logged -- plus the live preview/projection and the
|
||||||
@ -14,17 +14,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
||||||
from python_pkg.diet_guard._budget import BudgetError, daily_budget
|
from diet_guard._budget import BudgetError, daily_budget
|
||||||
from python_pkg.diet_guard._estimator import Nutrition, scale_nutrition
|
from diet_guard._estimator import Nutrition, scale_nutrition
|
||||||
from python_pkg.diet_guard._gatelock_core import _GateCore
|
from diet_guard._gatelock_core import _GateCore
|
||||||
from python_pkg.diet_guard._gatelock_ui import (
|
from diet_guard._gatelock_ui import (
|
||||||
DEFAULT_PER_GRAMS,
|
DEFAULT_PER_GRAMS,
|
||||||
SUGGESTION_ROWS,
|
SUGGESTION_ROWS,
|
||||||
UNIT_ITEMS,
|
UNIT_ITEMS,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
|
from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
|
||||||
from python_pkg.diet_guard._resolve import suggest_foods
|
from diet_guard._resolve import suggest_foods
|
||||||
from python_pkg.diet_guard._state import today_total_kcal
|
from diet_guard._state import today_total_kcal
|
||||||
|
|
||||||
|
|
||||||
def _format_preview(nutrition: Nutrition) -> str:
|
def _format_preview(nutrition: Nutrition) -> str:
|
||||||
|
|||||||
@ -4,7 +4,7 @@ A meal is a named group of individually-macroed items -- e.g. a dinner of
|
|||||||
salad + chicken + rice, each entered with its own calories and macros. The
|
salad + chicken + rice, each entered with its own calories and macros. The
|
||||||
meal's nutrition is the sum of its items. Both the individual items and the
|
meal's nutrition is the sum of its items. Both the individual items and the
|
||||||
composite meal are saved to the food bank (see
|
composite meal are saved to the food bank (see
|
||||||
:func:`python_pkg.diet_guard._foodbank.remember_meal`), so next time each item
|
:func:`diet_guard._foodbank.remember_meal`), so next time each item
|
||||||
autocompletes on its own and the whole meal can be picked as one summed entry.
|
autocompletes on its own and the whole meal can be picked as one summed entry.
|
||||||
|
|
||||||
This module is deliberately pure (no I/O): the sum is a total function of its
|
This module is deliberately pure (no I/O): the sum is a total function of its
|
||||||
@ -17,7 +17,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
@ -52,7 +52,7 @@ def meal_total(items: Sequence[MealItem]) -> Nutrition:
|
|||||||
items: The meal's components.
|
items: The meal's components.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A :class:`~python_pkg.diet_guard._estimator.Nutrition` whose fields are
|
A :class:`~diet_guard._estimator.Nutrition` whose fields are
|
||||||
the per-item sums.
|
the per-item sums.
|
||||||
"""
|
"""
|
||||||
return Nutrition(
|
return Nutrition(
|
||||||
|
|||||||
@ -10,7 +10,7 @@ Two problems this solves, both seen in real use:
|
|||||||
|
|
||||||
So this module gives diet_guard, for each common countable food, the typical
|
So this module gives diet_guard, for each common countable food, the typical
|
||||||
mass of one piece and its macros per 100 g. It is consulted *before* Open Food
|
mass of one piece and its macros per 100 g. It is consulted *before* Open Food
|
||||||
Facts (see :mod:`python_pkg.diet_guard._resolve`), so a bare staple resolves
|
Facts (see :mod:`diet_guard._resolve`), so a bare staple resolves
|
||||||
locally and sensibly, and a count multiplies cleanly into grams.
|
locally and sensibly, and a count multiplies cleanly into grams.
|
||||||
|
|
||||||
The numbers are deliberately round "good enough" averages (USDA ballpark); the
|
The numbers are deliberately round "good enough" averages (USDA ballpark); the
|
||||||
@ -22,8 +22,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
from python_pkg.diet_guard._fuzzy import match_score
|
from diet_guard._fuzzy import match_score
|
||||||
|
|
||||||
# Same close-match bar the food bank uses, so matching feels consistent.
|
# Same close-match bar the food bank uses, so matching feels consistent.
|
||||||
_MATCH_THRESHOLD = 0.6
|
_MATCH_THRESHOLD = 0.6
|
||||||
@ -130,7 +130,7 @@ def staple_nutrition(description: str) -> Nutrition | None:
|
|||||||
|
|
||||||
The grams are fixed at 100 so the result is a clean reference basis the
|
The grams are fixed at 100 so the result is a clean reference basis the
|
||||||
caller can rescale to the actual amount eaten via
|
caller can rescale to the actual amount eaten via
|
||||||
:func:`python_pkg.diet_guard._estimator.scale_nutrition`.
|
:func:`diet_guard._estimator.scale_nutrition`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
description: Free-text food name.
|
description: Free-text food name.
|
||||||
|
|||||||
@ -19,15 +19,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from python_pkg.diet_guard._estimator import (
|
from diet_guard._estimator import (
|
||||||
Nutrition,
|
Nutrition,
|
||||||
estimate_off,
|
estimate_off,
|
||||||
manual,
|
manual,
|
||||||
off_candidates,
|
off_candidates,
|
||||||
scale_nutrition,
|
scale_nutrition,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._foodbank import lookup_food, search_foods
|
from diet_guard._foodbank import lookup_food, search_foods
|
||||||
from python_pkg.diet_guard._portions import staple_nutrition, suggest_staples
|
from diet_guard._portions import staple_nutrition, suggest_staples
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@ -5,8 +5,8 @@ function of its ``now`` argument and the configured slot constants, so the
|
|||||||
fiddly time-of-day edges (07:59 vs 08:00, the 20:00->22:00 tail, the midnight
|
fiddly time-of-day edges (07:59 vs 08:00, the 20:00->22:00 tail, the midnight
|
||||||
reset) are exhaustively unit-testable without mocking the filesystem or the
|
reset) are exhaustively unit-testable without mocking the filesystem or the
|
||||||
wall clock. The stateful "which slots have I actually logged?" question lives
|
wall clock. The stateful "which slots have I actually logged?" question lives
|
||||||
in :mod:`python_pkg.diet_guard._state`; the two are composed in
|
in :mod:`diet_guard._state`; the two are composed in
|
||||||
:mod:`python_pkg.diet_guard._gate`.
|
:mod:`diet_guard._gate`.
|
||||||
|
|
||||||
A "slot" is simply the integer hour at which a meal checkpoint opens (08, 12,
|
A "slot" is simply the integer hour at which a meal checkpoint opens (08, 12,
|
||||||
16, 20). A slot is *elapsed* once its hour has arrived and we are still inside
|
16, 20). A slot is *elapsed* once its hour has arrived and we are still inside
|
||||||
@ -18,7 +18,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from python_pkg.diet_guard._constants import (
|
from diet_guard._constants import (
|
||||||
GATE_DAY_START_HOUR,
|
GATE_DAY_START_HOUR,
|
||||||
GATE_EATING_END_HOUR,
|
GATE_EATING_END_HOUR,
|
||||||
GATE_SLOT_INTERVAL_HOURS,
|
GATE_SLOT_INTERVAL_HOURS,
|
||||||
|
|||||||
@ -20,12 +20,12 @@ from gatelock.log_integrity import (
|
|||||||
verify_entry_hmac,
|
verify_entry_hmac,
|
||||||
)
|
)
|
||||||
|
|
||||||
from python_pkg.diet_guard._budget import daily_budget
|
from diet_guard._budget import daily_budget
|
||||||
from python_pkg.diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE
|
from diet_guard._coerce import as_float
|
||||||
from python_pkg.shared.coerce import as_float
|
from diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ Three safety nets run for every test:
|
|||||||
``setxkbmap`` against the live X session.
|
``setxkbmap`` against the live X session.
|
||||||
|
|
||||||
The ``gate`` fixture and its supporting fakes (``FakeEntry``, ``_FAKE_TK``, ...)
|
The ``gate`` fixture and its supporting fakes (``FakeEntry``, ``_FAKE_TK``, ...)
|
||||||
build a demo :class:`~python_pkg.diet_guard._gatelock.MealGate` whose widgets
|
build a demo :class:`~diet_guard._gatelock.MealGate` whose widgets
|
||||||
are functional in-memory stand-ins, shared by ``test_gatelock.py`` and
|
are functional in-memory stand-ins, shared by ``test_gatelock.py`` and
|
||||||
``test_gatelock_mealflow.py``.
|
``test_gatelock_mealflow.py``.
|
||||||
"""
|
"""
|
||||||
@ -26,15 +26,15 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from python_pkg.diet_guard import (
|
from diet_guard import (
|
||||||
_gatelock,
|
_gatelock,
|
||||||
_gatelock_core,
|
_gatelock_core,
|
||||||
_gatelock_mealflow,
|
_gatelock_mealflow,
|
||||||
_gatelock_nutrition,
|
_gatelock_nutrition,
|
||||||
_gatelock_ui,
|
_gatelock_ui,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
from python_pkg.diet_guard._gatelock import MealGate
|
from diet_guard._gatelock import MealGate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
@ -46,19 +46,19 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]:
|
|||||||
"""Redirect all on-disk diet_guard state into a temp dir."""
|
"""Redirect all on-disk diet_guard state into a temp dir."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.diet_guard._budget.BUDGET_FILE",
|
"diet_guard._budget.BUDGET_FILE",
|
||||||
tmp_path / ".budget",
|
tmp_path / ".budget",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.diet_guard._state.FOOD_LOG_FILE",
|
"diet_guard._state.FOOD_LOG_FILE",
|
||||||
tmp_path / "food_log.json",
|
tmp_path / "food_log.json",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.diet_guard._foodbank.FOOD_BANK_FILE",
|
"diet_guard._foodbank.FOOD_BANK_FILE",
|
||||||
tmp_path / "food_bank.json",
|
tmp_path / "food_bank.json",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.diet_guard._gatelock.GATE_LOCK_FILE",
|
"diet_guard._gatelock.GATE_LOCK_FILE",
|
||||||
tmp_path / ".gate.lock",
|
tmp_path / ".gate.lock",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -69,8 +69,8 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]:
|
|||||||
def _block_real_tk() -> Iterator[None]:
|
def _block_real_tk() -> Iterator[None]:
|
||||||
"""Replace tk + the window class in _gatelock so no real window can open."""
|
"""Replace tk + the window class in _gatelock so no real window can open."""
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.diet_guard._gatelock.tk", MagicMock()),
|
patch("diet_guard._gatelock.tk", MagicMock()),
|
||||||
patch("python_pkg.diet_guard._gatelock.GateRoot", MagicMock()),
|
patch("diet_guard._gatelock.GateRoot", MagicMock()),
|
||||||
):
|
):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|||||||
@ -10,8 +10,8 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from python_pkg.diet_guard import _budget
|
from diet_guard import _budget
|
||||||
from python_pkg.diet_guard._budget import (
|
from diet_guard._budget import (
|
||||||
Biometrics,
|
Biometrics,
|
||||||
BudgetLockedError,
|
BudgetLockedError,
|
||||||
BudgetNotInitializedError,
|
BudgetNotInitializedError,
|
||||||
|
|||||||
@ -11,15 +11,15 @@ import io
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from python_pkg.diet_guard import _cli
|
from diet_guard import _cli
|
||||||
from python_pkg.diet_guard._budget import (
|
from diet_guard._budget import (
|
||||||
BudgetLockedError,
|
BudgetLockedError,
|
||||||
BudgetNotInitializedError,
|
BudgetNotInitializedError,
|
||||||
BudgetSealBrokenError,
|
BudgetSealBrokenError,
|
||||||
seal_budget,
|
seal_budget,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._cli import _eaten_grams, _Portion, main
|
from diet_guard._cli import _eaten_grams, _Portion, main
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
@ -10,9 +10,9 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from python_pkg.diet_guard import _estimator
|
from diet_guard import _estimator
|
||||||
from python_pkg.diet_guard._constants import DEFAULT_PORTION_GRAMS
|
from diet_guard._constants import DEFAULT_PORTION_GRAMS
|
||||||
from python_pkg.diet_guard._estimator import (
|
from diet_guard._estimator import (
|
||||||
Nutrition,
|
Nutrition,
|
||||||
estimate,
|
estimate,
|
||||||
estimate_off,
|
estimate_off,
|
||||||
|
|||||||
@ -10,15 +10,15 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from python_pkg.diet_guard import _foodbank
|
from diet_guard import _foodbank
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
from python_pkg.diet_guard._foodbank import (
|
from diet_guard._foodbank import (
|
||||||
lookup_food,
|
lookup_food,
|
||||||
remember_food,
|
remember_food,
|
||||||
remember_meal,
|
remember_meal,
|
||||||
search_foods,
|
search_foods,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._meal import MealItem
|
from diet_guard._meal import MealItem
|
||||||
|
|
||||||
_NUT = Nutrition(
|
_NUT = Nutrition(
|
||||||
kcal=250,
|
kcal=250,
|
||||||
|
|||||||
@ -6,7 +6,7 @@ the degenerate empty-input branch that falls back to a whole-string ratio.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from python_pkg.diet_guard._fuzzy import match_score, token_score
|
from diet_guard._fuzzy import match_score, token_score
|
||||||
|
|
||||||
|
|
||||||
class TestTokenScore:
|
class TestTokenScore:
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from python_pkg.diet_guard._gate import due_slots, gate_is_due, gate_message
|
from diet_guard._gate import due_slots, gate_is_due, gate_message
|
||||||
|
|
||||||
|
|
||||||
def _at(hour: int) -> datetime:
|
def _at(hour: int) -> datetime:
|
||||||
@ -20,7 +20,7 @@ def _at(hour: int) -> datetime:
|
|||||||
def _logged(slots: set[int]) -> object:
|
def _logged(slots: set[int]) -> object:
|
||||||
"""Patch the logged-slots source so the decision is deterministic."""
|
"""Patch the logged-slots source so the decision is deterministic."""
|
||||||
return patch(
|
return patch(
|
||||||
"python_pkg.diet_guard._gate.logged_slots_today",
|
"diet_guard._gate.logged_slots_today",
|
||||||
return_value=slots,
|
return_value=slots,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ class TestDueSlots:
|
|||||||
with (
|
with (
|
||||||
_logged(set()),
|
_logged(set()),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.diet_guard._gate.now_local",
|
"diet_guard._gate.now_local",
|
||||||
return_value=_at(9),
|
return_value=_at(9),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
|
|||||||
@ -15,24 +15,24 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from python_pkg.diet_guard import (
|
from diet_guard import (
|
||||||
_gatelock,
|
_gatelock,
|
||||||
_gatelock_support,
|
_gatelock_support,
|
||||||
_gatelock_ui,
|
_gatelock_ui,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._budget import seal_budget
|
from diet_guard._budget import seal_budget
|
||||||
from python_pkg.diet_guard._gatelock import (
|
from diet_guard._gatelock import (
|
||||||
MealGate,
|
MealGate,
|
||||||
_pending_slots,
|
_pending_slots,
|
||||||
acquire_gate_lock,
|
acquire_gate_lock,
|
||||||
release_gate_lock,
|
release_gate_lock,
|
||||||
)
|
)
|
||||||
from python_pkg.diet_guard._gatelock_core import _safe_float
|
from diet_guard._gatelock_core import _safe_float
|
||||||
from python_pkg.diet_guard._gatelock_nutrition import _format_preview
|
from diet_guard._gatelock_nutrition import _format_preview
|
||||||
from python_pkg.diet_guard._gatelock_support import wait_for_display
|
from diet_guard._gatelock_support import wait_for_display
|
||||||
from python_pkg.diet_guard._gatelock_ui import DEFAULT_PER_GRAMS
|
from diet_guard._gatelock_ui import DEFAULT_PER_GRAMS
|
||||||
from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS
|
from diet_guard._portions import DEFAULT_ITEM_GRAMS
|
||||||
from python_pkg.diet_guard.tests.conftest import _FAKE_TK, _FakeTclError, _nutrition
|
from diet_guard.tests.conftest import _FAKE_TK, _FakeTclError, _nutrition
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Module-level helpers
|
# Module-level helpers
|
||||||
|
|||||||
@ -12,14 +12,14 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from python_pkg.diet_guard import _gatelock_mealflow
|
from diet_guard import _gatelock_mealflow
|
||||||
from python_pkg.diet_guard._budget import seal_budget
|
from diet_guard._budget import seal_budget
|
||||||
from python_pkg.diet_guard._meal import MealItem
|
from diet_guard._meal import MealItem
|
||||||
from python_pkg.diet_guard._state import log_meal
|
from diet_guard._state import log_meal
|
||||||
from python_pkg.diet_guard.tests.conftest import _nutrition
|
from diet_guard.tests.conftest import _nutrition
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from python_pkg.diet_guard._gatelock import MealGate
|
from diet_guard._gatelock import MealGate
|
||||||
|
|
||||||
|
|
||||||
class TestReferenceModel:
|
class TestReferenceModel:
|
||||||
|
|||||||
@ -10,12 +10,12 @@ import importlib
|
|||||||
|
|
||||||
|
|
||||||
def test_main_module_imports() -> None:
|
def test_main_module_imports() -> None:
|
||||||
"""The ``python -m python_pkg.diet_guard`` entry module imports cleanly."""
|
"""The ``python -m diet_guard`` entry module imports cleanly."""
|
||||||
module = importlib.import_module("python_pkg.diet_guard.__main__")
|
module = importlib.import_module("diet_guard.__main__")
|
||||||
assert hasattr(module, "main")
|
assert hasattr(module, "main")
|
||||||
|
|
||||||
|
|
||||||
def test_package_imports() -> None:
|
def test_package_imports() -> None:
|
||||||
"""The package itself imports without side effects."""
|
"""The package itself imports without side effects."""
|
||||||
package = importlib.import_module("python_pkg.diet_guard")
|
package = importlib.import_module("diet_guard")
|
||||||
assert package is not None
|
assert package is not None
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
from python_pkg.diet_guard._meal import MEAL_SOURCE, MealItem, meal_total
|
from diet_guard._meal import MEAL_SOURCE, MealItem, meal_total
|
||||||
|
|
||||||
|
|
||||||
def _item(
|
def _item(
|
||||||
|
|||||||
@ -6,7 +6,7 @@ Nutrition / suggestion builders.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from python_pkg.diet_guard._portions import (
|
from diet_guard._portions import (
|
||||||
estimate_unit_grams,
|
estimate_unit_grams,
|
||||||
staple_nutrition,
|
staple_nutrition,
|
||||||
suggest_staples,
|
suggest_staples,
|
||||||
|
|||||||
@ -8,9 +8,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
from python_pkg.diet_guard._foodbank import remember_food
|
from diet_guard._foodbank import remember_food
|
||||||
from python_pkg.diet_guard._resolve import (
|
from diet_guard._resolve import (
|
||||||
ManualMacros,
|
ManualMacros,
|
||||||
lookup_candidates,
|
lookup_candidates,
|
||||||
resolve_nutrition,
|
resolve_nutrition,
|
||||||
@ -82,7 +82,7 @@ class TestResolveBankAndStaple:
|
|||||||
def test_off_fallback(self) -> None:
|
def test_off_fallback(self) -> None:
|
||||||
"""An unknown, non-staple food falls through to Open Food Facts."""
|
"""An unknown, non-staple food falls through to Open Food Facts."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.diet_guard._resolve.estimate_off",
|
"diet_guard._resolve.estimate_off",
|
||||||
return_value=_OFF,
|
return_value=_OFF,
|
||||||
):
|
):
|
||||||
result = resolve_nutrition("exotic dish")
|
result = resolve_nutrition("exotic dish")
|
||||||
@ -117,7 +117,7 @@ class TestLookupCandidates:
|
|||||||
def test_off_candidates(self) -> None:
|
def test_off_candidates(self) -> None:
|
||||||
"""An unknown food returns the OFF alternatives, labelled by source."""
|
"""An unknown food returns the OFF alternatives, labelled by source."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.diet_guard._resolve.off_candidates",
|
"diet_guard._resolve.off_candidates",
|
||||||
return_value=[_OFF],
|
return_value=[_OFF],
|
||||||
):
|
):
|
||||||
candidates = lookup_candidates("exotic dish")
|
candidates = lookup_candidates("exotic dish")
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from python_pkg.diet_guard._slots import (
|
from diet_guard._slots import (
|
||||||
current_slot,
|
current_slot,
|
||||||
day_slots,
|
day_slots,
|
||||||
elapsed_slots,
|
elapsed_slots,
|
||||||
|
|||||||
@ -12,10 +12,10 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from python_pkg.diet_guard import _state
|
from diet_guard import _state
|
||||||
from python_pkg.diet_guard._budget import BudgetNotInitializedError, seal_budget
|
from diet_guard._budget import BudgetNotInitializedError, seal_budget
|
||||||
from python_pkg.diet_guard._estimator import Nutrition
|
from diet_guard._estimator import Nutrition
|
||||||
from python_pkg.diet_guard._state import (
|
from diet_guard._state import (
|
||||||
consumption_band,
|
consumption_band,
|
||||||
entry_kcal,
|
entry_kcal,
|
||||||
load_log,
|
load_log,
|
||||||
|
|||||||
@ -6,9 +6,13 @@
|
|||||||
#
|
#
|
||||||
# What it does:
|
# What it does:
|
||||||
# 1. Ensures system deps (setxkbmap for VT-disable, requests for OFF lookups)
|
# 1. Ensures system deps (setxkbmap for VT-disable, requests for OFF lookups)
|
||||||
# 2. Installs + enables the systemd user timer that fires the gate every ~30m
|
# 2. pip-installs this package + gatelock into system Python's user
|
||||||
# 3. Seals your daily budget from biometrics (only if not already sealed)
|
# site-packages (the systemd service runs /usr/bin/python directly, not
|
||||||
# 4. Locks the budget file immutable with `chattr +i` (the real tamper gate)
|
# a venv, so the package must live where that interpreter can find it —
|
||||||
|
# see CLAUDE.md's "Production dependency installation" section)
|
||||||
|
# 3. Installs + enables the systemd user timer that fires the gate every ~30m
|
||||||
|
# 4. Seals your daily budget from biometrics (only if not already sealed)
|
||||||
|
# 5. Locks the budget file immutable with `chattr +i` (the real tamper gate)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@ -16,9 +20,7 @@ set -euo pipefail
|
|||||||
# Split declare/assign so the command-substitution exit code is not masked (SC2155).
|
# Split declare/assign so the command-substitution exit code is not masked (SC2155).
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||||
readonly SCRIPT_DIR
|
readonly SCRIPT_DIR
|
||||||
# python_pkg/diet_guard -> repo root (two levels up).
|
readonly REPO_DIR="$SCRIPT_DIR"
|
||||||
REPO_DIR="$(readlink -f "$SCRIPT_DIR/../..")"
|
|
||||||
readonly REPO_DIR
|
|
||||||
readonly SERVICE_SRC="$SCRIPT_DIR/diet-guard-gate.service"
|
readonly SERVICE_SRC="$SCRIPT_DIR/diet-guard-gate.service"
|
||||||
readonly TIMER_SRC="$SCRIPT_DIR/diet-guard-gate.timer"
|
readonly TIMER_SRC="$SCRIPT_DIR/diet-guard-gate.timer"
|
||||||
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
||||||
@ -28,22 +30,23 @@ readonly BUDGET_FILE="$DATA_DIR/.budget"
|
|||||||
echo "=== Diet Guard Installer ==="
|
echo "=== Diet Guard Installer ==="
|
||||||
|
|
||||||
# 1. System dependencies ------------------------------------------------------
|
# 1. System dependencies ------------------------------------------------------
|
||||||
echo "[1/4] Checking system dependencies..."
|
echo "[1/5] Checking system dependencies..."
|
||||||
if ! command -v setxkbmap &>/dev/null; then
|
if ! command -v setxkbmap &>/dev/null; then
|
||||||
echo " Installing xorg-setxkbmap (gate disables VT switching while locked)..."
|
echo " Installing xorg-setxkbmap (gate disables VT switching while locked)..."
|
||||||
sudo pacman -S --noconfirm xorg-setxkbmap
|
sudo pacman -S --noconfirm xorg-setxkbmap
|
||||||
else
|
else
|
||||||
echo " setxkbmap present"
|
echo " setxkbmap present"
|
||||||
fi
|
fi
|
||||||
if ! python -c 'import requests' 2>/dev/null; then
|
|
||||||
echo " Installing python-requests (Open Food Facts lookups)..."
|
|
||||||
sudo pacman -S --noconfirm python-requests
|
|
||||||
else
|
|
||||||
echo " python-requests present"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. systemd user timer + service --------------------------------------------
|
# 2. Install this package + its dependencies into system Python -------------
|
||||||
echo "[2/4] Installing systemd user timer + service..."
|
echo "[2/5] Installing diet_guard + dependencies for /usr/bin/python..."
|
||||||
|
/usr/bin/python3 -m pip install --user --break-system-packages -e "$REPO_DIR"
|
||||||
|
echo " Installed. Verifying import..."
|
||||||
|
/usr/bin/python3 -c "import diet_guard; import gatelock" \
|
||||||
|
&& echo " diet_guard and gatelock import cleanly from the system interpreter."
|
||||||
|
|
||||||
|
# 3. systemd user timer + service --------------------------------------------
|
||||||
|
echo "[3/5] Installing systemd user timer + service..."
|
||||||
mkdir -p "$SYSTEMD_USER_DIR"
|
mkdir -p "$SYSTEMD_USER_DIR"
|
||||||
cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.service"
|
cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.service"
|
||||||
cp "$TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.timer"
|
cp "$TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.timer"
|
||||||
@ -51,17 +54,17 @@ systemctl --user daemon-reload
|
|||||||
systemctl --user enable --now diet-guard-gate.timer
|
systemctl --user enable --now diet-guard-gate.timer
|
||||||
echo " Timer enabled and started (fires the gate every ~30 min)."
|
echo " Timer enabled and started (fires the gate every ~30 min)."
|
||||||
|
|
||||||
# 3. Seal the daily budget (hidden) ------------------------------------------
|
# 4. Seal the daily budget (hidden) ------------------------------------------
|
||||||
echo "[3/4] Sealing your daily budget..."
|
echo "[4/5] Sealing your daily budget..."
|
||||||
if [[ -e "$BUDGET_FILE" ]]; then
|
if [[ -e "$BUDGET_FILE" ]]; then
|
||||||
echo " Budget already sealed at $BUDGET_FILE - skipping init."
|
echo " Budget already sealed at $BUDGET_FILE - skipping init."
|
||||||
else
|
else
|
||||||
echo " Enter your biometrics (used once then discarded; the value is hidden):"
|
echo " Enter your biometrics (used once then discarded; the value is hidden):"
|
||||||
(cd "$REPO_DIR" && python -m python_pkg.diet_guard init)
|
python -m diet_guard init
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Lock the budget immutable (the real tamper friction) --------------------
|
# 5. Lock the budget immutable (the real tamper friction) --------------------
|
||||||
echo "[4/4] Locking the budget file (chattr +i)..."
|
echo "[5/5] Locking the budget file (chattr +i)..."
|
||||||
read -r attrs _ <<<"$(lsattr -d "$BUDGET_FILE" 2>/dev/null || true)"
|
read -r attrs _ <<<"$(lsattr -d "$BUDGET_FILE" 2>/dev/null || true)"
|
||||||
if [[ "$attrs" == *i* ]]; then
|
if [[ "$attrs" == *i* ]]; then
|
||||||
echo " Already immutable."
|
echo " Already immutable."
|
||||||
@ -73,5 +76,4 @@ fi
|
|||||||
echo "=== Installation complete ==="
|
echo "=== Installation complete ==="
|
||||||
echo "The gate checks every ~30 min (08:00-22:00) and locks until you log a meal"
|
echo "The gate checks every ~30 min (08:00-22:00) and locks until you log a meal"
|
||||||
echo "once you have gone 5h without logging."
|
echo "once you have gone 5h without logging."
|
||||||
echo "Test the lock now (safe, closeable): \
|
echo "Test the lock now (safe, closeable): python -m diet_guard gate --demo"
|
||||||
cd $REPO_DIR && python -m python_pkg.diet_guard gate --demo"
|
|
||||||
172
pyproject.toml
Normal file
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