screen-locker/pyproject.toml
Krzysztof kuhy Rudnicki ffc6c72df0 Push under a persisted per-install uuid, not a fixed role id
screen_locker pushed manual workouts as "pc" and the workout app as "phone".
A role constant collides the moment a second machine takes the same role, and
after a reinstall the new install inherits the previous one's CRDT identity.
Both halves now mint a uuid once and persist it: the PC in
~/.local/share/screen_locker/.device_id, the app under SharedPreferences
'crdt.nodeId' -- the same key every sibling app uses.

The old constant survives as the legacy id and is passed to sync_log /
syncLog, so devices/<legacy>/ is skipped as this device's own rather than
re-downloaded and re-merged as a peer's on every tick. It drops to None once
that path is reclaimed.

Note the *read* loop in _workout_sync deliberately still merges every device
including this one: the PC needs its own manual workouts in the merged view,
so "skip own" would be wrong there. Only the push path and the HLC node id
move.

The test fixture redirecting the new id file is added in the same change that
introduces the constant -- without it the first test that syncs mints a uuid
into the real state dir and this machine's live sync identity is decided by
whichever test ran first. Verified: ~/.local/share/screen_locker/ held no
.device_id after a full suite run.

Verified against the live backend, not just mocks: ran the real
push_pc_workouts, which minted 57582b3b and pushed 9 records. In the RTDB the
new device's log is a strict superset of both old paths -- 0 of devices/pc/'s
10 records and 0 of devices/phone/'s 9 are missing from it.

Python: 907 tests, 100% branch coverage. workout_app: 313 tests, analyzer
clean. Pins bumped to crdt-sync-v0.6.0 / crdt_sync_dart-v0.7.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 17:31:15 +02:00

238 lines
8.3 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.4.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.6.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>=2,<3"]
[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"]
# PERF203 (try/except inside a loop) describes a retry loop exactly, and lands
# where its cost is irrelevant: each iteration makes a network call and then
# sleeps for seconds, so handler setup is noise. Hoisting the handler out of
# the loop would mean swallowing the error into a return value, which the
# repo's no-silent-failures rule rightly discourages.
"screen_locker/_sync_retry.py" = ["PERF203"]
"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]
# The repo root on the path, so a file outside the package (scripts/) can be
# linted on its own. pylint otherwise infers the path from the files it is
# given, so `scripts/verify_screen_fits.py` resolved `screen_locker` only when
# a package file happened to be in the same batch -- passing or failing by
# what else was staged.
init-hook = "import sys; sys.path.insert(0, '.')"
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"]