[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.2#subdirectory=gatelock", "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync", ] [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"]