utils/gatelock/gatelock/_guards.py
Krzysztof kuhy Rudnicki 6154dc09bf Pin pre-commit's ruff to 0.16.7 and satisfy the CPY001 it brings
The three Python packages pinned ruff twice at different versions:
requirements.txt at 0.16.7, .pre-commit-config.yaml at v0.15.2. Only the
older one ever ran, so `select = ["ALL"]` was enforced two minor versions
behind. Measured before aligning: v0.15.2 reports 0 findings, every 0.16.x
reports CPY001 on each file -- 70 crdt-sync, 23 freedays, 71 gatelock, and
no other rule.

Resolved by adding the notice rather than ignoring the rule, so the gate
keeps its teeth.

Two gatelock modules sat at exactly the 250-line cap, so a one-line header
tipped them to 251. Both are split along a real seam rather than shaved:

  _widget_fills.py    colour arithmetic over LockConfig, no tkinter import
  _surfaces_focus.py  selects among existing surfaces, never mutates them

The focus split keeps _surfaces.py's documented containment (it stays the
only module that creates, moves or destroys windows), and the static guard
in test_recovery_invariants.py globs _surfaces*.py, so the new sibling is
covered with no list to extend. widgets.py re-exports the fills helpers,
which keeps gatelock.widgets the single public surface its tests import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:11:24 +02:00

114 lines
3.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""Safety guards shared by every locker app.
Both guards here were duplicated across the consuming repos (and one of them
was missing from ``wake_alarm`` entirely, which is exactly the kind of drift a
shared library exists to stop).
The second one, :func:`wait_for_x_server`, changed meaning when it moved here,
and the change is the point. Its ancestor gated *whether to arm at all*: on a
boot where the display was not ready it logged "will retry on the next timer
tick" and exited without locking. Combined with a monitor that came up
modeless, that left the machine unlocked until the next tick.
So the rule now is: **output count never gates arming.** Zero live outputs
means lock without showing, not decline to lock. This function may only answer
"can we talk to an X server at all", because without one there is no Tk and
genuinely nothing to do. It must never ask how many outputs are live.
"""
from __future__ import annotations
import logging
import sys
import time
import tkinter as tk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT_S = 60.0
_DEFAULT_INTERVAL_S = 1.0
def assert_not_under_pytest(what: str) -> None:
"""Refuse to build a real lock window inside a test run.
A lock window takes a global input grab and covers every screen. One built
by accident during a test run would black out the developer's machine, so
this fails loudly instead.
Call this from an app's entry point, never from inside gatelock: gatelock's
own tests mock the Tk *root* rather than the ``tkinter`` module, so the
check would fire throughout its own suite.
Args:
what: Name of the thing being built, for the error message.
Raises:
RuntimeError: If pytest is running with a real ``tkinter``.
"""
if "pytest" not in sys.modules:
return
if getattr(tk, "__name__", "") != "tkinter":
# tkinter is mocked, so no real window can appear. This is the normal
# path for a properly-isolated test.
return
message = (
f"refusing to build {what} under pytest with real tkinter -- "
"this would grab the input of the machine running the tests"
)
raise RuntimeError(message)
def wait_for_x_server(
*,
timeout_s: float = _DEFAULT_TIMEOUT_S,
interval_s: float = _DEFAULT_INTERVAL_S,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
probe: Callable[[], bool] | None = None,
) -> bool:
"""Wait until an X server will accept a connection.
Absorbs the cold-boot race where a unit starts before the display manager
has finished. It asks one question only -- is there an X server -- and
deliberately says nothing about whether any monitor is lit. A dark screen
is a reason to lock silently, never a reason to skip locking.
Args:
timeout_s: Give up after this long.
interval_s: Seconds between attempts.
sleep: Injected for tests.
monotonic: Injected for tests.
probe: Injected for tests; defaults to opening a throwaway Tk root.
Returns:
True if an X server answered, False on timeout.
"""
attempt = probe if probe is not None else _probe_x_server
deadline = monotonic() + timeout_s
while True:
if attempt():
return True
if monotonic() >= deadline:
_logger.error(
"no X server answered within %.0fs; cannot build a lock window",
timeout_s,
)
return False
sleep(interval_s)
def _probe_x_server() -> bool:
"""Whether a throwaway Tk root can be created and destroyed."""
try:
root = tk.Tk()
except tk.TclError:
return False
root.destroy()
return True