mirror of
https://github.com/kuhyx/gatelock.git
synced 2026-07-04 13:23:16 +02:00
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:
commit
1a99424dbd
19
.github/workflows/pre-commit.yml
vendored
Normal file
19
.github/workflows/pre-commit.yml
vendored
Normal 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
28
.github/workflows/python-tests.yml
vendored
Normal 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
12
.gitignore
vendored
Normal 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
122
.pre-commit-config.yaml
Normal 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
21
LICENSE
Normal 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
73
README.md
Normal 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
16
gatelock/__init__.py
Normal 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
36
gatelock/_root.py
Normal 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
37
gatelock/_vt.py
Normal 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
246
gatelock/_window.py
Normal 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
110
gatelock/log_integrity.py
Normal 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)
|
||||||
1
gatelock/tests/__init__.py
Normal file
1
gatelock/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the gatelock package."""
|
||||||
53
gatelock/tests/conftest.py
Normal file
53
gatelock/tests/conftest.py
Normal 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
|
||||||
139
gatelock/tests/test_log_integrity.py
Normal file
139
gatelock/tests/test_log_integrity.py
Normal 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
|
||||||
46
gatelock/tests/test_root.py
Normal file
46
gatelock/tests/test_root.py
Normal 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
59
gatelock/tests/test_vt.py
Normal 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()
|
||||||
259
gatelock/tests/test_window.py
Normal file
259
gatelock/tests/test_window.py
Normal 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
|
||||||
167
gatelock/tests/test_window_lifecycle.py
Normal file
167
gatelock/tests/test_window_lifecycle.py
Normal 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
149
pyproject.toml
Normal 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
7
requirements.txt
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user