mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
The morning routine is started by systemd right after boot and right after
resume, so its sync ran seconds before DHCP/DNS were up. Every such run failed
outright — and said this:
Workout sync push FAILED for 6 workout(s): network error reading
screen-locker-sync/devices — a 403 here means the token lacks
contents:write on kuhyx/syncs
Both halves of that were wrong. It was not a 403, and the token was fine: the
same six workouts pushed successfully by hand minutes later on the same token,
which reports push=true on the repo. The message cost a real investigation
chasing a permissions bug that did not exist.
- _sync_retry.with_sync_retry wraps the three sync call sites in a bounded
retry (4 attempts, 2s/4s/8s backoff — ~14s, enough for the network to come
up, short enough not to drag the routine). Verified offline in a network
namespace: three retries, then an honest give-up.
- Any GitHubSyncError retries, deliberately including RepoNotFoundError.
Classifying transient vs permanent would couple us to which exception
crdt_sync chains where; a few wasted seconds on a genuinely bad token is the
cheaper mistake.
- The push failure log now reports what actually happened. The contents:write
hint survives only for RepoNotFoundError, which is the one error that
actually implicates the repo or the token.
Retry at the application layer rather than ordering the unit after
network-online.target: the same race hits on resume, where boot ordering does
not apply, and these are user units that cannot depend on a system target.
The tests' autouse no-sleep fixture patches the module-local `sleep` name
rather than `_sync_retry.time.sleep` — the latter mutates the shared time
module object and silently disabled sleep for the whole suite, breaking an
unrelated temperature timeout test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
232 lines
7.9 KiB
TOML
232 lines
7.9 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"]
|
|
# 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]
|
|
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"]
|