mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
The workout log stored ONE workout per calendar day, so a second same-day
workout was silently dropped and could never earn credit.
Multiple workouts per day:
- workout_log.json: {date: entry} -> {date: [entry, ...]}. Legacy entries are
wrapped on read (_log_io.load_workout_log) so their HMACs stay valid — no
re-signing; every write normalizes the file, migrating it progressively.
- New write chokepoint (_log_mixin.write_signed_entry) APPENDS and dedups by a
stable workout_id, returning RecordResult so callers can tell a new workout
from a duplicate. Re-verifying the same workout is a no-op.
- count_weekly_workouts is anti-gaming: each VERIFIED workout counts
individually; self-reported manual entries count at most once per day. Four
real workouts in one day satisfy the week; four manual entries count as one.
- Reward: the day's first counted workout gets the base +2h; an additional
same-day VERIFIED workout gets +1h; an additional manual gets nothing. The
old "+1h when weekly_count > 4" gate is replaced — weekly volume is the
banked bonus's job and shouldn't be paid twice.
- Fixed: the unlock screen labelled the +1h "#{weekly_count - 4}", which can
render negative under the new rule. Removed the dead runnerup_manual type.
- Flutter: the manual budget counts distinct DAYS, matching the PC.
PC->phone sync (it had never worked once):
- push_pc_manuals pushed from a side-store written at log-time, so anything
logged before that feature existed could never publish. _manual_push now
derives the crdt log directly from workout_log.json — the single source of
truth — covering manual AND verified runs. The record id is the workout's own
workout_id (derived for legacy entries to match the phone's format) and the
HLC comes from the entry's own timestamp, so a re-push is byte-identical and
the repo never churns.
- The phone reads every kind (readMergedWorkoutPayloads) and lists the PC's
workouts under "SYNCED FROM PC". The manual budget still sees manual-only, so
a verified run can't consume a self-report slot.
- Settings claimed "Connected." whenever a token STRING existed. It now
verifies against the API on open, so a revoked token reports 401 instead of
showing a green lie.
Never fail silently:
- 30 swallowed exceptions across 19 files now log WARNING with the concrete
cause AND consequence — plus the token guards in _workout_sync that returned
[] in total silence, the exact bug that hid the dead sync for weeks.
- New pre-commit hook (scripts/check_silent_failures.py) fails any except that
neither re-raises nor logs loudly. ruff's S110 only catches `except: pass`;
the real pattern here was `except: log.info(); return []`. Documented in
CLAUDE.md.
Verified on real data: the Jul 13 run (pulled from its actual TCX over ADB) now
counts alongside that day's manual squash, and all three workouts are published
to kuhyx/syncs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
226 lines
7.5 KiB
TOML
226 lines
7.5 KiB
TOML
[project]
|
|
name = "screen-locker"
|
|
version = "1.0.0"
|
|
description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration"
|
|
requires-python = ">=3.10"
|
|
# gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm
|
|
# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport,
|
|
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
|
|
dependencies = [
|
|
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.0#subdirectory=gatelock",
|
|
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync",
|
|
]
|
|
|
|
[project.scripts]
|
|
screen-locker-status = "screen_locker.status_view:main"
|
|
|
|
# Optional extras. The `mcp` group is ONLY needed by the read-only MCP server
|
|
# (screen_locker._mcp), which Claude Code spawns via a dedicated venv (see
|
|
# scripts/setup_mcp.sh / .mcp.json). It is deliberately NOT a core dependency
|
|
# so the systemd/CLI system-python path stays dependency-light and never has
|
|
# to import the MCP SDK.
|
|
[project.optional-dependencies]
|
|
mcp = ["mcp>=1.9"]
|
|
|
|
[tool.setuptools.packages.find]
|
|
# This repo also holds stronglift_replacement/ (an unrelated Flutter app, not
|
|
# a Python package); without this, setuptools' automatic flat-layout
|
|
# discovery sees two top-level directories and refuses to guess which is the
|
|
# installable package.
|
|
include = ["screen_locker*"]
|
|
|
|
[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" = [
|
|
"ANN", "ARG", "D", "DTZ011", "E501", "FBT",
|
|
"PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "SLF001",
|
|
]
|
|
"**/test_*.py" = [
|
|
"ANN", "ARG", "D", "DTZ011", "E501", "FBT",
|
|
"PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "SLF001",
|
|
]
|
|
"screen_locker/_runnerup_verification.py" = ["S314"]
|
|
# URL is always built from the hardcoded https:// _WTTR_URL constant with
|
|
# only the (quoted) city interpolated — never user/attacker controlled.
|
|
# ruff's S310 can't verify that via static analysis of a variable URL, so
|
|
# it always flags urlopen() here regardless; a runtime scheme check was
|
|
# considered and rejected (it can't ever trigger, so it's just an untested
|
|
# branch and a crash risk in the lock's startup path for zero real benefit).
|
|
"screen_locker/_temperature.py" = ["S310"]
|
|
"screen_locker/_shutdown_base.py" = ["SLF001"]
|
|
# run_status walks the weekly-status pipeline (weekly logic, RunnerUp scan,
|
|
# bonus, heat-skip) as a sequence of stages — genuinely branchy by design, same
|
|
# rationale as the validation-chain functions below; not a config-object case.
|
|
"screen_locker/_status.py" = ["C901", "PLR0912", "PLR0915", "SLF001", "T201"]
|
|
# _heat_skip.py opens a tkinter fullscreen window; tk's .attributes()/other
|
|
# calls take positional booleans (its API), which FBT003 always flags.
|
|
"screen_locker/_heat_skip.py" = ["FBT003"]
|
|
# StatusWindow.__init__ takes one arg per independently-mocked dependency
|
|
# (verifier_factory, temperature_fetcher, log_file, ...) — same
|
|
# dependency-injection rationale as _status_data.py's gather_status below.
|
|
"screen_locker/status_view.py" = ["PLC0415", "PLR0913", "SLF001", "T201"]
|
|
# The verify-and-credit flow split out of status_view.py: it deliberately
|
|
# drives the bare verifier's own mixin methods (_verify_phone_workout,
|
|
# _verify_runnerup_workout, _apply_workout_credit) — the same single-source-of
|
|
# -truth reuse status_view.py above is exempted for — and _make_bare_verifier
|
|
# imports ScreenLocker inside the function to avoid an import cycle.
|
|
"screen_locker/_status_view_verify.py" = ["PLC0415", "SLF001"]
|
|
# explain_lock_decision takes one arg per real file path + sick history, and
|
|
# returns early per lock-decision-chain stage (see module docstring) — both
|
|
# are the deliberate design, not something to collapse into a config object.
|
|
"screen_locker/_compliance_state.py" = ["C901", "PLR0911", "PLR0913"]
|
|
# gather_status takes one arg per real file path, all independently
|
|
# overridable for tests — same rationale as _compliance_state.py above.
|
|
"screen_locker/_status_data.py" = ["PLR0913"]
|
|
# validate_manual_workout returns early per failed evidence-form check (same
|
|
# validation-chain shape as _compliance_state.py's explain_lock_decision) —
|
|
# deliberate design, not something to collapse into a config object.
|
|
"screen_locker/_manual_workout.py" = ["C901", "PLR0911"]
|
|
# _verify_phone_workout returns early per verification-chain stage (clock
|
|
# check, sync, ADB, HTTP, then terminal statuses) — same shape as the two
|
|
# ignores above, not something to collapse into a config object.
|
|
"screen_locker/_phone_verification.py" = ["C901", "PLR0911"]
|
|
"scripts/check_file_length.py" = ["PTH123"]
|
|
|
|
[tool.ruff.lint.pydocstyle]
|
|
convention = "google"
|
|
|
|
[tool.ruff.lint.isort]
|
|
force-single-line = false
|
|
force-sort-within-sections = true
|
|
known-first-party = ["screen_locker"]
|
|
|
|
[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"
|
|
ignore = [".venv", "__pycache__"]
|
|
ignore-patterns = [".*\\.pyi$"]
|
|
|
|
[tool.pylint.messages_control]
|
|
enable = "all"
|
|
disable = []
|
|
|
|
[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 = ["screen_locker/tests"]
|
|
python_files = ["test_*.py", "*_test.py"]
|
|
python_classes = ["Test*"]
|
|
python_functions = ["test_*"]
|
|
addopts = [
|
|
"-v",
|
|
"--strict-markers",
|
|
"--strict-config",
|
|
"-ra",
|
|
"--cov=screen_locker",
|
|
"--cov-branch",
|
|
"--cov-report=term-missing",
|
|
"--cov-report=lcov",
|
|
# A fixture-only plugin module, not conftest.py (already at the
|
|
# repo's 400-line-per-file cap) -- see its own docstring.
|
|
"-p",
|
|
"screen_locker.tests.test_sync_fixtures",
|
|
]
|
|
filterwarnings = [
|
|
"error",
|
|
"ignore::DeprecationWarning",
|
|
"default::pytest.PytestUnraisableExceptionWarning",
|
|
]
|
|
|
|
[tool.coverage.run]
|
|
source = ["screen_locker"]
|
|
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"]
|