[project] name = "screen-locker" version = "1.0.0" description = "Tkinter/systemd screen locker with workout tracking, shared sick days, 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 + sync transports, also used by # diet_guard; here for phone-workout sync, Firebase RTDB only since 2026-10-09 dependencies = [ "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.11.0#subdirectory=gatelock", "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync", "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays", "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time", "holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds", "sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays", ] [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.3.0"] [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", # Personal repo, no copyright policy (ruff 0.17 made CPY001 stable under # select=ALL). "CPY001", "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"] # explain_lock_decision takes one arg per real file path and input, 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 # Serial on purpose. With jobs != 1 pylint ships the linter to its workers via # dill.dumps(), and dill 0.4.1 (newest release) reads code.co_lnotab, which # CPython 3.15 removed -- every run crashed once setup-python's "3.x" became # 3.15.1. The fix sits on dill master, unreleased; return to 0 once a dill # release past 0.4.1 ships. The pre-commit hooks take this value too. jobs = 1 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 # ScreenLocker composes ~30 mixins; collapsing them is not the goal. max-parents = 40 # Keyword-only arguments count: the gather_*/explain_* entry points take one # per real file path so tests can override each. max-args = 8 # ManualWorkoutDraft is a flat 23-field record mirroring the evidence form and # the wire payload; StatusWindow / ScreenLocker hold UI and injected state. max-attributes = 25 [tool.pylint.similarities] # The remaining overlaps are 6-11 line fragments of deliberately parallel code # (Tk button rows, the two JSON ledgers' loaders, mixin host stubs); a real # copy-paste block is longer than this. min-similarity-lines = 12 [tool.pylint.format] # BaseHTTPRequestHandler dispatches on the method names do_GET / do_POST. good-names-rgxs = "^do_(GET|POST)$" [tool.pylint.classes] # The mixin-host surface: module-level helpers (shutdown_base, status, the # status window's verify flow) drive the ScreenLocker's own mixin methods, the # same single-source-of-truth reuse ruff exempts via per-file SLF001. exclude-protected = [ "_asdict", "_fields", "_replace", "_source", "_make", "os._exit", "_adjust_shutdown_time_by", "_apply_sick_floor", "_apply_workout_credit", "_credit_ingested_workout", "_read_shutdown_config", "_scan_and_fill_week_runnerup", "_try_fill_stronglifts_for_week", "_verify_phone_workout", "_verify_runnerup_workout", "_write_shutdown_config", ] [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", # The commit gate runs plain `python -m pytest -q`; serial it took 26 s of # its 60 s budget, 4 workers 9 s. Fixed, not `auto`: auto is 24 workers # here, past capped.sh's 4 GiB. CI's own `-n auto` comes later and wins; # `-n0` gets a serial run back. "-n", "4", "--dist", "worksteal", # 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 # sys.monitoring (PEP 669) rather than the C tracer: test_shutdown_ceiling_ # invariant.py alone measured 13 s vs 22 s, for the same line+branch data. core = "sysmon" 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"]