diff --git a/gatelock/.pre-commit-config.yaml b/gatelock/.pre-commit-config.yaml index 38695e0..8957b02 100644 --- a/gatelock/.pre-commit-config.yaml +++ b/gatelock/.pre-commit-config.yaml @@ -101,7 +101,7 @@ repos: - --disable-error-code=list-item - repo: https://github.com/pylint-dev/pylint - rev: v3.3.2 + rev: v4.0.8 hooks: - id: pylint args: diff --git a/gatelock/gatelock/__init__.py b/gatelock/gatelock/__init__.py index 6c1a2b2..e495b32 100644 --- a/gatelock/gatelock/__init__.py +++ b/gatelock/gatelock/__init__.py @@ -13,11 +13,8 @@ from gatelock._arbiter import ( RANK_SCREEN_LOCKER, RANK_WAKE_ALARM, Arbiter, - ArbiterVerdict, - Claim, - default_runtime_dir, - grab_strength, ) +from gatelock._claims import ArbiterVerdict, Claim, default_runtime_dir, grab_strength from gatelock._detect import OutputChangeDetector from gatelock._escape import ( EscapeDraft, diff --git a/gatelock/gatelock/_arbiter.py b/gatelock/gatelock/_arbiter.py index 90215fb..ba293b1 100644 --- a/gatelock/gatelock/_arbiter.py +++ b/gatelock/gatelock/_arbiter.py @@ -42,7 +42,6 @@ from gatelock._claims import ( Claim, _try_lock, default_runtime_dir, - grab_strength, live_claims, read_claim_if_held, ) @@ -52,20 +51,6 @@ if TYPE_CHECKING: _logger = logging.getLogger(__name__) -# Re-exported after the 250-line split: `from gatelock._arbiter import -# Claim` is used by _window, the tests and gatelock's own __init__. -__all__ = [ - "RANK_DIET_GUARD", - "RANK_SCREEN_LOCKER", - "RANK_WAKE_ALARM", - "Arbiter", - "ArbiterVerdict", - "Claim", - "default_runtime_dir", - "grab_strength", -] - - RANK_WAKE_ALARM: Final = 300 RANK_SCREEN_LOCKER: Final = 200 RANK_DIET_GUARD: Final = 100 diff --git a/gatelock/gatelock/_detect.py b/gatelock/gatelock/_detect.py index 1506193..93dbb4d 100644 --- a/gatelock/gatelock/_detect.py +++ b/gatelock/gatelock/_detect.py @@ -32,6 +32,8 @@ import threading import tkinter as tk from typing import Any +from gatelock._xlib import load_xlib + _logger = logging.getLogger(__name__) _SELECT_TIMEOUT_S = 0.5 @@ -64,15 +66,14 @@ class _RandrEventSource: def _connect(self) -> bool: """Open the connection and subscribe to layout changes.""" - try: - from Xlib import display as xdisplay - from Xlib.ext import randr - except ImportError: + modules = load_xlib("Xlib.display", "Xlib.ext.randr") + if modules is None: _logger.info( "python-xlib is not installed; falling back to Tk " "plus xrandr polling for output-change detection" ) return False + xdisplay, randr = modules try: self._display = xdisplay.Display() root = self._display.screen().root diff --git a/gatelock/gatelock/_escape.py b/gatelock/gatelock/_escape.py index f726f55..ad4296d 100644 --- a/gatelock/gatelock/_escape.py +++ b/gatelock/gatelock/_escape.py @@ -31,11 +31,6 @@ import json import logging from typing import TYPE_CHECKING, Any -from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac - -if TYPE_CHECKING: - from pathlib import Path - from gatelock._escape_types import ( EscapeDraft, EscapeHistory, @@ -43,6 +38,10 @@ from gatelock._escape_types import ( _parse_iso, _today_iso, ) +from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac + +if TYPE_CHECKING: + from pathlib import Path _logger = logging.getLogger(__name__) diff --git a/gatelock/gatelock/_outputs.py b/gatelock/gatelock/_outputs.py index 2f49160..f7ca46c 100644 --- a/gatelock/gatelock/_outputs.py +++ b/gatelock/gatelock/_outputs.py @@ -17,11 +17,11 @@ Two backends compute the same predicate: * **``xrandr --query``** text -- a re-derivation of the same predicate for systems without ``python-xlib``. -``python-xlib`` is an optional dependency, so its import lives inside a -function rather than at module scope. That is deliberate and load-bearing: a -module-scope ``try/except ImportError`` is resolved at import time, which makes -the ``except`` arm permanently unreachable for branch coverage -- and this is -precisely the degradation path that must stay tested. +``python-xlib`` is an optional dependency, loaded on demand through +:mod:`gatelock._xlib` rather than at module scope. That is deliberate and +load-bearing: a module-scope ``try/except ImportError`` is resolved at import +time, which makes the ``except`` arm permanently unreachable for branch +coverage -- and this is precisely the degradation path that must stay tested. """ from __future__ import annotations @@ -30,13 +30,13 @@ import logging import re from typing import TYPE_CHECKING -if TYPE_CHECKING: - import tkinter as tk - from gatelock._output_types import Output, OutputRect, OutputScan from gatelock._randr import RandrBackend from gatelock._xrandr import parse_xrandr_query, scan_xrandr +if TYPE_CHECKING: + import tkinter as tk + # Re-exported after the 250-line split, so `from gatelock._outputs import # Output` -- which the tests and sibling modules do -- keeps resolving. __all__ = [ diff --git a/gatelock/gatelock/_preempt.py b/gatelock/gatelock/_preempt.py index 5c8f1a0..5f23251 100644 --- a/gatelock/gatelock/_preempt.py +++ b/gatelock/gatelock/_preempt.py @@ -19,7 +19,8 @@ import signal from typing import TYPE_CHECKING if TYPE_CHECKING: - from gatelock._arbiter import Arbiter, Claim + from gatelock._arbiter import Arbiter + from gatelock._claims import Claim from gatelock._config import LockConfig _logger = logging.getLogger(__name__) diff --git a/gatelock/gatelock/_queue.py b/gatelock/gatelock/_queue.py index 1b22bb6..3b450e4 100644 --- a/gatelock/gatelock/_queue.py +++ b/gatelock/gatelock/_queue.py @@ -40,7 +40,8 @@ QUEUE_HEARTBEAT_SECONDS: Final = 300.0 if TYPE_CHECKING: from collections.abc import Callable - from gatelock._arbiter import Arbiter, Claim + from gatelock._arbiter import Arbiter + from gatelock._claims import Claim @dataclass(frozen=True) diff --git a/gatelock/gatelock/_randr.py b/gatelock/gatelock/_randr.py index 4982f7b..4fe2c40 100644 --- a/gatelock/gatelock/_randr.py +++ b/gatelock/gatelock/_randr.py @@ -13,6 +13,7 @@ import logging from typing import TYPE_CHECKING, Protocol from gatelock._output_types import Output, OutputRect +from gatelock._xlib import load_xlib if TYPE_CHECKING: from collections.abc import Sequence @@ -41,23 +42,27 @@ if TYPE_CHECKING: output: int class _XRoot(Protocol): - def xrandr_get_screen_resources(self) -> _ScreenResources: ... + def xrandr_get_screen_resources(self) -> _ScreenResources: + """Mirror ``root.xrandr_get_screen_resources()``.""" - def xrandr_get_output_primary(self) -> _PrimaryReply: ... + def xrandr_get_output_primary(self) -> _PrimaryReply: + """Mirror ``root.xrandr_get_output_primary()``.""" class _XScreen(Protocol): root: _XRoot class _XDisplay(Protocol): - def screen(self) -> _XScreen: ... + def screen(self) -> _XScreen: + """Mirror ``Display.screen()``.""" - def xrandr_get_output_info( - self, output: int, timestamp: int - ) -> _OutputInfo: ... + def xrandr_get_output_info(self, output: int, timestamp: int) -> _OutputInfo: + """Mirror ``Display.xrandr_get_output_info()``.""" - def xrandr_get_crtc_info(self, crtc: int, timestamp: int) -> _CrtcInfo: ... + def xrandr_get_crtc_info(self, crtc: int, timestamp: int) -> _CrtcInfo: + """Mirror ``Display.xrandr_get_crtc_info()``.""" - def close(self) -> None: ... + def close(self) -> None: + """Mirror ``Display.close()``.""" _logger = logging.getLogger(__name__) @@ -88,16 +93,15 @@ class RandrBackend: @classmethod def create(cls) -> RandrBackend | None: """Open an X connection for RandR queries, or None if unavailable.""" - try: - from Xlib import display as xdisplay - except ImportError: + modules = load_xlib("Xlib.display") + if modules is None: _logger.debug( "python-xlib is not installed; RandR events and RandR " "enumeration are unavailable, falling back to xrandr" ) return None try: - return cls(xdisplay.Display()) + return cls(modules[0].Display()) except (OSError, ValueError) as exc: _logger.warning("could not open an X display for RandR: %s", exc) return None diff --git a/gatelock/gatelock/_recovery.py b/gatelock/gatelock/_recovery.py index 06ae94e..7cf7966 100644 --- a/gatelock/gatelock/_recovery.py +++ b/gatelock/gatelock/_recovery.py @@ -56,11 +56,7 @@ class RecoveryLoop: def __init__(self, root: tk.Misc, collaborators: RecoveryCollaborators) -> None: """Wire the loop to the pieces it re-asserts over.""" self._root = root - self._config = collaborators.config - self._surfaces = collaborators.surfaces - self._enumerator = collaborators.enumerator - self._detector = collaborators.detector - self._hooks = collaborators.hooks + self._parts = collaborators self._running = False self._ticks = 0 self._drain_job: str | None = None @@ -92,18 +88,22 @@ class RecoveryLoop: """Queue the next cheap drain tick.""" if not self._running: return - self._drain_job = self._root.after(self._config.detect_drain_ms, self._drain) + self._drain_job = self._root.after( + self._parts.config.detect_drain_ms, self._drain + ) def _schedule_verify(self) -> None: """Queue the next full verify tick.""" if not self._running: return - self._verify_job = self._root.after(self._config.recovery_tick_ms, self._verify) + self._verify_job = self._root.after( + self._parts.config.recovery_tick_ms, self._verify + ) def _drain(self) -> None: """Run a full tick only if a push signal arrived. Cheap otherwise.""" try: - if self._detector.take_pending(): + if self._parts.detector.take_pending(): _logger.debug("output-change signal received; re-asserting the lock") self.tick() # Same fail-open reasoning as _verify. @@ -136,7 +136,7 @@ class RecoveryLoop: What this pass saw and corrected. """ self._ticks += 1 - scan = self._enumerator.scan() + scan = self._parts.enumerator.scan() if not scan.ok: # No information is not a reason to change anything. Keep every @@ -146,9 +146,9 @@ class RecoveryLoop: ) return RecoveryReport(scan_ok=False, source=scan.source) - self._surfaces.update_backdrop() - delta = self._surfaces.apply(scan) - corrected = self._surfaces.enforce() + self._parts.surfaces.update_backdrop() + delta = self._parts.surfaces.apply(scan) + corrected = self._parts.surfaces.enforce() grab_reasserted = self._reassert_grab() vt_reasserted = self._reassert_vt() if delta.created: @@ -191,7 +191,7 @@ class RecoveryLoop: Returns: True if the grab had to be re-taken. """ - if self._config.resolved_grab() != "global": + if self._parts.config.resolved_grab() != "global": return False if self.holds_grab(): return False @@ -226,8 +226,8 @@ class RecoveryLoop: that widget the actual Tk focus target. """ with contextlib.suppress(tk.TclError): - index = self._surfaces.preferred_focus_index() - self._hooks.on_focus_ready(self._surfaces.focus_surface(index)) + index = self._parts.surfaces.preferred_focus_index() + self._parts.hooks.on_focus_ready(self._parts.surfaces.focus_surface(index)) def _reassert_vt(self) -> bool: """Periodically re-disable VT switching. Never re-enables it. @@ -235,7 +235,7 @@ class RecoveryLoop: Returns: True on ticks where the re-assert ran. """ - if not self._config.resolved_disable_vt(): + if not self._parts.config.resolved_disable_vt(): return False if self._ticks % _VT_REASSERT_EVERY: return False diff --git a/gatelock/gatelock/_scrollfit.py b/gatelock/gatelock/_scrollfit.py index 528348d..81e132e 100644 --- a/gatelock/gatelock/_scrollfit.py +++ b/gatelock/gatelock/_scrollfit.py @@ -150,9 +150,9 @@ def request_content_size(target: FitTarget) -> None: """ wanted_w = target.content.winfo_reqwidth() wanted_h = target.content.winfo_reqheight() - bar = target.scrollbar.winfo_reqwidth() if target.scrollbar else 0 + bar_w = target.scrollbar.winfo_reqwidth() if target.scrollbar else 0 target.canvas.configure( - width=max(1, min(wanted_w, target.canvas.winfo_screenwidth() - bar)), + width=max(1, min(wanted_w, target.canvas.winfo_screenwidth() - bar_w)), height=max(1, min(wanted_h, target.canvas.winfo_screenheight())), ) @@ -173,7 +173,7 @@ def report_overflow(target: FitTarget) -> None: # A viewport Tk has not laid out yet reports 1px, which is not an # overflow -- reporting it would cry wolf on every startup and teach # the reader to ignore the one message that matters. - if view_h > _UNMAPPED_VIEWPORT_PX and wanted_h > view_h: + if _UNMAPPED_VIEWPORT_PX < view_h < wanted_h: _logger.warning( "lock content overflows its surface: %dpx of content in a " "%dpx viewport (%dpx off-screen); it is reachable only by " diff --git a/gatelock/gatelock/_window.py b/gatelock/gatelock/_window.py index fa0f3d2..edfaa41 100644 --- a/gatelock/gatelock/_window.py +++ b/gatelock/gatelock/_window.py @@ -103,25 +103,27 @@ class LockWindow: self._closed = False self._focus_notified = False self._preempted_pids: set[int] = set() - self._surfaces = SurfaceSet(root, config, hooks) - self._enumerator = OutputEnumerator(root) - self._detector = OutputChangeDetector(root) - self._recovery = RecoveryLoop( + surfaces = SurfaceSet(root, config, hooks) + enumerator = OutputEnumerator(root) + detector = OutputChangeDetector(root) + recovery = RecoveryLoop( root, RecoveryCollaborators( config=config, - surfaces=self._surfaces, - enumerator=self._enumerator, - detector=self._detector, + surfaces=surfaces, + enumerator=enumerator, + detector=detector, hooks=hooks, ), ) + # The arming bundle is also where the window reaches its + # collaborators afterwards; it is the one owner of those four. self._arming = _arming.ArmingCollaborators( config=config, - surfaces=self._surfaces, - enumerator=self._enumerator, - detector=self._detector, - recovery=self._recovery, + surfaces=surfaces, + enumerator=enumerator, + detector=detector, + recovery=recovery, notify_focus_ready=self._notify_focus_ready, log_grab_blocked=self._log_grab_blocked, ) @@ -129,7 +131,7 @@ class LockWindow: @property def surfaces(self) -> SurfaceSet: """The live surface set, for apps that need to fan work out over it.""" - return self._surfaces + return self._arming.surfaces # -- window mechanics ----------------------------------------------------- @@ -179,8 +181,8 @@ class LockWindow: return self._focus_notified = True with contextlib.suppress(tk.TclError): - index = self._surfaces.preferred_focus_index() - self._hooks.on_focus_ready(self._surfaces.focus_surface(index)) + index = self._arming.surfaces.preferred_focus_index() + self._hooks.on_focus_ready(self._arming.surfaces.focus_surface(index)) # -- lifecycle -------------------------------------------------------- @@ -222,15 +224,15 @@ class LockWindow: if self._closed: return self._closed = True - self._recovery.stop() - self._detector.stop() + self._arming.recovery.stop() + self._arming.detector.stop() self._hooks.on_close() self._restore_vt() if self._arbiter is not None: self._arbiter.release() - self._enumerator.close() + self._arming.enumerator.close() with contextlib.suppress(tk.TclError): - self._surfaces.destroy_all() + self._arming.surfaces.destroy_all() with contextlib.suppress(tk.TclError): self.root.destroy() diff --git a/gatelock/gatelock/_xlib.py b/gatelock/gatelock/_xlib.py new file mode 100644 index 0000000..08ba2e9 --- /dev/null +++ b/gatelock/gatelock/_xlib.py @@ -0,0 +1,30 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Loading the optional ``python-xlib`` dependency, in one place. + +``python-xlib`` is optional: without it, output detection falls back to Tk +```` events plus ``xrandr`` polling. The fallback is the path that +must stay tested, and a module-scope ``try/except ImportError`` would resolve +it once at import time, leaving the ``except`` arm permanently unreachable for +branch coverage. So the modules are loaded on demand, through :mod:`importlib`, +by the two call sites that need them. +""" + +from __future__ import annotations + +import importlib +from types import ModuleType + + +def load_xlib(*names: str) -> tuple[ModuleType, ...] | None: + """Import the named ``Xlib`` modules, or None if python-xlib is unavailable. + + Args: + names: Fully-qualified module names, e.g. ``"Xlib.display"``. + + Returns: + The modules in the order asked for, or None on the first ImportError. + """ + try: + return tuple(importlib.import_module(name) for name in names) + except ImportError: + return None diff --git a/gatelock/gatelock/tests/test_arbiter.py b/gatelock/gatelock/tests/test_arbiter.py index 8043510..08e1b08 100644 --- a/gatelock/gatelock/tests/test_arbiter.py +++ b/gatelock/gatelock/tests/test_arbiter.py @@ -18,11 +18,14 @@ from gatelock._arbiter import ( RANK_SCREEN_LOCKER, RANK_WAKE_ALARM, Arbiter, +) +from gatelock._claims import ( Claim, + _same_file, + _try_lock, default_runtime_dir, grab_strength, ) -from gatelock._claims import _same_file, _try_lock if TYPE_CHECKING: from collections.abc import Iterator diff --git a/gatelock/gatelock/tests/test_arbiter_lifecycle.py b/gatelock/gatelock/tests/test_arbiter_lifecycle.py index 74b82f6..519e11c 100644 --- a/gatelock/gatelock/tests/test_arbiter_lifecycle.py +++ b/gatelock/gatelock/tests/test_arbiter_lifecycle.py @@ -18,8 +18,8 @@ from gatelock._arbiter import ( RANK_SCREEN_LOCKER, RANK_WAKE_ALARM, Arbiter, - Claim, ) +from gatelock._claims import Claim if TYPE_CHECKING: from collections.abc import Iterator diff --git a/gatelock/gatelock/tests/test_arbiter_release.py b/gatelock/gatelock/tests/test_arbiter_release.py index aa35ab4..58f6692 100644 --- a/gatelock/gatelock/tests/test_arbiter_release.py +++ b/gatelock/gatelock/tests/test_arbiter_release.py @@ -18,8 +18,8 @@ from gatelock._arbiter import ( RANK_SCREEN_LOCKER, RANK_WAKE_ALARM, Arbiter, - Claim, ) +from gatelock._claims import Claim if TYPE_CHECKING: from collections.abc import Iterator diff --git a/gatelock/gatelock/tests/test_recovery.py b/gatelock/gatelock/tests/test_recovery.py index 1d71fa1..1cf0d88 100644 --- a/gatelock/gatelock/tests/test_recovery.py +++ b/gatelock/gatelock/tests/test_recovery.py @@ -122,7 +122,7 @@ class TestScheduling: """No change signal means no full pass.""" recovery, enumerator, _surfaces = loop enumerator.scan.return_value = BOTH - recovery._detector.take_pending.return_value = False + recovery._parts.detector.take_pending.return_value = False recovery._running = True recovery._drain() enumerator.scan.assert_not_called() @@ -133,7 +133,7 @@ class TestScheduling: """A pending change triggers a full pass.""" recovery, enumerator, _surfaces = loop enumerator.scan.return_value = BOTH - recovery._detector.take_pending.return_value = True + recovery._parts.detector.take_pending.return_value = True recovery._running = True recovery._drain() enumerator.scan.assert_called_once_with() @@ -172,7 +172,7 @@ class TestScheduling: """Same for the cheap cadence.""" recovery, enumerator, _surfaces = loop enumerator.scan.side_effect = RuntimeError("boom") - recovery._detector.take_pending.return_value = True + recovery._parts.detector.take_pending.return_value = True recovery._running = True with caplog.at_level(logging.ERROR): recovery._drain() diff --git a/gatelock/gatelock/tests/test_window_surfaces.py b/gatelock/gatelock/tests/test_window_surfaces.py index 4657737..c0840a3 100644 --- a/gatelock/gatelock/tests/test_window_surfaces.py +++ b/gatelock/gatelock/tests/test_window_surfaces.py @@ -8,8 +8,8 @@ from unittest.mock import MagicMock, patch from gatelock._arbiter import ( RANK_DIET_GUARD, RANK_SCREEN_LOCKER, - Claim, ) +from gatelock._claims import Claim from gatelock._detect import _RandrEventSource from gatelock._outputs import Output, OutputRect from gatelock._window import LockConfig diff --git a/gatelock/gatelock/tests/test_window_surfaces_preempt.py b/gatelock/gatelock/tests/test_window_surfaces_preempt.py index a23c00d..5f62b26 100644 --- a/gatelock/gatelock/tests/test_window_surfaces_preempt.py +++ b/gatelock/gatelock/tests/test_window_surfaces_preempt.py @@ -15,8 +15,8 @@ from gatelock._arbiter import ( RANK_DIET_GUARD, RANK_SCREEN_LOCKER, RANK_WAKE_ALARM, - Claim, ) +from gatelock._claims import Claim from gatelock._detect import _RandrEventSource from gatelock._outputs import Output, OutputRect from gatelock._window import LockConfig diff --git a/gatelock/pyproject.toml b/gatelock/pyproject.toml index 6507c63..ada4e76 100644 --- a/gatelock/pyproject.toml +++ b/gatelock/pyproject.toml @@ -95,16 +95,40 @@ color_output = true exclude = [".venv/"] [tool.pylint.main] +# pre-commit runs from the monorepo root (~/src/utils), so the package dir +# goes on the path for `import gatelock` to resolve the same way it does +# under pytest -- otherwise pylint reports import-error for the package's +# own modules. +init-hook = "import sys; sys.path.insert(0, 'gatelock')" analyse-fallback-blocks = true persistent = true jobs = 0 py-version = "3.14" ignore = [".venv", "__pycache__"] +# Test suites intentionally use patterns (protected-access, fixture-arg +# shadowing) that don't apply to source code; ruff's per-file-ignores carry +# the equivalent exemptions, so tests stay linted -- by ruff. This is the same +# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has +# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the +# basename of what pylint is *given*, and pre-commit hands it explicit file +# paths, so a directory name in `ignore` never fires under the hook. +ignore-paths = ["^(.*/)?tests/.*$"] ignore-patterns = [".*\\.pyi$"] [tool.pylint.messages_control] enable = "all" -disable = [] +# Globally disabled checks, matching wake-alarm / diet-guard: each is a +# stylistic preference that conflicts with deliberate, clearer code. +# Everything else stays at max strictness. +disable = [ + # use-implicit-booleaness family (C1803/C1804/C1805): pylint wants + # `not x` / `not s` instead of `x == 0` / `s == ""`. Explicit comparisons + # against 0 and "" state numeric/string intent more clearly than truthiness + # (and are not equivalent when the value may be None), so we keep them. + "use-implicit-booleaness-not-comparison", + "use-implicit-booleaness-not-comparison-to-string", + "use-implicit-booleaness-not-comparison-to-zero", +] [tool.pylint.design] min-public-methods = 0