mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the one that already had this idea: scheduled_skips.json was a bare list of dates, checked first in the lock chain, that only this app could see. That is exactly what the shared pool generalises, so the chain now asks freedays and a day taken here also stands down diet-guard, wake-alarm, leetcode-guard and home-guard. Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d, 10/90d), the 120-character justification and the escalating countdown all exist to make a sick day *hard* to take. Free days are the opposite -- declared ahead, no reason, consecutive runs allowed. Merging the two policies would have had to ruin one of them, so only the storage of the frictionless half moved. This fixes a real bug in passing. is_scheduled_skip_today compared against a UTC day while add_scheduled_skip.py wrote a local one, so a skip added near midnight could name a date the chain never matched. freedays owns "what is today" for both sides now, in local time, and the disagreement cannot recur. scripts/migrate_scheduled_skips.py moves the existing dates across; it is idempotent and has a --dry-run. Already run here: the 8 real skips (2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and count against 2026, leaving 27 of 35. The original file is gitignored and was left on disk untouched. add_scheduled_skip.py becomes a thin wrapper over freedays rather than being deleted, because arm.sh calls it and the name is in muscle memory. Left as-is it would have kept writing to a file nothing reads. conftest.py went over the 250-line cap, so the two fixtures that keep the suite off real user state moved to _real_state_fixtures.py. They belong together and neither subsumes the other: _isolate_home covers paths resolved from Path.home() at call time, while the freedays redirect covers paths bound into module constants at import time, which redirecting home afterwards cannot reach. requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one distribution, so those stale pins would have made the hook environments unresolvable rather than merely old. 1256 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
253 lines
9.3 KiB
TOML
253 lines
9.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.14"
|
|
# 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.7.3#subdirectory=gatelock",
|
|
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
|
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays",
|
|
]
|
|
|
|
[project.scripts]
|
|
screen-locker-status = "screen_locker.status_view:main"
|
|
screen-locker-web = "screen_locker._web_server: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.1.1"]
|
|
|
|
[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 = "py314"
|
|
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",
|
|
]
|
|
# The ET.parse call this covers moved to _runnerup_tcx.py when
|
|
# _runnerup_verification.py was split for the 250-line cap. Same waiver, new
|
|
# home -- the TCX files are pulled off the user's own phone by adb.
|
|
"screen_locker/_runnerup_tcx.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"]
|
|
# _workout_handoff.py must pause gatelock's recovery watchdog while the Flutter
|
|
# app holds the screen. The loop re-takes any grab it finds missing every
|
|
# 1000ms -- by design, so that pulling a cable is not an escape -- and
|
|
# holds_grab() is `grab_current() is not None`, which is False once another X
|
|
# client holds it. Left running it would steal the screen back from the workout
|
|
# app within a second. gatelock exposes no public pause (only close(), which
|
|
# also restores VT switching and would hand the user Ctrl+Alt+F2), and adding
|
|
# one there would contradict _recovery.py's monotonicity proof that no
|
|
# transition ever decreases the grab.
|
|
"screen_locker/_workout_handoff.py" = ["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.14"
|
|
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.14"
|
|
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"]
|