commit 1a99424dbd609ef8b9239c6e814fcb873d0b1665 Author: Krzysztof kuhy Rudnicki Date: Fri Jun 19 17:39:44 2026 +0200 Initial gatelock: shared lock-window + HMAC log-integrity backend Extracted from wake_alarm, screen-locker, and diet_guard, which each independently implemented fullscreen-lock, input-grab, VT-disable, and HMAC-signed-state mechanics at different levels of maturity (the HMAC module was already a hand-copied duplicate between two of them). LockConfig exposes overrideredirect/grab/disable_vt as independent axes so one LockWindow can reproduce all three projects' exact prior behavior, plus screen-locker's confirmed upgrade to retry-forever grab. diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..98911e8 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,19 @@ +name: pre-commit + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install -r requirements.txt + - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..66e5931 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,28 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests with coverage + run: python -m pytest -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eec3a7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +.venv/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.coverage +coverage.lcov +htmlcov/ +dist/ +build/ +*.egg-info/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..670b0d3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,122 @@ +# Pre-commit Configuration for gatelock +# Install: pre-commit install && pre-commit install --hook-type pre-push +# Run: pre-commit run --all-files +# Update: pre-commit autoupdate + +default_language_version: + python: python3 +default_stages: [pre-commit] +fail_fast: false + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-added-large-files + args: [--maxkb=2000] + - id: check-merge-conflict + - id: detect-private-key + - id: debug-statements + - id: name-tests-test + args: [--pytest-test-first] + - id: check-ast + - id: mixed-line-ending + args: [--fix=lf] + - id: requirements-txt-fixer + + - repo: local + hooks: + - id: no-noqa + name: Block noqa comments + entry: '(?i)#\s*(noqa|type:\s*ignore)' + language: pygrep + types: [python] + - id: no-ruff-noqa + name: Block ruff noqa file-level comments + entry: '(?i)#\s*ruff:\s*noqa' + language: pygrep + types: [python] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.2 + hooks: + - id: ruff + args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes] + types_or: [python, pyi] + - id: ruff-format + types_or: [python, pyi] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + args: + - --ignore-missing-imports + - --no-error-summary + - --disable-error-code=no-untyped-def + - --disable-error-code=no-untyped-call + - --disable-error-code=var-annotated + - --disable-error-code=no-any-unimported + - --disable-error-code=type-arg + - --disable-error-code=no-any-return + - --disable-error-code=misc + - --disable-error-code=unused-ignore + - --disable-error-code=unreachable + - --disable-error-code=assignment + - --disable-error-code=no-redef + - --disable-error-code=attr-defined + - --disable-error-code=arg-type + - --disable-error-code=union-attr + - --disable-error-code=call-overload + - --disable-error-code=return-value + - --disable-error-code=redundant-cast + - --disable-error-code=empty-body + - --disable-error-code=list-item + + - repo: https://github.com/pylint-dev/pylint + rev: v3.3.2 + hooks: + - id: pylint + args: + - --rcfile=pyproject.toml + - --fail-under=8.0 + - --jobs=4 + additional_dependencies: + - pytest + + - repo: https://github.com/PyCQA/bandit + rev: 1.7.10 + hooks: + - id: bandit + args: + - -c + - pyproject.toml + - --severity-level=high + - --confidence-level=medium + - --skip=B113 + additional_dependencies: ["bandit[toml]"] + exclude: ^(tests/|.*test.*\.py$) + + - repo: local + hooks: + - id: pytest-coverage + name: pytest with coverage enforcement + entry: python -m pytest + language: system + types: [python] + pass_filenames: false + require_serial: true + stages: [pre-push] + + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + args: + - --skip=*.json,*.lock,.git,__pycache__,.venv diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d1aa637 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Krzysztof Rudnicki + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6da5c58 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# gatelock + +Shared fullscreen lock-window + HMAC log-integrity backend, extracted from +three personal automation tools that each independently implemented "lock the +screen with a blocking overlay until a condition is met": +[diet_guard](https://github.com/kuhyx/testsAndMisc/tree/main/python_pkg/diet_guard), +[wake_alarm](https://github.com/kuhyx/testsAndMisc/tree/main/python_pkg/wake_alarm), +and [screen-locker](https://github.com/kuhyx/screen-locker). + +## Why + +All three reimplemented the same tkinter fullscreen mechanics (overrideredirect, +input grab, VT-switch disable) at different levels of maturity, and the +HMAC-signed-state module had already been hand-copied between two of them +because they live in separate repos. `gatelock` is the one place that logic +now lives. + +## Install + +```bash +pip install "gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0" +``` + +## Usage + +```python +import tkinter as tk +from gatelock import GateRoot, LockConfig, LockWindow + +class MyGate: + def __init__(self, *, demo_mode: bool) -> None: + self.root = GateRoot() + self.root.on_callback_error = self._handle_callback_error + config = LockConfig(mode="soft" if demo_mode else "hard") + self._lock = LockWindow(self.root, config, hooks=self) + self._lock.setup() + # ... build your widgets ... + self._lock.grab_input() + + def on_focus_ready(self) -> None: + self.my_entry.focus_force() + + def on_callback_error(self) -> None: + self.close() + + def on_close(self) -> None: + ... # release any hardware/state held while locked + + def close(self) -> None: + self._lock.close() + + def run(self) -> None: + self._lock.run() +``` + +`LockConfig`'s `mode` preset bundles the common combination ("soft" = topmost +only, typeable, WM-escapable; "hard" = overrideredirect + global grab + +VT-disable). Each axis (`overrideredirect`, `grab`, `disable_vt`, +`grab_retry_ms`) can be set explicitly to reproduce a consumer's exact prior +behavior where it diverges from the preset. + +`gatelock.log_integrity` ports the HMAC-signed state module used by all three +projects; `DEFAULT_HMAC_KEY_FILE` (`/etc/workout-locker/hmac.key`) is +unchanged so existing signed history keeps verifying. + +## Development + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +pytest +pre-commit install && pre-commit install --hook-type pre-push +``` diff --git a/gatelock/__init__.py b/gatelock/__init__.py new file mode 100644 index 0000000..22c05ed --- /dev/null +++ b/gatelock/__init__.py @@ -0,0 +1,16 @@ +"""Shared lock-window and HMAC log-integrity backend for blocking-overlay apps.""" + +from __future__ import annotations + +from gatelock._root import GateRoot +from gatelock._vt import disable_vt_switching, restore_vt_switching +from gatelock._window import LockConfig, LockWindow, LockWindowHooks + +__all__ = [ + "GateRoot", + "LockConfig", + "LockWindow", + "LockWindowHooks", + "disable_vt_switching", + "restore_vt_switching", +] diff --git a/gatelock/_root.py b/gatelock/_root.py new file mode 100644 index 0000000..134d5b5 --- /dev/null +++ b/gatelock/_root.py @@ -0,0 +1,36 @@ +"""Safe Tk root window that never lets a callback exception escape.""" + +from __future__ import annotations + +import logging +import tkinter as tk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from types import TracebackType + +_logger = logging.getLogger(__name__) + + +class GateRoot(tk.Tk): + """Tk root that routes callback errors to a handler instead of crashing. + + Overriding ``report_callback_exception`` is the idiomatic, blind-except-free + way to guarantee that no exception raised inside a Tk callback escapes the + event loop -- essential while a global input grab is held, since a crashed + mainloop would leave the screen grabbed with no way to release it. + """ + + on_callback_error: Callable[[], None] | None = None + + def report_callback_exception( + self, + exc: type[BaseException], + val: BaseException, + tb: TracebackType | None, + ) -> None: + """Log a callback error and notify the handler; never re-raise.""" + _logger.error("gatelock callback error", exc_info=(exc, val, tb)) + if self.on_callback_error is not None: + self.on_callback_error() diff --git a/gatelock/_vt.py b/gatelock/_vt.py new file mode 100644 index 0000000..5ae9e23 --- /dev/null +++ b/gatelock/_vt.py @@ -0,0 +1,37 @@ +"""VT-switch (Ctrl+Alt+Fn) disable/restore via setxkbmap. + +Disabling VT switching is what stops a locked window from being bypassed by +dropping to a TTY. Best-effort: silently does nothing if setxkbmap isn't +installed, matching the behavior of every implementation this was ported from. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess + +_logger = logging.getLogger(__name__) + + +def disable_vt_switching() -> bool: + """Best-effort disable of Ctrl+Alt+Fn VT switching. + + Returns: + True if setxkbmap was found and the disable command ran (the caller + should later call :func:`restore_vt_switching`); False if setxkbmap + is unavailable and VT switching could not be touched. + """ + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is None: + _logger.warning("setxkbmap not found; VT switching stays enabled") + return False + subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False) + return True + + +def restore_vt_switching() -> None: + """Re-enable VT switching. Safe to call even if it was never disabled.""" + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is not None: + subprocess.run([setxkbmap, "-option", ""], check=False) diff --git a/gatelock/_window.py b/gatelock/_window.py new file mode 100644 index 0000000..8ef68f1 --- /dev/null +++ b/gatelock/_window.py @@ -0,0 +1,246 @@ +"""Fullscreen lock window: setup, input grab, VT-disable, safe lifecycle. + +Generalizes the window mechanics that wake_alarm, screen-locker, and +diet_guard each implemented separately. The three differ along independent +axes (whether the window is WM-unmanaged, what kind of input grab is taken, +whether VT switching is disabled, how a failed global grab is retried) -- +:class:`LockConfig` exposes each axis explicitly, with ``mode`` as a +convenience preset, so one class can reproduce all three projects' existing +behavior exactly. +""" + +from __future__ import annotations + +import atexit +import contextlib +from dataclasses import dataclass +import logging +import signal +import tkinter as tk +from typing import TYPE_CHECKING, Literal, Protocol + +from gatelock._vt import disable_vt_switching, restore_vt_switching + +if TYPE_CHECKING: + from types import FrameType + +_logger = logging.getLogger(__name__) + +GrabKind = Literal["none", "local", "global"] +LockMode = Literal["soft", "hard"] + +# Periodic no-op so a grabbed, event-starved loop keeps handing control back +# to Python, letting SIGTERM/SIGINT be serviced promptly. +_KEEPALIVE_MS = 250 +# Default retry interval for a "global" grab that initially fails (e.g. a +# fullscreen game holds it). Used whenever grab_retry_ms is left unset. +_DEFAULT_GRAB_RETRY_MS = 200 + + +@dataclass(frozen=True) +class LockConfig: + """Declarative knobs for one :class:`LockWindow` instance. + + Each field left as ``None`` is derived from ``mode``; an explicit value + always overrides the preset for that one axis. + + Attributes: + mode: Preset bundling the common combination. "soft" = topmost only, + typeable, WM-escapable (today's wake_alarm). "hard" = + overrideredirect + global grab + VT-disable (today's diet_guard + and screen-locker production lock). + overrideredirect: Force a WM-unmanaged window. None = derive from mode. + grab: Input grab strategy. None = derive from mode. + disable_vt: Disable Ctrl+Alt+Fn VT switching. None = derive from mode. + grab_retry_ms: Retry interval in ms for a "global" grab that initially + fails. 0 means "try once, then fall back to a local grab" (the + original screen-locker behavior). Left unset (None), a "global" + grab retries forever every 200ms until it succeeds (the + diet_guard behavior, robust to e.g. a fullscreen game holding the + grab) -- there is no give-up/fallback in that case. + grab_log_every: Log a warning every N failed retry-forever attempts. + bg: Background color for the root window. + """ + + mode: LockMode = "hard" + overrideredirect: bool | None = None + grab: GrabKind | None = None + disable_vt: bool | None = None + grab_retry_ms: int | None = None + grab_log_every: int = 25 + bg: str = "#1a1a1a" + + def resolved_overrideredirect(self) -> bool: + """Return the effective overrideredirect setting.""" + if self.overrideredirect is not None: + return self.overrideredirect + return self.mode == "hard" + + def resolved_grab(self) -> GrabKind: + """Return the effective grab strategy.""" + if self.grab is not None: + return self.grab + return "global" if self.mode == "hard" else "none" + + def resolved_disable_vt(self) -> bool: + """Return whether VT switching should be disabled.""" + if self.disable_vt is not None: + return self.disable_vt + return self.mode == "hard" + + +class LockWindowHooks(Protocol): + """Callbacks :class:`LockWindow` invokes; the embedding app supplies all.""" + + def on_focus_ready(self) -> None: + """Called once the window is mapped and (if applicable) grabbed. + + The app should focus its first input widget here. + """ + + def on_callback_error(self) -> None: + """Called when a Tk callback raised (see :class:`~gatelock.GateRoot`).""" + + def on_close(self) -> None: + """Called once, from :meth:`LockWindow.close`, before VT is restored. + + Runs on every exit path -- normal dismiss, SIGTERM, SIGINT -- not just + a clean close, so app-specific teardown (restoring hardware state, + etc.) can't be skipped by killing the process. + """ + + +class LockWindow: + """Fullscreen window setup, input grab, and exit-path lifecycle.""" + + def __init__( + self, + root: tk.Tk, + config: LockConfig, + hooks: LockWindowHooks, + ) -> None: + """Initialize the lock window wrapper. + + Args: + root: The Tk root to configure and own the lifecycle of. + config: Declarative lock behavior for this instance. + hooks: App-supplied callbacks for focus, errors, and teardown. + """ + self.root = root + self._config = config + self._hooks = hooks + self._vt_disabled = False + self._closed = False + + # -- window mechanics ----------------------------------------------------- + + def setup(self) -> None: + """Configure the lock window per the resolved config.""" + screen_w = self.root.winfo_screenwidth() + screen_h = self.root.winfo_screenheight() + self.root.geometry(f"{screen_w}x{screen_h}+0+0") + self.root.attributes(topmost=True) + self.root.configure(bg=self._config.bg, cursor="arrow") + if self._config.resolved_overrideredirect(): + self.root.overrideredirect(boolean=True) + self.root.attributes(fullscreen=True) + if self._config.resolved_disable_vt(): + self._vt_disabled = disable_vt_switching() + + def grab_input(self) -> None: + """Force focus to the window, then acquire the configured grab.""" + self.root.update_idletasks() + self.root.focus_force() + grab = self._config.resolved_grab() + if grab == "global": + self._acquire_global_grab(attempt=1) + elif grab == "local": + with contextlib.suppress(tk.TclError): + self.root.grab_set() + self.root.after(100, self._notify_focus_ready) + + def _acquire_global_grab(self, *, attempt: int) -> None: + """Acquire the global input grab, retrying per ``grab_retry_ms``. + + Args: + attempt: 1-based attempt counter, used only to throttle the log. + """ + retry_ms = self._config.grab_retry_ms + try: + self.root.grab_set_global() + except tk.TclError: + if retry_ms == 0: + _logger.warning("Global grab failed, falling back to local grab") + with contextlib.suppress(tk.TclError): + self.root.grab_set() + return + effective_retry_ms = retry_ms or _DEFAULT_GRAB_RETRY_MS + if not attempt % self._config.grab_log_every: + _logger.warning( + "global grab still blocked after %d attempts (another " + "app -- e.g. a fullscreen game -- holds it); waiting " + "for it to free", + attempt, + ) + self.root.after( + effective_retry_ms, + lambda: self._acquire_global_grab(attempt=attempt + 1), + ) + return + with contextlib.suppress(tk.TclError): + self.root.focus_force() + self._notify_focus_ready() + + def _notify_focus_ready(self) -> None: + """Tell the app it can focus its first input widget now.""" + with contextlib.suppress(tk.TclError): + self._hooks.on_focus_ready() + + # -- lifecycle -------------------------------------------------------- + + def _install_signal_handlers(self) -> None: + """Ensure VT switching is restored on crash or kill, not just close.""" + atexit.register(self._restore_vt) + for sig in (signal.SIGTERM, signal.SIGINT): + with contextlib.suppress(ValueError): + signal.signal(sig, self._on_signal) + + def _on_signal(self, _signum: int, _frame: FrameType | None) -> None: + """Raise so SIGTERM/SIGINT unwind through run()'s try/finally.""" + raise SystemExit(0) + + def _keepalive(self) -> None: + """Re-arm a periodic no-op so pending signals get serviced promptly.""" + with contextlib.suppress(tk.TclError): + self.root.after(_KEEPALIVE_MS, self._keepalive) + + def _restore_vt(self) -> None: + """Restore VT switching; idempotent, safe to call on any exit path.""" + if not self._vt_disabled: + return + restore_vt_switching() + self._vt_disabled = False + + def close(self) -> None: + """Run app teardown, restore VT switching, destroy the window. + + Idempotent -- safe to call directly (normal dismiss) and again from + :meth:`run`'s ``finally`` (crash/signal exit) without double-running + app teardown. + """ + if self._closed: + return + self._closed = True + self._hooks.on_close() + self._restore_vt() + with contextlib.suppress(tk.TclError): + self.root.destroy() + + def run(self) -> None: + """Run the Tk mainloop, guaranteeing cleanup on every exit path.""" + self._install_signal_handlers() + self._keepalive() + try: + self.root.mainloop() + finally: + self.close() diff --git a/gatelock/log_integrity.py b/gatelock/log_integrity.py new file mode 100644 index 0000000..8eb28a3 --- /dev/null +++ b/gatelock/log_integrity.py @@ -0,0 +1,110 @@ +"""HMAC-based integrity checking for signed state entries. + +Ported from the byte-for-byte-duplicated copies in testsAndMisc's +``python_pkg/shared/log_integrity.py`` and screen-locker's +``_log_integrity.py``. ``DEFAULT_HMAC_KEY_FILE`` keeps the exact same literal +path both copies used -- changing it would invalidate every already-signed +entry in wake_alarm's, screen-locker's, and diet_guard's existing state files. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +from pathlib import Path +import secrets + +_logger = logging.getLogger(__name__) + +# HMAC key for signing state entries (root-owned, 0600). +DEFAULT_HMAC_KEY_FILE = Path("/etc/workout-locker/hmac.key") + + +def _load_hmac_key(key_file: Path) -> bytes | None: + """Load the HMAC key from ``key_file``. + + Returns the key bytes, or None if the file cannot be read. + """ + try: + return key_file.read_bytes().strip() + except OSError: + _logger.warning("Cannot read HMAC key from %s", key_file) + return None + + +def generate_hmac_key(key_file: Path | None = None) -> bytes | None: + """Generate a new HMAC key and write it to ``key_file``. + + The key file's parent must be writable (requires root or a setup script). + + Args: + key_file: Where to write the new key. None (the default) targets the + shared, root-owned key location all three lockers expect -- read + fresh from :data:`DEFAULT_HMAC_KEY_FILE` on every call, so tests + (or callers) can repoint it by patching that module attribute. + + Returns: + The new key bytes, or None on failure. + """ + target = key_file if key_file is not None else DEFAULT_HMAC_KEY_FILE + key = secrets.token_bytes(32) + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(key) + except OSError: + _logger.warning("Cannot write HMAC key to %s", target) + return None + return key + + +def compute_entry_hmac( + entry_data: dict[str, object], + *, + key_file: Path | None = None, +) -> str | None: + """Compute HMAC-SHA256 for a state entry. + + Args: + entry_data: The entry dict (without the 'hmac' field). + key_file: Where to read the signing key from. None (the default) + reads :data:`DEFAULT_HMAC_KEY_FILE` fresh on every call. + + Returns: + Hex-encoded HMAC string, or None if the key is unavailable. + """ + target = key_file if key_file is not None else DEFAULT_HMAC_KEY_FILE + key = _load_hmac_key(target) + if key is None: + return None + payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":")) + return hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() + + +def verify_entry_hmac( + entry: dict[str, object], + *, + key_file: Path | None = None, +) -> bool: + """Verify the HMAC signature of a state entry. + + Args: + entry: The full entry dict, including the 'hmac' field. + key_file: Where to read the signing key from. None (the default) + reads :data:`DEFAULT_HMAC_KEY_FILE` fresh on every call. + + Returns: + True if the HMAC is valid, False if invalid or the key is unavailable. + """ + stored_hmac = entry.get("hmac") + if not isinstance(stored_hmac, str): + return False + target = key_file if key_file is not None else DEFAULT_HMAC_KEY_FILE + key = _load_hmac_key(target) + if key is None: + return False + entry_without_hmac = {k: v for k, v in entry.items() if k != "hmac"} + payload = json.dumps(entry_without_hmac, sort_keys=True, separators=(",", ":")) + expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(stored_hmac, expected) diff --git a/gatelock/tests/__init__.py b/gatelock/tests/__init__.py new file mode 100644 index 0000000..f7f5ba7 --- /dev/null +++ b/gatelock/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the gatelock package.""" diff --git a/gatelock/tests/conftest.py b/gatelock/tests/conftest.py new file mode 100644 index 0000000..5510598 --- /dev/null +++ b/gatelock/tests/conftest.py @@ -0,0 +1,53 @@ +"""Shared fixtures for gatelock tests. + +``LockWindow`` takes its Tk root as a constructor argument (composition, not +inheritance), so tests never need a real display -- a plain ``MagicMock`` +stands in for the root everywhere. ``GateRoot`` does subclass ``tk.Tk`` +directly (that's the point: Tkinter calls ``report_callback_exception`` on +the real root), so its tests build an instance via ``__new__`` to exercise +the overridden method without running ``tk.Tk.__init__`` (which requires a +display). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from gatelock._window import LockConfig, LockWindow + +if TYPE_CHECKING: + from collections.abc import Generator + + +@pytest.fixture +def mock_root() -> MagicMock: + """A MagicMock standing in for a tkinter root window.""" + root = MagicMock() + root.winfo_screenwidth.return_value = 1920 + root.winfo_screenheight.return_value = 1080 + return root + + +@pytest.fixture +def mock_subprocess_run() -> Generator[MagicMock]: + """Block real subprocess calls (setxkbmap) and fake its presence.""" + with ( + patch("gatelock._vt.shutil.which", return_value="/usr/bin/setxkbmap"), + patch("gatelock._vt.subprocess.run") as mock, + ): + yield mock + + +def make_window( + root: MagicMock, + *, + config: LockConfig | None = None, + hooks: MagicMock | None = None, +) -> tuple[LockWindow, MagicMock]: + """Build a LockWindow over a mock root, returning it with its hooks mock.""" + hooks = hooks if hooks is not None else MagicMock() + window = LockWindow(root, config or LockConfig(), hooks) + return window, hooks diff --git a/gatelock/tests/test_log_integrity.py b/gatelock/tests/test_log_integrity.py new file mode 100644 index 0000000..9c88d38 --- /dev/null +++ b/gatelock/tests/test_log_integrity.py @@ -0,0 +1,139 @@ +"""Tests for log_integrity HMAC signing and verification.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from gatelock.log_integrity import ( + compute_entry_hmac, + generate_hmac_key, + verify_entry_hmac, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestGenerateHmacKey: + """Tests for generate_hmac_key.""" + + def test_generates_and_writes_key(self, tmp_path: Path) -> None: + """Generates a 32-byte key and writes it to the given file.""" + key_file = tmp_path / "subdir" / "hmac.key" + + result = generate_hmac_key(key_file) + + assert result is not None + assert len(result) == 32 + assert key_file.read_bytes() == result + + def test_returns_none_on_write_failure(self, tmp_path: Path) -> None: + """Returns None when the key file cannot be written.""" + key_file = tmp_path / "hmac.key" + + with patch.object( + type(key_file.parent), "mkdir", side_effect=OSError("denied") + ): + result = generate_hmac_key(key_file) + + assert result is None + + def test_defaults_to_default_key_file_path(self) -> None: + """Calling with no argument targets DEFAULT_HMAC_KEY_FILE.""" + with patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE") as mock_default: + mock_default.parent.mkdir.side_effect = OSError("denied") + result = generate_hmac_key() + + assert result is None + mock_default.parent.mkdir.assert_called_once() + + +class TestComputeEntryHmac: + """Tests for compute_entry_hmac.""" + + def test_computes_hmac_for_entry(self, tmp_path: Path) -> None: + """Produces the expected hex HMAC for a given key and entry.""" + key_file = tmp_path / "hmac.key" + key = b"test_key_12345" + key_file.write_bytes(key) + entry: dict[str, object] = { + "timestamp": "2025-01-01T00:00:00", + "workout_data": {"type": "test"}, + } + + result = compute_entry_hmac(entry, key_file=key_file) + + assert result is not None + payload = json.dumps(entry, sort_keys=True, separators=(",", ":")) + expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() + assert result == expected + + def test_returns_none_when_key_file_missing(self, tmp_path: Path) -> None: + """Returns None when the key file does not exist.""" + key_file = tmp_path / "nonexistent.key" + + result = compute_entry_hmac({"data": "test"}, key_file=key_file) + + assert result is None + + def test_defaults_to_default_key_file_path(self, tmp_path: Path) -> None: + """Calling with no key_file argument reads DEFAULT_HMAC_KEY_FILE.""" + key_file = tmp_path / "hmac.key" + key_file.write_bytes(b"default-path-key") + with patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key_file): + result = compute_entry_hmac({"data": "test"}) + + assert result is not None + + +class TestVerifyEntryHmac: + """Tests for verify_entry_hmac.""" + + def test_valid_hmac(self, tmp_path: Path) -> None: + """Verification passes when the stored HMAC matches the recomputed one.""" + key_file = tmp_path / "hmac.key" + key = b"verification_key" + key_file.write_bytes(key) + entry_data: dict[str, object] = { + "timestamp": "2025-01-01", + "workout_data": {"type": "test"}, + } + payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":")) + correct_hmac = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() + entry: dict[str, object] = {**entry_data, "hmac": correct_hmac} + + assert verify_entry_hmac(entry, key_file=key_file) is True + + def test_invalid_hmac(self, tmp_path: Path) -> None: + """Verification fails when the stored HMAC does not match.""" + key_file = tmp_path / "hmac.key" + key_file.write_bytes(b"verification_key") + entry: dict[str, object] = { + "timestamp": "2025-01-01", + "hmac": "wrong_hmac_value", + } + + assert verify_entry_hmac(entry, key_file=key_file) is False + + def test_missing_hmac_field(self, tmp_path: Path) -> None: + """Verification fails when the entry has no hmac field at all.""" + entry: dict[str, object] = {"timestamp": "2025-01-01"} + + assert verify_entry_hmac(entry, key_file=tmp_path / "hmac.key") is False + + def test_non_string_hmac_field(self, tmp_path: Path) -> None: + """Verification fails when the hmac field is not a string.""" + entry: dict[str, object] = {"timestamp": "2025-01-01", "hmac": 12345} + + assert verify_entry_hmac(entry, key_file=tmp_path / "hmac.key") is False + + def test_missing_key_file(self, tmp_path: Path) -> None: + """Verification fails when the key file does not exist.""" + key_file = tmp_path / "nonexistent.key" + entry: dict[str, object] = {"timestamp": "2025-01-01", "hmac": "some_hmac"} + + assert verify_entry_hmac(entry, key_file=key_file) is False diff --git a/gatelock/tests/test_root.py b/gatelock/tests/test_root.py new file mode 100644 index 0000000..bdc97c0 --- /dev/null +++ b/gatelock/tests/test_root.py @@ -0,0 +1,46 @@ +"""Tests for GateRoot's safe callback-exception handling.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from gatelock._root import GateRoot + + +def _make_root() -> GateRoot: + """Build a GateRoot without running tk.Tk.__init__ (no display needed).""" + return GateRoot.__new__(GateRoot) + + +def _raise_boom() -> None: + """Raise a ValueError for tests to catch via pytest.raises.""" + msg = "boom" + raise ValueError(msg) + + +class TestReportCallbackException: + """Tests for report_callback_exception.""" + + def test_calls_handler_when_set(self) -> None: + """The configured handler is invoked on a callback error.""" + root = _make_root() + handler = MagicMock() + root.on_callback_error = handler + + with pytest.raises(ValueError, match="boom") as exc_info: + _raise_boom() + + root.report_callback_exception(exc_info.type, exc_info.value, exc_info.tb) + + handler.assert_called_once_with() + + def test_does_not_raise_when_no_handler_set(self) -> None: + """No handler configured: logs only, never re-raises.""" + root = _make_root() + + with pytest.raises(ValueError, match="boom") as exc_info: + _raise_boom() + + root.report_callback_exception(exc_info.type, exc_info.value, exc_info.tb) diff --git a/gatelock/tests/test_vt.py b/gatelock/tests/test_vt.py new file mode 100644 index 0000000..9226967 --- /dev/null +++ b/gatelock/tests/test_vt.py @@ -0,0 +1,59 @@ +"""Tests for VT-switch disable/restore.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from gatelock._vt import disable_vt_switching, restore_vt_switching + +_SETXKBMAP = "/usr/bin/setxkbmap" + + +class TestDisableVtSwitching: + """Tests for disable_vt_switching.""" + + def test_runs_setxkbmap_and_returns_true( + self, mock_subprocess_run: MagicMock + ) -> None: + """Disables VT switching and reports success when setxkbmap exists.""" + result = disable_vt_switching() + + assert result is True + mock_subprocess_run.assert_called_once_with( + [_SETXKBMAP, "-option", "srvrkeys:none"], + check=False, + ) + + def test_returns_false_when_setxkbmap_missing(self) -> None: + """No subprocess call and returns False when setxkbmap is unavailable.""" + with ( + patch("gatelock._vt.shutil.which", return_value=None), + patch("gatelock._vt.subprocess.run") as mock_run, + ): + result = disable_vt_switching() + + assert result is False + mock_run.assert_not_called() + + +class TestRestoreVtSwitching: + """Tests for restore_vt_switching.""" + + def test_runs_setxkbmap(self, mock_subprocess_run: MagicMock) -> None: + """Restores VT switching when setxkbmap exists.""" + restore_vt_switching() + + mock_subprocess_run.assert_called_once_with( + [_SETXKBMAP, "-option", ""], + check=False, + ) + + def test_no_call_when_setxkbmap_missing(self) -> None: + """No subprocess call when setxkbmap is unavailable.""" + with ( + patch("gatelock._vt.shutil.which", return_value=None), + patch("gatelock._vt.subprocess.run") as mock_run, + ): + restore_vt_switching() + + mock_run.assert_not_called() diff --git a/gatelock/tests/test_window.py b/gatelock/tests/test_window.py new file mode 100644 index 0000000..42edc2b --- /dev/null +++ b/gatelock/tests/test_window.py @@ -0,0 +1,259 @@ +"""Tests for LockConfig and LockWindow.""" + +from __future__ import annotations + +import tkinter as tk +from unittest.mock import MagicMock, patch + +from gatelock._window import LockConfig +from gatelock.tests.conftest import make_window + + +class TestLockConfigResolution: + """Tests for LockConfig's per-axis resolution logic.""" + + def test_hard_mode_defaults(self) -> None: + """Hard mode resolves to overrideredirect + global grab + VT disable.""" + config = LockConfig(mode="hard") + assert config.resolved_overrideredirect() is True + assert config.resolved_grab() == "global" + assert config.resolved_disable_vt() is True + + def test_soft_mode_defaults(self) -> None: + """Soft mode resolves to no overrideredirect, no grab, no VT disable.""" + config = LockConfig(mode="soft") + assert config.resolved_overrideredirect() is False + assert config.resolved_grab() == "none" + assert config.resolved_disable_vt() is False + + def test_explicit_overrides_win_over_mode(self) -> None: + """Explicit fields override the mode preset (screen-locker demo case).""" + config = LockConfig(mode="soft", overrideredirect=True, grab="local") + assert config.resolved_overrideredirect() is True + assert config.resolved_grab() == "local" + assert config.resolved_disable_vt() is False + + def test_explicit_false_overrides_hard_mode(self) -> None: + """An explicit False/none is respected even under mode="hard".""" + config = LockConfig( + mode="hard", overrideredirect=False, grab="none", disable_vt=False + ) + assert config.resolved_overrideredirect() is False + assert config.resolved_grab() == "none" + assert config.resolved_disable_vt() is False + + +class TestSetup: + """Tests for LockWindow.setup.""" + + def test_hard_mode_sets_overrideredirect_and_disables_vt( + self, mock_root: MagicMock + ) -> None: + """Hard mode calls overrideredirect and disables VT switching.""" + window, _hooks = make_window(mock_root, config=LockConfig(mode="hard")) + + with patch( + "gatelock._window.disable_vt_switching", return_value=True + ) as mock_disable: + window.setup() + + mock_root.overrideredirect.assert_called_once_with(boolean=True) + mock_root.attributes.assert_any_call(fullscreen=True) + mock_root.attributes.assert_any_call(topmost=True) + mock_disable.assert_called_once() + assert window._vt_disabled is True + + def test_soft_mode_skips_overrideredirect_and_vt( + self, mock_root: MagicMock + ) -> None: + """Soft mode never calls overrideredirect or disables VT switching.""" + window, _hooks = make_window(mock_root, config=LockConfig(mode="soft")) + + with patch("gatelock._window.disable_vt_switching") as mock_disable: + window.setup() + + mock_root.overrideredirect.assert_not_called() + mock_disable.assert_not_called() + assert window._vt_disabled is False + + def test_uses_configured_background(self, mock_root: MagicMock) -> None: + """The configured bg color is passed to root.configure.""" + window, _hooks = make_window( + mock_root, config=LockConfig(mode="soft", bg="#000000") + ) + + window.setup() + + mock_root.configure.assert_called_once_with(bg="#000000", cursor="arrow") + + +class TestGrabInput: + """Tests for LockWindow.grab_input.""" + + def test_global_grab_dispatches_to_acquire(self, mock_root: MagicMock) -> None: + """grab="global" triggers the retry-aware acquisition path.""" + window, _hooks = make_window( + mock_root, config=LockConfig(mode="soft", grab="global") + ) + + with patch.object(window, "_acquire_global_grab") as mock_acquire: + window.grab_input() + + mock_acquire.assert_called_once_with(attempt=1) + mock_root.after.assert_called_once_with(100, window._notify_focus_ready) + + def test_local_grab_calls_grab_set(self, mock_root: MagicMock) -> None: + """grab="local" calls grab_set directly, no retry logic.""" + window, _hooks = make_window( + mock_root, config=LockConfig(mode="soft", grab="local") + ) + + window.grab_input() + + mock_root.grab_set.assert_called_once_with() + + def test_local_grab_swallows_tclerror(self, mock_root: MagicMock) -> None: + """A TclError from grab_set (e.g. window already gone) is swallowed.""" + mock_root.grab_set.side_effect = tk.TclError("gone") + window, _hooks = make_window( + mock_root, config=LockConfig(mode="soft", grab="local") + ) + + window.grab_input() # must not raise + + def test_none_grab_takes_no_grab_action(self, mock_root: MagicMock) -> None: + """grab="none" calls neither grab_set nor grab_set_global.""" + window, _hooks = make_window( + mock_root, config=LockConfig(mode="soft", grab="none") + ) + + window.grab_input() + + mock_root.grab_set.assert_not_called() + mock_root.grab_set_global.assert_not_called() + + +class TestAcquireGlobalGrab: + """Tests for LockWindow._acquire_global_grab.""" + + def test_success_focuses_and_notifies(self, mock_root: MagicMock) -> None: + """A successful grab forces focus and notifies the hook.""" + window, hooks = make_window(mock_root, config=LockConfig(mode="hard")) + + window._acquire_global_grab(attempt=1) + + mock_root.grab_set_global.assert_called_once_with() + mock_root.focus_force.assert_called_once_with() + hooks.on_focus_ready.assert_called_once_with() + + def test_success_swallows_tclerror_from_focus(self, mock_root: MagicMock) -> None: + """A TclError while focusing after a successful grab is swallowed.""" + mock_root.focus_force.side_effect = tk.TclError("gone") + window, hooks = make_window(mock_root, config=LockConfig(mode="hard")) + + window._acquire_global_grab(attempt=1) # must not raise + + hooks.on_focus_ready.assert_not_called() + + def test_failure_with_retry_zero_falls_back_to_local( + self, mock_root: MagicMock + ) -> None: + """grab_retry_ms=0 falls back to a local grab on the first failure.""" + mock_root.grab_set_global.side_effect = tk.TclError("held by another client") + window, _hooks = make_window( + mock_root, config=LockConfig(mode="hard", grab_retry_ms=0) + ) + + window._acquire_global_grab(attempt=1) + + mock_root.grab_set.assert_called_once_with() + mock_root.after.assert_not_called() + + def test_failure_with_retry_zero_swallows_local_grab_tclerror( + self, mock_root: MagicMock + ) -> None: + """The local-grab fallback itself swallows a TclError too.""" + mock_root.grab_set_global.side_effect = tk.TclError("held") + mock_root.grab_set.side_effect = tk.TclError("also gone") + window, _hooks = make_window( + mock_root, config=LockConfig(mode="hard", grab_retry_ms=0) + ) + + window._acquire_global_grab(attempt=1) # must not raise + + def test_failure_with_default_retry_schedules_retry( + self, mock_root: MagicMock + ) -> None: + """Default (None) retry interval reschedules every 200ms, logging every 25th.""" + mock_root.grab_set_global.side_effect = tk.TclError("held") + window, _hooks = make_window(mock_root, config=LockConfig(mode="hard")) + + window._acquire_global_grab(attempt=24) + + assert mock_root.after.call_count == 1 + scheduled_delay = mock_root.after.call_args[0][0] + assert scheduled_delay == 200 + + def test_failure_logs_every_grab_log_every_attempts( + self, mock_root: MagicMock + ) -> None: + """A warning is logged only when attempt is a multiple of grab_log_every.""" + mock_root.grab_set_global.side_effect = tk.TclError("held") + window, _hooks = make_window( + mock_root, config=LockConfig(mode="hard", grab_log_every=5) + ) + + with patch("gatelock._window._logger") as mock_logger: + window._acquire_global_grab(attempt=5) + mock_logger.warning.assert_called_once() + + with patch("gatelock._window._logger") as mock_logger: + window._acquire_global_grab(attempt=3) + mock_logger.warning.assert_not_called() + + def test_failure_with_custom_retry_ms_uses_it(self, mock_root: MagicMock) -> None: + """An explicit positive grab_retry_ms is used as the reschedule delay.""" + mock_root.grab_set_global.side_effect = tk.TclError("held") + window, _hooks = make_window( + mock_root, config=LockConfig(mode="hard", grab_retry_ms=50) + ) + + window._acquire_global_grab(attempt=1) + + scheduled_delay = mock_root.after.call_args[0][0] + assert scheduled_delay == 50 + + def test_rescheduled_callback_increments_attempt( + self, mock_root: MagicMock + ) -> None: + """The rescheduled callback re-invokes with attempt + 1.""" + mock_root.grab_set_global.side_effect = tk.TclError("held") + window, _hooks = make_window(mock_root, config=LockConfig(mode="hard")) + + window._acquire_global_grab(attempt=1) + scheduled_callback = mock_root.after.call_args[0][1] + + with patch.object(window, "_acquire_global_grab") as mock_acquire: + scheduled_callback() + + mock_acquire.assert_called_once_with(attempt=2) + + +class TestNotifyFocusReady: + """Tests for LockWindow._notify_focus_ready.""" + + def test_calls_hook(self, mock_root: MagicMock) -> None: + """The on_focus_ready hook is invoked.""" + window, hooks = make_window(mock_root) + + window._notify_focus_ready() + + hooks.on_focus_ready.assert_called_once_with() + + def test_swallows_tclerror_from_hook(self, mock_root: MagicMock) -> None: + """A TclError raised by the hook (widget already destroyed) is swallowed.""" + hooks = MagicMock() + hooks.on_focus_ready.side_effect = tk.TclError("destroyed") + window, _hooks = make_window(mock_root, hooks=hooks) + + window._notify_focus_ready() # must not raise diff --git a/gatelock/tests/test_window_lifecycle.py b/gatelock/tests/test_window_lifecycle.py new file mode 100644 index 0000000..88f84e7 --- /dev/null +++ b/gatelock/tests/test_window_lifecycle.py @@ -0,0 +1,167 @@ +"""Tests for LockWindow signal handling, keepalive, close, and run lifecycle.""" + +from __future__ import annotations + +import tkinter as tk +from unittest.mock import MagicMock, patch + +import pytest + +from gatelock.tests.conftest import make_window + + +class TestSignalLifecycle: + """Tests for signal handler installation and dispatch.""" + + def test_install_signal_handlers_registers_atexit_and_signals( + self, mock_root: MagicMock + ) -> None: + """atexit and SIGTERM/SIGINT handlers are registered.""" + window, _hooks = make_window(mock_root) + + with ( + patch("gatelock._window.atexit.register") as mock_atexit, + patch("gatelock._window.signal.signal") as mock_signal, + ): + window._install_signal_handlers() + + mock_atexit.assert_called_once_with(window._restore_vt) + assert mock_signal.call_count == 2 + + def test_install_signal_handlers_swallows_value_error( + self, mock_root: MagicMock + ) -> None: + """A ValueError from signal.signal (e.g. not main thread) is swallowed.""" + window, _hooks = make_window(mock_root) + + with ( + patch("gatelock._window.atexit.register"), + patch( + "gatelock._window.signal.signal", + side_effect=ValueError("not main thread"), + ), + ): + window._install_signal_handlers() # must not raise + + def test_on_signal_raises_system_exit(self, mock_root: MagicMock) -> None: + """The signal handler raises SystemExit(0) to unwind run()'s finally.""" + window, _hooks = make_window(mock_root) + + with pytest.raises(SystemExit) as exc_info: + window._on_signal(15, None) + + assert exc_info.value.code == 0 + + +class TestKeepalive: + """Tests for LockWindow._keepalive.""" + + def test_reschedules_itself(self, mock_root: MagicMock) -> None: + """Reschedules another keepalive tick via root.after.""" + window, _hooks = make_window(mock_root) + + window._keepalive() + + mock_root.after.assert_called_once_with(250, window._keepalive) + + def test_swallows_tclerror_when_window_gone(self, mock_root: MagicMock) -> None: + """A TclError (window destroyed) from root.after is swallowed.""" + mock_root.after.side_effect = tk.TclError("destroyed") + window, _hooks = make_window(mock_root) + + window._keepalive() # must not raise + + +class TestRestoreVt: + """Tests for LockWindow._restore_vt.""" + + def test_restores_when_disabled(self, mock_root: MagicMock) -> None: + """Calls restore_vt_switching and clears the flag when it was disabled.""" + window, _hooks = make_window(mock_root) + window._vt_disabled = True + + with patch("gatelock._window.restore_vt_switching") as mock_restore: + window._restore_vt() + + mock_restore.assert_called_once_with() + assert window._vt_disabled is False + + def test_noop_when_never_disabled(self, mock_root: MagicMock) -> None: + """Does nothing when VT switching was never disabled.""" + window, _hooks = make_window(mock_root) + + with patch("gatelock._window.restore_vt_switching") as mock_restore: + window._restore_vt() + + mock_restore.assert_not_called() + + +class TestClose: + """Tests for LockWindow.close.""" + + def test_runs_teardown_restores_vt_and_destroys(self, mock_root: MagicMock) -> None: + """A normal close runs the hook, restores VT, and destroys the root.""" + window, hooks = make_window(mock_root) + window._vt_disabled = True + + with patch("gatelock._window.restore_vt_switching") as mock_restore: + window.close() + + hooks.on_close.assert_called_once_with() + mock_restore.assert_called_once_with() + mock_root.destroy.assert_called_once_with() + + def test_idempotent_second_call_is_noop(self, mock_root: MagicMock) -> None: + """Calling close() twice only runs teardown once.""" + window, hooks = make_window(mock_root) + + window.close() + window.close() + + hooks.on_close.assert_called_once_with() + mock_root.destroy.assert_called_once_with() + + def test_swallows_tclerror_from_destroy(self, mock_root: MagicMock) -> None: + """A TclError from destroy() (already gone) is swallowed.""" + mock_root.destroy.side_effect = tk.TclError("already destroyed") + window, _hooks = make_window(mock_root) + + window.close() # must not raise + + +class TestRun: + """Tests for LockWindow.run.""" + + def test_runs_mainloop_then_closes(self, mock_root: MagicMock) -> None: + """Installs signal handlers, keeps alive, runs mainloop, then closes.""" + window, hooks = make_window(mock_root) + + with ( + patch.object(window, "_install_signal_handlers") as mock_install, + patch.object(window, "_keepalive") as mock_keepalive, + ): + window.run() + + mock_install.assert_called_once_with() + mock_keepalive.assert_called_once_with() + mock_root.mainloop.assert_called_once_with() + hooks.on_close.assert_called_once_with() + + def test_closes_even_if_mainloop_raises(self, mock_root: MagicMock) -> None: + """A SystemExit out of mainloop still runs close() via finally.""" + mock_root.mainloop.side_effect = SystemExit(0) + window, hooks = make_window(mock_root) + + with ( + patch.object(window, "_install_signal_handlers"), + patch.object(window, "_keepalive"), + ): + try: + window.run() + except SystemExit: + pass + else: + msg = "expected SystemExit to propagate" + raise AssertionError(msg) + + hooks.on_close.assert_called_once_with() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0abff25 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,149 @@ +[project] +name = "gatelock" +version = "0.1.0" +description = "Shared fullscreen lock-window + HMAC log-integrity backend for blocking-overlay apps" +requires-python = ">=3.10" +dependencies = [] # pure stdlib — tkinter is bundled with Python + +[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" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"] +"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.isort] +force-single-line = false +force-sort-within-sections = true +known-first-party = ["gatelock"] + +[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 = ["gatelock/tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--strict-markers", + "--strict-config", + "-ra", + "--cov=gatelock", + "--cov-branch", + "--cov-report=term-missing", + "--cov-report=lcov", +] +filterwarnings = [ + "error", + "ignore::DeprecationWarning", + "default::pytest.PytestUnraisableExceptionWarning", +] + +[tool.coverage.run] +source = ["gatelock"] +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"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6cd88cc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +bandit[toml]>=1.7 +mypy>=1.13 +pre-commit>=4.0 +pylint>=3.3 +pytest>=8.0 +pytest-cov>=5.0 +ruff>=0.15