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.
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-19 17:39:44 +02:00
commit f1f3f742c6
20 changed files with 1600 additions and 0 deletions

19
.github/workflows/pre-commit.yml vendored Normal file
View File

@ -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

28
.github/workflows/python-tests.yml vendored Normal file
View File

@ -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

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
__pycache__/
*.pyc
.venv/
.mypy_cache/
.ruff_cache/
.pytest_cache/
.coverage
coverage.lcov
htmlcov/
dist/
build/
*.egg-info/

122
.pre-commit-config.yaml Normal file
View File

@ -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

21
LICENSE Normal file
View File

@ -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.

73
README.md Normal file
View File

@ -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
```

16
gatelock/__init__.py Normal file
View File

@ -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",
]

36
gatelock/_root.py Normal file
View File

@ -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()

37
gatelock/_vt.py Normal file
View File

@ -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)

246
gatelock/_window.py Normal file
View File

@ -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()

110
gatelock/log_integrity.py Normal file
View File

@ -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)

View File

@ -0,0 +1 @@
"""Tests for the gatelock package."""

View File

@ -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

View File

@ -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

View File

@ -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)

59
gatelock/tests/test_vt.py Normal file
View File

@ -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()

View File

@ -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

View File

@ -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()

149
pyproject.toml Normal file
View File

@ -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"]

7
requirements.txt Normal file
View File

@ -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