mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 18:34:01 +01:00
Compare commits
No commits in common. "main" and "focus-project-v0.10.0" have entirely different histories.
main
...
focus-proj
@ -16,4 +16,3 @@ overwrite it with `repo_contract/templates/check_repo_contract.shim.sh`.
|
|||||||
- lint: `ruff check agent_ready coverage_gaps flutter_web_drive repo_contract`
|
- lint: `ruff check agent_ready coverage_gaps flutter_web_drive repo_contract`
|
||||||
- coverage: `PYTHONPATH=. python -m pytest -q agent_ready/tests flutter_web_drive/tests repo_contract/tests unified-design-system/tests --cov=agent_ready --cov=flutter_web_drive --cov=repo_contract --cov=conformance --cov=conformance_diff --cov-branch --cov-report=xml --cov-fail-under=100`
|
- coverage: `PYTHONPATH=. python -m pytest -q agent_ready/tests flutter_web_drive/tests repo_contract/tests unified-design-system/tests --cov=agent_ready --cov=flutter_web_drive --cov=repo_contract --cov=conformance --cov=conformance_diff --cov-branch --cov-report=xml --cov-fail-under=100`
|
||||||
- coverage-gaps: `coverage-gaps coverage.xml`
|
- coverage-gaps: `coverage-gaps coverage.xml`
|
||||||
- deploy: `git -C ~/src/utils pull --ff-only && ~/src/utils/scripts/deploy.sh` (installs the sickdays CLI + launcher from the live checkout, then exits 1 unless every released library's version tag is on origin and matches HEAD and the live `sickdays` runs the text-free checkout; `--verify-only` for the health check alone)
|
|
||||||
|
|||||||
@ -4,13 +4,13 @@ description: >-
|
|||||||
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
|
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
|
||||||
dialog on desktop, a download on web, plus a versioned export envelope.
|
dialog on desktop, a download on web, plus a versioned export envelope.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.2.2
|
version: 0.2.1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
file_picker: 13.3.3
|
file_picker: 13.3.2
|
||||||
file_selector: 1.1.0
|
file_selector: 1.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Regression: a timer-only focus day after the anti-cheat cutover pays 0.
|
|
||||||
|
|
||||||
Runs the REAL ``focus_project`` (the sibling package of this monorepo):
|
|
||||||
timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time
|
|
||||||
itself does not change; this pins that the gate reaches pay.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime
|
|
||||||
import importlib
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from earned_time import credit_units, earners_for
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from types import ModuleType
|
|
||||||
|
|
||||||
DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM
|
|
||||||
_SIBLING = Path(__file__).resolve().parents[3] / "focus_project"
|
|
||||||
|
|
||||||
|
|
||||||
def at(hour: int, minute: int) -> float:
|
|
||||||
return datetime(2026, 11, 9, hour, minute).astimezone().timestamp()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def real_fp(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
|
||||||
) -> ModuleType:
|
|
||||||
"""The real focus_project on redirected state (HOME, data dir, key)."""
|
|
||||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
||||||
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data"))
|
|
||||||
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file))
|
|
||||||
monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json"))
|
|
||||||
monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake
|
|
||||||
monkeypatch.syspath_prepend(str(_SIBLING))
|
|
||||||
fp = importlib.import_module("focus_project")
|
|
||||||
fp.create_project(
|
|
||||||
"hr",
|
|
||||||
name="HR",
|
|
||||||
start=DAY,
|
|
||||||
end=DAY,
|
|
||||||
replaces=["leetcode"],
|
|
||||||
daily_minutes=120,
|
|
||||||
unlock_minutes=30,
|
|
||||||
)
|
|
||||||
return fp
|
|
||||||
|
|
||||||
|
|
||||||
def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None:
|
|
||||||
log = tmp_path / "data" / "activity.jsonl"
|
|
||||||
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
|
|
||||||
harvest = importlib.import_module("focus_project.harvest")
|
|
||||||
assert harvest.harvest(now=at(23, 0)) == 0
|
|
||||||
(item,) = (e for e in earners_for(DAY) if e.name == "focus")
|
|
||||||
return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY)
|
|
||||||
|
|
||||||
|
|
||||||
def row(source: str, kind: str, ts: float) -> dict[str, object]:
|
|
||||||
return {"ts": ts, "source": source, "kind": kind, "project": "hr"}
|
|
||||||
|
|
||||||
|
|
||||||
TIMER = (
|
|
||||||
row("timer", "start", at(9, 0)),
|
|
||||||
row("timer", "checkin", at(9, 5)),
|
|
||||||
row("timer", "stop", at(9, 10)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_timer_only_day_pays_zero(
|
|
||||||
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
|
||||||
) -> None:
|
|
||||||
assert date(2026, 11, 8) == real_fp.BACKING_FROM
|
|
||||||
assert pay(real_fp, tmp_path, key_file, *TIMER) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_backed_timer_day_still_pays(
|
|
||||||
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
|
||||||
) -> None:
|
|
||||||
beat = row("tutor", "heartbeat", at(9, 5))
|
|
||||||
assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
# Pre-commit Configuration for feedback_inbox
|
|
||||||
# Install: pre-commit install && pre-commit install --hook-type pre-push
|
|
||||||
# Run: pre-commit run --all-files
|
|
||||||
# Update: pre-commit autoupdate
|
|
||||||
|
|
||||||
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
|
|
||||||
# one level up. pre-commit always runs hooks from the git root, so every path
|
|
||||||
# below is written relative to THAT, not to this directory, and the top-level
|
|
||||||
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
|
|
||||||
# config exactly the same way:
|
|
||||||
# pre-commit run --config feedback_inbox/.pre-commit-config.yaml --all-files
|
|
||||||
files: ^feedback_inbox/
|
|
||||||
|
|
||||||
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: file-length-cap
|
|
||||||
name: file length <= 250 lines
|
|
||||||
entry: bash scripts/check_file_length.sh
|
|
||||||
language: system
|
|
||||||
pass_filenames: true
|
|
||||||
- id: subproject-scope
|
|
||||||
name: subproject scope regex still matches
|
|
||||||
entry: scripts/check_subproject_scope.sh feedback_inbox
|
|
||||||
language: script
|
|
||||||
# always_run so the top-level `files` filter cannot skip the one hook
|
|
||||||
# whose job is to notice that filter has stopped matching anything.
|
|
||||||
always_run: true
|
|
||||||
pass_filenames: false
|
|
||||||
- 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.17.4
|
|
||||||
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: v4.0.8
|
|
||||||
hooks:
|
|
||||||
- id: pylint
|
|
||||||
args:
|
|
||||||
- --rcfile=feedback_inbox/pyproject.toml
|
|
||||||
- --fail-under=10
|
|
||||||
- --jobs=4
|
|
||||||
additional_dependencies:
|
|
||||||
- pytest
|
|
||||||
|
|
||||||
- repo: https://github.com/PyCQA/bandit
|
|
||||||
rev: 1.9.4
|
|
||||||
hooks:
|
|
||||||
- id: bandit
|
|
||||||
args:
|
|
||||||
- -c
|
|
||||||
- feedback_inbox/pyproject.toml
|
|
||||||
- --severity-level=high
|
|
||||||
- --confidence-level=medium
|
|
||||||
- --skip=B113
|
|
||||||
additional_dependencies: ["bandit[toml]"]
|
|
||||||
exclude: ^(feedback_inbox/feedback_inbox/tests/|.*test.*\.py$)
|
|
||||||
|
|
||||||
- repo: local
|
|
||||||
hooks:
|
|
||||||
- id: pytest-coverage
|
|
||||||
name: pytest with coverage enforcement
|
|
||||||
entry: scripts/run_subproject_tests.sh feedback_inbox
|
|
||||||
language: script
|
|
||||||
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
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
# feedback_inbox
|
|
||||||
|
|
||||||
Turns every game's dev-feedback bundle into one todo-app task, so feedback
|
|
||||||
kuhy saves with F12 is never forgotten.
|
|
||||||
|
|
||||||
A game saves `~/data/<repo>_binaries/feedback/<stamp>/` (`report.json`,
|
|
||||||
screenshots, logs) and appends a ready-to-run Claude prompt to
|
|
||||||
`feedback-<date>.md` beside it. This package imports each complete bundle once
|
|
||||||
as a task in the todo app for `kuhyx/<repo>`.
|
|
||||||
|
|
||||||
## Task shape
|
|
||||||
|
|
||||||
- Title: `MANUAL WORK REQUIRED: <repo> feedback: <first line of his note>` (note capped
|
|
||||||
at 120 characters). The prefix makes todoImplementer list it as a human part and never
|
|
||||||
run it. No note: `... feedback: <game> <combo> <HH:MM>`. A `report.json` that does
|
|
||||||
not parse still gets a task, tagged `[corrupt report.json]`; the note then
|
|
||||||
comes from the markdown entry.
|
|
||||||
- Body: a `Due: <next day 10:00>` line, then the bundle's markdown entry (the
|
|
||||||
prompt with absolute paths).
|
|
||||||
- The task is its own note (`add_task` with `new_note`). The server stores that as
|
|
||||||
`<repo> todos: <title>` plus a `## <title>` section, so right after adding, the inbox
|
|
||||||
rewrites it with `update_task` to the exact title and a section-free body (the entry's
|
|
||||||
own `##` headings become `###`). todoImplementer splits notes at `## ` headings, and a
|
|
||||||
title that does not start with `MANUAL WORK REQUIRED: ` would make a runnable part.
|
|
||||||
The marker records `format: 2` once the rewrite worked; a failed rewrite is retried on
|
|
||||||
the next sweep (never a second task), and markers without it are repaired the same way.
|
|
||||||
- Closing the task cancels the feedback. "Act on feedback" in a Claude session
|
|
||||||
means working every open `<repo> feedback:` task.
|
|
||||||
|
|
||||||
The todo app has no due-date or reminder field yet, so the due time is only
|
|
||||||
text in the body.
|
|
||||||
|
|
||||||
## How it runs
|
|
||||||
|
|
||||||
| Unit | Does |
|
|
||||||
|---|---|
|
|
||||||
| `feedback-inbox.path` | `PathChanged` on every existing `*_binaries/feedback` folder; runs `feedback-inbox run` |
|
|
||||||
| `feedback-inbox-sync.path` | `PathChanged` on `~/data` and every `*_binaries` folder; runs `sync` then `run` |
|
|
||||||
| `feedback-inbox-sync.timer` | every 5 min, the same sweep (events during a run are lost by `.path` units) |
|
|
||||||
|
|
||||||
`.path` units take no globs: `feedback-inbox sync` writes the folder list into
|
|
||||||
the drop-ins `feedback-inbox{,-sync}.path.d/dirs.conf` and reloads systemd only
|
|
||||||
when the set of games changed.
|
|
||||||
|
|
||||||
A bundle is complete when `report.json` is at least 2 s old and its markdown
|
|
||||||
entry exists (matched by the stamp folder in the entry's paths). Incomplete
|
|
||||||
bundles are retried; `run` waits up to 15 s for them.
|
|
||||||
|
|
||||||
The import marker is `.inbox-imported.json` inside the bundle (task id, title,
|
|
||||||
due). It is written only after the todo server accepted the task, so a server
|
|
||||||
outage leaves no marker, exits non-zero, and the next sweep retries.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/run_subproject_tests.sh feedback_inbox # from the monorepo root
|
|
||||||
feedback_inbox/install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
`install.sh` builds a stdlib-only venv, links `feedback-inbox` into
|
|
||||||
`~/.local/bin`, mints a todo-server token into `~/.config/feedback-inbox/token`
|
|
||||||
(0600; revoke by deleting its line in `~/.config/todo-mcp/tokens`) and enables
|
|
||||||
the units. Enabling sweeps once, so existing bundles are imported then.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
`pytest` from this directory (100 % branch coverage enforced). Tests use a temp
|
|
||||||
`HOME`, a fake todo client, and a local HTTP server for the MCP client; the
|
|
||||||
autouse `conftest.py` redirects the one path resolver and points the todo URL
|
|
||||||
at a dead port.
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Turns every game dev-feedback bundle into one todo-app task."""
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""``python -m feedback_inbox``."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from feedback_inbox._cli import main
|
|
||||||
|
|
||||||
|
|
||||||
def run() -> int:
|
|
||||||
"""Run the CLI on ``sys.argv``."""
|
|
||||||
return main()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(run())
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Finding games' feedback folders and bundles, and the stamp arithmetic."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BINARIES_SUFFIX = "_binaries"
|
|
||||||
STAMP_FORMAT = "%Y-%m-%dT%H-%M-%S"
|
|
||||||
_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}$")
|
|
||||||
_REMINDER_HOUR = 10
|
|
||||||
|
|
||||||
|
|
||||||
def binaries_dirs(data_root: Path) -> list[Path]:
|
|
||||||
"""Every ``<game>_binaries`` directory directly under ``data_root``."""
|
|
||||||
if not data_root.is_dir():
|
|
||||||
return []
|
|
||||||
return sorted(
|
|
||||||
p
|
|
||||||
for p in data_root.iterdir()
|
|
||||||
if p.name.endswith(BINARIES_SUFFIX) and p.is_dir()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def feedback_dirs(data_root: Path) -> list[Path]:
|
|
||||||
"""Every existing ``<game>_binaries/feedback`` directory."""
|
|
||||||
return [
|
|
||||||
d / "feedback" for d in binaries_dirs(data_root) if (d / "feedback").is_dir()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def repo_name(feedback_dir: Path) -> str:
|
|
||||||
"""The game's repo name: ``cleanup-plate_binaries`` -> ``cleanup-plate``."""
|
|
||||||
return feedback_dir.parent.name.removesuffix(BINARIES_SUFFIX)
|
|
||||||
|
|
||||||
|
|
||||||
def bundles_in(feedback_dir: Path) -> list[Path]:
|
|
||||||
"""Bundle folders (named by a millisecond stamp), oldest first."""
|
|
||||||
return sorted(
|
|
||||||
p for p in feedback_dir.iterdir() if p.is_dir() and _STAMP.match(p.name)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_stamp(stamp: str) -> datetime:
|
|
||||||
"""The local time a bundle was saved, from its folder name."""
|
|
||||||
return datetime.strptime(stamp[:19], STAMP_FORMAT)
|
|
||||||
|
|
||||||
|
|
||||||
def due_at(stamp: str) -> datetime:
|
|
||||||
"""The reminder time: the day after the bundle, at 10:00 local."""
|
|
||||||
day = parse_stamp(stamp) + timedelta(days=1)
|
|
||||||
return day.replace(hour=_REMINDER_HOUR, minute=0, second=0, microsecond=0)
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""``feedback-inbox run | sync``: what the systemd units execute."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import fcntl
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from feedback_inbox._inbox import Importer, Outcome, Waiter, run_settled
|
|
||||||
from feedback_inbox._paths import resolve_paths
|
|
||||||
from feedback_inbox._todo import McpTodoClient
|
|
||||||
from feedback_inbox._units import run_systemctl, sync_units
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable, Sequence
|
|
||||||
|
|
||||||
from feedback_inbox._todo import TodoClient
|
|
||||||
|
|
||||||
LOG = logging.getLogger("feedback_inbox")
|
|
||||||
OWNER = "kuhyx"
|
|
||||||
DEFAULT_WAIT = 15.0
|
|
||||||
|
|
||||||
|
|
||||||
def _parser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser(prog="feedback-inbox")
|
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
|
||||||
run = sub.add_parser("run", help="import new bundles as todo tasks")
|
|
||||||
run.add_argument("--wait", type=float, default=DEFAULT_WAIT)
|
|
||||||
sub.add_parser("sync", help="re-arm the path units for the current games")
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def _run(wait: float, client: TodoClient | None) -> int:
|
|
||||||
paths = resolve_paths()
|
|
||||||
todo = client or McpTodoClient(paths.mcp_url, paths.token_file)
|
|
||||||
paths.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
# Blocking lock: a second run waits, then finds the markers and skips.
|
|
||||||
with paths.lock_file.open("a", encoding="utf-8") as lock:
|
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
||||||
results = run_settled(
|
|
||||||
paths.data_root,
|
|
||||||
Importer(todo, OWNER),
|
|
||||||
Waiter(wait, time.time, time.sleep),
|
|
||||||
)
|
|
||||||
failed = 0
|
|
||||||
for result in results:
|
|
||||||
if result.outcome is Outcome.FAILED:
|
|
||||||
failed += 1
|
|
||||||
LOG.error("failed %s: %s", result.bundle, result.detail)
|
|
||||||
elif result.outcome is not Outcome.SKIPPED:
|
|
||||||
LOG.info("%s %s %s", result.outcome, result.bundle, result.task_id or "")
|
|
||||||
return 1 if failed else 0
|
|
||||||
|
|
||||||
|
|
||||||
def _sync(systemctl: Callable[[Sequence[str]], int]) -> int:
|
|
||||||
paths = resolve_paths()
|
|
||||||
try:
|
|
||||||
changed = sync_units(paths.data_root, paths.unit_dir, systemctl)
|
|
||||||
except RuntimeError:
|
|
||||||
LOG.exception("sync failed")
|
|
||||||
return 1
|
|
||||||
LOG.info("watch list %s", "rewritten" if changed else "unchanged")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main(
|
|
||||||
argv: Sequence[str] | None = None,
|
|
||||||
*,
|
|
||||||
client: TodoClient | None = None,
|
|
||||||
systemctl: Callable[[Sequence[str]], int] = run_systemctl,
|
|
||||||
) -> int:
|
|
||||||
"""Entry point; ``client`` and ``systemctl`` are test seams."""
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
||||||
args = _parser().parse_args(argv)
|
|
||||||
if args.command == "run":
|
|
||||||
return _run(args.wait, client)
|
|
||||||
return _sync(systemctl)
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""A bundle's markdown entry and its report.json."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_SECTION = re.compile(r"(?m)^(?=## )")
|
|
||||||
_GAME = re.compile(r"Game `([^`]*)`, combo `([^`]*)`")
|
|
||||||
_BODY_END = re.compile(r"(?m)^- Screenshot")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Report:
|
|
||||||
"""The fields of report.json the inbox needs."""
|
|
||||||
|
|
||||||
note: str
|
|
||||||
game: str
|
|
||||||
combo: str
|
|
||||||
|
|
||||||
|
|
||||||
def find_entry(feedback_dir: Path, bundle: Path) -> str | None:
|
|
||||||
"""The ``feedback-<date>.md`` section whose paths point into ``bundle``.
|
|
||||||
|
|
||||||
Matched by the stamp folder in its paths, not by the heading time (which
|
|
||||||
drops the milliseconds), so a bundle never picks up a neighbour's entry.
|
|
||||||
"""
|
|
||||||
needle = f"/{bundle.name}/"
|
|
||||||
for md in sorted(feedback_dir.glob("feedback-*.md")):
|
|
||||||
try:
|
|
||||||
text = md.read_text(encoding="utf-8")
|
|
||||||
except OSError, UnicodeDecodeError:
|
|
||||||
continue
|
|
||||||
for section in _SECTION.split(text):
|
|
||||||
if needle in section:
|
|
||||||
return section.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def read_report(path: Path) -> Report | None:
|
|
||||||
"""Parse report.json; ``None`` when it cannot be read as a JSON object."""
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except OSError, UnicodeDecodeError, json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return None
|
|
||||||
return Report(
|
|
||||||
note=_text(data.get("note")),
|
|
||||||
game=_text(data.get("game")),
|
|
||||||
combo=_text(data.get("combo")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _text(value: object) -> str:
|
|
||||||
return value if isinstance(value, str) else ""
|
|
||||||
|
|
||||||
|
|
||||||
def note_from_entry(entry: str) -> str:
|
|
||||||
"""His note, as written between the heading and the file list."""
|
|
||||||
_head, _, rest = entry.partition("\n")
|
|
||||||
return _BODY_END.split(rest, maxsplit=1)[0].strip()
|
|
||||||
|
|
||||||
|
|
||||||
def game_and_combo(entry: str) -> tuple[str, str]:
|
|
||||||
"""Game and combo from the entry's ``Game ..., combo ...`` line."""
|
|
||||||
found = _GAME.search(entry)
|
|
||||||
return (found.group(1), found.group(2)) if found else ("", "")
|
|
||||||
@ -1,235 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Turn complete feedback bundles into todo tasks, once each."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import StrEnum
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from feedback_inbox._bundle import (
|
|
||||||
bundles_in,
|
|
||||||
due_at,
|
|
||||||
feedback_dirs,
|
|
||||||
parse_stamp,
|
|
||||||
repo_name,
|
|
||||||
)
|
|
||||||
from feedback_inbox._entry import (
|
|
||||||
Report,
|
|
||||||
find_entry,
|
|
||||||
game_and_combo,
|
|
||||||
note_from_entry,
|
|
||||||
read_report,
|
|
||||||
)
|
|
||||||
from feedback_inbox._todo import TodoError
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from feedback_inbox._todo import TodoClient
|
|
||||||
|
|
||||||
LOG = logging.getLogger("feedback_inbox")
|
|
||||||
MARKER = ".inbox-imported.json"
|
|
||||||
# Marker format 2: the stored note was rewritten to our exact title and body.
|
|
||||||
FORMAT = 2
|
|
||||||
SETTLE_SECONDS = 2.0
|
|
||||||
TITLE_CAP = 120
|
|
||||||
MANUAL_PREFIX = "MANUAL WORK REQUIRED: "
|
|
||||||
CORRUPT_TAG = " [corrupt report.json]"
|
|
||||||
CORRUPT_LINE = "Warning: report.json is corrupt; the note below comes from the entry."
|
|
||||||
|
|
||||||
|
|
||||||
class Outcome(StrEnum):
|
|
||||||
"""What happened to one bundle in a run."""
|
|
||||||
|
|
||||||
IMPORTED = "imported"
|
|
||||||
SKIPPED = "skipped"
|
|
||||||
PENDING = "pending"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Result:
|
|
||||||
"""One bundle's outcome, for the journal and the exit code."""
|
|
||||||
|
|
||||||
bundle: Path
|
|
||||||
outcome: Outcome
|
|
||||||
task_id: str | None = None
|
|
||||||
detail: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TaskDraft:
|
|
||||||
"""The task to create: title, body and due time."""
|
|
||||||
|
|
||||||
title: str
|
|
||||||
body: str
|
|
||||||
due: str
|
|
||||||
|
|
||||||
|
|
||||||
def _demote(entry: str) -> str:
|
|
||||||
"""Turn ``## `` headings into ``### ``: no section may start a todo part.
|
|
||||||
|
|
||||||
The todo app splits a note into runnable parts at every ``## `` heading,
|
|
||||||
so the entry's own headings must not look like sections.
|
|
||||||
"""
|
|
||||||
return re.sub(r"(?m)^##(?=\s)", "###", entry)
|
|
||||||
|
|
||||||
|
|
||||||
def _first_line(note: str, fallback: str) -> str:
|
|
||||||
first = next((x.strip() for x in note.splitlines() if x.strip()), "")
|
|
||||||
if len(first) > TITLE_CAP:
|
|
||||||
first = first[: TITLE_CAP - 1] + "…"
|
|
||||||
return first or fallback
|
|
||||||
|
|
||||||
|
|
||||||
def build_task(repo: str, stamp: str, entry: str, report: Report | None) -> TaskDraft:
|
|
||||||
"""Compose the title and body from the entry and (maybe corrupt) report."""
|
|
||||||
found_game, found_combo = game_and_combo(entry)
|
|
||||||
note = note_from_entry(entry) if report is None else report.note
|
|
||||||
game = (report.game if report else "") or found_game
|
|
||||||
combo = (report.combo if report else "") or found_combo
|
|
||||||
clock = parse_stamp(stamp).strftime("%H:%M")
|
|
||||||
fallback = " ".join(x for x in (game, combo, clock) if x)
|
|
||||||
suffix = CORRUPT_TAG if report is None else ""
|
|
||||||
title = f"{MANUAL_PREFIX}{repo} feedback: {_first_line(note, fallback)}{suffix}"
|
|
||||||
due = due_at(stamp)
|
|
||||||
lines = [f"Due: {due:%Y-%m-%d %H:%M}", ""]
|
|
||||||
if report is None:
|
|
||||||
lines += [CORRUPT_LINE, ""]
|
|
||||||
return TaskDraft(title, "\n".join([*lines, _demote(entry)]), due.isoformat())
|
|
||||||
|
|
||||||
|
|
||||||
def _write_marker(bundle: Path, payload: dict[str, object]) -> None:
|
|
||||||
tmp = bundle / f"{MARKER}.tmp"
|
|
||||||
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
||||||
tmp.replace(bundle / MARKER)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_marker(bundle: Path) -> dict[str, object] | None:
|
|
||||||
"""The bundle's marker, or None when it is not a usable marker."""
|
|
||||||
try:
|
|
||||||
data = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
|
|
||||||
except OSError, UnicodeDecodeError, json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
ok = isinstance(data, dict) and isinstance(data.get("task_id"), str)
|
|
||||||
return data if ok and isinstance(data.get("title"), str) else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Importer:
|
|
||||||
"""Who files the tasks (``client``) under which GitHub owner."""
|
|
||||||
|
|
||||||
client: TodoClient
|
|
||||||
owner: str
|
|
||||||
settle: float = SETTLE_SECONDS
|
|
||||||
|
|
||||||
def import_bundle(self, feedback_dir: Path, bundle: Path, now: float) -> Result:
|
|
||||||
"""Import one bundle if complete; repair the title of one already filed."""
|
|
||||||
if (bundle / MARKER).exists():
|
|
||||||
return self._repair(feedback_dir, bundle)
|
|
||||||
report_path = bundle / "report.json"
|
|
||||||
entry = find_entry(feedback_dir, bundle)
|
|
||||||
if entry is None or not report_path.is_file():
|
|
||||||
return Result(bundle, Outcome.PENDING, detail="report or entry missing")
|
|
||||||
if now - report_path.stat().st_mtime < self.settle:
|
|
||||||
return Result(bundle, Outcome.PENDING, detail="report.json settling")
|
|
||||||
repo = repo_name(feedback_dir)
|
|
||||||
draft = build_task(repo, bundle.name, entry, read_report(report_path))
|
|
||||||
return self._file(bundle, f"{self.owner}/{repo}", draft)
|
|
||||||
|
|
||||||
def _file(self, bundle: Path, repo: str, draft: TaskDraft) -> Result:
|
|
||||||
"""Create the task, write the marker, then fix the title."""
|
|
||||||
try:
|
|
||||||
ref = self.client.add_task(repo, draft.title, draft.body)
|
|
||||||
except TodoError as exc:
|
|
||||||
return Result(bundle, Outcome.FAILED, detail=str(exc))
|
|
||||||
# Marker first: once the task exists a retry must never add a second.
|
|
||||||
# A merged task belongs to another note, which is not ours to rewrite.
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"task_id": ref.task_id,
|
|
||||||
"title": draft.title,
|
|
||||||
"due": draft.due,
|
|
||||||
"merged": ref.merged,
|
|
||||||
}
|
|
||||||
if ref.merged:
|
|
||||||
payload["format"] = FORMAT
|
|
||||||
try:
|
|
||||||
_write_marker(bundle, payload)
|
|
||||||
except OSError as exc:
|
|
||||||
return Result(
|
|
||||||
bundle,
|
|
||||||
Outcome.FAILED,
|
|
||||||
ref.task_id,
|
|
||||||
f"task {ref.task_id} filed but marker not written: {exc}",
|
|
||||||
)
|
|
||||||
if ref.merged:
|
|
||||||
return Result(
|
|
||||||
bundle, Outcome.IMPORTED, ref.task_id, "merged into an existing note"
|
|
||||||
)
|
|
||||||
return self._finish(bundle, payload, draft)
|
|
||||||
|
|
||||||
def _repair(self, feedback_dir: Path, bundle: Path) -> Result:
|
|
||||||
"""Finish a filed task whose rewrite never completed (or predates it)."""
|
|
||||||
data = _read_marker(bundle)
|
|
||||||
if data is None or data.get("merged") or data.get("format") == FORMAT:
|
|
||||||
return Result(bundle, Outcome.SKIPPED)
|
|
||||||
entry = find_entry(feedback_dir, bundle)
|
|
||||||
if entry is None:
|
|
||||||
return Result(bundle, Outcome.PENDING, detail="entry missing for repair")
|
|
||||||
report = read_report(bundle / "report.json")
|
|
||||||
draft = build_task(repo_name(feedback_dir), bundle.name, entry, report)
|
|
||||||
return self._finish(bundle, data, draft)
|
|
||||||
|
|
||||||
def _finish(
|
|
||||||
self, bundle: Path, data: dict[str, object], draft: TaskDraft
|
|
||||||
) -> Result:
|
|
||||||
"""Rewrite the stored note to the exact title/body, then mark it done."""
|
|
||||||
task_id = str(data["task_id"])
|
|
||||||
try:
|
|
||||||
self.client.update_task(task_id, draft.title, draft.body)
|
|
||||||
except TodoError as exc:
|
|
||||||
return Result(bundle, Outcome.FAILED, task_id, f"update: {exc}")
|
|
||||||
marked = {**data, "title": draft.title, "due": draft.due, "format": FORMAT}
|
|
||||||
try:
|
|
||||||
_write_marker(bundle, marked)
|
|
||||||
except OSError as exc:
|
|
||||||
return Result(bundle, Outcome.FAILED, task_id, f"marker not written: {exc}")
|
|
||||||
return Result(bundle, Outcome.IMPORTED, task_id)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Waiter:
|
|
||||||
"""How long to wait for half-written bundles, and the clock to do it by."""
|
|
||||||
|
|
||||||
wait: float
|
|
||||||
clock: Callable[[], float]
|
|
||||||
sleep: Callable[[float], None]
|
|
||||||
|
|
||||||
|
|
||||||
def run_once(data_root: Path, importer: Importer, now: float) -> list[Result]:
|
|
||||||
"""Import every complete, not yet imported bundle of every game."""
|
|
||||||
return [
|
|
||||||
importer.import_bundle(folder, bundle, now)
|
|
||||||
for folder in feedback_dirs(data_root)
|
|
||||||
for bundle in bundles_in(folder)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def run_settled(data_root: Path, importer: Importer, waiter: Waiter) -> list[Result]:
|
|
||||||
"""Like :func:`run_once`, retrying up to ``wait`` s while bundles are pending.
|
|
||||||
|
|
||||||
A bundle is written in pieces; the watch fires on the first piece, so a
|
|
||||||
short wait lets the rest land instead of leaving it to the timer.
|
|
||||||
"""
|
|
||||||
deadline = waiter.clock() + waiter.wait
|
|
||||||
while True:
|
|
||||||
results = run_once(data_root, importer, waiter.clock())
|
|
||||||
pending = any(r.outcome is Outcome.PENDING for r in results)
|
|
||||||
if not pending or waiter.clock() >= deadline:
|
|
||||||
return results
|
|
||||||
waiter.sleep(1.0)
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Every on-disk and network location, resolved in one place.
|
|
||||||
|
|
||||||
Tests redirect :func:`resolve_paths` once; nothing else names a real path.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
MCP_URL = "http://127.0.0.1:8735/mcp"
|
|
||||||
# Port 9 (discard) refuses connections: a test that forgets to inject a client
|
|
||||||
# fails closed instead of reaching the live todo server.
|
|
||||||
DEAD_URL = "http://127.0.0.1:9/mcp"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Paths:
|
|
||||||
"""Where the inbox reads games, its token, its lock and its units."""
|
|
||||||
|
|
||||||
data_root: Path
|
|
||||||
token_file: Path
|
|
||||||
lock_file: Path
|
|
||||||
unit_dir: Path
|
|
||||||
mcp_url: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def under(cls, root: Path) -> Paths:
|
|
||||||
"""A throwaway layout below ``root`` that can never reach the live app."""
|
|
||||||
return cls(
|
|
||||||
data_root=root / "data",
|
|
||||||
token_file=root / "token",
|
|
||||||
lock_file=root / "state" / "lock",
|
|
||||||
unit_dir=root / "units",
|
|
||||||
mcp_url=DEAD_URL,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_paths(given: Paths | None = None) -> Paths:
|
|
||||||
"""Return ``given`` or the real layout under the user's home."""
|
|
||||||
if given is not None:
|
|
||||||
return given
|
|
||||||
home = Path.home()
|
|
||||||
return Paths(
|
|
||||||
data_root=home / "data",
|
|
||||||
token_file=home / ".config" / "feedback-inbox" / "token",
|
|
||||||
lock_file=home / ".local" / "share" / "feedback-inbox" / "lock",
|
|
||||||
unit_dir=home / ".config" / "systemd" / "user",
|
|
||||||
mcp_url=MCP_URL,
|
|
||||||
)
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""The todo app's MCP server as a minimal ``add_task`` client."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING, Protocol
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_PROTOCOL = "2025-03-26"
|
|
||||||
_TIMEOUT = 20
|
|
||||||
_REPLY = re.compile(r"^(added|merged into|appended to) (\S+)")
|
|
||||||
|
|
||||||
|
|
||||||
class TodoError(Exception):
|
|
||||||
"""The todo server could not take the task."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TaskRef:
|
|
||||||
"""The task a call created (or the note it was merged into)."""
|
|
||||||
|
|
||||||
task_id: str
|
|
||||||
merged: bool
|
|
||||||
|
|
||||||
|
|
||||||
class TodoClient(Protocol):
|
|
||||||
"""What the inbox needs from a todo app."""
|
|
||||||
|
|
||||||
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
|
|
||||||
"""Create one task in its own note and return its id."""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def update_task(self, task_id: str, title: str, body: str) -> None:
|
|
||||||
"""Replace the task's stored title and body with exactly these."""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class McpTodoClient:
|
|
||||||
"""Streamable-HTTP MCP client: initialize, then one ``add_task`` call."""
|
|
||||||
|
|
||||||
def __init__(self, url: str, token_file: Path) -> None:
|
|
||||||
"""Remember where the server and the (lazily read) token are."""
|
|
||||||
self._url = url
|
|
||||||
self._token_file = token_file
|
|
||||||
|
|
||||||
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
|
|
||||||
"""Create a task with its own note via ``add_task``.
|
|
||||||
|
|
||||||
The server stores a ``new_note`` title as ``<name> todos: <title>``;
|
|
||||||
:meth:`update_task` restores the exact title (and drops the
|
|
||||||
``## <title>`` section the server adds) afterwards.
|
|
||||||
"""
|
|
||||||
arguments = {"repo": repo, "title": title, "body": body, "new_note": True}
|
|
||||||
said = self._call("add_task", arguments)
|
|
||||||
found = _REPLY.match(said)
|
|
||||||
if not found:
|
|
||||||
msg = f"add_task refused: {said[:120]}"
|
|
||||||
raise TodoError(msg)
|
|
||||||
return TaskRef(task_id=found.group(2), merged=found.group(1) != "added")
|
|
||||||
|
|
||||||
def update_task(self, task_id: str, title: str, body: str) -> None:
|
|
||||||
"""Rewrite via ``update_task``: exact title, and body with no sections."""
|
|
||||||
said = self._call("update_task", {"id": task_id, "title": title, "body": body})
|
|
||||||
if not said.startswith("updated"):
|
|
||||||
msg = f"update_task refused: {said[:120]}"
|
|
||||||
raise TodoError(msg)
|
|
||||||
|
|
||||||
def _call(self, tool: str, arguments: dict[str, object]) -> str:
|
|
||||||
token = self._token()
|
|
||||||
session = self._initialize(token)
|
|
||||||
payload = {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 2,
|
|
||||||
"method": "tools/call",
|
|
||||||
"params": {"name": tool, "arguments": arguments},
|
|
||||||
}
|
|
||||||
return _tool_text(self._post(token, payload, session)[1])
|
|
||||||
|
|
||||||
def _token(self) -> str:
|
|
||||||
try:
|
|
||||||
return self._token_file.read_text(encoding="utf-8").strip()
|
|
||||||
except OSError as exc:
|
|
||||||
msg = f"token file unreadable: {self._token_file}"
|
|
||||||
raise TodoError(msg) from exc
|
|
||||||
|
|
||||||
def _initialize(self, token: str) -> str:
|
|
||||||
init = {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 1,
|
|
||||||
"method": "initialize",
|
|
||||||
"params": {
|
|
||||||
"protocolVersion": _PROTOCOL,
|
|
||||||
"capabilities": {},
|
|
||||||
"clientInfo": {"name": "feedback-inbox", "version": "0.1.0"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
headers, _body = self._post(token, init, None)
|
|
||||||
session = headers.get("mcp-session-id")
|
|
||||||
if not session:
|
|
||||||
msg = "todo server sent no session id"
|
|
||||||
raise TodoError(msg)
|
|
||||||
self._post(
|
|
||||||
token,
|
|
||||||
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
||||||
session,
|
|
||||||
)
|
|
||||||
return session
|
|
||||||
|
|
||||||
def _post(
|
|
||||||
self,
|
|
||||||
token: str,
|
|
||||||
payload: dict[str, object],
|
|
||||||
session: str | None,
|
|
||||||
) -> tuple[dict[str, str], str]:
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json, text/event-stream",
|
|
||||||
}
|
|
||||||
if session:
|
|
||||||
headers["mcp-session-id"] = session
|
|
||||||
request = urllib.request.Request(
|
|
||||||
self._url,
|
|
||||||
data=json.dumps(payload).encode(),
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=_TIMEOUT) as resp:
|
|
||||||
return (
|
|
||||||
{k.lower(): v for k, v in resp.headers.items()},
|
|
||||||
resp.read().decode(),
|
|
||||||
)
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
exc.close()
|
|
||||||
msg = f"todo server answered HTTP {exc.code}"
|
|
||||||
raise TodoError(msg) from exc
|
|
||||||
except OSError as exc:
|
|
||||||
msg = f"todo server unreachable: {exc}"
|
|
||||||
raise TodoError(msg) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_text(body: str) -> str:
|
|
||||||
"""The text a tool call answered; an MCP-level error becomes TodoError."""
|
|
||||||
text = body.strip()
|
|
||||||
if not text.startswith("{"):
|
|
||||||
lines = [x[5:].strip() for x in text.splitlines() if x.startswith("data:")]
|
|
||||||
text = next((x for x in lines if x.startswith("{")), "")
|
|
||||||
try:
|
|
||||||
result = json.loads(text)["result"]
|
|
||||||
said = str(result["content"][0]["text"])
|
|
||||||
failed = bool(result.get("isError"))
|
|
||||||
except (ValueError, KeyError, IndexError, TypeError) as exc:
|
|
||||||
msg = f"unparsable MCP reply: {body[:80]!r}"
|
|
||||||
raise TodoError(msg) from exc
|
|
||||||
if failed:
|
|
||||||
msg = f"tool refused: {said[:120]}"
|
|
||||||
raise TodoError(msg)
|
|
||||||
return said
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Keep the path units' watch lists equal to the games that exist.
|
|
||||||
|
|
||||||
systemd ``.path`` units take no globs, so each ``*_binaries`` folder is listed
|
|
||||||
in a generated drop-in. The set changes only when a game is added or removed;
|
|
||||||
the drop-in is rewritten, and systemd reloaded, only then (a reload per tick
|
|
||||||
would be the daemon-reload loop the fleet rules forbid).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from feedback_inbox._bundle import binaries_dirs, feedback_dirs
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable, Sequence
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
WATCH_PATH = "feedback-inbox.path"
|
|
||||||
SYNC_PATH = "feedback-inbox-sync.path"
|
|
||||||
SYSTEMCTL = "/usr/bin/systemctl"
|
|
||||||
_HEADER = "# Generated by `feedback-inbox sync`; do not edit.\n[Path]\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _dropin(unit_dir: Path, unit: str) -> Path:
|
|
||||||
return unit_dir / f"{unit}.d" / "dirs.conf"
|
|
||||||
|
|
||||||
|
|
||||||
def _render(folders: list[Path]) -> str:
|
|
||||||
return _HEADER + "".join(f"PathChanged={d}\n" for d in folders)
|
|
||||||
|
|
||||||
|
|
||||||
def desired(data_root: Path, unit_dir: Path) -> dict[Path, str]:
|
|
||||||
"""The drop-ins that should exist: feedback folders, and game folders."""
|
|
||||||
return {
|
|
||||||
_dropin(unit_dir, WATCH_PATH): _render(feedback_dirs(data_root)),
|
|
||||||
_dropin(unit_dir, SYNC_PATH): _render(binaries_dirs(data_root)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def run_systemctl(args: Sequence[str]) -> int:
|
|
||||||
"""Run ``systemctl --user <args>`` and return its exit code."""
|
|
||||||
return subprocess.run([SYSTEMCTL, "--user", *args], check=False).returncode
|
|
||||||
|
|
||||||
|
|
||||||
def sync_units(
|
|
||||||
data_root: Path,
|
|
||||||
unit_dir: Path,
|
|
||||||
systemctl: Callable[[Sequence[str]], int],
|
|
||||||
) -> bool:
|
|
||||||
"""Rewrite drifted drop-ins, then reload and re-arm once; True if changed."""
|
|
||||||
changed = False
|
|
||||||
for path, text in desired(data_root, unit_dir).items():
|
|
||||||
if path.is_file() and path.read_text(encoding="utf-8") == text:
|
|
||||||
continue
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(text, encoding="utf-8")
|
|
||||||
changed = True
|
|
||||||
if not changed:
|
|
||||||
return False
|
|
||||||
for args in (["daemon-reload"], ["restart", WATCH_PATH, SYNC_PATH]):
|
|
||||||
if systemctl(args):
|
|
||||||
msg = f"systemctl --user {' '.join(args)} failed"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
return True
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Tests for feedback_inbox."""
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Redirect the one path resolver, so no test can touch real user state.
|
|
||||||
|
|
||||||
Every writer resolves its paths through ``feedback_inbox._paths.resolve_paths``;
|
|
||||||
redirecting that one function moves all of them (kuhy's real ``~/data``,
|
|
||||||
token file, lock and systemd unit dir included).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from feedback_inbox._paths import Paths
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Paths:
|
|
||||||
"""Point every default path at ``tmp_path`` before any test runs."""
|
|
||||||
redirected = Paths.under(tmp_path / "home")
|
|
||||||
|
|
||||||
def _resolve(given: Paths | None = None) -> Paths:
|
|
||||||
return given if given is not None else redirected
|
|
||||||
|
|
||||||
monkeypatch.setattr("feedback_inbox._cli.resolve_paths", _resolve)
|
|
||||||
redirected.data_root.mkdir(parents=True)
|
|
||||||
return redirected
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Discovery, stamps and due dates."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from feedback_inbox._bundle import bundles_in, due_at, feedback_dirs, repo_name
|
|
||||||
from feedback_inbox.tests.test_support import make_bundle
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from feedback_inbox._paths import Paths
|
|
||||||
|
|
||||||
|
|
||||||
def test_feedback_dirs_covers_every_game(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root, game="cleanup-plate")
|
|
||||||
make_bundle(paths.data_root, game="dread-grid")
|
|
||||||
(paths.data_root / "other" / "feedback").mkdir(parents=True)
|
|
||||||
(paths.data_root / "empty_binaries").mkdir()
|
|
||||||
(paths.data_root / "file_binaries").write_text("x", encoding="utf-8")
|
|
||||||
found = feedback_dirs(paths.data_root)
|
|
||||||
assert [repo_name(d) for d in found] == ["cleanup-plate", "dread-grid"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_feedback_dirs_without_data_root(paths: Paths) -> None:
|
|
||||||
assert feedback_dirs(paths.data_root / "missing") == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_bundles_in_keeps_only_stamp_folders(paths: Paths) -> None:
|
|
||||||
first = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124")
|
|
||||||
make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162")
|
|
||||||
feedback = first.parent
|
|
||||||
(feedback / "notes").mkdir()
|
|
||||||
(feedback / "2026-11-11T13-52-33-999").write_text("x", encoding="utf-8")
|
|
||||||
names = [b.name for b in bundles_in(feedback)]
|
|
||||||
assert names == ["2026-11-11T13-52-33-162", "2026-11-11T13-53-19-124"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_due_is_next_day_ten(paths: Paths) -> None:
|
|
||||||
assert due_at("2026-11-11T13-52-33-162") == datetime(2026, 11, 12, 10, 0)
|
|
||||||
assert due_at("2026-12-31T23-59-59-001") == datetime(2027, 1, 1, 10, 0)
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""The command line: run, sync, exit codes, lock."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from feedback_inbox import __main__ as entry
|
|
||||||
from feedback_inbox._cli import main
|
|
||||||
from feedback_inbox._inbox import MARKER
|
|
||||||
from feedback_inbox._paths import Paths, resolve_paths
|
|
||||||
from feedback_inbox.tests.test_support import FakeTodo, make_bundle
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_imports_and_exits_zero(
|
|
||||||
paths: Paths, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
todo = FakeTodo()
|
|
||||||
with caplog.at_level(logging.INFO):
|
|
||||||
assert main(["run", "--wait", "0"], client=todo) == 0
|
|
||||||
assert (bundle / MARKER).exists()
|
|
||||||
assert len(todo.calls) == 1
|
|
||||||
assert "imported" in caplog.text
|
|
||||||
assert main(["run", "--wait", "0"], client=todo) == 0
|
|
||||||
assert len(todo.calls) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_fails_loudly_when_todo_is_down(
|
|
||||||
paths: Paths, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
assert main(["run", "--wait", "0"], client=FakeTodo(fail=True)) == 1
|
|
||||||
assert not (bundle / MARKER).exists()
|
|
||||||
assert "todo down" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_pending_is_not_an_error(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root, entry=False)
|
|
||||||
assert main(["run", "--wait", "0"], client=FakeTodo()) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_builds_the_real_client_from_paths(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root)
|
|
||||||
assert main(["run", "--wait", "0"]) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_with_nothing_to_do_needs_no_token(paths: Paths) -> None:
|
|
||||||
assert main(["run", "--wait", "0"]) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_takes_the_lock_file(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root)
|
|
||||||
assert main(["run", "--wait", "0"], client=FakeTodo()) == 0
|
|
||||||
assert paths.lock_file.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_command(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root)
|
|
||||||
calls: list[list[str]] = []
|
|
||||||
|
|
||||||
def fake(args: Sequence[str]) -> int:
|
|
||||||
calls.append(list(args))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
assert main(["sync"], systemctl=fake) == 0
|
|
||||||
assert calls[0] == ["daemon-reload"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_failure_exit_code(paths: Paths, caplog: pytest.LogCaptureFixture) -> None:
|
|
||||||
make_bundle(paths.data_root)
|
|
||||||
assert main(["sync"], systemctl=lambda _a: 1) == 1
|
|
||||||
assert "daemon-reload" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_usage_error() -> None:
|
|
||||||
with pytest.raises(SystemExit):
|
|
||||||
main([])
|
|
||||||
|
|
||||||
|
|
||||||
def test_real_paths_are_under_home(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr("feedback_inbox._paths.Path.home", lambda: tmp_path)
|
|
||||||
real = resolve_paths()
|
|
||||||
assert real.data_root == tmp_path / "data"
|
|
||||||
assert real.token_file == tmp_path / ".config" / "feedback-inbox" / "token"
|
|
||||||
assert real.unit_dir == tmp_path / ".config" / "systemd" / "user"
|
|
||||||
assert real.mcp_url == "http://127.0.0.1:8735/mcp"
|
|
||||||
given = Paths.under(tmp_path)
|
|
||||||
assert resolve_paths(given) is given
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_entry_point(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr("sys.argv", ["feedback-inbox", "run", "--wait", "0"])
|
|
||||||
assert entry.run() == 0
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Finding a bundle's markdown entry and reading its report."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from feedback_inbox._entry import (
|
|
||||||
find_entry,
|
|
||||||
game_and_combo,
|
|
||||||
note_from_entry,
|
|
||||||
read_report,
|
|
||||||
)
|
|
||||||
from feedback_inbox.tests.test_support import make_bundle
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from feedback_inbox._paths import Paths
|
|
||||||
|
|
||||||
|
|
||||||
def test_find_entry_picks_the_matching_section(paths: Paths) -> None:
|
|
||||||
first = make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162", note="alpha")
|
|
||||||
second = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124", note="beta")
|
|
||||||
entry = find_entry(first.parent, second)
|
|
||||||
assert entry is not None
|
|
||||||
assert "beta" in entry
|
|
||||||
assert "alpha" not in entry
|
|
||||||
assert find_entry(first.parent, first) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_find_entry_missing(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root, entry=False)
|
|
||||||
assert find_entry(bundle.parent, bundle) is None
|
|
||||||
(bundle.parent / "feedback-2026-11-11.md").write_bytes(b"\xff\xfe")
|
|
||||||
assert find_entry(bundle.parent, bundle) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_report(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root, note=" hello \nworld")
|
|
||||||
report = read_report(bundle / "report.json")
|
|
||||||
assert report is not None
|
|
||||||
assert report.note == " hello \nworld"
|
|
||||||
assert (report.game, report.combo) == ("museum", "loop=P2")
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_report_corrupt_forms(tmp_path: Path) -> None:
|
|
||||||
path = tmp_path / "report.json"
|
|
||||||
for bad in ("{broken", "[1]", '{"note": 3, "game": null}'):
|
|
||||||
path.write_text(bad, encoding="utf-8")
|
|
||||||
report = read_report(path)
|
|
||||||
if bad == '{"note": 3, "game": null}':
|
|
||||||
assert report is not None
|
|
||||||
assert (report.note, report.game, report.combo) == ("", "", "")
|
|
||||||
else:
|
|
||||||
assert report is None
|
|
||||||
path.write_bytes(b"\xff\xfe")
|
|
||||||
assert read_report(path) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_entry_fallbacks(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root, note="line one\nline two")
|
|
||||||
entry = find_entry(bundle.parent, bundle)
|
|
||||||
assert entry is not None
|
|
||||||
assert note_from_entry(entry) == "line one\nline two"
|
|
||||||
assert game_and_combo(entry) == ("museum", "loop=P2,carry=d,id=c")
|
|
||||||
assert note_from_entry("## head only") == ""
|
|
||||||
assert game_and_combo("nothing here") == ("", "")
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Import rules: complete, incomplete, idempotent, titles, due date, failures."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from feedback_inbox._entry import Report
|
|
||||||
from feedback_inbox._inbox import (
|
|
||||||
MARKER,
|
|
||||||
Importer,
|
|
||||||
Outcome,
|
|
||||||
Waiter,
|
|
||||||
build_task,
|
|
||||||
run_once,
|
|
||||||
run_settled,
|
|
||||||
)
|
|
||||||
from feedback_inbox.tests.test_support import STAMP, FakeTodo, make_bundle
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from feedback_inbox._paths import Paths
|
|
||||||
|
|
||||||
NOW = 1_000_000.0
|
|
||||||
OWNER = "kuhyx"
|
|
||||||
MANUAL = "MANUAL WORK REQUIRED: "
|
|
||||||
|
|
||||||
|
|
||||||
def _run(paths: Paths, todo: FakeTodo) -> list[Outcome]:
|
|
||||||
return [r.outcome for r in run_once(paths.data_root, Importer(todo, OWNER), NOW)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_bundle_becomes_one_task(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
|
||||||
((repo, title, body),) = todo.calls
|
|
||||||
assert repo == "kuhyx/cleanup-plate"
|
|
||||||
assert (
|
|
||||||
title
|
|
||||||
== MANUAL + "cleanup-plate feedback: I would like to see the painting IN FULL"
|
|
||||||
)
|
|
||||||
assert body.startswith("Due: 2026-11-12 10:00\n\n### 2026-11-11T13:52:33")
|
|
||||||
assert f"{bundle}/report.json" in body
|
|
||||||
marker = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
|
|
||||||
assert marker["task_id"] == "id0001" + "0" * 28
|
|
||||||
assert marker["due"] == "2026-11-12T10:00:00"
|
|
||||||
|
|
||||||
|
|
||||||
def test_rerun_never_duplicates(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root)
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
|
||||||
assert _run(paths, todo) == [Outcome.SKIPPED]
|
|
||||||
assert len(todo.calls) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_incomplete_bundles_wait(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", report=None)
|
|
||||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", entry=False)
|
|
||||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", age=0.5)
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.PENDING] * 3
|
|
||||||
assert todo.calls == []
|
|
||||||
later = run_once(paths.data_root, Importer(todo, OWNER), NOW + 10)
|
|
||||||
assert [r.outcome for r in later] == [
|
|
||||||
Outcome.PENDING,
|
|
||||||
Outcome.PENDING,
|
|
||||||
Outcome.IMPORTED,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_games(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root, game="cleanup-plate", note="a")
|
|
||||||
make_bundle(paths.data_root, game="dread-grid", note="b")
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.IMPORTED] * 2
|
|
||||||
assert [(c[0], c[1]) for c in todo.calls] == [
|
|
||||||
("kuhyx/cleanup-plate", "MANUAL WORK REQUIRED: cleanup-plate feedback: a"),
|
|
||||||
("kuhyx/dread-grid", "MANUAL WORK REQUIRED: dread-grid feedback: b"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_corrupt_report_still_a_task(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root, report="{broken", note="keep me")
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
|
||||||
((_, title, body),) = todo.calls
|
|
||||||
assert (
|
|
||||||
title
|
|
||||||
== "MANUAL WORK REQUIRED: cleanup-plate feedback: keep me [corrupt report.json]"
|
|
||||||
)
|
|
||||||
assert "report.json is corrupt" in body
|
|
||||||
assert (bundle / MARKER).exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_corrupt_report_without_note(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root, report="[]", note="")
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
|
||||||
title = todo.calls[0][1]
|
|
||||||
game_part = "museum loop=P2,carry=d,id=c 13:52"
|
|
||||||
assert title == f"{MANUAL}cleanup-plate feedback: {game_part} [corrupt report.json]"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_note_title_names_game_combo_time(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root, note="")
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
|
||||||
assert (
|
|
||||||
todo.calls[0][1]
|
|
||||||
== "MANUAL WORK REQUIRED: cleanup-plate feedback: museum loop=P2 13:52"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_title_uses_first_line_and_caps_length() -> None:
|
|
||||||
report = Report(note="\n " + "x" * 300 + "\nsecond", game="g", combo="c")
|
|
||||||
draft = build_task("repo", STAMP, "## e", report)
|
|
||||||
assert draft.title == "MANUAL WORK REQUIRED: repo feedback: " + "x" * 119 + "…"
|
|
||||||
bare = build_task("repo", STAMP, "## e", Report(note="", game="", combo=""))
|
|
||||||
assert bare.title == "MANUAL WORK REQUIRED: repo feedback: 13:52"
|
|
||||||
|
|
||||||
|
|
||||||
def test_todo_failure_leaves_no_marker(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
todo = FakeTodo(fail=True)
|
|
||||||
results = run_once(paths.data_root, Importer(todo, OWNER), NOW)
|
|
||||||
assert [r.outcome for r in results] == [Outcome.FAILED]
|
|
||||||
assert "todo down" in results[0].detail
|
|
||||||
assert not (bundle / MARKER).exists()
|
|
||||||
assert _run(paths, FakeTodo()) == [Outcome.IMPORTED]
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_is_flagged_but_marked(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root)
|
|
||||||
results = run_once(paths.data_root, Importer(FakeTodo(merged=True), OWNER), NOW)
|
|
||||||
assert results[0].outcome is Outcome.IMPORTED
|
|
||||||
assert "merged" in results[0].detail
|
|
||||||
|
|
||||||
|
|
||||||
def test_marker_write_failure_is_reported(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
(bundle / MARKER).mkdir()
|
|
||||||
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
|
|
||||||
assert results[0].outcome is Outcome.SKIPPED
|
|
||||||
(bundle / MARKER).rmdir()
|
|
||||||
bundle.chmod(0o500)
|
|
||||||
try:
|
|
||||||
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
|
|
||||||
finally:
|
|
||||||
bundle.chmod(0o700)
|
|
||||||
assert results[0].outcome is Outcome.FAILED
|
|
||||||
assert "marker" in results[0].detail
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_settled_waits_then_gives_up(paths: Paths, tmp_path: Path) -> None:
|
|
||||||
make_bundle(paths.data_root, age=0.0, now=NOW)
|
|
||||||
clock = [NOW]
|
|
||||||
slept: list[float] = []
|
|
||||||
|
|
||||||
def sleep(seconds: float) -> None:
|
|
||||||
slept.append(seconds)
|
|
||||||
clock[0] += seconds
|
|
||||||
|
|
||||||
todo = FakeTodo()
|
|
||||||
results = run_settled(
|
|
||||||
paths.data_root, Importer(todo, OWNER), Waiter(5.0, lambda: clock[0], sleep)
|
|
||||||
)
|
|
||||||
assert [r.outcome for r in results] == [Outcome.IMPORTED]
|
|
||||||
assert slept == [1.0, 1.0]
|
|
||||||
|
|
||||||
other = tmp_path / "other"
|
|
||||||
other.mkdir()
|
|
||||||
make_bundle(other, report=None)
|
|
||||||
slept.clear()
|
|
||||||
results = run_settled(
|
|
||||||
other, Importer(todo, OWNER), Waiter(3.0, lambda: clock[0], sleep)
|
|
||||||
)
|
|
||||||
assert [r.outcome for r in results] == [Outcome.PENDING]
|
|
||||||
assert slept == [1.0, 1.0, 1.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_title_starts_with_the_manual_prefix(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", note="a")
|
|
||||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", note="")
|
|
||||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", report="{")
|
|
||||||
make_bundle(paths.data_root, game="dread-grid", note="b")
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.IMPORTED] * 4
|
|
||||||
titles = [call[1] for call in todo.calls]
|
|
||||||
assert len(titles) == 4
|
|
||||||
assert all(t.startswith("MANUAL WORK REQUIRED: ") for t in titles)
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Exact titles, section-free bodies, and repair of half-finished tasks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from feedback_inbox._entry import Report
|
|
||||||
from feedback_inbox._inbox import (
|
|
||||||
FORMAT,
|
|
||||||
MARKER,
|
|
||||||
Importer,
|
|
||||||
Outcome,
|
|
||||||
build_task,
|
|
||||||
run_once,
|
|
||||||
)
|
|
||||||
from feedback_inbox.tests.test_support import STAMP, FakeTodo, make_bundle
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from feedback_inbox._paths import Paths
|
|
||||||
|
|
||||||
NOW = 1_000_000.0
|
|
||||||
OWNER = "kuhyx"
|
|
||||||
MANUAL = "MANUAL WORK REQUIRED: "
|
|
||||||
|
|
||||||
|
|
||||||
def _run(paths: Paths, todo: FakeTodo) -> list[Outcome]:
|
|
||||||
return [r.outcome for r in run_once(paths.data_root, Importer(todo, OWNER), NOW)]
|
|
||||||
|
|
||||||
|
|
||||||
def _marker(bundle: Path) -> dict[str, object]:
|
|
||||||
data: dict[str, object] = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _no_extra_sections(text: str) -> bool:
|
|
||||||
return not any(line.startswith("## ") for line in text.splitlines())
|
|
||||||
|
|
||||||
|
|
||||||
def test_stored_note_is_one_manual_part(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
|
||||||
(stored,) = todo.stored.values()
|
|
||||||
assert stored.startswith(MANUAL)
|
|
||||||
assert _no_extra_sections(stored)
|
|
||||||
assert "### 2026-11-11T13:52:33" in stored
|
|
||||||
assert _marker(bundle)["format"] == FORMAT
|
|
||||||
|
|
||||||
|
|
||||||
def test_entry_headings_never_become_sections() -> None:
|
|
||||||
entry = "## a heading\ntext\n## another\n### kept"
|
|
||||||
draft = build_task("r", STAMP, entry, Report(note="n", game="", combo=""))
|
|
||||||
assert _no_extra_sections(draft.body)
|
|
||||||
assert "### a heading\ntext\n### another\n### kept" in draft.body
|
|
||||||
|
|
||||||
|
|
||||||
def test_failed_update_keeps_marker_and_retries_without_a_second_task(
|
|
||||||
paths: Paths,
|
|
||||||
) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
todo = FakeTodo(fail_update=True)
|
|
||||||
results = run_once(paths.data_root, Importer(todo, OWNER), NOW)
|
|
||||||
assert [r.outcome for r in results] == [Outcome.FAILED]
|
|
||||||
assert "update refused" in results[0].detail
|
|
||||||
assert "format" not in _marker(bundle)
|
|
||||||
todo.fail_update = False
|
|
||||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
|
||||||
assert len(todo.calls) == 1
|
|
||||||
(stored,) = todo.stored.values()
|
|
||||||
assert stored.startswith(MANUAL)
|
|
||||||
assert _no_extra_sections(stored)
|
|
||||||
assert _run(paths, todo) == [Outcome.SKIPPED]
|
|
||||||
|
|
||||||
|
|
||||||
def test_old_marker_is_repaired_from_the_bundle(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
task_id = "abc" + "0" * 33
|
|
||||||
(bundle / MARKER).write_text(
|
|
||||||
json.dumps({"task_id": task_id, "title": "old", "title_fixed": True}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
|
||||||
assert todo.calls == []
|
|
||||||
assert todo.stored[task_id].startswith(MANUAL)
|
|
||||||
assert _no_extra_sections(todo.stored[task_id])
|
|
||||||
assert _marker(bundle)["format"] == FORMAT
|
|
||||||
|
|
||||||
|
|
||||||
def test_old_marker_whose_entry_vanished_waits(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root, entry=False)
|
|
||||||
(bundle / MARKER).write_text(
|
|
||||||
json.dumps({"task_id": "t" * 36, "title": "old"}), encoding="utf-8"
|
|
||||||
)
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.PENDING]
|
|
||||||
assert todo.updated == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_merged_task_is_not_rewritten(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
todo = FakeTodo(merged=True)
|
|
||||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
|
||||||
assert todo.updated == []
|
|
||||||
assert _marker(bundle)["format"] == FORMAT
|
|
||||||
assert _run(paths, todo) == [Outcome.SKIPPED]
|
|
||||||
|
|
||||||
|
|
||||||
def test_old_merged_marker_is_left_alone(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
(bundle / MARKER).write_text(
|
|
||||||
json.dumps({"task_id": "t" * 36, "title": "x", "merged": True}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.SKIPPED]
|
|
||||||
assert todo.updated == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_unreadable_marker_is_left_alone(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
for junk in ("{broken", "[1]", '{"task_id": 5}'):
|
|
||||||
(bundle / MARKER).write_text(junk, encoding="utf-8")
|
|
||||||
todo = FakeTodo()
|
|
||||||
assert _run(paths, todo) == [Outcome.SKIPPED]
|
|
||||||
assert todo.calls == []
|
|
||||||
assert todo.updated == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_repair_reports_an_unwritable_marker(paths: Paths) -> None:
|
|
||||||
bundle = make_bundle(paths.data_root)
|
|
||||||
(bundle / MARKER).write_text(
|
|
||||||
json.dumps({"task_id": "t" * 36, "title": "T"}), encoding="utf-8"
|
|
||||||
)
|
|
||||||
bundle.chmod(0o500)
|
|
||||||
try:
|
|
||||||
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
|
|
||||||
finally:
|
|
||||||
bundle.chmod(0o700)
|
|
||||||
assert results[0].outcome is Outcome.FAILED
|
|
||||||
assert "marker not written" in results[0].detail
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Builders for fake feedback bundles and a fake todo client."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from feedback_inbox._todo import TaskRef, TodoError
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
STAMP = "2026-11-11T13-52-33-162"
|
|
||||||
NOTE = "I would like to see the painting IN FULL\n\nsecond paragraph"
|
|
||||||
|
|
||||||
|
|
||||||
def entry_text(bundle: Path, note: str = NOTE, game: str = "museum") -> str:
|
|
||||||
"""The markdown entry a game appends for ``bundle``."""
|
|
||||||
head = note.splitlines()[0][:70] if note else ""
|
|
||||||
parts = [f"## 2026-11-11T13:52:33: {head}", ""]
|
|
||||||
if note:
|
|
||||||
parts += [note, ""]
|
|
||||||
parts += [
|
|
||||||
f"- Screenshot: `{bundle}/annotated.png`",
|
|
||||||
f"- Report: `{bundle}/report.json`",
|
|
||||||
f"- Game `{game}`, combo `loop=P2,carry=d,id=c`, build abc, session `s`",
|
|
||||||
"",
|
|
||||||
"Task: Read the annotated screenshot and fix it.",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def make_bundle(
|
|
||||||
data_root: Path,
|
|
||||||
*,
|
|
||||||
game: str = "cleanup-plate",
|
|
||||||
stamp: str = STAMP,
|
|
||||||
note: str = NOTE,
|
|
||||||
report: str | None = "ok",
|
|
||||||
entry: bool = True,
|
|
||||||
age: float = 60.0,
|
|
||||||
now: float = 1_000_000.0,
|
|
||||||
) -> Path:
|
|
||||||
"""Create ``<game>_binaries/feedback/<stamp>/`` and its md entry."""
|
|
||||||
feedback = data_root / f"{game}_binaries" / "feedback"
|
|
||||||
bundle = feedback / stamp
|
|
||||||
bundle.mkdir(parents=True, exist_ok=True)
|
|
||||||
if report is not None:
|
|
||||||
path = bundle / "report.json"
|
|
||||||
if report == "ok":
|
|
||||||
body = json.dumps({"note": note, "game": "museum", "combo": "loop=P2"})
|
|
||||||
else:
|
|
||||||
body = report
|
|
||||||
path.write_text(body, encoding="utf-8")
|
|
||||||
os.utime(path, (now - age, now - age))
|
|
||||||
if entry:
|
|
||||||
md = feedback / f"feedback-{stamp[:10]}.md"
|
|
||||||
with md.open("a", encoding="utf-8") as handle:
|
|
||||||
handle.write(entry_text(bundle, note))
|
|
||||||
return bundle
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FakeTodo:
|
|
||||||
"""A todo server stand-in that stores notes the way the real one does."""
|
|
||||||
|
|
||||||
calls: list[tuple[str, str, str]] = field(default_factory=list)
|
|
||||||
stored: dict[str, str] = field(default_factory=dict)
|
|
||||||
fail: bool = False
|
|
||||||
fail_update: bool = False
|
|
||||||
merged: bool = False
|
|
||||||
updated: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
|
|
||||||
if self.fail:
|
|
||||||
msg = "todo down"
|
|
||||||
raise TodoError(msg)
|
|
||||||
self.calls.append((repo, title, body))
|
|
||||||
task_id = f"id{len(self.calls):04d}" + "0" * 28
|
|
||||||
# A new_note task: title "<name> todos: <title>", then a "## <title>"
|
|
||||||
# section holding the body.
|
|
||||||
name = repo.rsplit("/", maxsplit=1)[-1]
|
|
||||||
self.stored[task_id] = f"{name} todos: {title}\n\n## {title}\n{body}"
|
|
||||||
return TaskRef(task_id=task_id, merged=self.merged)
|
|
||||||
|
|
||||||
def update_task(self, task_id: str, title: str, body: str) -> None:
|
|
||||||
if self.fail_update:
|
|
||||||
msg = "update refused"
|
|
||||||
raise TodoError(msg)
|
|
||||||
self.updated.append(task_id)
|
|
||||||
self.stored[task_id] = f"{title}\n\n{body}"
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""The MCP client against a real local HTTP server (never the live todo app)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from feedback_inbox._todo import McpTodoClient, TodoError
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
FULL_ID = "a1b2c3d4" + "e" * 28
|
|
||||||
|
|
||||||
|
|
||||||
class _Handler(BaseHTTPRequestHandler):
|
|
||||||
seen: ClassVar[list[dict[str, object]]] = []
|
|
||||||
headers_seen: ClassVar[list[dict[str, str]]] = []
|
|
||||||
tool_text: ClassVar[str] = f"added {FULL_ID}\nline"
|
|
||||||
is_error: ClassVar[bool] = False
|
|
||||||
status: ClassVar[int] = 200
|
|
||||||
sse: ClassVar[bool] = True
|
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
|
||||||
length = int(self.headers["Content-Length"])
|
|
||||||
body = json.loads(self.rfile.read(length))
|
|
||||||
type(self).seen.append(body)
|
|
||||||
type(self).headers_seen.append({k.lower(): v for k, v in self.headers.items()})
|
|
||||||
if type(self).status != 200:
|
|
||||||
self.send_response(type(self).status)
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("mcp-session-id", "sess-1")
|
|
||||||
self.end_headers()
|
|
||||||
method = body.get("method")
|
|
||||||
if method == "initialize":
|
|
||||||
self.wfile.write(
|
|
||||||
b'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n'
|
|
||||||
)
|
|
||||||
elif method == "tools/call":
|
|
||||||
result: dict[str, object] = {
|
|
||||||
"content": [{"type": "text", "text": type(self).tool_text}],
|
|
||||||
}
|
|
||||||
if type(self).is_error:
|
|
||||||
result["isError"] = True
|
|
||||||
reply = json.dumps({"jsonrpc": "2.0", "id": 2, "result": result})
|
|
||||||
out = f"event: message\ndata: {reply}\n\n" if type(self).sse else reply
|
|
||||||
self.wfile.write(out.encode())
|
|
||||||
|
|
||||||
def log_message(self, *_args: object) -> None:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def server() -> Iterator[str]:
|
|
||||||
_Handler.seen = []
|
|
||||||
_Handler.headers_seen = []
|
|
||||||
_Handler.tool_text = f"added {FULL_ID}\nline"
|
|
||||||
_Handler.is_error = False
|
|
||||||
_Handler.status = 200
|
|
||||||
_Handler.sse = True
|
|
||||||
httpd = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
||||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
yield f"http://127.0.0.1:{httpd.server_port}/mcp"
|
|
||||||
httpd.shutdown()
|
|
||||||
httpd.server_close()
|
|
||||||
thread.join()
|
|
||||||
|
|
||||||
|
|
||||||
def _client(url: str, tmp_path: Path) -> McpTodoClient:
|
|
||||||
token = tmp_path / "token"
|
|
||||||
token.write_text("secret-token\n", encoding="utf-8")
|
|
||||||
return McpTodoClient(url, token)
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_task_round_trip(server: str, tmp_path: Path) -> None:
|
|
||||||
ref = _client(server, tmp_path).add_task("kuhyx/cleanup-plate", "t", "b")
|
|
||||||
assert (ref.task_id, ref.merged) == (FULL_ID, False)
|
|
||||||
methods = [m.get("method") for m in _Handler.seen]
|
|
||||||
assert methods == ["initialize", "notifications/initialized", "tools/call"]
|
|
||||||
call = _Handler.seen[2]["params"]
|
|
||||||
assert call == {
|
|
||||||
"name": "add_task",
|
|
||||||
"arguments": {
|
|
||||||
"repo": "kuhyx/cleanup-plate",
|
|
||||||
"title": "t",
|
|
||||||
"body": "b",
|
|
||||||
"new_note": True,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert _Handler.headers_seen[0]["authorization"] == "Bearer secret-token"
|
|
||||||
assert _Handler.headers_seen[2]["mcp-session-id"] == "sess-1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_plain_json_reply_and_merge(server: str, tmp_path: Path) -> None:
|
|
||||||
_Handler.sse = False
|
|
||||||
_Handler.tool_text = f"merged into {FULL_ID}\nstart: skipped"
|
|
||||||
ref = _client(server, tmp_path).add_task("r/x", "t", "b")
|
|
||||||
assert (ref.task_id, ref.merged) == (FULL_ID, True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_tool_error_is_reported(server: str, tmp_path: Path) -> None:
|
|
||||||
_Handler.is_error = True
|
|
||||||
_Handler.tool_text = "boom"
|
|
||||||
with pytest.raises(TodoError, match="boom"):
|
|
||||||
_client(server, tmp_path).add_task("r/x", "t", "b")
|
|
||||||
|
|
||||||
|
|
||||||
def test_unexpected_reply_text(server: str, tmp_path: Path) -> None:
|
|
||||||
_Handler.tool_text = "who knows"
|
|
||||||
with pytest.raises(TodoError, match="who knows"):
|
|
||||||
_client(server, tmp_path).add_task("r/x", "t", "b")
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_error(server: str, tmp_path: Path) -> None:
|
|
||||||
_Handler.status = 403
|
|
||||||
with pytest.raises(TodoError, match="403"):
|
|
||||||
_client(server, tmp_path).add_task("r/x", "t", "b")
|
|
||||||
|
|
||||||
|
|
||||||
def test_unreachable_server(tmp_path: Path) -> None:
|
|
||||||
with pytest.raises(TodoError, match="unreachable"):
|
|
||||||
_client("http://127.0.0.1:9/mcp", tmp_path).add_task("r/x", "t", "b")
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_token(tmp_path: Path) -> None:
|
|
||||||
client = McpTodoClient("http://127.0.0.1:9/mcp", tmp_path / "none")
|
|
||||||
with pytest.raises(TodoError, match="token"):
|
|
||||||
client.add_task("r/x", "t", "b")
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_session_header(server: str, tmp_path: Path) -> None:
|
|
||||||
class _NoSession(_Handler):
|
|
||||||
def do_POST(self) -> None:
|
|
||||||
self.rfile.read(int(self.headers["Content-Length"]))
|
|
||||||
self.send_response(200)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b"{}")
|
|
||||||
|
|
||||||
httpd = HTTPServer(("127.0.0.1", 0), _NoSession)
|
|
||||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
try:
|
|
||||||
client = _client(f"http://127.0.0.1:{httpd.server_port}/mcp", tmp_path)
|
|
||||||
with pytest.raises(TodoError, match="session"):
|
|
||||||
client.add_task("r/x", "t", "b")
|
|
||||||
finally:
|
|
||||||
httpd.shutdown()
|
|
||||||
httpd.server_close()
|
|
||||||
thread.join()
|
|
||||||
|
|
||||||
|
|
||||||
def test_garbage_json(server: str, tmp_path: Path) -> None:
|
|
||||||
_Handler.sse = False
|
|
||||||
_Handler.tool_text = "x"
|
|
||||||
|
|
||||||
class _Bad(_Handler):
|
|
||||||
def do_POST(self) -> None:
|
|
||||||
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("mcp-session-id", "s")
|
|
||||||
self.end_headers()
|
|
||||||
if body.get("method") == "tools/call":
|
|
||||||
self.wfile.write(b"not json")
|
|
||||||
|
|
||||||
httpd = HTTPServer(("127.0.0.1", 0), _Bad)
|
|
||||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
try:
|
|
||||||
client = _client(f"http://127.0.0.1:{httpd.server_port}/mcp", tmp_path)
|
|
||||||
with pytest.raises(TodoError, match="unparsable"):
|
|
||||||
client.add_task("r/x", "t", "b")
|
|
||||||
finally:
|
|
||||||
httpd.shutdown()
|
|
||||||
httpd.server_close()
|
|
||||||
thread.join()
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_task_replaces_title_and_body(server: str, tmp_path: Path) -> None:
|
|
||||||
_Handler.tool_text = f"updated {FULL_ID}\nline"
|
|
||||||
_client(server, tmp_path).update_task(FULL_ID, "MANUAL WORK REQUIRED: x", "b")
|
|
||||||
call = _Handler.seen[2]["params"]
|
|
||||||
assert call == {
|
|
||||||
"name": "update_task",
|
|
||||||
"arguments": {"id": FULL_ID, "title": "MANUAL WORK REQUIRED: x", "body": "b"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_task_refused(server: str, tmp_path: Path) -> None:
|
|
||||||
_Handler.tool_text = "no such task"
|
|
||||||
with pytest.raises(TodoError, match="no such task"):
|
|
||||||
_client(server, tmp_path).update_task(FULL_ID, "t", "b")
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""The path-unit drop-ins follow the set of game folders, and only then reload."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from feedback_inbox._units import (
|
|
||||||
SYNC_PATH,
|
|
||||||
WATCH_PATH,
|
|
||||||
desired,
|
|
||||||
run_systemctl,
|
|
||||||
sync_units,
|
|
||||||
)
|
|
||||||
from feedback_inbox.tests.test_support import make_bundle
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from feedback_inbox._paths import Paths
|
|
||||||
|
|
||||||
|
|
||||||
class _Systemctl:
|
|
||||||
def __init__(self, code: int = 0) -> None:
|
|
||||||
self.calls: list[list[str]] = []
|
|
||||||
self.code = code
|
|
||||||
|
|
||||||
def __call__(self, args: Sequence[str]) -> int:
|
|
||||||
self.calls.append(list(args))
|
|
||||||
return self.code
|
|
||||||
|
|
||||||
|
|
||||||
def test_desired_lists_feedback_and_binaries_dirs(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root, game="cleanup-plate")
|
|
||||||
(paths.data_root / "dread-grid_binaries").mkdir()
|
|
||||||
wanted = desired(paths.data_root, paths.unit_dir)
|
|
||||||
watch = wanted[paths.unit_dir / f"{WATCH_PATH}.d" / "dirs.conf"]
|
|
||||||
sync = wanted[paths.unit_dir / f"{SYNC_PATH}.d" / "dirs.conf"]
|
|
||||||
feedback = paths.data_root / "cleanup-plate_binaries" / "feedback"
|
|
||||||
assert f"PathChanged={feedback}\n" in watch
|
|
||||||
assert "dread-grid" not in watch
|
|
||||||
assert f"PathChanged={paths.data_root}/cleanup-plate_binaries\n" in sync
|
|
||||||
assert f"PathChanged={paths.data_root}/dread-grid_binaries\n" in sync
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_writes_reloads_and_restarts_once(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root)
|
|
||||||
ctl = _Systemctl()
|
|
||||||
assert sync_units(paths.data_root, paths.unit_dir, ctl) is True
|
|
||||||
assert ctl.calls == [
|
|
||||||
["daemon-reload"],
|
|
||||||
["restart", WATCH_PATH, SYNC_PATH],
|
|
||||||
]
|
|
||||||
again = _Systemctl()
|
|
||||||
assert sync_units(paths.data_root, paths.unit_dir, again) is False
|
|
||||||
assert again.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_new_game_triggers_a_rewrite(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root, game="cleanup-plate")
|
|
||||||
sync_units(paths.data_root, paths.unit_dir, _Systemctl())
|
|
||||||
make_bundle(paths.data_root, game="dread-grid")
|
|
||||||
ctl = _Systemctl()
|
|
||||||
assert sync_units(paths.data_root, paths.unit_dir, ctl) is True
|
|
||||||
text = (paths.unit_dir / f"{WATCH_PATH}.d" / "dirs.conf").read_text(
|
|
||||||
encoding="utf-8"
|
|
||||||
)
|
|
||||||
assert "dread-grid_binaries/feedback" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_systemctl_failure_is_an_error(paths: Paths) -> None:
|
|
||||||
make_bundle(paths.data_root)
|
|
||||||
ctl = _Systemctl(code=1)
|
|
||||||
with pytest.raises(RuntimeError, match="daemon-reload"):
|
|
||||||
sync_units(paths.data_root, paths.unit_dir, ctl)
|
|
||||||
assert ctl.calls == [["daemon-reload"]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_systemctl_uses_the_user_manager(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
seen: list[list[str]] = []
|
|
||||||
|
|
||||||
class _Done:
|
|
||||||
returncode = 3
|
|
||||||
|
|
||||||
def fake_run(cmd: list[str], *, check: bool) -> _Done:
|
|
||||||
assert check is False
|
|
||||||
seen.append(cmd)
|
|
||||||
return _Done()
|
|
||||||
|
|
||||||
monkeypatch.setattr("feedback_inbox._units.subprocess.run", fake_run)
|
|
||||||
assert run_systemctl(["restart", "x"]) == 3
|
|
||||||
assert seen == [["/usr/bin/systemctl", "--user", "restart", "x"]]
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Install feedback-inbox for the current user: its own venv, the CLI on PATH,
|
|
||||||
# a todo-server token, and the systemd user units that watch every game's
|
|
||||||
# ~/data/<game>_binaries/feedback/ folder. Idempotent; rerun after any change.
|
|
||||||
#
|
|
||||||
# Enabling the units runs the inbox once (the sweep timer fires at once), so
|
|
||||||
# every bundle already on disk becomes a task: that is the backfill.
|
|
||||||
# Run the test suite first (scripts/run_subproject_tests.sh feedback_inbox).
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
readonly REPO_DIR
|
|
||||||
readonly STATE_DIR="${HOME}/.local/share/feedback-inbox"
|
|
||||||
readonly VENV_DIR="${STATE_DIR}/venv"
|
|
||||||
readonly BIN_DIR="${HOME}/.local/bin"
|
|
||||||
readonly UNIT_DIR="${HOME}/.config/systemd/user"
|
|
||||||
readonly CONFIG_DIR="${HOME}/.config/feedback-inbox"
|
|
||||||
readonly TOKEN_FILE="${CONFIG_DIR}/token"
|
|
||||||
readonly MINT="${HOME}/src/todo/mcp/mint_token.sh"
|
|
||||||
readonly SYSTEM_PYTHON="/usr/bin/python3"
|
|
||||||
readonly UNITS=(
|
|
||||||
feedback-inbox.service feedback-inbox.path
|
|
||||||
feedback-inbox-sync.service feedback-inbox-sync.path feedback-inbox-sync.timer
|
|
||||||
)
|
|
||||||
|
|
||||||
log() { printf 'install: %s\n' "$1" >&2; }
|
|
||||||
fail() { printf 'install: FAILED -- %s\n' "$1" >&2; exit 1; }
|
|
||||||
|
|
||||||
build_venv() {
|
|
||||||
log "building the venv at $VENV_DIR"
|
|
||||||
mkdir -p "$STATE_DIR"
|
|
||||||
[[ -x "$VENV_DIR/bin/python" ]] || "$SYSTEM_PYTHON" -m venv "$VENV_DIR" \
|
|
||||||
|| fail "could not create the venv"
|
|
||||||
"$VENV_DIR/bin/pip" install --quiet --no-deps -e "$REPO_DIR" \
|
|
||||||
|| fail "feedback-inbox itself (stdlib only, no other packages)"
|
|
||||||
mkdir -p "$BIN_DIR"
|
|
||||||
ln -sf "$VENV_DIR/bin/feedback-inbox" "$BIN_DIR/feedback-inbox"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_token() {
|
|
||||||
# The service has no shell, so TODO_MCP_TOKEN from ~/.zshenv is invisible
|
|
||||||
# to it. It gets its own revocable token (delete its line in
|
|
||||||
# ~/.config/todo-mcp/tokens); the value goes straight to a 0600 file.
|
|
||||||
[[ -s "$TOKEN_FILE" ]] && return 0
|
|
||||||
[[ -x "$MINT" ]] || fail "$MINT is missing (the todo repo)"
|
|
||||||
log "minting a todo-server token for feedback-inbox"
|
|
||||||
mkdir -p "$CONFIG_DIR"
|
|
||||||
(umask 077 && "$MINT" feedback-inbox >"$TOKEN_FILE") || fail "token mint"
|
|
||||||
[[ -s "$TOKEN_FILE" ]] || fail "token file is empty"
|
|
||||||
}
|
|
||||||
|
|
||||||
install_units() {
|
|
||||||
log "installing and enabling the units"
|
|
||||||
mkdir -p "$UNIT_DIR"
|
|
||||||
local unit
|
|
||||||
for unit in "${UNITS[@]}"; do
|
|
||||||
install -m 644 "$REPO_DIR/systemd/$unit" "$UNIT_DIR/"
|
|
||||||
done
|
|
||||||
systemctl --user daemon-reload || fail "daemon-reload"
|
|
||||||
# Drop-ins first, so the paths are armed with every current game folder.
|
|
||||||
"$VENV_DIR/bin/feedback-inbox" sync || fail "writing the watch lists"
|
|
||||||
systemctl --user enable --now feedback-inbox.path feedback-inbox-sync.path \
|
|
||||||
feedback-inbox-sync.timer || fail "enabling the units"
|
|
||||||
}
|
|
||||||
|
|
||||||
verify() {
|
|
||||||
local unit
|
|
||||||
for unit in feedback-inbox.path feedback-inbox-sync.path feedback-inbox-sync.timer; do
|
|
||||||
systemctl --user is-active --quiet "$unit" || fail "$unit is not active"
|
|
||||||
done
|
|
||||||
local folders
|
|
||||||
folders="$(systemctl --user show feedback-inbox.path -p Paths | grep -c '/feedback (PathChanged)' || true)"
|
|
||||||
log "ok: watching ${folders} feedback folder(s)"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
build_venv
|
|
||||||
ensure_token
|
|
||||||
install_units
|
|
||||||
verify
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=61"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "feedback-inbox"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Turns every game dev-feedback bundle into one todo-app task"
|
|
||||||
requires-python = ">=3.14"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
feedback-inbox = "feedback_inbox._cli:main"
|
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
|
||||||
include = ["feedback_inbox*"]
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
target-version = "py314"
|
|
||||||
include = ["*.py", "**/*.py"]
|
|
||||||
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["ALL"]
|
|
||||||
ignore = [
|
|
||||||
"D203",
|
|
||||||
"D213",
|
|
||||||
"COM812",
|
|
||||||
"ISC001",
|
|
||||||
"S603",
|
|
||||||
# Stamps are local wall-clock names the games write; naive on purpose.
|
|
||||||
"DTZ001",
|
|
||||||
"DTZ007",
|
|
||||||
# The todo server is loopback http by design (MCP_URL), never user input.
|
|
||||||
"S310",
|
|
||||||
]
|
|
||||||
fixable = ["ALL"]
|
|
||||||
unfixable = []
|
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
|
||||||
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "N802", "PLR0913", "PLR2004", "S101", "SLF001"]
|
|
||||||
"**/test_*.py" = ["ARG", "D", "PLC0415", "N802", "PLR0913", "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 = ["feedback_inbox"]
|
|
||||||
|
|
||||||
[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.14"
|
|
||||||
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]
|
|
||||||
# The repo root on the path, so pylint resolves the package's own imports the
|
|
||||||
# same way pytest does instead of reporting import-error for them.
|
|
||||||
init-hook = "import sys; sys.path.insert(0, 'feedback_inbox')"
|
|
||||||
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 = []
|
|
||||||
|
|
||||||
[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 = ["feedback_inbox/tests"]
|
|
||||||
python_files = ["test_*.py", "*_test.py"]
|
|
||||||
python_classes = ["Test*"]
|
|
||||||
python_functions = ["test_*"]
|
|
||||||
addopts = [
|
|
||||||
"-v",
|
|
||||||
"--strict-markers",
|
|
||||||
"--strict-config",
|
|
||||||
"-ra",
|
|
||||||
"--cov=feedback_inbox",
|
|
||||||
"--cov-branch",
|
|
||||||
"--cov-report=term-missing",
|
|
||||||
"--cov-report=lcov",
|
|
||||||
]
|
|
||||||
filterwarnings = [
|
|
||||||
"error",
|
|
||||||
"ignore::DeprecationWarning",
|
|
||||||
"default::pytest.PytestUnraisableExceptionWarning",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
|
||||||
source = ["feedback_inbox"]
|
|
||||||
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"]
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
# Exact pins, enforced by the shared dependency-freshness gate: a `>=`
|
|
||||||
# floor is satisfied by anything newer, so it tells a checker nothing
|
|
||||||
# about whether this repo is actually current.
|
|
||||||
bandit[toml]==1.9.4
|
|
||||||
mypy==2.4.1
|
|
||||||
pre-commit==4.7.0
|
|
||||||
pylint==4.1.3
|
|
||||||
pytest==9.2.0
|
|
||||||
pytest-cov==7.1.0
|
|
||||||
ruff==0.17.4
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=feedback-inbox: a game folder appeared or went away
|
|
||||||
|
|
||||||
[Path]
|
|
||||||
# ~/data fires when a *_binaries folder is created; the generated drop-in
|
|
||||||
# feedback-inbox-sync.path.d/dirs.conf adds each existing *_binaries folder, so
|
|
||||||
# a game's later `feedback/` subfolder is seen too.
|
|
||||||
PathChanged=%h/data
|
|
||||||
Unit=feedback-inbox-sync.service
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=feedback-inbox: re-arm the watches for new games, then import
|
|
||||||
Documentation=file:///home/kuhy/src/utils/feedback_inbox/README.md
|
|
||||||
StartLimitIntervalSec=0
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
WorkingDirectory=/
|
|
||||||
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox sync
|
|
||||||
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox run --wait 0
|
|
||||||
Nice=10
|
|
||||||
TimeoutStartSec=2min
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=feedback-inbox: fallback sweep for events the path units missed
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
# Events that land while a run is active are lost by .path units; this catches
|
|
||||||
# them. No Persistent=: a missed sweep is harmless, the next one runs soon.
|
|
||||||
OnBootSec=2min
|
|
||||||
OnUnitActiveSec=5min
|
|
||||||
Unit=feedback-inbox-sync.service
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=feedback-inbox: watch every game's feedback folder
|
|
||||||
|
|
||||||
[Path]
|
|
||||||
# systemd paths take no globs. The folders themselves are listed in the
|
|
||||||
# generated drop-in feedback-inbox.path.d/dirs.conf (`feedback-inbox sync`);
|
|
||||||
# this baseline is the token file, which exists once installed and never churns.
|
|
||||||
PathChanged=%h/.config/feedback-inbox/token
|
|
||||||
Unit=feedback-inbox.service
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=feedback-inbox: turn new game feedback bundles into todo tasks
|
|
||||||
Documentation=file:///home/kuhy/src/utils/feedback_inbox/README.md
|
|
||||||
# The .path unit fires on every write in a feedback folder; bursts (a bundle is
|
|
||||||
# written in pieces) must not trip the default start limit.
|
|
||||||
StartLimitIntervalSec=0
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
WorkingDirectory=/
|
|
||||||
# Waits up to 15 s for a half-written bundle to finish before leaving it to the
|
|
||||||
# timer. Exits non-zero when the todo server refuses, so the failure is visible.
|
|
||||||
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox run --wait 15
|
|
||||||
Nice=10
|
|
||||||
TimeoutStartSec=2min
|
|
||||||
@ -9,7 +9,7 @@ clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
|||||||
earns only once the real clock reaches it. An unreadable registry reads as 0
|
earns only once the real clock reaches it. An unreadable registry reads as 0
|
||||||
(logged): fail closed.
|
(logged): fail closed.
|
||||||
|
|
||||||
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused
|
Unbacked minutes (0.8.0): timer time the anti-cheat gate refused
|
||||||
(:mod:`focus_project.sources.activity`) and no other source covers, on the
|
(:mod:`focus_project.sources.activity`) and no other source covers, on the
|
||||||
local day up to "now"; carry and the cap play no part. Shown, never paid.
|
local day up to "now"; carry and the cap play no part. Shown, never paid.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,131 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains.
|
|
||||||
|
|
||||||
The core rule lives in ``test_backing.py``; these pin the edges the spec
|
|
||||||
named (C5-C7) with leetcode-ledger and other-project fixtures.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import focus_project as fp
|
|
||||||
from focus_project import minutes
|
|
||||||
from focus_project._paths import activity_path
|
|
||||||
from focus_project.tests.test_leetcode_source import ac_row, write
|
|
||||||
from focus_project.tests.test_minutes import at
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
DAY = date(2026, 11, 9) # after the cutover
|
|
||||||
PRE = date(2026, 11, 7) # before it
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
||||||
"""Nothing may fall back to the real home directory."""
|
|
||||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
||||||
|
|
||||||
|
|
||||||
def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None:
|
|
||||||
fp.create_project(
|
|
||||||
pid,
|
|
||||||
name=pid,
|
|
||||||
start=date(2026, 11, 6),
|
|
||||||
end=date(2026, 11, 12),
|
|
||||||
replaces=["leetcode"],
|
|
||||||
daily_minutes=240,
|
|
||||||
unlock_minutes=30,
|
|
||||||
site_patterns=list(patterns),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None:
|
|
||||||
"""Append ``(source, kind, ts[, project])`` events (project default hr)."""
|
|
||||||
activity_path().parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with activity_path().open("a", encoding="utf-8") as handle:
|
|
||||||
for item in items:
|
|
||||||
source, kind, ts = item[:3]
|
|
||||||
pid = item[3] if len(item) == 4 else "hr"
|
|
||||||
row = {"ts": ts, "source": source, "kind": kind, "project": pid}
|
|
||||||
handle.write(json.dumps(row) + "\n")
|
|
||||||
|
|
||||||
|
|
||||||
def timer(day: date, *marks: tuple[int, int]) -> None:
|
|
||||||
"""A segment: start at the first mark, check-ins, stop at the last."""
|
|
||||||
kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"]
|
|
||||||
rows(
|
|
||||||
*(
|
|
||||||
("timer", k, at(h, m, day=day))
|
|
||||||
for k, (h, m) in zip(kinds, marks, strict=True)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None:
|
|
||||||
rows((source, "heartbeat", at(h, m, day=day), pid))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("source", ["sql_drills", "leetcode"])
|
|
||||||
def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None:
|
|
||||||
make()
|
|
||||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
|
||||||
beat(source, 9, 5)
|
|
||||||
assert minutes.minutes_for(DAY) == 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_browser_and_other_project_beats_do_not_back() -> None:
|
|
||||||
make() # "other" needs no registry entry: rows are filtered by id
|
|
||||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
|
||||||
beat("browser", 9, 4)
|
|
||||||
beat("tutor", 9, 4, pid="other")
|
|
||||||
assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only
|
|
||||||
assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10
|
|
||||||
|
|
||||||
|
|
||||||
def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None:
|
|
||||||
make()
|
|
||||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
|
||||||
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
|
||||||
assert minutes.minutes_for(DAY) == 11 # 8:59-9:10
|
|
||||||
|
|
||||||
|
|
||||||
def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None:
|
|
||||||
make("hr", ("sqlzoo.net",))
|
|
||||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
|
||||||
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
|
||||||
assert minutes.minutes_for(DAY) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_span_across_the_cutover_midnight_follows_the_day() -> None:
|
|
||||||
make()
|
|
||||||
rows(
|
|
||||||
("timer", "start", at(23, 58, day=date(2026, 11, 7))),
|
|
||||||
("timer", "checkin", at(0, 3, day=date(2026, 11, 8))),
|
|
||||||
("timer", "stop", at(0, 5, day=date(2026, 11, 8))),
|
|
||||||
)
|
|
||||||
assert minutes.minutes_for(date(2026, 11, 7)) == 2
|
|
||||||
assert minutes.minutes_for(date(2026, 11, 8)) == 0
|
|
||||||
assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None:
|
|
||||||
"""A tutor beat chains the segment (0.8.0) and backs the span it ends.
|
|
||||||
|
|
||||||
start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a
|
|
||||||
backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and
|
|
||||||
nothing bridges it, so it shows as unbacked.
|
|
||||||
"""
|
|
||||||
make()
|
|
||||||
rows(
|
|
||||||
("timer", "start", at(9, 0, day=DAY)),
|
|
||||||
("timer", "checkin", at(9, 8, day=DAY)),
|
|
||||||
)
|
|
||||||
beat("tutor", 9, 16)
|
|
||||||
assert minutes.minutes_for(DAY) == 8
|
|
||||||
assert minutes.unbacked_minutes_for(DAY) == 8
|
|
||||||
@ -97,25 +97,6 @@ A claim from a pre-0.11 writer has no key and reads as `grab != "none"`. Root
|
|||||||
has no `XDG_RUNTIME_DIR`, so a root reader passes the user's dir
|
has no `XDG_RUNTIME_DIR`, so a root reader passes the user's dir
|
||||||
(`/run/user/1000/gatelock/claims`).
|
(`/run/user/1000/gatelock/claims`).
|
||||||
|
|
||||||
`LockWindow` yields to a system credential prompt that holds the X grab. If the
|
|
||||||
global grab stays unobtainable for more than 2 s AND a mapped window whose
|
|
||||||
WM_CLASS is in `gatelock._prompt_names.SYSTEM_PROMPT_CLASSES` (`gcr-prompter`,
|
|
||||||
`pinentry*`, polkit agents, ssh-askpass variants) is on screen, that prompt's
|
|
||||||
top-level window is raised above the lock and the lock's periodic re-raise
|
|
||||||
stops covering it while it stays mapped. The lock stays mapped, VT switching
|
|
||||||
stays disabled and the grab keeps being retried; once the grab is taken the lock
|
|
||||||
is back on top. Any other grab holder (a fullscreen game) never gets this.
|
|
||||||
`scripts/verify_prompt_yield.py` proves it on a private Xvfb (never `:0`).
|
|
||||||
|
|
||||||
Lockers that start together do not fight. `wait_for_turn` waits
|
|
||||||
`STARTUP_GRACE_SECONDS` (1.5 s) after `publish()` before its first check, so
|
|
||||||
simultaneous starters see each other. An armed lock also steps back (`_stepback`)
|
|
||||||
when a strictly higher-ranked, live claim that locks at least as firmly appears
|
|
||||||
later: it hides, releases the grab and its holder lock (the claim stays, so
|
|
||||||
lower ranks keep queueing), waits windowless, and re-arms fully once nothing
|
|
||||||
outranks it. `scripts/verify_all_lockers.py` checks this with five real
|
|
||||||
processes on a private Xvfb.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -222,19 +222,14 @@ class Arbiter:
|
|||||||
return self._claim
|
return self._claim
|
||||||
return read_claim_if_held(path)
|
return read_claim_if_held(path)
|
||||||
|
|
||||||
def drop_holder(self) -> None:
|
|
||||||
"""Drop only the holder lock; the claim stays (see ``_stepback``)."""
|
|
||||||
if self._holder_handle is not None:
|
|
||||||
self._holder_handle.close()
|
|
||||||
self._holder_handle = None
|
|
||||||
|
|
||||||
def release(self) -> None:
|
def release(self) -> None:
|
||||||
"""Drop the holder lock and this app's claim.
|
"""Drop the holder lock and this app's claim.
|
||||||
|
|
||||||
Must run on every clean exit path (``flock`` covers a killed process):
|
Must run on every exit path. ``flock`` covers a killed process for
|
||||||
the morning routine runs the alarm and then the workout locker as
|
free, but a *cleanly* exiting app has to release explicitly -- the
|
||||||
sequential subprocesses, and a lingering claim would make the second
|
morning routine runs the alarm and then the workout locker as
|
||||||
stand down against an app that had already finished.
|
sequential subprocesses, and a lingering claim would make the workout
|
||||||
|
locker stand down against an app that had already finished.
|
||||||
"""
|
"""
|
||||||
for handle in (self._holder_handle, self._claim_handle):
|
for handle in (self._holder_handle, self._claim_handle):
|
||||||
if handle is not None:
|
if handle is not None:
|
||||||
|
|||||||
@ -22,12 +22,11 @@ Nothing here reaches back into the window, so the arming sequence can be read
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from gatelock._prompts import PromptYield
|
|
||||||
from gatelock._surfaces import needs_backdrop_root
|
from gatelock._surfaces import needs_backdrop_root
|
||||||
from gatelock._vt import disable_vt_switching
|
from gatelock._vt import disable_vt_switching
|
||||||
|
|
||||||
@ -71,8 +70,6 @@ class ArmingCollaborators:
|
|||||||
still blocked, to name the holder and stand a weaker one down.
|
still blocked, to name the holder and stand a weaker one down.
|
||||||
notify_grab_held: Called once a grab call has actually succeeded, so
|
notify_grab_held: Called once a grab call has actually succeeded, so
|
||||||
the claim's ``grab_held`` turns true only then.
|
the claim's ``grab_held`` turns true only then.
|
||||||
prompt_yield: Times the blocked grab (shared with the recovery loop) and
|
|
||||||
decides whether a system credential prompt gets raised over us.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
config: LockConfig
|
config: LockConfig
|
||||||
@ -83,7 +80,6 @@ class ArmingCollaborators:
|
|||||||
notify_focus_ready: Callable[[], None]
|
notify_focus_ready: Callable[[], None]
|
||||||
log_grab_blocked: Callable[[int], None]
|
log_grab_blocked: Callable[[int], None]
|
||||||
notify_grab_held: Callable[[], None] = _no_grab_listener
|
notify_grab_held: Callable[[], None] = _no_grab_listener
|
||||||
prompt_yield: PromptYield = field(default_factory=PromptYield)
|
|
||||||
|
|
||||||
|
|
||||||
def setup(root: tk.Tk, collab: ArmingCollaborators) -> bool:
|
def setup(root: tk.Tk, collab: ArmingCollaborators) -> bool:
|
||||||
@ -171,16 +167,14 @@ def acquire_global_grab(
|
|||||||
"""
|
"""
|
||||||
retry_ms = collab.config.grab.retry_ms
|
retry_ms = collab.config.grab.retry_ms
|
||||||
try:
|
try:
|
||||||
collab.prompt_yield.check_grab()
|
|
||||||
root.grab_set_global()
|
root.grab_set_global()
|
||||||
except tk.TclError as exc:
|
except tk.TclError:
|
||||||
if retry_ms == 0:
|
if retry_ms == 0:
|
||||||
_logger.warning("Global grab failed (%s), falling back to local grab", exc)
|
_logger.warning("Global grab failed, falling back to local grab")
|
||||||
with contextlib.suppress(tk.TclError):
|
with contextlib.suppress(tk.TclError):
|
||||||
root.grab_set()
|
root.grab_set()
|
||||||
collab.notify_grab_held()
|
collab.notify_grab_held()
|
||||||
return
|
return
|
||||||
collab.prompt_yield.grab_blocked(str(exc))
|
|
||||||
effective_retry_ms = retry_ms or _DEFAULT_GRAB_RETRY_MS
|
effective_retry_ms = retry_ms or _DEFAULT_GRAB_RETRY_MS
|
||||||
if not attempt % collab.config.grab.log_every:
|
if not attempt % collab.config.grab.log_every:
|
||||||
collab.log_grab_blocked(attempt)
|
collab.log_grab_blocked(attempt)
|
||||||
@ -189,8 +183,6 @@ def acquire_global_grab(
|
|||||||
lambda: acquire_global_grab(root, collab, attempt=attempt + 1),
|
lambda: acquire_global_grab(root, collab, attempt=attempt + 1),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if collab.prompt_yield.grab_held():
|
|
||||||
collab.surfaces.raise_all()
|
|
||||||
collab.notify_grab_held()
|
collab.notify_grab_held()
|
||||||
with contextlib.suppress(tk.TclError):
|
with contextlib.suppress(tk.TclError):
|
||||||
root.focus_force()
|
root.focus_force()
|
||||||
|
|||||||
@ -1,132 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Asking X whether the global grab is free, without blocking Tk.
|
|
||||||
|
|
||||||
Tk's ``grab -global`` retries internally and blocks the WHOLE event loop for
|
|
||||||
about 1.0 s on every failed attempt (measured 2026-11-11). The lock retries
|
|
||||||
every 200 ms and once per recovery tick, so while a foreign client held the
|
|
||||||
grab (the keyring prompt) the loop was starved: no repaint, no clicks, rows and
|
|
||||||
buttons without their text.
|
|
||||||
|
|
||||||
The probe asks first, on a throwaway python-xlib connection: grab the keyboard
|
|
||||||
and the pointer on the root with ``GrabModeAsync`` and give them straight
|
|
||||||
back. ``AlreadyGrabbed`` means somebody else holds one, so the caller skips
|
|
||||||
Tk's call for this attempt; a failed request costs microseconds instead of a
|
|
||||||
second. Anything else -- free, or any status or error we do not understand --
|
|
||||||
means "unknown", and the caller makes today's Tk call: the probe can only ever
|
|
||||||
*spare* a doomed attempt, never prevent one that might have worked.
|
|
||||||
|
|
||||||
Deliberately not part of the ``_recovery_*`` family: it releases the grab it
|
|
||||||
briefly took, which that family's static invariant forbids by name.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING, Protocol
|
|
||||||
|
|
||||||
from gatelock._xlib import load_xlib, xlib_failures
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from types import ModuleType
|
|
||||||
|
|
||||||
# python-xlib ships no stubs; spell out the surface used (see _randr.py).
|
|
||||||
class _XRoot(Protocol):
|
|
||||||
def grab_keyboard(self, *args: int | bool) -> int:
|
|
||||||
"""Mirror ``Window.grab_keyboard()``."""
|
|
||||||
|
|
||||||
def grab_pointer(self, *args: int | bool) -> int:
|
|
||||||
"""Mirror ``Window.grab_pointer()``."""
|
|
||||||
|
|
||||||
class _XScreen(Protocol):
|
|
||||||
root: _XRoot
|
|
||||||
|
|
||||||
class _XDisplay(Protocol):
|
|
||||||
def screen(self) -> _XScreen:
|
|
||||||
"""Mirror ``Display.screen()``."""
|
|
||||||
|
|
||||||
def ungrab_keyboard(self, time: int) -> None:
|
|
||||||
"""Mirror ``Display.ungrab_keyboard()``."""
|
|
||||||
|
|
||||||
def ungrab_pointer(self, time: int) -> None:
|
|
||||||
"""Mirror ``Display.ungrab_pointer()``."""
|
|
||||||
|
|
||||||
def sync(self) -> None:
|
|
||||||
"""Mirror ``Display.sync()``."""
|
|
||||||
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_OWNER_EVENTS = True
|
|
||||||
_NO_EVENTS = 0
|
|
||||||
|
|
||||||
|
|
||||||
class GrabProbe:
|
|
||||||
"""Answers "is another X client holding the keyboard or pointer?"."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Start with nothing to repeat."""
|
|
||||||
self._last_warning: str | None = None
|
|
||||||
|
|
||||||
def _warn_once(self, message: str) -> None:
|
|
||||||
"""Warn about a failure, but only when the message changes."""
|
|
||||||
if message != self._last_warning:
|
|
||||||
_logger.warning("%s", message)
|
|
||||||
self._last_warning = message
|
|
||||||
|
|
||||||
def check(self) -> None:
|
|
||||||
"""Raise ``tk.TclError`` if the grab is known to be held elsewhere.
|
|
||||||
|
|
||||||
Raised as Tk's own exception so the callers keep a single ``except``
|
|
||||||
for "could not grab", whoever noticed.
|
|
||||||
"""
|
|
||||||
reason = self.blocked()
|
|
||||||
if reason is not None:
|
|
||||||
raise tk.TclError(reason)
|
|
||||||
|
|
||||||
def blocked(self) -> str | None:
|
|
||||||
"""Why the grab cannot be taken right now, or None if unknown or free."""
|
|
||||||
modules = load_xlib("Xlib.display", "Xlib.X", "Xlib.error")
|
|
||||||
if modules is None:
|
|
||||||
self._warn_once("python-xlib is not installed; cannot probe the grab")
|
|
||||||
return None
|
|
||||||
xdisplay, xconst, xerror = modules
|
|
||||||
failures = xlib_failures(xerror)
|
|
||||||
try:
|
|
||||||
display = xdisplay.Display()
|
|
||||||
except failures as exc:
|
|
||||||
self._warn_once(f"cannot open an X connection to probe the grab: {exc}")
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return _probe(display, xconst)
|
|
||||||
except failures as exc:
|
|
||||||
self._warn_once(f"probing the grab failed: {exc}")
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
display.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _probe(display: _XDisplay, xconst: ModuleType) -> str | None:
|
|
||||||
"""Grab keyboard then pointer on the root, release both, report a holder."""
|
|
||||||
root = display.screen().root
|
|
||||||
modes = (xconst.GrabModeAsync, xconst.GrabModeAsync)
|
|
||||||
device = "keyboard"
|
|
||||||
status = root.grab_keyboard(_OWNER_EVENTS, *modes, xconst.CurrentTime)
|
|
||||||
if status == xconst.GrabSuccess:
|
|
||||||
device = "pointer"
|
|
||||||
status = root.grab_pointer(
|
|
||||||
_OWNER_EVENTS,
|
|
||||||
_NO_EVENTS,
|
|
||||||
*modes,
|
|
||||||
xconst.NONE,
|
|
||||||
xconst.NONE,
|
|
||||||
xconst.CurrentTime,
|
|
||||||
)
|
|
||||||
display.ungrab_keyboard(xconst.CurrentTime)
|
|
||||||
display.ungrab_pointer(xconst.CurrentTime)
|
|
||||||
display.sync()
|
|
||||||
if status == xconst.AlreadyGrabbed:
|
|
||||||
return f"grab failed: another application has grab ({device}, probed)"
|
|
||||||
return None
|
|
||||||
@ -6,9 +6,8 @@ windows. This half owns the geometry questions -- is the window mapped, is it
|
|||||||
still exactly on its output -- and the re-assertion pass that answers "no" by
|
still exactly on its output -- and the re-assertion pass that answers "no" by
|
||||||
correcting it.
|
correcting it.
|
||||||
|
|
||||||
Everything the recovery loop reaches here only ever *adds* visibility; that
|
Everything here only ever *adds* visibility. Nothing in this module can hide
|
||||||
asymmetry is what makes it monotonic. The one hider, :func:`set_visible`, is
|
or unmap a surface; that asymmetry is what makes the recovery loop monotonic.
|
||||||
reached only by :mod:`gatelock._stepback`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -56,9 +55,7 @@ def geometry_matches(surface: _Surface) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def enforce_one(
|
def enforce_one(surface: _Surface, *, overrideredirect: bool) -> bool:
|
||||||
surface: _Surface, *, overrideredirect: bool, lift: bool = True
|
|
||||||
) -> bool:
|
|
||||||
"""Re-assert one surface's placement and stacking.
|
"""Re-assert one surface's placement and stacking.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -66,9 +63,6 @@ def enforce_one(
|
|||||||
overrideredirect: Whether the lock runs WM-unmanaged, in which case
|
overrideredirect: Whether the lock runs WM-unmanaged, in which case
|
||||||
the flag is re-set -- it can be dropped across an unmap/map cycle,
|
the flag is re-set -- it can be dropped across an unmap/map cycle,
|
||||||
and a managed lock window would be moved by the WM.
|
and a managed lock window would be moved by the WM.
|
||||||
lift: Whether to raise the window. False only while a system prompt
|
|
||||||
is being yielded to (:mod:`gatelock._prompts`); placement and
|
|
||||||
mapping are corrected either way.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if anything needed correcting.
|
True if anything needed correcting.
|
||||||
@ -83,32 +77,10 @@ def enforce_one(
|
|||||||
changed = True
|
changed = True
|
||||||
if overrideredirect:
|
if overrideredirect:
|
||||||
window.overrideredirect(boolean=True)
|
window.overrideredirect(boolean=True)
|
||||||
if lift:
|
window.lift()
|
||||||
window.lift()
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def set_visible(
|
|
||||||
surfaces: Mapping[str, _Surface], *, visible: bool, overrideredirect: bool
|
|
||||||
) -> None:
|
|
||||||
"""Hide or show every surface. Only a stepping-back lock ever hides.
|
|
||||||
|
|
||||||
Showing restores the creation order (override-redirect while still
|
|
||||||
unmapped, geometry, then map and raise) rather than ``enforce_one``'s
|
|
||||||
map-first order, so a surface cannot come back window-manager-managed.
|
|
||||||
"""
|
|
||||||
for surface in surfaces.values():
|
|
||||||
window = surface.window
|
|
||||||
if not visible:
|
|
||||||
window.withdraw()
|
|
||||||
continue
|
|
||||||
if overrideredirect:
|
|
||||||
window.overrideredirect(boolean=True)
|
|
||||||
set_geometry(window, surface.info.rect)
|
|
||||||
window.deiconify()
|
|
||||||
window.lift()
|
|
||||||
|
|
||||||
|
|
||||||
def blind_outputs(
|
def blind_outputs(
|
||||||
surfaces: Mapping[str, _Surface], names: frozenset[str]
|
surfaces: Mapping[str, _Surface], names: frozenset[str]
|
||||||
) -> tuple[str, ...]:
|
) -> tuple[str, ...]:
|
||||||
@ -133,14 +105,13 @@ def blind_outputs(
|
|||||||
|
|
||||||
|
|
||||||
def enforce_all(
|
def enforce_all(
|
||||||
surfaces: Mapping[str, _Surface], *, overrideredirect: bool, lift: bool = True
|
surfaces: Mapping[str, _Surface], *, overrideredirect: bool
|
||||||
) -> tuple[str, ...]:
|
) -> tuple[str, ...]:
|
||||||
"""Re-assert every surface's placement and stacking.
|
"""Re-assert every surface's placement and stacking.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
surfaces: The live surfaces, keyed by output name.
|
surfaces: The live surfaces, keyed by output name.
|
||||||
overrideredirect: Whether the lock runs WM-unmanaged.
|
overrideredirect: Whether the lock runs WM-unmanaged.
|
||||||
lift: Whether to raise the surfaces; see :func:`enforce_one`.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The names of the outputs whose surfaces needed correcting, sorted.
|
The names of the outputs whose surfaces needed correcting, sorted.
|
||||||
@ -148,7 +119,7 @@ def enforce_all(
|
|||||||
corrected = [
|
corrected = [
|
||||||
surface.info.output_name
|
surface.info.output_name
|
||||||
for surface in surfaces.values()
|
for surface in surfaces.values()
|
||||||
if enforce_one(surface, overrideredirect=overrideredirect, lift=lift)
|
if enforce_one(surface, overrideredirect=overrideredirect)
|
||||||
]
|
]
|
||||||
return tuple(sorted(corrected))
|
return tuple(sorted(corrected))
|
||||||
|
|
||||||
|
|||||||
@ -22,39 +22,22 @@ if TYPE_CHECKING:
|
|||||||
from gatelock._arbiter import Arbiter
|
from gatelock._arbiter import Arbiter
|
||||||
from gatelock._claims import Claim
|
from gatelock._claims import Claim
|
||||||
from gatelock._config import LockConfig
|
from gatelock._config import LockConfig
|
||||||
from gatelock._prompts import PromptYield
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _foreign_holder(arbiter: Arbiter | None) -> Claim | None:
|
|
||||||
"""The gatelock app holding the screen, unless that app is this process.
|
|
||||||
|
|
||||||
``Arbiter.describe_holder`` answers "who owns the holder file", and when
|
|
||||||
this process wrote it that is our own claim. Reporting it as the grab's
|
|
||||||
holder made the lock blame itself (2026-11-11) and hid the real holder.
|
|
||||||
"""
|
|
||||||
holder = arbiter.describe_holder() if arbiter else None
|
|
||||||
if holder is None or arbiter is None:
|
|
||||||
return None
|
|
||||||
return None if holder.instance_id == arbiter.claim.instance_id else holder
|
|
||||||
|
|
||||||
|
|
||||||
def log_grab_blocked(
|
def log_grab_blocked(
|
||||||
attempt: int,
|
attempt: int,
|
||||||
*,
|
*,
|
||||||
arbiter: Arbiter | None,
|
arbiter: Arbiter | None,
|
||||||
config: LockConfig,
|
config: LockConfig,
|
||||||
preempted_pids: set[int],
|
preempted_pids: set[int],
|
||||||
prompt_yield: PromptYield | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Say who is actually holding the grab, rather than guessing.
|
"""Say who is actually holding the grab, rather than guessing.
|
||||||
|
|
||||||
v0.1.1 always blamed "a fullscreen game". On 2026-07-25 the holder was
|
v0.1.1 always blamed "a fullscreen game". On 2026-07-25 the holder was
|
||||||
the other locker, and that guess sent the diagnosis in the wrong
|
the other locker, and that guess sent the diagnosis in the wrong
|
||||||
direction for the length of the outage. On 2026-11-11 the lock blamed
|
direction for the length of the outage.
|
||||||
*itself* (its own holder file) while the holder was the GNOME Keyring
|
|
||||||
prompt, so a holder that is this process counts as "no gatelock holder".
|
|
||||||
|
|
||||||
A holder that outranks us is left alone entirely -- retrying is the
|
A holder that outranks us is left alone entirely -- retrying is the
|
||||||
correct behavior there, matching :func:`gatelock.wait_for_turn`'s own
|
correct behavior there, matching :func:`gatelock.wait_for_turn`'s own
|
||||||
@ -63,36 +46,24 @@ def log_grab_blocked(
|
|||||||
as long as it takes to finish on its own (diet_guard held the grab for
|
as long as it takes to finish on its own (diet_guard held the grab for
|
||||||
~3 minutes against a higher-ranked screen_locker on 2026-08-21).
|
~3 minutes against a higher-ranked screen_locker on 2026-08-21).
|
||||||
|
|
||||||
X cannot say which client holds a grab, so credential prompts seen on
|
|
||||||
screen are named as *present*, not as the verdict.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
attempt: 1-based attempt counter, reported in the log line.
|
attempt: 1-based attempt counter, reported in the log line.
|
||||||
arbiter: This app's arbiter, or None when arbitration is disabled.
|
arbiter: This app's arbiter, or None when arbitration is disabled.
|
||||||
config: The window's configuration, consulted for the preempt opt-in.
|
config: The window's configuration, consulted for the preempt opt-in.
|
||||||
preempted_pids: Pids already signalled; mutated by the preempt path.
|
preempted_pids: Pids already signalled; mutated by the preempt path.
|
||||||
prompt_yield: The grab-block policy; supplies Tk's error text for the
|
|
||||||
failed grab and the system prompts seen on screen.
|
|
||||||
"""
|
"""
|
||||||
reason = prompt_yield.last_reason if prompt_yield else ""
|
holder = arbiter.describe_holder() if arbiter else None
|
||||||
prompts = prompt_yield.prompt_names if prompt_yield else ()
|
|
||||||
cause = f" ({reason})" if reason else ""
|
|
||||||
holder = _foreign_holder(arbiter)
|
|
||||||
if holder is None:
|
if holder is None:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"global grab still blocked after %d attempts%s; no other gatelock "
|
"global grab still blocked after %d attempts; no gatelock app "
|
||||||
"app holds it -- a non-gatelock X client does (e.g. a fullscreen "
|
"holds it -- likely another X client (e.g. a fullscreen game)",
|
||||||
"game or a credential prompt)%s",
|
|
||||||
attempt,
|
attempt,
|
||||||
cause,
|
|
||||||
f"; system prompt on screen: {', '.join(prompts)}" if prompts else "",
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"global grab still blocked after %d attempts%s; held by gatelock app "
|
"global grab still blocked after %d attempts; held by gatelock app "
|
||||||
"%r (rank %d, pid %d) since %s",
|
"%r (rank %d, pid %d) since %s",
|
||||||
attempt,
|
attempt,
|
||||||
cause,
|
|
||||||
holder.app,
|
holder.app,
|
||||||
holder.rank,
|
holder.rank,
|
||||||
holder.pid,
|
holder.pid,
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Which X windows are known system credential prompts.
|
|
||||||
|
|
||||||
Incident 2026-11-11: at login the GNOME Keyring unlock prompt (``gcr-prompter``)
|
|
||||||
took the X keyboard and pointer grab ~1 s before a lock armed. The lock's
|
|
||||||
override-redirect windows then covered the prompt, so the grab could never be
|
|
||||||
taken and nothing on screen could be clicked. Yielding to *these* windows --
|
|
||||||
and only these -- is the one deliberate exception to "the lock is always on
|
|
||||||
top"; anything else holding the grab (a fullscreen game) stays covered.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from fnmatch import fnmatchcase
|
|
||||||
from typing import TYPE_CHECKING, Final
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterable
|
|
||||||
|
|
||||||
SYSTEM_PROMPT_CLASSES: Final = (
|
|
||||||
"gcr-prompter",
|
|
||||||
"pinentry*",
|
|
||||||
"polkit-gnome-authentication-agent-1",
|
|
||||||
"lxpolkit",
|
|
||||||
"ssh-askpass*",
|
|
||||||
"x11-ssh-askpass",
|
|
||||||
"ksshaskpass",
|
|
||||||
"gnome-ssh-askpass",
|
|
||||||
)
|
|
||||||
"""``fnmatch`` patterns, lowercase, matched against WM_CLASS instance or class."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PromptWindow:
|
|
||||||
"""A mapped system prompt: the client and the ancestor below the root.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
window_id: The client window that carries the WM_CLASS.
|
|
||||||
toplevel_id: Its ancestor that is a direct child of the root. Under a
|
|
||||||
reparenting window manager (i3) that is the WM's frame, and it is
|
|
||||||
the window whose stacking position matters.
|
|
||||||
wm_class: The ``(instance, class)`` pair.
|
|
||||||
"""
|
|
||||||
|
|
||||||
window_id: int
|
|
||||||
toplevel_id: int
|
|
||||||
wm_class: tuple[str, str]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
"""The WM_CLASS instance, for log lines."""
|
|
||||||
return self.wm_class[0]
|
|
||||||
|
|
||||||
|
|
||||||
def is_system_prompt(wm_class: Iterable[str]) -> bool:
|
|
||||||
"""Whether a WM_CLASS (instance or class, any case) is an allowlisted prompt."""
|
|
||||||
return any(
|
|
||||||
fnmatchcase(part.lower(), pattern)
|
|
||||||
for part in wm_class
|
|
||||||
for pattern in SYSTEM_PROMPT_CLASSES
|
|
||||||
)
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Stepping aside for a system credential prompt that holds the X grab.
|
|
||||||
|
|
||||||
Incident 2026-11-11: the GNOME Keyring prompt (``gcr-prompter``) was mapped and
|
|
||||||
holding the X keyboard+pointer grab when a lock armed. The lock's
|
|
||||||
override-redirect windows covered it, so the grab could never be taken, every
|
|
||||||
click went to a window nobody could see, and the lock's own UI was dead.
|
|
||||||
|
|
||||||
The policy (approved by the user): when the global grab has been unobtainable
|
|
||||||
for more than :data:`YIELD_AFTER_S` AND a mapped window on the allowlist
|
|
||||||
(:data:`gatelock._prompt_names.SYSTEM_PROMPT_CLASSES`) is on screen, raise that
|
|
||||||
prompt above the lock and stop the lock's own periodic re-raise from covering
|
|
||||||
it again, for as long as the prompt stays mapped. Everything else stays as it
|
|
||||||
was: the lock is still mapped everywhere, VT switching stays disabled and the
|
|
||||||
grab is still retried every tick. Once the grab is taken the lock stacks on top
|
|
||||||
again. Anything not on the allowlist -- a fullscreen game -- is never yielded
|
|
||||||
to.
|
|
||||||
|
|
||||||
Known limitation of an allowlist keyed on WM_CLASS: a window that merely
|
|
||||||
*claims* one of those classes while holding a grab would be raised too.
|
|
||||||
|
|
||||||
This module is the policy and the clock. All X access is in
|
|
||||||
:mod:`gatelock._prompts_x`. It is deliberately NOT named ``_recovery_*``: the
|
|
||||||
recovery family is statically checked to contain no release call, and keeping
|
|
||||||
the X plumbing out of it keeps that check meaningful.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING, Final, Protocol
|
|
||||||
|
|
||||||
from gatelock._grabprobe import GrabProbe
|
|
||||||
from gatelock._prompts_x import XPromptProbe
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from gatelock._prompt_names import PromptWindow
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
YIELD_AFTER_S: Final = 2.0
|
|
||||||
"""How long the grab must stay unobtainable before looking for a prompt."""
|
|
||||||
|
|
||||||
|
|
||||||
class PromptProbe(Protocol):
|
|
||||||
"""What the policy needs from the X side."""
|
|
||||||
|
|
||||||
def raise_prompts(self) -> tuple[PromptWindow, ...]:
|
|
||||||
"""Raise every mapped allowlisted prompt; return those it raised."""
|
|
||||||
|
|
||||||
|
|
||||||
class PromptYield:
|
|
||||||
"""Times a blocked grab and decides whether to raise a prompt over the lock."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
probe: PromptProbe | None = None,
|
|
||||||
*,
|
|
||||||
grab_probe: GrabProbe | None = None,
|
|
||||||
clock: Callable[[], float] = time.monotonic,
|
|
||||||
after_s: float = YIELD_AFTER_S,
|
|
||||||
) -> None:
|
|
||||||
"""Build the policy; nothing is blocked yet.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
probe: Finds and raises prompts. Defaults to the python-xlib one.
|
|
||||||
grab_probe: Asks X whether the grab is free. Defaults to the real one.
|
|
||||||
clock: Monotonic seconds; injected by tests.
|
|
||||||
after_s: Seconds the grab must stay blocked before yielding.
|
|
||||||
"""
|
|
||||||
self._probe: PromptProbe = probe if probe is not None else XPromptProbe()
|
|
||||||
self._grab_probe = grab_probe if grab_probe is not None else GrabProbe()
|
|
||||||
self._clock = clock
|
|
||||||
self._after_s = after_s
|
|
||||||
self._blocked_since: float | None = None
|
|
||||||
self._reason = ""
|
|
||||||
self._prompts: tuple[PromptWindow, ...] = ()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def last_reason(self) -> str:
|
|
||||||
"""Tk's message for the most recent failed grab (empty if none yet)."""
|
|
||||||
return self._reason
|
|
||||||
|
|
||||||
@property
|
|
||||||
def yielding(self) -> bool:
|
|
||||||
"""Whether a prompt is being yielded to right now."""
|
|
||||||
return bool(self._prompts)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def prompt_names(self) -> tuple[str, ...]:
|
|
||||||
"""WM_CLASS instances of the prompts currently yielded to."""
|
|
||||||
return tuple(prompt.name for prompt in self._prompts)
|
|
||||||
|
|
||||||
def check_grab(self) -> None:
|
|
||||||
"""Raise ``tk.TclError`` if the grab is known to be held elsewhere.
|
|
||||||
|
|
||||||
Call before every ``grab_set_global()``: Tk blocks its whole event
|
|
||||||
loop for ~1 s on each failed attempt, so a doomed one is skipped.
|
|
||||||
"""
|
|
||||||
self._grab_probe.check()
|
|
||||||
|
|
||||||
def grab_blocked(self, reason: str) -> None:
|
|
||||||
"""Record a failed grab; the first failure starts the clock.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
reason: Tk's error text ("another application has grab", ...).
|
|
||||||
"""
|
|
||||||
self._reason = reason
|
|
||||||
if self._blocked_since is None:
|
|
||||||
self._blocked_since = self._clock()
|
|
||||||
|
|
||||||
def grab_held(self) -> bool:
|
|
||||||
"""Record that the grab is ours; end any yield.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True when a yield was in progress, so the caller must lift the
|
|
||||||
lock over whatever it had stepped aside for.
|
|
||||||
"""
|
|
||||||
self._blocked_since = None
|
|
||||||
was_yielding = self.yielding
|
|
||||||
self._prompts = ()
|
|
||||||
if was_yielding:
|
|
||||||
_logger.warning("global grab taken; the lock stacks on top again")
|
|
||||||
return was_yielding
|
|
||||||
|
|
||||||
def settle(self) -> None:
|
|
||||||
"""Re-decide, and re-raise the prompt. Call LAST in a recovery tick.
|
|
||||||
|
|
||||||
Last, so that nothing the tick lifted can end up above the prompt.
|
|
||||||
"""
|
|
||||||
since = self._blocked_since
|
|
||||||
if since is None or self._clock() - since < self._after_s:
|
|
||||||
return
|
|
||||||
previous = self.prompt_names
|
|
||||||
self._prompts = self._probe.raise_prompts()
|
|
||||||
if self.prompt_names == previous:
|
|
||||||
return
|
|
||||||
if self._prompts:
|
|
||||||
_logger.warning(
|
|
||||||
"global grab blocked for %.1fs (%s) and system prompt %s is on "
|
|
||||||
"screen; raising it above the lock so it can be answered. The "
|
|
||||||
"lock stays up, VT stays disabled, the grab keeps being retried",
|
|
||||||
self._clock() - since,
|
|
||||||
self._reason,
|
|
||||||
", ".join(self.prompt_names),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_logger.info("system prompt %s is gone; lock on top", ", ".join(previous))
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Finding a system credential prompt on screen and raising it, over python-xlib.
|
|
||||||
|
|
||||||
Kept apart from the policy in :mod:`gatelock._prompts` so that everything that
|
|
||||||
touches the X server lives in one place, behind :class:`XPromptProbe`. It opens
|
|
||||||
a short-lived connection per call: the caller asks at most once a second, and
|
|
||||||
only while the grab has been blocked for a while, so a persistent connection
|
|
||||||
would need a teardown path for no gain.
|
|
||||||
|
|
||||||
Candidates come from the window manager's ``_NET_CLIENT_LIST`` *and* a walk of
|
|
||||||
the root's children and grandchildren. The walk is not redundant: a bare X
|
|
||||||
server has no ``_NET_CLIENT_LIST``, and under i3 the WM_CLASS sits on the
|
|
||||||
client, one level below the frame i3 reparents it into. What gets raised is
|
|
||||||
the ancestor directly below the root (the frame), never the client.
|
|
||||||
|
|
||||||
Every X failure -- no python-xlib, no connection, a window that vanished
|
|
||||||
between listing and reading it (BadWindow) -- ends the scan with "no prompt
|
|
||||||
found", warned once per distinct message: the lock must stay on top whenever
|
|
||||||
it cannot be sure.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING, Protocol
|
|
||||||
|
|
||||||
from gatelock._prompt_names import PromptWindow, is_system_prompt
|
|
||||||
from gatelock._xlib import load_xlib, xlib_failures
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable, Sequence
|
|
||||||
from types import ModuleType
|
|
||||||
|
|
||||||
# python-xlib ships no stubs; spell out the surface used (see _randr.py).
|
|
||||||
class _Tree(Protocol):
|
|
||||||
children: Sequence[_XWindow]
|
|
||||||
parent: _XWindow
|
|
||||||
|
|
||||||
class _Attributes(Protocol):
|
|
||||||
map_state: int
|
|
||||||
|
|
||||||
class _Property(Protocol):
|
|
||||||
value: Sequence[int]
|
|
||||||
|
|
||||||
class _XWindow(Protocol):
|
|
||||||
id: int
|
|
||||||
|
|
||||||
def query_tree(self) -> _Tree:
|
|
||||||
"""Mirror ``Window.query_tree()``."""
|
|
||||||
|
|
||||||
def get_wm_class(self) -> tuple[str, str] | None:
|
|
||||||
"""Mirror ``Window.get_wm_class()``."""
|
|
||||||
|
|
||||||
def get_attributes(self) -> _Attributes:
|
|
||||||
"""Mirror ``Window.get_attributes()``."""
|
|
||||||
|
|
||||||
def get_full_property(self, atom: object, kind: int) -> _Property | None:
|
|
||||||
"""Mirror ``Window.get_full_property()``."""
|
|
||||||
|
|
||||||
def configure(
|
|
||||||
self, *, stack_mode: int, onerror: Callable[[object], None]
|
|
||||||
) -> None:
|
|
||||||
"""Mirror ``Window.configure()``."""
|
|
||||||
|
|
||||||
class _XScreen(Protocol):
|
|
||||||
root: _XWindow
|
|
||||||
|
|
||||||
class _XDisplay(Protocol):
|
|
||||||
def screen(self) -> _XScreen:
|
|
||||||
"""Mirror ``Display.screen()``."""
|
|
||||||
|
|
||||||
def intern_atom(self, name: str) -> object:
|
|
||||||
"""Mirror ``Display.intern_atom()``."""
|
|
||||||
|
|
||||||
def create_resource_object(self, kind: str, window_id: int) -> _XWindow:
|
|
||||||
"""Mirror ``Display.create_resource_object()``."""
|
|
||||||
|
|
||||||
def sync(self) -> None:
|
|
||||||
"""Mirror ``Display.sync()``."""
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""Mirror ``Display.close()``."""
|
|
||||||
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class XPromptProbe:
|
|
||||||
"""Looks for allowlisted prompts and raises their top-level windows."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Start with nothing to repeat."""
|
|
||||||
self._last_warning: str | None = None
|
|
||||||
|
|
||||||
def _warn_once(self, message: str) -> None:
|
|
||||||
"""Warn about a failure, but only when the message changes."""
|
|
||||||
if message != self._last_warning:
|
|
||||||
_logger.warning("%s", message)
|
|
||||||
self._last_warning = message
|
|
||||||
|
|
||||||
def raise_prompts(self) -> tuple[PromptWindow, ...]:
|
|
||||||
"""Raise every mapped system prompt above everything else.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The prompts that are on screen and were raised; empty when there
|
|
||||||
are none, or when anything went wrong (the lock then stays on top).
|
|
||||||
"""
|
|
||||||
modules = load_xlib("Xlib.display", "Xlib.X", "Xlib.error")
|
|
||||||
if modules is None:
|
|
||||||
self._warn_once("python-xlib is not installed; cannot look for a prompt")
|
|
||||||
return ()
|
|
||||||
xdisplay, xconst, xerror = modules
|
|
||||||
failures = xlib_failures(xerror)
|
|
||||||
try:
|
|
||||||
display: _XDisplay = xdisplay.Display()
|
|
||||||
except failures as exc:
|
|
||||||
self._warn_once(f"cannot open an X connection to look for a prompt: {exc}")
|
|
||||||
return ()
|
|
||||||
refused: list[object] = []
|
|
||||||
try:
|
|
||||||
found = _find(display, xconst)
|
|
||||||
for prompt in found:
|
|
||||||
top = display.create_resource_object("window", prompt.toplevel_id)
|
|
||||||
top.configure(stack_mode=xconst.Above, onerror=refused.append)
|
|
||||||
display.sync()
|
|
||||||
except failures as exc:
|
|
||||||
self._warn_once(f"looking for a prompt failed: {exc}")
|
|
||||||
return ()
|
|
||||||
finally:
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
display.close()
|
|
||||||
if refused:
|
|
||||||
self._warn_once(f"the X server refused to raise the prompt: {refused[0]}")
|
|
||||||
return ()
|
|
||||||
self._last_warning = None
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _find(display: _XDisplay, xconst: ModuleType) -> tuple[PromptWindow, ...]:
|
|
||||||
"""Every viewable allowlisted window, with its top-level ancestor."""
|
|
||||||
root = display.screen().root
|
|
||||||
ids: dict[int, None] = {}
|
|
||||||
listed = root.get_full_property(
|
|
||||||
display.intern_atom("_NET_CLIENT_LIST"), xconst.AnyPropertyType
|
|
||||||
)
|
|
||||||
if listed is not None:
|
|
||||||
ids.update(dict.fromkeys(int(value) for value in listed.value))
|
|
||||||
for child in root.query_tree().children:
|
|
||||||
ids[child.id] = None
|
|
||||||
ids.update(dict.fromkeys(grand.id for grand in child.query_tree().children))
|
|
||||||
found: list[PromptWindow] = []
|
|
||||||
for window_id in ids:
|
|
||||||
window = display.create_resource_object("window", window_id)
|
|
||||||
wm_class = window.get_wm_class()
|
|
||||||
if not wm_class or not is_system_prompt(wm_class):
|
|
||||||
continue
|
|
||||||
if window.get_attributes().map_state != xconst.IsViewable:
|
|
||||||
continue
|
|
||||||
found.append(
|
|
||||||
PromptWindow(
|
|
||||||
window_id=window_id,
|
|
||||||
toplevel_id=_toplevel(window, root.id),
|
|
||||||
wm_class=(str(wm_class[0]), str(wm_class[1])),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tuple(found)
|
|
||||||
|
|
||||||
|
|
||||||
def _toplevel(window: _XWindow, root_id: int) -> int:
|
|
||||||
"""The id of ``window``'s ancestor that is a direct child of the root."""
|
|
||||||
top = window
|
|
||||||
parent = top.query_tree().parent
|
|
||||||
while parent.id != root_id:
|
|
||||||
top, parent = parent, parent.query_tree().parent
|
|
||||||
return top.id
|
|
||||||
@ -30,12 +30,6 @@ from typing import TYPE_CHECKING, Final
|
|||||||
_logger: Final = logging.getLogger(__name__)
|
_logger: Final = logging.getLogger(__name__)
|
||||||
|
|
||||||
QUEUE_POLL_SECONDS: Final = 2.0
|
QUEUE_POLL_SECONDS: Final = 2.0
|
||||||
# After publishing, wait this long before the FIRST turn check, so lockers that
|
|
||||||
# start together see each other's claims. Without it a weaker locker whose
|
|
||||||
# check ran before a stronger one had published armed alone, and the stronger
|
|
||||||
# then armed on top: two windows, the weaker holding the grab, the visible one
|
|
||||||
# unclickable. Read at call time (tests set it to 0).
|
|
||||||
STARTUP_GRACE_SECONDS: Final = 1.5
|
|
||||||
QUEUE_DEADLINE_SECONDS: Final = 6 * 60 * 60
|
QUEUE_DEADLINE_SECONDS: Final = 6 * 60 * 60
|
||||||
# How often to re-state a still-blocked wait at WARNING. A long wait used
|
# How often to re-state a still-blocked wait at WARNING. A long wait used
|
||||||
# to produce exactly one INFO line at the moment it started and nothing
|
# to produce exactly one INFO line at the moment it started and nothing
|
||||||
@ -212,8 +206,6 @@ def wait_for_turn(
|
|||||||
|
|
||||||
started = clock()
|
started = clock()
|
||||||
wait = _Wait(arbiter, on_state, started)
|
wait = _Wait(arbiter, on_state, started)
|
||||||
if STARTUP_GRACE_SECONDS > 0:
|
|
||||||
time_source.sleep(STARTUP_GRACE_SECONDS)
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
blockers = stronger_claims(arbiter)
|
blockers = stronger_claims(arbiter)
|
||||||
|
|||||||
@ -41,7 +41,6 @@ import logging
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
||||||
from gatelock._grabwatch import GrabWatch
|
from gatelock._grabwatch import GrabWatch
|
||||||
from gatelock._recovery_grab import reassert_grab
|
|
||||||
from gatelock._recovery_types import RecoveryCollaborators, RecoveryReport
|
from gatelock._recovery_types import RecoveryCollaborators, RecoveryReport
|
||||||
from gatelock._vt import disable_vt_switching
|
from gatelock._vt import disable_vt_switching
|
||||||
|
|
||||||
@ -64,11 +63,6 @@ class RecoveryLoop:
|
|||||||
self._verify_job: str | None = None
|
self._verify_job: str | None = None
|
||||||
self._grab = GrabWatch(root)
|
self._grab = GrabWatch(root)
|
||||||
|
|
||||||
@property
|
|
||||||
def running(self) -> bool:
|
|
||||||
"""Whether the loop is enforcing (False once stopped, e.g. study mode)."""
|
|
||||||
return self._running
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ticks(self) -> int:
|
def ticks(self) -> int:
|
||||||
"""How many full verify passes have run."""
|
"""How many full verify passes have run."""
|
||||||
@ -152,17 +146,13 @@ class RecoveryLoop:
|
|||||||
)
|
)
|
||||||
return RecoveryReport(scan_ok=False, source=scan.source)
|
return RecoveryReport(scan_ok=False, source=scan.source)
|
||||||
|
|
||||||
# Read BEFORE enforce: while a system prompt is being yielded to, the
|
|
||||||
# tick must not lift the lock over it. The prompt is re-raised LAST.
|
|
||||||
yielding = self._parts.prompt_yield.yielding
|
|
||||||
self._parts.surfaces.update_backdrop()
|
self._parts.surfaces.update_backdrop()
|
||||||
delta = self._parts.surfaces.apply(scan)
|
delta = self._parts.surfaces.apply(scan)
|
||||||
corrected = self._parts.surfaces.enforce(lift=not yielding)
|
corrected = self._parts.surfaces.enforce()
|
||||||
grab_reasserted = self._reassert_grab()
|
grab_reasserted = self._reassert_grab()
|
||||||
vt_reasserted = self._reassert_vt()
|
vt_reasserted = self._reassert_vt()
|
||||||
if delta.created:
|
if delta.created:
|
||||||
self._reassert_focus()
|
self._reassert_focus()
|
||||||
self._parts.prompt_yield.settle()
|
|
||||||
|
|
||||||
if delta.unverified:
|
if delta.unverified:
|
||||||
_logger.error(
|
_logger.error(
|
||||||
@ -198,9 +188,20 @@ class RecoveryLoop:
|
|||||||
def _reassert_grab(self) -> bool:
|
def _reassert_grab(self) -> bool:
|
||||||
"""Re-take the global grab if we lost it. Never releases one.
|
"""Re-take the global grab if we lost it. Never releases one.
|
||||||
|
|
||||||
Delegates to :func:`gatelock._recovery_grab.reassert_grab`.
|
Returns:
|
||||||
|
True if the grab had to be re-taken.
|
||||||
"""
|
"""
|
||||||
return reassert_grab(self._root, self._parts, self._grab)
|
if self._parts.config.resolved_grab() != "global":
|
||||||
|
return False
|
||||||
|
held = self.holds_grab()
|
||||||
|
reasserted = False
|
||||||
|
if not held:
|
||||||
|
with contextlib.suppress(tk.TclError):
|
||||||
|
self._root.grab_set_global()
|
||||||
|
_logger.warning("global grab had been lost; re-acquired it")
|
||||||
|
reasserted = held = True
|
||||||
|
self._parts.on_grab_held(held=held)
|
||||||
|
return reasserted
|
||||||
|
|
||||||
def holds_grab(self) -> bool:
|
def holds_grab(self) -> bool:
|
||||||
"""Whether the grab is held by a window this application owns.
|
"""Whether the grab is held by a window this application owns.
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Re-taking a lost global grab, once per recovery tick.
|
|
||||||
|
|
||||||
Split out of :mod:`gatelock._recovery` for the 250-line cap, and kept in its
|
|
||||||
``_recovery_*`` family on purpose: the static invariant test globs the family,
|
|
||||||
so this file is held to the same rule -- it may take the grab, never release it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from gatelock._grabwatch import GrabWatch
|
|
||||||
from gatelock._recovery_types import RecoveryCollaborators
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def reassert_grab(
|
|
||||||
root: tk.Misc, parts: RecoveryCollaborators, watch: GrabWatch
|
|
||||||
) -> bool:
|
|
||||||
"""Re-take the global grab if we lost it. Never releases one.
|
|
||||||
|
|
||||||
A failed attempt is reported with Tk's own message (once per distinct
|
|
||||||
message: this runs every tick) and timed by the prompt-yield policy. A
|
|
||||||
grab we hold ends any yield, and then the lock is lifted over whatever it
|
|
||||||
had stepped aside for.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
root: The Tk root that holds the grab.
|
|
||||||
parts: The loop's collaborators.
|
|
||||||
watch: Answers "is the grab ours?" and throttles the warning.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if the grab had to be re-taken.
|
|
||||||
"""
|
|
||||||
if parts.config.resolved_grab() != "global":
|
|
||||||
return False
|
|
||||||
held = watch.holds_grab()
|
|
||||||
reasserted = False
|
|
||||||
if not held:
|
|
||||||
try:
|
|
||||||
parts.prompt_yield.check_grab()
|
|
||||||
root.grab_set_global()
|
|
||||||
except tk.TclError as exc:
|
|
||||||
parts.prompt_yield.grab_blocked(str(exc))
|
|
||||||
watch.warn_once(f"could not re-take the global grab ({exc})")
|
|
||||||
else:
|
|
||||||
_logger.warning("global grab had been lost; re-acquired it")
|
|
||||||
reasserted = held = True
|
|
||||||
if held and parts.prompt_yield.grab_held():
|
|
||||||
parts.surfaces.raise_all()
|
|
||||||
parts.on_grab_held(held=held)
|
|
||||||
return reasserted
|
|
||||||
@ -8,11 +8,9 @@ against a report without pulling in the machinery that builds one.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Protocol
|
from typing import TYPE_CHECKING, Protocol
|
||||||
|
|
||||||
from gatelock._prompts import PromptYield
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from gatelock._config import LockConfig
|
from gatelock._config import LockConfig
|
||||||
from gatelock._detect import OutputChangeDetector
|
from gatelock._detect import OutputChangeDetector
|
||||||
@ -62,5 +60,3 @@ class RecoveryCollaborators:
|
|||||||
hooks: LockWindowHooks
|
hooks: LockWindowHooks
|
||||||
on_grab_held: GrabHeldListener = ignore_grab_held
|
on_grab_held: GrabHeldListener = ignore_grab_held
|
||||||
"""Told each tick whether the grab is really ours (``Claim.grab_held``)."""
|
"""Told each tick whether the grab is really ours (``Claim.grab_held``)."""
|
||||||
prompt_yield: PromptYield = field(default_factory=PromptYield)
|
|
||||||
"""Times a blocked grab and raises a system prompt over the lock."""
|
|
||||||
|
|||||||
@ -1,186 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""A weaker armed lock steps back for a stronger one that appears.
|
|
||||||
|
|
||||||
Lockers that start together can still race: a weaker one that armed before a
|
|
||||||
stronger one had published stays armed, and the stronger then arms on top. Two
|
|
||||||
windows, the weaker one holding the grab, the visible one unclickable -- the
|
|
||||||
same symptom as the 2026-11-11 keyring incident. The startup grace in
|
|
||||||
:mod:`gatelock._queue` closes most of that window; this closes the rest (a
|
|
||||||
stronger locker that starts later, e.g. three seconds after).
|
|
||||||
|
|
||||||
While armed, the lock checks about once a second for a **live** claim that is
|
|
||||||
**strictly** higher ranked AND locks at least as firmly (grab kind and VT:
|
|
||||||
the arbiter's own rule that rank must never weaken a lock). Equal, lower,
|
|
||||||
weaker-locking and dead claims never trigger it. On a hit the lock suspends:
|
|
||||||
recovery and detector stop, the grab is released, surfaces and backdrop are
|
|
||||||
hidden and the holder lock is dropped. The **claim stays published**, so
|
|
||||||
lower-ranked apps keep queueing behind it. VT switching is left as it is: the
|
|
||||||
stronger lock disables it itself, and re-disabling is part of re-arming. It then
|
|
||||||
waits windowless -- the same turn logic as :func:`gatelock.wait_for_turn` --
|
|
||||||
and re-arms fully (VT, backdrop, surfaces, grab) once nothing qualifying is
|
|
||||||
live. It is the same process and lock: the app's state and callbacks are
|
|
||||||
untouched.
|
|
||||||
|
|
||||||
This module holds the library's ONLY ``grab_release`` and the only caller of
|
|
||||||
``SurfaceSet.set_visible(visible=False)``; a test pins both. It is kept out of
|
|
||||||
the ``_recovery*`` family, whose static invariant forbids release calls.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from gatelock._queue import QUEUE_DEADLINE_SECONDS, stronger_claims
|
|
||||||
from gatelock._vt import disable_vt_switching
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from gatelock._arbiter import Arbiter
|
|
||||||
from gatelock._arming import ArmingCollaborators
|
|
||||||
from gatelock._claims import Claim
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def outranking(arbiter: Arbiter) -> tuple[Claim, ...]:
|
|
||||||
"""Live claims that must make us step back: higher rank, at least as firm.
|
|
||||||
|
|
||||||
``stronger_claims`` proves liveness with ``flock`` (a dead owner's claim is
|
|
||||||
reaped, never returned) and compares rank alone; the extra strength test is
|
|
||||||
what stops a stronger-ranked but weaker-locking app from disarming us.
|
|
||||||
"""
|
|
||||||
mine = arbiter.claim
|
|
||||||
return tuple(c for c in stronger_claims(arbiter) if c.at_least_as_strong_as(mine))
|
|
||||||
|
|
||||||
|
|
||||||
def _attempt(action: Callable[[], None], what: str) -> None:
|
|
||||||
"""Run one Tk step; a failure is logged and the rest still happens."""
|
|
||||||
try:
|
|
||||||
action()
|
|
||||||
except tk.TclError as exc:
|
|
||||||
_logger.warning("step-back: %s failed (%s)", what, exc)
|
|
||||||
|
|
||||||
|
|
||||||
class StepBack:
|
|
||||||
"""Suspends an armed lock for a stronger one, and re-arms it afterwards."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
root: tk.Tk,
|
|
||||||
arming: ArmingCollaborators,
|
|
||||||
arbiter: Arbiter | None,
|
|
||||||
rearm: Callable[[], None],
|
|
||||||
*,
|
|
||||||
clock: Callable[[], float] = time.monotonic,
|
|
||||||
) -> None:
|
|
||||||
"""Wire the controller; nothing is scheduled until :meth:`start`.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
root: The Tk root (backdrop and grab holder).
|
|
||||||
arming: The window's arming collaborators (surfaces, loop, ...).
|
|
||||||
arbiter: This app's arbiter; None disables stepping back.
|
|
||||||
rearm: Re-takes the grab and restarts the loops
|
|
||||||
(``LockWindow.grab_input``).
|
|
||||||
clock: Monotonic seconds; injected by tests.
|
|
||||||
"""
|
|
||||||
self._root = root
|
|
||||||
self._arming = arming
|
|
||||||
self._arbiter = arbiter
|
|
||||||
self._rearm = rearm
|
|
||||||
self._clock = clock
|
|
||||||
self._job: str | None = None
|
|
||||||
self._since: float | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def suspended(self) -> bool:
|
|
||||||
"""Whether the lock is currently stepped back."""
|
|
||||||
return self._since is not None
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
"""Begin (or restart) the periodic check. Idempotent."""
|
|
||||||
if self._arbiter is not None:
|
|
||||||
self._schedule()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
"""Cancel the periodic check."""
|
|
||||||
if self._job is not None:
|
|
||||||
_attempt(lambda: self._root.after_cancel(str(self._job)), "cancel")
|
|
||||||
self._job = None
|
|
||||||
|
|
||||||
def _schedule(self) -> None:
|
|
||||||
"""Queue the next check, replacing any pending one."""
|
|
||||||
self.stop()
|
|
||||||
self._job = self._root.after(self._arming.config.recovery_tick_ms, self._tick)
|
|
||||||
|
|
||||||
def _tick(self) -> None:
|
|
||||||
"""One check; a raise must not end the checking."""
|
|
||||||
self._job = None
|
|
||||||
try:
|
|
||||||
if self._since is None:
|
|
||||||
self._maybe_suspend()
|
|
||||||
else:
|
|
||||||
self._maybe_resume(self._since)
|
|
||||||
except Exception: # pylint: disable=broad-exception-caught
|
|
||||||
_logger.exception("step-back check raised; checking continues")
|
|
||||||
finally:
|
|
||||||
self._schedule()
|
|
||||||
|
|
||||||
def _maybe_suspend(self) -> None:
|
|
||||||
"""Step back if, and only if, a qualifying claim is live."""
|
|
||||||
arbiter = self._arbiter
|
|
||||||
# A stopped loop means the app took the lock down on purpose (study
|
|
||||||
# mode); it is not ours to suspend or, later, to re-arm.
|
|
||||||
if arbiter is None or not self._arming.recovery.running:
|
|
||||||
return
|
|
||||||
blockers = outranking(arbiter)
|
|
||||||
if not blockers:
|
|
||||||
return
|
|
||||||
_logger.warning(
|
|
||||||
"%s (rank %d) steps back: %s is live and outranks it; hiding, "
|
|
||||||
"releasing the grab, waiting with no window",
|
|
||||||
arbiter.claim.app,
|
|
||||||
arbiter.claim.rank,
|
|
||||||
", ".join(f"{c.app} (rank {c.rank})" for c in blockers),
|
|
||||||
)
|
|
||||||
# First: if any step below raised, the next tick must still see us as
|
|
||||||
# suspended and re-arm, never as armed with a half-stopped loop.
|
|
||||||
self._since = self._clock()
|
|
||||||
self._arming.recovery.stop()
|
|
||||||
self._arming.detector.stop()
|
|
||||||
_attempt(self._root.grab_release, "grab release")
|
|
||||||
_attempt(lambda: self._arming.surfaces.set_visible(visible=False), "hide")
|
|
||||||
_attempt(self._root.withdraw, "backdrop hide")
|
|
||||||
arbiter.set_grab_held(held=False)
|
|
||||||
arbiter.drop_holder()
|
|
||||||
|
|
||||||
def _maybe_resume(self, since: float) -> None:
|
|
||||||
"""Re-arm once nothing qualifying is live (or the backstop is hit)."""
|
|
||||||
arbiter = self._arbiter
|
|
||||||
if arbiter is None: # pragma: no cover - never suspended without one
|
|
||||||
return
|
|
||||||
waited = self._clock() - since
|
|
||||||
if outranking(arbiter):
|
|
||||||
if waited < QUEUE_DEADLINE_SECONDS:
|
|
||||||
return
|
|
||||||
_logger.error(
|
|
||||||
"%s waited %.0fs stepped back and gave up -- re-arming anyway "
|
|
||||||
"rather than leaving the machine unlocked",
|
|
||||||
arbiter.claim.app,
|
|
||||||
waited,
|
|
||||||
)
|
|
||||||
_logger.warning(
|
|
||||||
"%s re-arms after %.0fs stepped back: nothing outranks it now",
|
|
||||||
arbiter.claim.app,
|
|
||||||
waited,
|
|
||||||
)
|
|
||||||
self._since = None
|
|
||||||
arbiter.acquire_holder()
|
|
||||||
if self._arming.config.resolved_disable_vt():
|
|
||||||
disable_vt_switching()
|
|
||||||
_attempt(self._root.deiconify, "backdrop show")
|
|
||||||
_attempt(lambda: self._arming.surfaces.set_visible(visible=True), "show")
|
|
||||||
self._rearm()
|
|
||||||
@ -27,8 +27,10 @@ recording, because each rules out an approach that looks reasonable:
|
|||||||
afterwards and the placement is ignored.
|
afterwards and the placement is ignored.
|
||||||
3. **A window-manager-managed toplevel cannot be placed per-output** -- i3
|
3. **A window-manager-managed toplevel cannot be placed per-output** -- i3
|
||||||
rewrites the geometry wholesale. Per-output placement *requires*
|
rewrites the geometry wholesale. Per-output placement *requires*
|
||||||
override-redirect, so ``attributes(fullscreen=True)`` is gone: an EWMH
|
override-redirect, so ``attributes(fullscreen=True)`` is gone from the lock
|
||||||
request an override-redirect window never sees.
|
path entirely: it is an EWMH request that an override-redirect window is
|
||||||
|
invisible to, and where it does apply it targets one monitor or the whole
|
||||||
|
bounding box. Neither is what per-output locking needs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -43,7 +45,6 @@ from gatelock._placement import (
|
|||||||
enforce_all,
|
enforce_all,
|
||||||
needs_backdrop_root,
|
needs_backdrop_root,
|
||||||
set_geometry,
|
set_geometry,
|
||||||
set_visible,
|
|
||||||
)
|
)
|
||||||
from gatelock._surface_types import (
|
from gatelock._surface_types import (
|
||||||
SurfaceBuilder,
|
SurfaceBuilder,
|
||||||
@ -53,7 +54,9 @@ from gatelock._surface_types import (
|
|||||||
from gatelock._surfaces_focus import focus_surface, preferred_focus_index
|
from gatelock._surfaces_focus import focus_surface, preferred_focus_index
|
||||||
from gatelock._textmirror import TextMirror, mirror_text_widgets
|
from gatelock._textmirror import TextMirror, mirror_text_widgets
|
||||||
|
|
||||||
# Re-exports after the 250-line split; listed so the autofixer keeps them.
|
# Names this module re-exports after the 250-line split. Listed explicitly so
|
||||||
|
# the autofixer cannot prune an import that exists *for* its re-export -- it
|
||||||
|
# stripped mirror_text_widgets once, and `gatelock` itself stopped importing.
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SurfaceBuilder",
|
"SurfaceBuilder",
|
||||||
"SurfaceDelta",
|
"SurfaceDelta",
|
||||||
@ -158,36 +161,27 @@ class SurfaceSet:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def verify(self, *, live_names: frozenset[str] | None = None) -> tuple[str, ...]:
|
def verify(self, *, live_names: frozenset[str] | None = None) -> tuple[str, ...]:
|
||||||
"""Return live outputs that are NOT currently showing a lock."""
|
"""Return live outputs that are NOT currently showing a lock.
|
||||||
|
|
||||||
|
This is the alarm for the original defect: an output the user can see,
|
||||||
|
with no lock on it, means the escape UI is somewhere they cannot reach.
|
||||||
|
"""
|
||||||
names = live_names if live_names is not None else self.names()
|
names = live_names if live_names is not None else self.names()
|
||||||
return blind_outputs(self._surfaces, names)
|
return blind_outputs(self._surfaces, names)
|
||||||
|
|
||||||
def enforce(self, *, lift: bool = True) -> tuple[str, ...]:
|
def enforce(self) -> tuple[str, ...]:
|
||||||
"""Re-assert every surface's placement and stacking.
|
"""Re-assert every surface's placement and stacking.
|
||||||
|
|
||||||
Only adds visibility: remaps, corrects drifted geometry, raises.
|
Only ever *adds* visibility: remaps what became unmapped, corrects
|
||||||
|
drifted geometry, and raises. Nothing here can hide a surface.
|
||||||
Args:
|
|
||||||
lift: False while a system prompt is yielded to (no raise).
|
|
||||||
"""
|
"""
|
||||||
return enforce_all(
|
return enforce_all(
|
||||||
self._surfaces,
|
self._surfaces,
|
||||||
overrideredirect=self._config.resolved_overrideredirect(),
|
overrideredirect=self._config.resolved_overrideredirect(),
|
||||||
lift=lift,
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_visible(self, *, visible: bool) -> None:
|
|
||||||
"""Hide or show every surface; only ``_stepback`` hides."""
|
|
||||||
set_visible(
|
|
||||||
self._surfaces,
|
|
||||||
visible=visible,
|
|
||||||
overrideredirect=self._config.resolved_overrideredirect(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def raise_all(self) -> None:
|
def raise_all(self) -> None:
|
||||||
"""Lift the backdrop and every surface above whatever else is on screen."""
|
"""Lift every surface above whatever else is on screen."""
|
||||||
if needs_backdrop_root(self._config):
|
|
||||||
self._root.lift()
|
|
||||||
for surface in self._surfaces.values():
|
for surface in self._surfaces.values():
|
||||||
surface.window.lift()
|
surface.window.lift()
|
||||||
|
|
||||||
|
|||||||
@ -31,9 +31,7 @@ from typing import TYPE_CHECKING
|
|||||||
from gatelock import _arming, _preempt
|
from gatelock import _arming, _preempt
|
||||||
from gatelock._detect import OutputChangeDetector
|
from gatelock._detect import OutputChangeDetector
|
||||||
from gatelock._outputs import OutputEnumerator
|
from gatelock._outputs import OutputEnumerator
|
||||||
from gatelock._prompts import PromptYield
|
|
||||||
from gatelock._recovery import RecoveryCollaborators, RecoveryLoop
|
from gatelock._recovery import RecoveryCollaborators, RecoveryLoop
|
||||||
from gatelock._stepback import StepBack
|
|
||||||
from gatelock._surfaces import SurfaceSet
|
from gatelock._surfaces import SurfaceSet
|
||||||
from gatelock._vt import restore_vt_switching
|
from gatelock._vt import restore_vt_switching
|
||||||
|
|
||||||
@ -82,7 +80,6 @@ class LockWindow:
|
|||||||
self._closed = False
|
self._closed = False
|
||||||
self._focus_notified = False
|
self._focus_notified = False
|
||||||
self._preempted_pids: set[int] = set()
|
self._preempted_pids: set[int] = set()
|
||||||
prompt_yield = PromptYield()
|
|
||||||
surfaces = SurfaceSet(root, config, hooks)
|
surfaces = SurfaceSet(root, config, hooks)
|
||||||
enumerator = OutputEnumerator(root)
|
enumerator = OutputEnumerator(root)
|
||||||
detector = OutputChangeDetector(root)
|
detector = OutputChangeDetector(root)
|
||||||
@ -95,7 +92,6 @@ class LockWindow:
|
|||||||
detector=detector,
|
detector=detector,
|
||||||
hooks=hooks,
|
hooks=hooks,
|
||||||
on_grab_held=self.report_grab_held,
|
on_grab_held=self.report_grab_held,
|
||||||
prompt_yield=prompt_yield,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# The arming bundle is also where the window reaches its
|
# The arming bundle is also where the window reaches its
|
||||||
@ -109,9 +105,7 @@ class LockWindow:
|
|||||||
notify_focus_ready=self._notify_focus_ready,
|
notify_focus_ready=self._notify_focus_ready,
|
||||||
log_grab_blocked=self._log_grab_blocked,
|
log_grab_blocked=self._log_grab_blocked,
|
||||||
notify_grab_held=lambda: self.report_grab_held(held=True),
|
notify_grab_held=lambda: self.report_grab_held(held=True),
|
||||||
prompt_yield=prompt_yield,
|
|
||||||
)
|
)
|
||||||
self._stepback = StepBack(root, self._arming, arbiter, self.grab_input)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def surfaces(self) -> SurfaceSet:
|
def surfaces(self) -> SurfaceSet:
|
||||||
@ -146,16 +140,24 @@ class LockWindow:
|
|||||||
# -- window mechanics -----------------------------------------------------
|
# -- window mechanics -----------------------------------------------------
|
||||||
|
|
||||||
def setup(self) -> None:
|
def setup(self) -> None:
|
||||||
"""Build the backdrop and a surface per live output (``_arming.setup``)."""
|
"""Configure the backdrop and build a surface on every live output.
|
||||||
|
|
||||||
|
Delegates to :func:`gatelock._arming.setup`.
|
||||||
|
"""
|
||||||
self._vt_disabled = _arming.setup(self.root, self._arming)
|
self._vt_disabled = _arming.setup(self.root, self._arming)
|
||||||
|
|
||||||
def grab_input(self) -> None:
|
def grab_input(self) -> None:
|
||||||
"""Take the configured grab, then watch outputs (``_arming.grab_input``)."""
|
"""Take the configured grab, then start watching for output changes.
|
||||||
|
|
||||||
|
Delegates to :func:`gatelock._arming.grab_input`.
|
||||||
|
"""
|
||||||
_arming.grab_input(self.root, self._arming)
|
_arming.grab_input(self.root, self._arming)
|
||||||
self._stepback.start()
|
|
||||||
|
|
||||||
def _acquire_global_grab(self, *, attempt: int) -> None:
|
def _acquire_global_grab(self, *, attempt: int) -> None:
|
||||||
"""Acquire the global grab, retrying (``_arming.acquire_global_grab``)."""
|
"""Acquire the global grab, retrying per ``grab_retry_ms``.
|
||||||
|
|
||||||
|
Delegates to :func:`gatelock._arming.acquire_global_grab`.
|
||||||
|
"""
|
||||||
_arming.acquire_global_grab(self.root, self._arming, attempt=attempt)
|
_arming.acquire_global_grab(self.root, self._arming, attempt=attempt)
|
||||||
|
|
||||||
def _log_grab_blocked(self, attempt: int) -> None:
|
def _log_grab_blocked(self, attempt: int) -> None:
|
||||||
@ -169,7 +171,6 @@ class LockWindow:
|
|||||||
arbiter=self._arbiter,
|
arbiter=self._arbiter,
|
||||||
config=self._config,
|
config=self._config,
|
||||||
preempted_pids=self._preempted_pids,
|
preempted_pids=self._preempted_pids,
|
||||||
prompt_yield=self._arming.prompt_yield,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _notify_focus_ready(self) -> None:
|
def _notify_focus_ready(self) -> None:
|
||||||
@ -227,7 +228,6 @@ class LockWindow:
|
|||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
self._stepback.stop()
|
|
||||||
self._arming.recovery.stop()
|
self._arming.recovery.stop()
|
||||||
self._arming.detector.stop()
|
self._arming.detector.stop()
|
||||||
self._hooks.on_close()
|
self._hooks.on_close()
|
||||||
|
|||||||
@ -31,22 +31,3 @@ def load_xlib(*names: str) -> tuple[ModuleType, ...] | None:
|
|||||||
return tuple(importlib.import_module(name) for name in names)
|
return tuple(importlib.import_module(name) for name in names)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def xlib_failures(xerror: ModuleType) -> tuple[type[BaseException], ...]:
|
|
||||||
"""Every exception a python-xlib connection or request can raise.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
xerror: The loaded ``Xlib.error`` module.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A tuple usable in an ``except`` clause.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
OSError,
|
|
||||||
ValueError,
|
|
||||||
xerror.DisplayError,
|
|
||||||
xerror.XauthError,
|
|
||||||
xerror.ConnectionClosedError,
|
|
||||||
xerror.XError,
|
|
||||||
)
|
|
||||||
|
|||||||
@ -17,7 +17,6 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from gatelock._arbiter import Arbiter
|
|
||||||
from gatelock._config import LockConfig
|
from gatelock._config import LockConfig
|
||||||
from gatelock._outputs import Output, OutputRect, OutputScan
|
from gatelock._outputs import Output, OutputRect, OutputScan
|
||||||
from gatelock._recovery import RecoveryCollaborators, RecoveryLoop
|
from gatelock._recovery import RecoveryCollaborators, RecoveryLoop
|
||||||
@ -79,12 +78,6 @@ def _hermetic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[None
|
|||||||
patch("gatelock._randr.RandrBackend.create", return_value=None),
|
patch("gatelock._randr.RandrBackend.create", return_value=None),
|
||||||
patch("gatelock._outputs.scan_xrandr", return_value=DEFAULT_OUTPUTS),
|
patch("gatelock._outputs.scan_xrandr", return_value=DEFAULT_OUTPUTS),
|
||||||
patch("gatelock._detect._RandrEventSource.start", return_value=False),
|
patch("gatelock._detect._RandrEventSource.start", return_value=False),
|
||||||
# A lock that yields to a prompt asks X what is on screen; no test may
|
|
||||||
# reach the developer's display for that.
|
|
||||||
patch("gatelock._prompts_x.load_xlib", return_value=None),
|
|
||||||
patch("gatelock._grabprobe.load_xlib", return_value=None),
|
|
||||||
# The startup grace is real time; the queue tests pass it explicitly.
|
|
||||||
patch("gatelock._queue.STARTUP_GRACE_SECONDS", 0.0),
|
|
||||||
patch("gatelock._vt.shutil.which", return_value="/usr/bin/setxkbmap"),
|
patch("gatelock._vt.shutil.which", return_value="/usr/bin/setxkbmap"),
|
||||||
patch("gatelock._vt.subprocess.run"),
|
patch("gatelock._vt.subprocess.run"),
|
||||||
):
|
):
|
||||||
@ -169,39 +162,3 @@ def build_loop(
|
|||||||
def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]:
|
def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]:
|
||||||
"""A recovery loop in hard mode, the configuration most tests want."""
|
"""A recovery loop in hard mode, the configuration most tests want."""
|
||||||
return build_loop(mock_root, LockConfig(mode="hard"))
|
return build_loop(mock_root, LockConfig(mode="hard"))
|
||||||
|
|
||||||
|
|
||||||
_MADE: list[Arbiter] = []
|
|
||||||
|
|
||||||
|
|
||||||
def make_arbiter(
|
|
||||||
runtime: Path, app: str, rank: int, *, grab: str = "global", vt: bool = True
|
|
||||||
) -> Arbiter:
|
|
||||||
"""A published arbiter in ``runtime``; released after the test."""
|
|
||||||
arbiter = Arbiter(app, rank, grab=grab, disable_vt=vt, runtime_dir=runtime)
|
|
||||||
arbiter.publish()
|
|
||||||
_MADE.append(arbiter)
|
|
||||||
return arbiter
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _release_arbiters() -> Generator[None]:
|
|
||||||
"""Close every claim file a test opened."""
|
|
||||||
yield
|
|
||||||
while _MADE:
|
|
||||||
_MADE.pop().release()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def armed(
|
|
||||||
mock_root: MagicMock, tmp_path: Path
|
|
||||||
) -> Generator[tuple[LockWindow, Arbiter]]:
|
|
||||||
"""A mid-rank (150) hard lock that is armed, holds the screen, and checks."""
|
|
||||||
arbiter = make_arbiter(tmp_path, "leetcode_guard", 150)
|
|
||||||
arbiter.acquire_holder()
|
|
||||||
config = LockConfig(mode="hard", app_name="leetcode_guard", rank=150)
|
|
||||||
window = LockWindow(mock_root, config, MagicMock(), arbiter=arbiter)
|
|
||||||
window.setup()
|
|
||||||
window.grab_input()
|
|
||||||
yield window, arbiter
|
|
||||||
window.close()
|
|
||||||
|
|||||||
@ -1,202 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Tests for WHO a blocked grab is blamed on, and the error text that explains it.
|
|
||||||
|
|
||||||
Incident 2026-11-11: the lock logged "held by gatelock app 'leetcode_guard'
|
|
||||||
(pid <its own pid>)" -- it blamed itself, because the holder file it had just
|
|
||||||
written is the one ``describe_holder`` reads -- while the real holder was the
|
|
||||||
GNOME Keyring prompt, an ordinary X client. Tk's own error text ("another
|
|
||||||
application has grab" vs "window not viewable") was swallowed as well.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import replace
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from gatelock import _preempt
|
|
||||||
from gatelock._arbiter import RANK_SCREEN_LOCKER, Arbiter
|
|
||||||
from gatelock._config import GrabPolicy, LockConfig
|
|
||||||
from gatelock._prompts import PromptYield
|
|
||||||
from gatelock.tests.conftest import make_window
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
REASON = "grab failed: another application has grab"
|
|
||||||
|
|
||||||
|
|
||||||
def _own_arbiter(tmp_path: Path) -> Arbiter:
|
|
||||||
"""A real arbiter that holds the screen, as a running lock does."""
|
|
||||||
arbiter = Arbiter(
|
|
||||||
"leetcode_guard",
|
|
||||||
RANK_SCREEN_LOCKER,
|
|
||||||
grab="global",
|
|
||||||
disable_vt=True,
|
|
||||||
runtime_dir=tmp_path,
|
|
||||||
)
|
|
||||||
arbiter.publish()
|
|
||||||
assert arbiter.acquire_holder()
|
|
||||||
return arbiter
|
|
||||||
|
|
||||||
|
|
||||||
class TestNoSelfBlame:
|
|
||||||
"""When the holder file is our own, the grab holder is somebody else."""
|
|
||||||
|
|
||||||
def test_own_claim_is_not_reported_as_the_holder(
|
|
||||||
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""No "held by gatelock app", no own pid, and a pointer at X clients."""
|
|
||||||
arbiter = _own_arbiter(tmp_path)
|
|
||||||
config = LockConfig(mode="hard", grab=GrabPolicy(preempt_weaker_holder=True))
|
|
||||||
with (
|
|
||||||
caplog.at_level(logging.WARNING),
|
|
||||||
patch("gatelock._preempt.os.kill") as kill,
|
|
||||||
):
|
|
||||||
_preempt.log_grab_blocked(
|
|
||||||
25, arbiter=arbiter, config=config, preempted_pids=set()
|
|
||||||
)
|
|
||||||
arbiter.release()
|
|
||||||
assert "held by gatelock app" not in caplog.text
|
|
||||||
assert f"pid {os.getpid()}" not in caplog.text
|
|
||||||
assert "non-gatelock X client" in caplog.text
|
|
||||||
kill.assert_not_called()
|
|
||||||
|
|
||||||
def test_prompt_on_screen_is_named(
|
|
||||||
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""A detected credential prompt is named as present, not as a verdict."""
|
|
||||||
arbiter = _own_arbiter(tmp_path)
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
_preempt.log_grab_blocked(
|
|
||||||
25,
|
|
||||||
arbiter=arbiter,
|
|
||||||
config=LockConfig(),
|
|
||||||
preempted_pids=set(),
|
|
||||||
prompt_yield=MagicMock(last_reason="", prompt_names=("gcr-prompter",)),
|
|
||||||
)
|
|
||||||
arbiter.release()
|
|
||||||
assert "gcr-prompter" in caplog.text
|
|
||||||
|
|
||||||
def test_without_arbiter_names_the_prompt_too(
|
|
||||||
self, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""Arbitration disabled: same message, same prompt."""
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
_preempt.log_grab_blocked(
|
|
||||||
25,
|
|
||||||
arbiter=None,
|
|
||||||
config=LockConfig(),
|
|
||||||
preempted_pids=set(),
|
|
||||||
prompt_yield=MagicMock(last_reason="", prompt_names=("pinentry",)),
|
|
||||||
)
|
|
||||||
assert "pinentry" in caplog.text
|
|
||||||
assert "fullscreen game" in caplog.text
|
|
||||||
|
|
||||||
def test_another_gatelock_app_is_still_named(
|
|
||||||
self, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""Only the self-reference changed: a real foreign holder is still blamed."""
|
|
||||||
arbiter = MagicMock()
|
|
||||||
arbiter.claim.instance_id = "ours"
|
|
||||||
arbiter.describe_holder.return_value = MagicMock(
|
|
||||||
app="diet_guard", rank=100, pid=4242, started="t", instance_id="theirs"
|
|
||||||
)
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
_preempt.log_grab_blocked(
|
|
||||||
25, arbiter=arbiter, config=LockConfig(), preempted_pids=set()
|
|
||||||
)
|
|
||||||
assert "held by gatelock app 'diet_guard'" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
class TestReasonInLogs:
|
|
||||||
"""Tk's own message rides along on every blocked-grab warning."""
|
|
||||||
|
|
||||||
def test_reason_with_no_gatelock_holder(
|
|
||||||
self, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""The no-holder branch carries it."""
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
_preempt.log_grab_blocked(
|
|
||||||
25,
|
|
||||||
arbiter=None,
|
|
||||||
config=LockConfig(),
|
|
||||||
preempted_pids=set(),
|
|
||||||
prompt_yield=MagicMock(last_reason=REASON, prompt_names=()),
|
|
||||||
)
|
|
||||||
assert REASON in caplog.text
|
|
||||||
|
|
||||||
def test_reason_with_a_gatelock_holder(
|
|
||||||
self, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""The holder branch carries it too."""
|
|
||||||
arbiter = MagicMock()
|
|
||||||
arbiter.claim.instance_id = "ours"
|
|
||||||
arbiter.describe_holder.return_value = MagicMock(
|
|
||||||
app="diet_guard", rank=100, pid=4242, started="t", instance_id="theirs"
|
|
||||||
)
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
_preempt.log_grab_blocked(
|
|
||||||
25,
|
|
||||||
arbiter=arbiter,
|
|
||||||
config=LockConfig(),
|
|
||||||
preempted_pids=set(),
|
|
||||||
prompt_yield=MagicMock(last_reason=REASON, prompt_names=()),
|
|
||||||
)
|
|
||||||
assert REASON in caplog.text
|
|
||||||
|
|
||||||
def test_window_passes_the_last_reason_and_prompts(
|
|
||||||
self, mock_root: MagicMock, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""``LockWindow`` hands the policy's state to the logger."""
|
|
||||||
window, _ = make_window(mock_root, config=LockConfig(mode="hard"))
|
|
||||||
stub = MagicMock(last_reason=REASON, prompt_names=("gcr-prompter",))
|
|
||||||
window._arming = replace(window._arming, prompt_yield=stub)
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
window._log_grab_blocked(25)
|
|
||||||
assert REASON in caplog.text
|
|
||||||
assert "gcr-prompter" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
class TestArming:
|
|
||||||
"""The arming retry loop feeds the same policy and surfaces the error."""
|
|
||||||
|
|
||||||
def test_failures_feed_the_policy(self, mock_root: MagicMock) -> None:
|
|
||||||
"""Each failed attempt records Tk's message."""
|
|
||||||
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
|
|
||||||
window, _ = make_window(mock_root, config=LockConfig(mode="hard"))
|
|
||||||
window._acquire_global_grab(attempt=1)
|
|
||||||
assert window._arming.prompt_yield.last_reason == REASON
|
|
||||||
|
|
||||||
def test_local_fallback_warning_has_the_reason(
|
|
||||||
self, mock_root: MagicMock, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""``retry_ms=0``: the fallback says why the global grab failed."""
|
|
||||||
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
|
|
||||||
config = LockConfig(mode="hard", grab=GrabPolicy(retry_ms=0))
|
|
||||||
window, _ = make_window(mock_root, config=config)
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
window._acquire_global_grab(attempt=1)
|
|
||||||
assert REASON in caplog.text
|
|
||||||
|
|
||||||
def test_success_ends_a_yield_and_relifts_the_lock(
|
|
||||||
self, mock_root: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""The prompt closed and the retry won: the lock covers the screen again."""
|
|
||||||
window, _ = make_window(mock_root, config=LockConfig(mode="hard"))
|
|
||||||
policy = MagicMock(spec=PromptYield)
|
|
||||||
policy.grab_held.return_value = True
|
|
||||||
window._arming = replace(window._arming, prompt_yield=policy)
|
|
||||||
window._acquire_global_grab(attempt=3)
|
|
||||||
policy.grab_held.assert_called_once_with()
|
|
||||||
mock_root.lift.assert_called()
|
|
||||||
|
|
||||||
def test_window_shares_one_policy(self, mock_root: MagicMock) -> None:
|
|
||||||
"""Arming and recovery must time the same block."""
|
|
||||||
window, _ = make_window(mock_root, config=LockConfig(mode="hard"))
|
|
||||||
assert window.recovery._parts.prompt_yield is window._arming.prompt_yield
|
|
||||||
@ -1,238 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Tests for the non-blocking grab probe (:mod:`gatelock._grabprobe`).
|
|
||||||
|
|
||||||
Tk's ``grab -global`` blocks the whole event loop for ~1 s per FAILED attempt.
|
|
||||||
The probe asks X first, on a throwaway connection, so a lock whose grab is held
|
|
||||||
by someone else skips the Tk call instead of freezing its own UI. python-xlib
|
|
||||||
is replaced by an in-memory fake; ``scripts/verify_grab_responsive.py`` runs the
|
|
||||||
real thing against a real foreign grab.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import replace
|
|
||||||
import logging
|
|
||||||
import tkinter as tk
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from gatelock._config import LockConfig
|
|
||||||
from gatelock._grabprobe import GrabProbe
|
|
||||||
from gatelock._prompts import PromptYield
|
|
||||||
from gatelock.tests.conftest import BOTH, build_loop, make_window
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
OK, TAKEN, NOT_VIEWABLE = 0, 1, 3
|
|
||||||
|
|
||||||
|
|
||||||
class XError(Exception):
|
|
||||||
"""Stands in for ``Xlib.error.XError``."""
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayError(Exception):
|
|
||||||
"""Stands in for ``Xlib.error.DisplayError``."""
|
|
||||||
|
|
||||||
|
|
||||||
class FakeRoot:
|
|
||||||
"""Answers grab requests from a script and records the ungrabs."""
|
|
||||||
|
|
||||||
def __init__(self, keyboard: int, pointer: int) -> None:
|
|
||||||
self.statuses = {"keyboard": keyboard, "pointer": pointer}
|
|
||||||
self.asked: list[str] = []
|
|
||||||
|
|
||||||
def grab_keyboard(self, *_args: object) -> int:
|
|
||||||
self.asked.append("keyboard")
|
|
||||||
return self.statuses["keyboard"]
|
|
||||||
|
|
||||||
def grab_pointer(self, *_args: object) -> int:
|
|
||||||
self.asked.append("pointer")
|
|
||||||
if self.statuses["pointer"] < 0:
|
|
||||||
message = "BadWindow"
|
|
||||||
raise XError(message)
|
|
||||||
return self.statuses["pointer"]
|
|
||||||
|
|
||||||
|
|
||||||
class FakeDisplay:
|
|
||||||
"""The slice of ``Xlib.display.Display`` the probe uses."""
|
|
||||||
|
|
||||||
def __init__(self, root: FakeRoot) -> None:
|
|
||||||
self.root = root
|
|
||||||
self.ungrabbed: list[str] = []
|
|
||||||
self.closed = False
|
|
||||||
|
|
||||||
def screen(self) -> SimpleNamespace:
|
|
||||||
return SimpleNamespace(root=self.root)
|
|
||||||
|
|
||||||
def ungrab_keyboard(self, _time: int) -> None:
|
|
||||||
self.ungrabbed.append("keyboard")
|
|
||||||
|
|
||||||
def ungrab_pointer(self, _time: int) -> None:
|
|
||||||
self.ungrabbed.append("pointer")
|
|
||||||
|
|
||||||
def sync(self) -> None:
|
|
||||||
self.ungrabbed.append("sync")
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.closed = True
|
|
||||||
|
|
||||||
|
|
||||||
def loaded(factory: Callable[[], FakeDisplay]) -> tuple[SimpleNamespace, ...]:
|
|
||||||
"""What ``load_xlib("Xlib.display", "Xlib.X", "Xlib.error")`` returns."""
|
|
||||||
return (
|
|
||||||
SimpleNamespace(Display=factory),
|
|
||||||
SimpleNamespace(
|
|
||||||
GrabSuccess=OK,
|
|
||||||
AlreadyGrabbed=TAKEN,
|
|
||||||
GrabModeAsync=1,
|
|
||||||
CurrentTime=0,
|
|
||||||
NONE=0,
|
|
||||||
),
|
|
||||||
SimpleNamespace(
|
|
||||||
DisplayError=DisplayError,
|
|
||||||
XauthError=DisplayError,
|
|
||||||
ConnectionClosedError=DisplayError,
|
|
||||||
XError=XError,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def probe_with(display: FakeDisplay) -> tuple[GrabProbe, object]:
|
|
||||||
"""A probe whose X connection is ``display`` (patch is the second item)."""
|
|
||||||
patcher = patch(
|
|
||||||
"gatelock._grabprobe.load_xlib", return_value=loaded(lambda: display)
|
|
||||||
)
|
|
||||||
patcher.start()
|
|
||||||
return GrabProbe(), patcher
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _unpatch() -> object:
|
|
||||||
yield
|
|
||||||
patch.stopall()
|
|
||||||
|
|
||||||
|
|
||||||
class TestProbe:
|
|
||||||
"""What the probe reports, and that it leaves nothing grabbed."""
|
|
||||||
|
|
||||||
def test_free_grab_is_released_at_once(self) -> None:
|
|
||||||
"""Nobody holds it: report free, and give the probe's own grab back."""
|
|
||||||
display = FakeDisplay(FakeRoot(OK, OK))
|
|
||||||
probe, _ = probe_with(display)
|
|
||||||
assert probe.blocked() is None
|
|
||||||
assert display.root.asked == ["keyboard", "pointer"]
|
|
||||||
assert display.ungrabbed == ["keyboard", "pointer", "sync"]
|
|
||||||
assert display.closed
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("keyboard", "pointer", "which"),
|
|
||||||
[(TAKEN, OK, "keyboard"), (OK, TAKEN, "pointer")],
|
|
||||||
)
|
|
||||||
def test_foreign_grab_is_reported(
|
|
||||||
self, keyboard: int, pointer: int, which: str
|
|
||||||
) -> None:
|
|
||||||
"""Either device held elsewhere blocks us; the reason names it."""
|
|
||||||
display = FakeDisplay(FakeRoot(keyboard, pointer))
|
|
||||||
probe, _ = probe_with(display)
|
|
||||||
reason = probe.blocked()
|
|
||||||
assert reason is not None
|
|
||||||
assert "another application has grab" in reason
|
|
||||||
assert which in reason
|
|
||||||
assert display.ungrabbed[-1] == "sync"
|
|
||||||
assert display.root.asked[-1] == which
|
|
||||||
|
|
||||||
def test_other_statuses_defer_to_tk(self) -> None:
|
|
||||||
"""Not viewable and the like are not a foreign holder: let Tk decide."""
|
|
||||||
display = FakeDisplay(FakeRoot(NOT_VIEWABLE, OK))
|
|
||||||
probe, _ = probe_with(display)
|
|
||||||
assert probe.blocked() is None
|
|
||||||
|
|
||||||
def test_check_raises_tcl_error_when_blocked(self) -> None:
|
|
||||||
"""``check`` speaks Tk's dialect so callers keep one except clause."""
|
|
||||||
probe, _ = probe_with(FakeDisplay(FakeRoot(TAKEN, OK)))
|
|
||||||
with pytest.raises(tk.TclError, match="another application has grab"):
|
|
||||||
probe.check()
|
|
||||||
|
|
||||||
def test_check_is_silent_when_free(self) -> None:
|
|
||||||
"""Free grab: nothing raised."""
|
|
||||||
probe, _ = probe_with(FakeDisplay(FakeRoot(OK, OK)))
|
|
||||||
probe.check()
|
|
||||||
|
|
||||||
|
|
||||||
class TestFailClosed:
|
|
||||||
"""When X cannot be asked, fall back to today's behaviour: call Tk."""
|
|
||||||
|
|
||||||
def test_missing_xlib(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
||||||
"""No python-xlib: unknown, said once."""
|
|
||||||
probe = GrabProbe()
|
|
||||||
with (
|
|
||||||
patch("gatelock._grabprobe.load_xlib", return_value=None),
|
|
||||||
caplog.at_level(logging.WARNING),
|
|
||||||
):
|
|
||||||
assert probe.blocked() is None
|
|
||||||
assert probe.blocked() is None
|
|
||||||
assert caplog.text.count("python-xlib") == 1
|
|
||||||
|
|
||||||
def test_no_connection(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
||||||
"""A connection that will not open: unknown, warned."""
|
|
||||||
|
|
||||||
def refuse() -> FakeDisplay:
|
|
||||||
message = "no display"
|
|
||||||
raise DisplayError(message)
|
|
||||||
|
|
||||||
probe = GrabProbe()
|
|
||||||
with (
|
|
||||||
patch("gatelock._grabprobe.load_xlib", return_value=loaded(refuse)),
|
|
||||||
caplog.at_level(logging.WARNING),
|
|
||||||
):
|
|
||||||
assert probe.blocked() is None
|
|
||||||
assert "X connection" in caplog.text
|
|
||||||
|
|
||||||
def test_request_error_still_closes(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
||||||
"""An X error mid-probe: unknown, warned, connection dropped."""
|
|
||||||
display = FakeDisplay(FakeRoot(OK, -1))
|
|
||||||
probe, _ = probe_with(display)
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
assert probe.blocked() is None
|
|
||||||
assert "BadWindow" in caplog.text
|
|
||||||
assert display.closed
|
|
||||||
|
|
||||||
|
|
||||||
class TestWiring:
|
|
||||||
"""The probe gates every global-grab attempt, in arming and in recovery."""
|
|
||||||
|
|
||||||
def test_policy_exposes_check_grab(self) -> None:
|
|
||||||
"""``PromptYield.check_grab`` is the one entry point."""
|
|
||||||
probe = MagicMock()
|
|
||||||
PromptYield(grab_probe=probe).check_grab()
|
|
||||||
probe.check.assert_called_once_with()
|
|
||||||
|
|
||||||
def test_arming_skips_tk_when_blocked(self, mock_root: MagicMock) -> None:
|
|
||||||
"""A foreign holder: the 1 s Tk call is never made, the reason is kept."""
|
|
||||||
window, _ = make_window(mock_root, config=LockConfig(mode="hard"))
|
|
||||||
policy = PromptYield(grab_probe=MagicMock())
|
|
||||||
policy._grab_probe.check.side_effect = tk.TclError("held")
|
|
||||||
window._arming = replace(window._arming, prompt_yield=policy)
|
|
||||||
window._acquire_global_grab(attempt=1)
|
|
||||||
mock_root.grab_set_global.assert_not_called()
|
|
||||||
assert policy.last_reason == "held"
|
|
||||||
mock_root.after.assert_called()
|
|
||||||
|
|
||||||
def test_recovery_skips_tk_when_blocked(self, mock_root: MagicMock) -> None:
|
|
||||||
"""Same in the recovery tick."""
|
|
||||||
recovery, enumerator, _ = build_loop(mock_root, LockConfig(mode="hard"))
|
|
||||||
enumerator.scan.return_value = BOTH
|
|
||||||
mock_root.grab_current.return_value = None
|
|
||||||
policy = PromptYield(grab_probe=MagicMock())
|
|
||||||
policy._grab_probe.check.side_effect = tk.TclError("held")
|
|
||||||
recovery._parts = recovery._parts.__class__(
|
|
||||||
**{**vars(recovery._parts), "prompt_yield": policy}
|
|
||||||
)
|
|
||||||
report = recovery.tick()
|
|
||||||
mock_root.grab_set_global.assert_not_called()
|
|
||||||
assert report.grab_reasserted is False
|
|
||||||
assert policy.last_reason == "held"
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Tests for the yield-to-system-prompt policy (:mod:`gatelock._prompts`).
|
|
||||||
|
|
||||||
Incident 2026-11-11: the GNOME Keyring prompt held the X grab, the lock
|
|
||||||
covered it, and nothing was clickable. The policy: once the grab has been
|
|
||||||
blocked for a couple of seconds AND an allowlisted credential prompt is on
|
|
||||||
screen, raise that prompt above the lock and stop covering it again.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from gatelock._prompt_names import (
|
|
||||||
SYSTEM_PROMPT_CLASSES,
|
|
||||||
PromptWindow,
|
|
||||||
is_system_prompt,
|
|
||||||
)
|
|
||||||
from gatelock._prompts import YIELD_AFTER_S, PromptYield
|
|
||||||
from gatelock._prompts_x import XPromptProbe
|
|
||||||
|
|
||||||
GCR = PromptWindow(
|
|
||||||
window_id=11, toplevel_id=10, wm_class=("gcr-prompter", "Gcr-prompter")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeClock:
|
|
||||||
"""A settable monotonic clock."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.now = 100.0
|
|
||||||
|
|
||||||
def __call__(self) -> float:
|
|
||||||
return self.now
|
|
||||||
|
|
||||||
|
|
||||||
class FakeProbe:
|
|
||||||
"""Answers ``raise_prompts`` from a script and counts the calls."""
|
|
||||||
|
|
||||||
def __init__(self, *prompts: PromptWindow) -> None:
|
|
||||||
self.prompts = prompts
|
|
||||||
self.calls = 0
|
|
||||||
|
|
||||||
def raise_prompts(self) -> tuple[PromptWindow, ...]:
|
|
||||||
self.calls += 1
|
|
||||||
return self.prompts
|
|
||||||
|
|
||||||
|
|
||||||
class TestAllowlist:
|
|
||||||
"""Only known credential prompts are ever yielded to."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"wm_class",
|
|
||||||
[
|
|
||||||
("gcr-prompter", "Gcr-prompter"),
|
|
||||||
("pinentry-gtk-2", "Pinentry-gtk-2"),
|
|
||||||
("pinentry", "Pinentry"),
|
|
||||||
(
|
|
||||||
"polkit-gnome-authentication-agent-1",
|
|
||||||
"Polkit-gnome-authentication-agent-1",
|
|
||||||
),
|
|
||||||
("lxpolkit", "Lxpolkit"),
|
|
||||||
("ssh-askpass", "SSH-Askpass"),
|
|
||||||
("x11-ssh-askpass", "X11-ssh-askpass"),
|
|
||||||
("ksshaskpass", "ksshaskpass"),
|
|
||||||
("gnome-ssh-askpass", "Gnome-ssh-askpass"),
|
|
||||||
("whatever", "GCR-PROMPTER"),
|
|
||||||
("Pinentry-Qt", "x"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_known_prompt_matches(self, wm_class: tuple[str, str]) -> None:
|
|
||||||
"""Instance or class, any case, with the documented globs."""
|
|
||||||
assert is_system_prompt(wm_class)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"wm_class",
|
|
||||||
[
|
|
||||||
("steam_game", "Steam_game"),
|
|
||||||
("firefox", "Firefox"),
|
|
||||||
("tk", "Tk"),
|
|
||||||
("pinentr", "x"),
|
|
||||||
("xpinentry", "x"),
|
|
||||||
("", ""),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_anything_else_does_not(self, wm_class: tuple[str, str]) -> None:
|
|
||||||
"""A fullscreen game must keep today's behaviour: lock stays on top."""
|
|
||||||
assert not is_system_prompt(wm_class)
|
|
||||||
|
|
||||||
def test_the_allowlist_is_one_named_constant(self) -> None:
|
|
||||||
"""Every pattern the policy approved is in ``SYSTEM_PROMPT_CLASSES``."""
|
|
||||||
assert set(SYSTEM_PROMPT_CLASSES) == {
|
|
||||||
"gcr-prompter",
|
|
||||||
"pinentry*",
|
|
||||||
"polkit-gnome-authentication-agent-1",
|
|
||||||
"lxpolkit",
|
|
||||||
"ssh-askpass*",
|
|
||||||
"x11-ssh-askpass",
|
|
||||||
"ksshaskpass",
|
|
||||||
"gnome-ssh-askpass",
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_prompt_window_name(self) -> None:
|
|
||||||
"""The log names the window by its WM_CLASS instance."""
|
|
||||||
assert GCR.name == "gcr-prompter"
|
|
||||||
|
|
||||||
|
|
||||||
class TestYieldPolicy:
|
|
||||||
"""When the lock steps aside, and when it does not."""
|
|
||||||
|
|
||||||
def _yielder(
|
|
||||||
self, *prompts: PromptWindow
|
|
||||||
) -> tuple[PromptYield, FakeClock, FakeProbe]:
|
|
||||||
clock, probe = FakeClock(), FakeProbe(*prompts)
|
|
||||||
return PromptYield(probe, clock=clock), clock, probe
|
|
||||||
|
|
||||||
def test_default_probe_is_the_xlib_one(self) -> None:
|
|
||||||
"""Without an explicit probe the real X one is used."""
|
|
||||||
assert isinstance(PromptYield()._probe, XPromptProbe)
|
|
||||||
|
|
||||||
def test_idle_before_anything_is_blocked(self) -> None:
|
|
||||||
"""No failed grab, no look at the screen."""
|
|
||||||
policy, _, probe = self._yielder(GCR)
|
|
||||||
policy.settle()
|
|
||||||
assert not policy.yielding
|
|
||||||
assert probe.calls == 0
|
|
||||||
|
|
||||||
def test_waits_out_the_delay(self) -> None:
|
|
||||||
"""A grab blocked for less than the delay is a blip, not a prompt."""
|
|
||||||
policy, clock, probe = self._yielder(GCR)
|
|
||||||
policy.grab_blocked("another application has grab")
|
|
||||||
clock.now += YIELD_AFTER_S - 0.1
|
|
||||||
policy.settle()
|
|
||||||
assert not policy.yielding
|
|
||||||
assert probe.calls == 0
|
|
||||||
|
|
||||||
def test_yields_to_a_prompt_after_the_delay(
|
|
||||||
self, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""Blocked past the delay with a prompt on screen: yield, and say so."""
|
|
||||||
policy, clock, _ = self._yielder(GCR)
|
|
||||||
policy.grab_blocked("another application has grab")
|
|
||||||
clock.now += YIELD_AFTER_S + 0.1
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
policy.settle()
|
|
||||||
assert policy.yielding
|
|
||||||
assert policy.prompt_names == ("gcr-prompter",)
|
|
||||||
assert "gcr-prompter" in caplog.text
|
|
||||||
|
|
||||||
def test_keeps_raising_it_every_settle(self) -> None:
|
|
||||||
"""Each tick re-raises the prompt, last, over anything the tick lifted."""
|
|
||||||
policy, clock, probe = self._yielder(GCR)
|
|
||||||
policy.grab_blocked("x")
|
|
||||||
clock.now += 5
|
|
||||||
policy.settle()
|
|
||||||
policy.settle()
|
|
||||||
assert probe.calls == 2
|
|
||||||
assert policy.yielding
|
|
||||||
|
|
||||||
def test_no_prompt_means_no_yield(self) -> None:
|
|
||||||
"""A game holding the grab: the probe finds nothing, the lock stays on top."""
|
|
||||||
policy, clock, probe = self._yielder()
|
|
||||||
policy.grab_blocked("another application has grab")
|
|
||||||
clock.now += 30
|
|
||||||
policy.settle()
|
|
||||||
assert not policy.yielding
|
|
||||||
assert policy.prompt_names == ()
|
|
||||||
assert probe.calls == 1
|
|
||||||
|
|
||||||
def test_stops_when_the_prompt_goes_away(
|
|
||||||
self, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""Prompt unmapped but grab still blocked: stack the lock normally again."""
|
|
||||||
policy, clock, probe = self._yielder(GCR)
|
|
||||||
policy.grab_blocked("x")
|
|
||||||
clock.now += 5
|
|
||||||
policy.settle()
|
|
||||||
probe.prompts = ()
|
|
||||||
with caplog.at_level(logging.INFO):
|
|
||||||
policy.settle()
|
|
||||||
assert not policy.yielding
|
|
||||||
assert "gone" in caplog.text
|
|
||||||
|
|
||||||
def test_the_clock_starts_at_the_first_failure(self) -> None:
|
|
||||||
"""Later failures must not restart the delay."""
|
|
||||||
policy, clock, _ = self._yielder(GCR)
|
|
||||||
policy.grab_blocked("first")
|
|
||||||
clock.now += 1.5
|
|
||||||
policy.grab_blocked("second")
|
|
||||||
clock.now += 0.6
|
|
||||||
policy.settle()
|
|
||||||
assert policy.yielding
|
|
||||||
assert policy.last_reason == "second"
|
|
||||||
|
|
||||||
def test_grab_held_ends_the_yield_and_says_so(self) -> None:
|
|
||||||
"""Taking the grab reports whether the lock must now re-raise itself."""
|
|
||||||
policy, clock, probe = self._yielder(GCR)
|
|
||||||
policy.grab_blocked("x")
|
|
||||||
clock.now += 5
|
|
||||||
policy.settle()
|
|
||||||
assert policy.grab_held() is True
|
|
||||||
assert not policy.yielding
|
|
||||||
assert policy.prompt_names == ()
|
|
||||||
policy.settle()
|
|
||||||
assert probe.calls == 1 # nothing blocked any more: no more X work
|
|
||||||
|
|
||||||
def test_grab_held_when_never_yielding(self) -> None:
|
|
||||||
"""The common case: nothing to undo."""
|
|
||||||
policy, _, _ = self._yielder(GCR)
|
|
||||||
assert policy.grab_held() is False
|
|
||||||
policy.grab_blocked("x")
|
|
||||||
assert policy.grab_held() is False
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Tests for finding and raising a credential prompt over python-xlib.
|
|
||||||
|
|
||||||
python-xlib is optional and there is no X server in the suite, so the library
|
|
||||||
is replaced by a small in-memory window tree. The real thing is exercised by
|
|
||||||
``scripts/verify_prompt_yield.py`` on a private Xvfb.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from gatelock._prompts_x import XPromptProbe
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable, Iterator
|
|
||||||
|
|
||||||
from gatelock._prompt_names import PromptWindow
|
|
||||||
|
|
||||||
ABOVE = 0
|
|
||||||
ANY = 0
|
|
||||||
VIEWABLE = 2
|
|
||||||
|
|
||||||
|
|
||||||
class XError(Exception):
|
|
||||||
"""Stands in for ``Xlib.error.XError``."""
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayError(Exception):
|
|
||||||
"""Stands in for ``Xlib.error.DisplayError``."""
|
|
||||||
|
|
||||||
|
|
||||||
class ClosedError(Exception):
|
|
||||||
"""Stands in for ``Xlib.error.ConnectionClosedError``."""
|
|
||||||
|
|
||||||
|
|
||||||
class NoAuthError(Exception):
|
|
||||||
"""Stands in for ``Xlib.error.XauthError``."""
|
|
||||||
|
|
||||||
|
|
||||||
class FakeWindow:
|
|
||||||
"""One node of the fake window tree."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
display: FakeDisplay,
|
|
||||||
wid: int,
|
|
||||||
wm_class: tuple[str, str] | None = None,
|
|
||||||
*,
|
|
||||||
viewable: bool = True,
|
|
||||||
) -> None:
|
|
||||||
self.display = display
|
|
||||||
self.id = wid
|
|
||||||
self.wm_class = wm_class
|
|
||||||
self.viewable = viewable
|
|
||||||
self.parent: FakeWindow | None = None
|
|
||||||
self.children: list[FakeWindow] = []
|
|
||||||
self.client_list: list[int] | None = None
|
|
||||||
self.raised = False
|
|
||||||
self.broken = False
|
|
||||||
self.configure_error: XError | None = None
|
|
||||||
display.windows[wid] = self
|
|
||||||
|
|
||||||
def add(self, child: FakeWindow) -> FakeWindow:
|
|
||||||
child.parent = self
|
|
||||||
self.children.append(child)
|
|
||||||
return child
|
|
||||||
|
|
||||||
def get_wm_class(self) -> tuple[str, str] | None:
|
|
||||||
if self.broken:
|
|
||||||
message = "BadWindow"
|
|
||||||
raise XError(message)
|
|
||||||
return self.wm_class
|
|
||||||
|
|
||||||
def get_attributes(self) -> SimpleNamespace:
|
|
||||||
return SimpleNamespace(map_state=VIEWABLE if self.viewable else 0)
|
|
||||||
|
|
||||||
def query_tree(self) -> SimpleNamespace:
|
|
||||||
return SimpleNamespace(children=list(self.children), parent=self.parent)
|
|
||||||
|
|
||||||
def get_full_property(self, atom: str, _kind: int) -> SimpleNamespace | None:
|
|
||||||
if atom != "_NET_CLIENT_LIST" or self.client_list is None:
|
|
||||||
return None
|
|
||||||
return SimpleNamespace(value=self.client_list)
|
|
||||||
|
|
||||||
def configure(self, *, stack_mode: int, onerror: Callable[[XError], None]) -> None:
|
|
||||||
assert stack_mode == ABOVE
|
|
||||||
self.raised = True
|
|
||||||
if self.configure_error is not None:
|
|
||||||
onerror(self.configure_error)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeDisplay:
|
|
||||||
"""The slice of ``Xlib.display.Display`` the probe uses."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.windows: dict[int, FakeWindow] = {}
|
|
||||||
self.root = FakeWindow(self, 1)
|
|
||||||
self.closed = False
|
|
||||||
self.synced = False
|
|
||||||
|
|
||||||
def screen(self) -> SimpleNamespace:
|
|
||||||
return SimpleNamespace(root=self.root)
|
|
||||||
|
|
||||||
def intern_atom(self, name: str) -> str:
|
|
||||||
return name
|
|
||||||
|
|
||||||
def create_resource_object(self, _kind: str, wid: int) -> FakeWindow:
|
|
||||||
return self.windows[wid]
|
|
||||||
|
|
||||||
def sync(self) -> None:
|
|
||||||
self.synced = True
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.closed = True
|
|
||||||
|
|
||||||
|
|
||||||
def modules(display_factory: Callable[[], FakeDisplay]) -> tuple[SimpleNamespace, ...]:
|
|
||||||
"""What ``load_xlib("Xlib.display", "Xlib.X", "Xlib.error")`` returns."""
|
|
||||||
return (
|
|
||||||
SimpleNamespace(Display=display_factory),
|
|
||||||
SimpleNamespace(Above=ABOVE, AnyPropertyType=ANY, IsViewable=VIEWABLE),
|
|
||||||
SimpleNamespace(
|
|
||||||
DisplayError=DisplayError,
|
|
||||||
XauthError=NoAuthError,
|
|
||||||
ConnectionClosedError=ClosedError,
|
|
||||||
XError=XError,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def probe_over(display: FakeDisplay) -> Iterator[XPromptProbe]:
|
|
||||||
"""A probe whose X connection is ``display``."""
|
|
||||||
loaded = modules(lambda: display)
|
|
||||||
with patch("gatelock._prompts_x.load_xlib", return_value=loaded):
|
|
||||||
yield XPromptProbe()
|
|
||||||
|
|
||||||
|
|
||||||
def scan(display: FakeDisplay) -> tuple[PromptWindow, ...]:
|
|
||||||
"""One ``raise_prompts()`` over ``display``."""
|
|
||||||
with probe_over(display) as probe:
|
|
||||||
return probe.raise_prompts()
|
|
||||||
|
|
||||||
|
|
||||||
class TestFinding:
|
|
||||||
"""Which windows count, and what gets raised."""
|
|
||||||
|
|
||||||
def test_framed_client_raises_the_frame(self) -> None:
|
|
||||||
"""i3 reparents: the class is on the client, the frame is the top-level."""
|
|
||||||
display = FakeDisplay()
|
|
||||||
frame = display.root.add(FakeWindow(display, 10, ("i3-frame", "i3-frame")))
|
|
||||||
client = frame.add(FakeWindow(display, 11, ("gcr-prompter", "Gcr-prompter")))
|
|
||||||
found = scan(display)
|
|
||||||
assert [(p.window_id, p.toplevel_id) for p in found] == [(11, 10)]
|
|
||||||
assert frame.raised
|
|
||||||
assert not client.raised
|
|
||||||
assert display.synced
|
|
||||||
assert display.closed
|
|
||||||
|
|
||||||
def test_unframed_client_is_its_own_toplevel(self) -> None:
|
|
||||||
"""Without a window manager the prompt is a direct root child."""
|
|
||||||
display = FakeDisplay()
|
|
||||||
prompt = display.root.add(
|
|
||||||
FakeWindow(display, 20, ("pinentry-gtk-2", "Pinentry"))
|
|
||||||
)
|
|
||||||
found = scan(display)
|
|
||||||
assert [(p.window_id, p.toplevel_id) for p in found] == [(20, 20)]
|
|
||||||
assert prompt.raised
|
|
||||||
|
|
||||||
def test_net_client_list_is_used_and_deduplicated(self) -> None:
|
|
||||||
"""A client listed by the WM and also found in the tree is raised once."""
|
|
||||||
display = FakeDisplay()
|
|
||||||
frame = display.root.add(FakeWindow(display, 10))
|
|
||||||
frame.add(FakeWindow(display, 11, ("gcr-prompter", "x")))
|
|
||||||
display.root.client_list = [11, 11]
|
|
||||||
found = scan(display)
|
|
||||||
assert [p.window_id for p in found] == [11]
|
|
||||||
|
|
||||||
def test_net_client_list_alone_finds_a_deeper_window(self) -> None:
|
|
||||||
"""A client the tree walk would not reach is still found via the WM list."""
|
|
||||||
display = FakeDisplay()
|
|
||||||
outer = display.root.add(FakeWindow(display, 10))
|
|
||||||
mid = outer.add(FakeWindow(display, 11))
|
|
||||||
deep = mid.add(FakeWindow(display, 12, ("lxpolkit", "Lxpolkit")))
|
|
||||||
display.root.client_list = [12]
|
|
||||||
found = scan(display)
|
|
||||||
assert [(p.window_id, p.toplevel_id) for p in found] == [(12, 10)]
|
|
||||||
assert outer.raised
|
|
||||||
assert not deep.raised
|
|
||||||
|
|
||||||
def test_unmapped_prompt_is_ignored(self) -> None:
|
|
||||||
"""A prompt on another workspace is not on screen: leave the lock alone."""
|
|
||||||
display = FakeDisplay()
|
|
||||||
display.root.add(FakeWindow(display, 10, ("gcr-prompter", "x"), viewable=False))
|
|
||||||
assert scan(display) == ()
|
|
||||||
|
|
||||||
def test_other_windows_are_ignored(self) -> None:
|
|
||||||
"""No class, or a class off the allowlist, is not a prompt."""
|
|
||||||
display = FakeDisplay()
|
|
||||||
display.root.add(FakeWindow(display, 10, ("steam_game", "Steam")))
|
|
||||||
display.root.add(FakeWindow(display, 11, None))
|
|
||||||
found = scan(display)
|
|
||||||
assert found == ()
|
|
||||||
assert not display.windows[10].raised
|
|
||||||
assert display.closed
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Failure handling of :class:`XPromptProbe`: every error fails closed.
|
|
||||||
|
|
||||||
Split from ``test_prompts_x.py`` (250-line cap), whose in-memory window tree
|
|
||||||
and python-xlib stand-ins it reuses.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from gatelock._prompts_x import XPromptProbe
|
|
||||||
from gatelock.tests.test_prompts_x import (
|
|
||||||
ClosedError,
|
|
||||||
DisplayError,
|
|
||||||
FakeDisplay,
|
|
||||||
FakeWindow,
|
|
||||||
NoAuthError,
|
|
||||||
XError,
|
|
||||||
modules,
|
|
||||||
probe_over,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFailures:
|
|
||||||
"""Anything going wrong fails closed: no prompt found, lock stays on top."""
|
|
||||||
|
|
||||||
def test_missing_xlib_is_reported_once(
|
|
||||||
self, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""No python-xlib: nothing to do, said once."""
|
|
||||||
probe = XPromptProbe()
|
|
||||||
with (
|
|
||||||
patch("gatelock._prompts_x.load_xlib", return_value=None),
|
|
||||||
caplog.at_level(logging.WARNING),
|
|
||||||
):
|
|
||||||
assert probe.raise_prompts() == ()
|
|
||||||
assert probe.raise_prompts() == ()
|
|
||||||
assert caplog.text.count("python-xlib") == 1
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"error",
|
|
||||||
[OSError("gone"), DisplayError("no display"), ClosedError(), NoAuthError()],
|
|
||||||
)
|
|
||||||
def test_no_connection(
|
|
||||||
self, error: Exception, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""An X connection that will not open is a warning, not a crash."""
|
|
||||||
|
|
||||||
def refuse() -> FakeDisplay:
|
|
||||||
raise error
|
|
||||||
|
|
||||||
probe = XPromptProbe()
|
|
||||||
with (
|
|
||||||
patch("gatelock._prompts_x.load_xlib", return_value=modules(refuse)),
|
|
||||||
caplog.at_level(logging.WARNING),
|
|
||||||
):
|
|
||||||
assert probe.raise_prompts() == ()
|
|
||||||
assert "X connection" in caplog.text
|
|
||||||
|
|
||||||
def test_window_vanishing_mid_scan(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
||||||
"""BadWindow from a window that went away aborts this scan, once-warned."""
|
|
||||||
display = FakeDisplay()
|
|
||||||
display.root.add(FakeWindow(display, 10, ("gcr-prompter", "x"))).broken = True
|
|
||||||
with probe_over(display) as probe, caplog.at_level(logging.WARNING):
|
|
||||||
assert probe.raise_prompts() == ()
|
|
||||||
assert probe.raise_prompts() == ()
|
|
||||||
assert caplog.text.count("BadWindow") == 1
|
|
||||||
assert display.closed
|
|
||||||
|
|
||||||
def test_a_good_scan_rearms_the_warning(
|
|
||||||
self, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""After a success the same failure is news again."""
|
|
||||||
display = FakeDisplay()
|
|
||||||
window = display.root.add(FakeWindow(display, 10, ("gcr-prompter", "x")))
|
|
||||||
with probe_over(display) as probe, caplog.at_level(logging.WARNING):
|
|
||||||
window.broken = True
|
|
||||||
probe.raise_prompts()
|
|
||||||
window.broken = False
|
|
||||||
assert probe.raise_prompts()
|
|
||||||
window.broken = True
|
|
||||||
probe.raise_prompts()
|
|
||||||
assert caplog.text.count("BadWindow") == 2
|
|
||||||
|
|
||||||
def test_raise_refused_is_not_a_yield(
|
|
||||||
self, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""If the X server rejects the restack, claim nothing."""
|
|
||||||
display = FakeDisplay()
|
|
||||||
prompt = display.root.add(FakeWindow(display, 10, ("gcr-prompter", "x")))
|
|
||||||
prompt.configure_error = XError("BadMatch")
|
|
||||||
with probe_over(display) as probe, caplog.at_level(logging.WARNING):
|
|
||||||
assert probe.raise_prompts() == ()
|
|
||||||
assert "BadMatch" in caplog.text
|
|
||||||
@ -64,17 +64,3 @@ class TestStaticInvariant:
|
|||||||
assert "self._sink.put" in loop_body
|
assert "self._sink.put" in loop_body
|
||||||
for forbidden in ("geometry(", "deiconify(", "lift(", "grab_"):
|
for forbidden in ("geometry(", "deiconify(", "lift(", "grab_"):
|
||||||
assert forbidden not in loop_body
|
assert forbidden not in loop_body
|
||||||
|
|
||||||
def test_stepback_is_the_only_release_path(self) -> None:
|
|
||||||
"""Only the step-back module may release the grab.
|
|
||||||
|
|
||||||
It triggers on a strictly stronger, live, at-least-as-firm claim and
|
|
||||||
nowhere else; any other module growing a release call is a bypass.
|
|
||||||
"""
|
|
||||||
callers = {
|
|
||||||
path.name
|
|
||||||
for path in SOURCE_ROOT.glob("*.py")
|
|
||||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8")))
|
|
||||||
if isinstance(node, ast.Attribute) and node.attr == "grab_release"
|
|
||||||
}
|
|
||||||
assert callers == {"_stepback.py"}
|
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""An output that comes up while the recovery loop is stopped is covered on restart.
|
|
||||||
|
|
||||||
leetcode-guard's study mode stops the recovery loop and the change detector on
|
|
||||||
purpose. On 2026-11-11 the 4K output (DP-2) only got a mode 3m20s after the
|
|
||||||
lock armed -- long after study mode had stopped both -- so the backdrop stayed
|
|
||||||
6400x1440 and no surface was built for it. That is the stopped loop doing what
|
|
||||||
it was told, not a detection bug: this pins that the FIRST tick after
|
|
||||||
``start()`` sizes the backdrop to the new screen and builds the missing surface.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from gatelock._config import LockConfig
|
|
||||||
from gatelock._outputs import OutputScan
|
|
||||||
from gatelock.tests.conftest import BOTH, HDMI, build_loop
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
HDMI_ONLY = OutputScan((HDMI,), "randr", ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_output_that_appeared_while_stopped_is_covered_on_restart(
|
|
||||||
mock_root: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""Stop, grow the screen, start: one tick later the new output is locked."""
|
|
||||||
recovery, enumerator, surfaces = build_loop(mock_root, LockConfig(mode="hard"))
|
|
||||||
enumerator.scan.return_value = HDMI_ONLY
|
|
||||||
mock_root.winfo_screenheight.return_value = 1440
|
|
||||||
recovery.tick()
|
|
||||||
assert surfaces.names() == {"HDMI-0"}
|
|
||||||
|
|
||||||
recovery.stop()
|
|
||||||
enumerator.scan.return_value = BOTH # DP-0 is the 4K output here
|
|
||||||
mock_root.winfo_screenwidth.return_value = 6400
|
|
||||||
mock_root.winfo_screenheight.return_value = 2160
|
|
||||||
recovery.tick() # study mode off: the first tick after start() is this one
|
|
||||||
|
|
||||||
assert surfaces.names() == {"DP-0", "HDMI-0"}
|
|
||||||
mock_root.geometry.assert_called_with("6400x2160+0+0")
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Tests for how a recovery tick treats a blocked grab and a waiting prompt.
|
|
||||||
|
|
||||||
The tick must (a) say WHY the re-grab failed, (b) feed the yield policy,
|
|
||||||
(c) stop lifting the lock over a prompt it is yielding to, and (d) re-raise
|
|
||||||
the prompt last, so nothing the tick lifted covers it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import replace
|
|
||||||
import logging
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from gatelock._config import LockConfig
|
|
||||||
from gatelock._prompts import PromptYield
|
|
||||||
from gatelock.tests.conftest import BOTH, build_loop
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from gatelock._recovery import RecoveryLoop
|
|
||||||
from gatelock._surfaces import SurfaceSet
|
|
||||||
|
|
||||||
REASON = "grab failed: another application has grab"
|
|
||||||
|
|
||||||
|
|
||||||
def _with_yield(recovery: RecoveryLoop, policy: MagicMock) -> MagicMock:
|
|
||||||
"""Swap the loop's prompt policy for a mock."""
|
|
||||||
recovery._parts = replace(recovery._parts, prompt_yield=policy)
|
|
||||||
return policy
|
|
||||||
|
|
||||||
|
|
||||||
def _policy(*, yielding: bool = False, was_yielding: bool = False) -> MagicMock:
|
|
||||||
policy = MagicMock(spec=PromptYield)
|
|
||||||
policy.yielding = yielding
|
|
||||||
policy.grab_held.return_value = was_yielding
|
|
||||||
return policy
|
|
||||||
|
|
||||||
|
|
||||||
def _tick_lifts(surfaces: SurfaceSet) -> int:
|
|
||||||
"""How many lift() calls the surfaces have seen."""
|
|
||||||
return sum(s.window.lift.call_count for s in surfaces._surfaces.values())
|
|
||||||
|
|
||||||
|
|
||||||
class TestBlockedGrab:
|
|
||||||
"""A failed re-grab is explained and handed to the yield policy."""
|
|
||||||
|
|
||||||
def test_failure_text_is_logged_once(
|
|
||||||
self,
|
|
||||||
loop: tuple[RecoveryLoop, MagicMock, SurfaceSet],
|
|
||||||
mock_root: MagicMock,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Tk's own message names the cause; the same cause is not repeated."""
|
|
||||||
recovery, enumerator, _ = loop
|
|
||||||
enumerator.scan.return_value = BOTH
|
|
||||||
mock_root.grab_current.return_value = None
|
|
||||||
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
recovery.tick()
|
|
||||||
recovery.tick()
|
|
||||||
assert caplog.text.count(REASON) == 1
|
|
||||||
|
|
||||||
def test_a_different_cause_is_logged_again(
|
|
||||||
self,
|
|
||||||
loop: tuple[RecoveryLoop, MagicMock, SurfaceSet],
|
|
||||||
mock_root: MagicMock,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
""" "Window not viewable" after "another application has grab" is news."""
|
|
||||||
recovery, enumerator, _ = loop
|
|
||||||
enumerator.scan.return_value = BOTH
|
|
||||||
mock_root.grab_current.return_value = None
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
|
|
||||||
recovery.tick()
|
|
||||||
mock_root.grab_set_global.side_effect = tk.TclError("not viewable")
|
|
||||||
recovery.tick()
|
|
||||||
assert "not viewable" in caplog.text
|
|
||||||
|
|
||||||
def test_failure_feeds_the_policy(
|
|
||||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""The reason reaches the policy that times the block."""
|
|
||||||
recovery, enumerator, _ = loop
|
|
||||||
policy = _with_yield(recovery, _policy())
|
|
||||||
enumerator.scan.return_value = BOTH
|
|
||||||
mock_root.grab_current.return_value = None
|
|
||||||
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
|
|
||||||
recovery.tick()
|
|
||||||
policy.grab_blocked.assert_called_once_with(REASON)
|
|
||||||
|
|
||||||
|
|
||||||
class TestYielding:
|
|
||||||
"""While a prompt is being yielded to, the lock stops covering it."""
|
|
||||||
|
|
||||||
def test_no_lift_while_yielding(
|
|
||||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""enforce() still places and maps surfaces, but does not raise them."""
|
|
||||||
recovery, enumerator, surfaces = loop
|
|
||||||
enumerator.scan.return_value = BOTH
|
|
||||||
mock_root.grab_current.return_value = None
|
|
||||||
mock_root.grab_set_global.side_effect = tk.TclError(REASON)
|
|
||||||
recovery.tick() # creates the surfaces (a create lifts once)
|
|
||||||
_with_yield(recovery, _policy(yielding=True))
|
|
||||||
before = _tick_lifts(surfaces)
|
|
||||||
recovery.tick()
|
|
||||||
assert _tick_lifts(surfaces) == before
|
|
||||||
|
|
||||||
def test_lifts_normally_when_not_yielding(
|
|
||||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""Anything that is not a known prompt keeps today's behaviour."""
|
|
||||||
recovery, enumerator, surfaces = loop
|
|
||||||
enumerator.scan.return_value = BOTH
|
|
||||||
recovery.tick()
|
|
||||||
before = _tick_lifts(surfaces)
|
|
||||||
_with_yield(recovery, _policy(yielding=False))
|
|
||||||
recovery.tick()
|
|
||||||
assert _tick_lifts(surfaces) > before
|
|
||||||
|
|
||||||
def test_settles_last(
|
|
||||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""The prompt is re-raised after everything else the tick did."""
|
|
||||||
recovery, enumerator, _ = loop
|
|
||||||
enumerator.scan.return_value = BOTH
|
|
||||||
policy = _with_yield(recovery, _policy(yielding=True))
|
|
||||||
seen: list[str] = []
|
|
||||||
|
|
||||||
def grab_current() -> MagicMock:
|
|
||||||
seen.append("grab")
|
|
||||||
return mock_root
|
|
||||||
|
|
||||||
def settle() -> None:
|
|
||||||
seen.append("settle")
|
|
||||||
|
|
||||||
policy.settle.side_effect = settle
|
|
||||||
mock_root.grab_current.side_effect = grab_current
|
|
||||||
recovery.tick()
|
|
||||||
assert seen == ["grab", "settle"]
|
|
||||||
|
|
||||||
def test_grab_retaken_lifts_the_lock_again(
|
|
||||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""The prompt closed, the grab came back: the lock covers everything again."""
|
|
||||||
recovery, enumerator, surfaces = loop
|
|
||||||
enumerator.scan.return_value = BOTH
|
|
||||||
recovery.tick()
|
|
||||||
_with_yield(recovery, _policy(yielding=True, was_yielding=True))
|
|
||||||
before = _tick_lifts(surfaces)
|
|
||||||
mock_root.grab_current.return_value = None # lost, then re-taken
|
|
||||||
recovery.tick()
|
|
||||||
assert _tick_lifts(surfaces) > before
|
|
||||||
mock_root.lift.assert_called()
|
|
||||||
|
|
||||||
def test_grab_held_is_reported_when_nothing_was_lost(
|
|
||||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""Holding the grab already (the arming loop took it) also ends the yield."""
|
|
||||||
recovery, enumerator, _ = loop
|
|
||||||
policy = _with_yield(recovery, _policy())
|
|
||||||
enumerator.scan.return_value = BOTH
|
|
||||||
mock_root.grab_current.return_value = mock_root
|
|
||||||
recovery.tick()
|
|
||||||
policy.grab_held.assert_called_once_with()
|
|
||||||
|
|
||||||
|
|
||||||
def test_surface_set_enforce_without_lift(mock_root: MagicMock) -> None:
|
|
||||||
"""``enforce(lift=False)`` corrects drift but never restacks."""
|
|
||||||
recovery, enumerator, surfaces = build_loop(mock_root, LockConfig(mode="hard"))
|
|
||||||
enumerator.scan.return_value = BOTH
|
|
||||||
recovery.tick()
|
|
||||||
window = surfaces._surfaces["DP-0"].window
|
|
||||||
window.withdraw() # unmapped: must be remapped even when not lifting
|
|
||||||
window.lift.reset_mock()
|
|
||||||
assert surfaces.enforce(lift=False) == ("DP-0",)
|
|
||||||
window.deiconify.assert_called()
|
|
||||||
window.lift.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_holds_grab_is_the_loops_own_question(
|
|
||||||
loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""The public probe embedders and verification harnesses use."""
|
|
||||||
recovery, _, _ = loop
|
|
||||||
mock_root.grab_current.return_value = mock_root
|
|
||||||
assert recovery.holds_grab() is True
|
|
||||||
mock_root.grab_current.return_value = None
|
|
||||||
assert recovery.holds_grab() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_raise_all_leaves_the_root_alone_without_a_backdrop(
|
|
||||||
mock_root: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""A soft, grab-less lock has no backdrop root to lift."""
|
|
||||||
config = LockConfig(mode="soft")
|
|
||||||
_, _, surfaces = build_loop(mock_root, config)
|
|
||||||
surfaces.raise_all()
|
|
||||||
mock_root.lift.assert_not_called()
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Tests for a weaker armed lock stepping back for a stronger one.
|
|
||||||
|
|
||||||
The trigger is the whole safety argument, so most tests are about what must NOT
|
|
||||||
trigger it: equal rank, lower rank, a dead claim, a stronger-ranked app that
|
|
||||||
locks more weakly, a loop the app stopped on purpose. See ``_stepback.py``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from gatelock._arbiter import RANK_DIET_GUARD, RANK_SCREEN_LOCKER
|
|
||||||
from gatelock._config import LockConfig
|
|
||||||
from gatelock._window import LockWindow
|
|
||||||
from gatelock.tests.conftest import make_arbiter
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from gatelock._arbiter import Arbiter
|
|
||||||
|
|
||||||
RANK_MID = 150
|
|
||||||
|
|
||||||
|
|
||||||
def _tick(window: LockWindow) -> None:
|
|
||||||
window._stepback._tick()
|
|
||||||
|
|
||||||
|
|
||||||
class TestTrigger:
|
|
||||||
"""Only a strictly stronger, live, at-least-as-firm claim makes it step back."""
|
|
||||||
|
|
||||||
def test_stronger_live_claim_suspends(
|
|
||||||
self,
|
|
||||||
armed: tuple[LockWindow, Arbiter],
|
|
||||||
mock_root: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Hide, release the grab, drop the holder; the claim stays published."""
|
|
||||||
window, arbiter = armed
|
|
||||||
strong = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
|
|
||||||
windows = [s.window for s in window.surfaces._surfaces.values()]
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
_tick(window)
|
|
||||||
assert window._stepback.suspended
|
|
||||||
assert "steps back" in caplog.text
|
|
||||||
assert "screen_locker" in caplog.text
|
|
||||||
mock_root.grab_release.assert_called_once_with()
|
|
||||||
mock_root.withdraw.assert_called_once_with()
|
|
||||||
assert windows
|
|
||||||
assert all(w.withdraw.called for w in windows)
|
|
||||||
assert not window.recovery.running
|
|
||||||
assert not arbiter.holds_screen
|
|
||||||
assert arbiter.claim.grab_held is False
|
|
||||||
assert arbiter.claim.instance_id in {
|
|
||||||
c.instance_id for c in strong.live_claims()
|
|
||||||
}
|
|
||||||
strong.release()
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("app", "rank"),
|
|
||||||
[("twin", RANK_MID), ("diet_guard", RANK_DIET_GUARD)],
|
|
||||||
)
|
|
||||||
def test_equal_or_lower_rank_never_does(
|
|
||||||
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path, app: str, rank: int
|
|
||||||
) -> None:
|
|
||||||
"""Rank must be strictly higher."""
|
|
||||||
window, _ = armed
|
|
||||||
other = make_arbiter(tmp_path, app, rank)
|
|
||||||
_tick(window)
|
|
||||||
assert not window._stepback.suspended
|
|
||||||
other.release()
|
|
||||||
|
|
||||||
def test_dead_claim_never_does(
|
|
||||||
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""A stale claim file (its owner died, flock gone) is no reason."""
|
|
||||||
window, _ = armed
|
|
||||||
ghost = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
|
|
||||||
assert ghost._claim_handle is not None
|
|
||||||
ghost._claim_handle.close() # the kernel's view of a dead owner
|
|
||||||
_tick(window)
|
|
||||||
assert not window._stepback.suspended
|
|
||||||
|
|
||||||
def test_a_weaker_locking_app_never_does(
|
|
||||||
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Rank must never weaken the lock: no grab, no VT -> no step back."""
|
|
||||||
window, _ = armed
|
|
||||||
soft = make_arbiter(
|
|
||||||
tmp_path, "alarm", RANK_SCREEN_LOCKER, grab="none", vt=False
|
|
||||||
)
|
|
||||||
_tick(window)
|
|
||||||
assert not window._stepback.suspended
|
|
||||||
soft.release()
|
|
||||||
|
|
||||||
def test_a_loop_the_app_stopped_is_left_alone(
|
|
||||||
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Study mode stops recovery on purpose; that is not ours to suspend."""
|
|
||||||
window, _ = armed
|
|
||||||
window.recovery.stop()
|
|
||||||
strong = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
|
|
||||||
_tick(window)
|
|
||||||
assert not window._stepback.suspended
|
|
||||||
strong.release()
|
|
||||||
|
|
||||||
def test_without_an_arbiter_it_is_inert(self, mock_root: MagicMock) -> None:
|
|
||||||
"""No arbitration, nothing to step back for."""
|
|
||||||
window = LockWindow(mock_root, LockConfig(mode="hard"), MagicMock())
|
|
||||||
window.setup()
|
|
||||||
window.grab_input()
|
|
||||||
mock_root.after.reset_mock()
|
|
||||||
window._stepback.start()
|
|
||||||
assert window._stepback._job is None
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Tests for the small pieces step-back is built from.
|
|
||||||
|
|
||||||
The startup grace in ``wait_for_turn``, ``Arbiter.drop_holder`` and
|
|
||||||
``SurfaceSet.set_visible``. The controller itself is in ``test_stepback.py``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock, call, patch
|
|
||||||
|
|
||||||
from gatelock import RANK_DIET_GUARD, RANK_SCREEN_LOCKER, Arbiter
|
|
||||||
from gatelock._config import LockConfig
|
|
||||||
from gatelock._queue import STARTUP_GRACE_SECONDS, Timebase, wait_for_turn
|
|
||||||
from gatelock.tests.conftest import DP0, build_loop, make_arbiter
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestStartupGrace:
|
|
||||||
"""Simultaneous starters must see each other before anyone arms."""
|
|
||||||
|
|
||||||
def test_the_real_constant_is_set(self) -> None:
|
|
||||||
"""Not zero in production (the test fixture zeroes the patched copy)."""
|
|
||||||
assert STARTUP_GRACE_SECONDS == 1.5
|
|
||||||
|
|
||||||
def test_waits_before_the_first_check_and_sees_a_late_publisher(
|
|
||||||
self, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""A stronger claim published DURING the grace is queued behind."""
|
|
||||||
weak = make_arbiter(tmp_path, "diet_guard", RANK_DIET_GUARD)
|
|
||||||
strong: list[Arbiter] = []
|
|
||||||
slept: list[float] = []
|
|
||||||
|
|
||||||
def sleep(seconds: float) -> None:
|
|
||||||
slept.append(seconds)
|
|
||||||
if not strong:
|
|
||||||
strong.append(
|
|
||||||
make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
|
|
||||||
)
|
|
||||||
elif len(slept) > 2:
|
|
||||||
strong[0].release()
|
|
||||||
|
|
||||||
clock = [0.0]
|
|
||||||
base = Timebase(sleep=sleep, now=lambda: clock[0])
|
|
||||||
with patch("gatelock._queue.STARTUP_GRACE_SECONDS", 1.5):
|
|
||||||
result = wait_for_turn(weak, timebase=base, poll=0.5)
|
|
||||||
assert slept[0] == 1.5
|
|
||||||
assert result.queued
|
|
||||||
assert result.blocked_by == ("screen_locker",)
|
|
||||||
|
|
||||||
def test_zero_grace_does_not_sleep(self, tmp_path: Path) -> None:
|
|
||||||
"""The fixture's zero skips the sleep entirely."""
|
|
||||||
slept: list[float] = []
|
|
||||||
base = Timebase(sleep=slept.append, now=lambda: 0.0)
|
|
||||||
wait_for_turn(
|
|
||||||
make_arbiter(tmp_path, "diet_guard", RANK_DIET_GUARD), timebase=base
|
|
||||||
)
|
|
||||||
assert slept == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestDropHolder:
|
|
||||||
"""Dropping the holder lock leaves the claim, and is idempotent."""
|
|
||||||
|
|
||||||
def test_drop_keeps_the_claim_and_frees_the_screen(self, tmp_path: Path) -> None:
|
|
||||||
"""Another app can take the holder lock; ours is still queued-behind."""
|
|
||||||
ours = make_arbiter(tmp_path, "diet_guard", RANK_DIET_GUARD)
|
|
||||||
other = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
|
|
||||||
assert ours.acquire_holder()
|
|
||||||
assert not other.acquire_holder()
|
|
||||||
ours.drop_holder()
|
|
||||||
ours.drop_holder()
|
|
||||||
assert not ours.holds_screen
|
|
||||||
assert ours.claim.instance_id in {c.instance_id for c in other.live_claims()}
|
|
||||||
assert other.acquire_holder()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetVisible:
|
|
||||||
"""Hide, and show in creation order."""
|
|
||||||
|
|
||||||
def test_hide_then_show(self, mock_root: MagicMock) -> None:
|
|
||||||
"""Withdrawn windows come back override-redirect BEFORE they map."""
|
|
||||||
_, enumerator, surfaces = build_loop(mock_root, LockConfig(mode="hard"))
|
|
||||||
enumerator.scan.return_value = MagicMock(live=(DP0,))
|
|
||||||
surfaces.apply(enumerator.scan.return_value)
|
|
||||||
window = surfaces._surfaces["DP-0"].window
|
|
||||||
window.reset_mock()
|
|
||||||
surfaces.set_visible(visible=False)
|
|
||||||
window.withdraw.assert_called_once_with()
|
|
||||||
window.reset_mock()
|
|
||||||
surfaces.set_visible(visible=True)
|
|
||||||
names = [c[0] for c in window.method_calls]
|
|
||||||
assert names.index("overrideredirect") < names.index("deiconify")
|
|
||||||
assert names[-1] == "lift"
|
|
||||||
assert call.overrideredirect(boolean=True) in window.method_calls
|
|
||||||
|
|
||||||
def test_soft_mode_is_not_forced_override_redirect(
|
|
||||||
self, mock_root: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""A managed lock must stay managed."""
|
|
||||||
_, enumerator, surfaces = build_loop(mock_root, LockConfig(mode="soft"))
|
|
||||||
enumerator.scan.return_value = MagicMock(live=(DP0,))
|
|
||||||
surfaces.apply(enumerator.scan.return_value)
|
|
||||||
window = surfaces._surfaces["DP-0"].window
|
|
||||||
window.reset_mock()
|
|
||||||
surfaces.set_visible(visible=True)
|
|
||||||
window.overrideredirect.assert_not_called()
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Re-arming after a step-back, and the failure paths around it.
|
|
||||||
|
|
||||||
Split from ``test_stepback.py`` (250-line cap); the ``armed`` fixture is in
|
|
||||||
``conftest.py``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from gatelock import _stepback
|
|
||||||
from gatelock._arbiter import RANK_SCREEN_LOCKER
|
|
||||||
from gatelock._config import GrabPolicy, LockConfig
|
|
||||||
from gatelock._window import LockWindow
|
|
||||||
from gatelock.tests.conftest import make_arbiter
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from gatelock._arbiter import Arbiter
|
|
||||||
|
|
||||||
RANK_MID = 150
|
|
||||||
|
|
||||||
|
|
||||||
def _tick(window: LockWindow) -> None:
|
|
||||||
window._stepback._tick()
|
|
||||||
|
|
||||||
|
|
||||||
class TestRearm:
|
|
||||||
"""It comes back, fully, once nothing qualifying is live."""
|
|
||||||
|
|
||||||
def _suspended(
|
|
||||||
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path
|
|
||||||
) -> tuple[LockWindow, Arbiter, Arbiter]:
|
|
||||||
window, arbiter = armed
|
|
||||||
strong = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
|
|
||||||
_tick(window)
|
|
||||||
assert window._stepback.suspended
|
|
||||||
return window, arbiter, strong
|
|
||||||
|
|
||||||
def test_stays_hidden_while_the_stronger_is_live(
|
|
||||||
self, armed: tuple[LockWindow, Arbiter], tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Windowless until the stronger one is gone."""
|
|
||||||
window, _, strong = self._suspended(armed, tmp_path)
|
|
||||||
_tick(window)
|
|
||||||
assert window._stepback.suspended
|
|
||||||
strong.release()
|
|
||||||
|
|
||||||
def test_rearms_fully_when_it_is_gone(
|
|
||||||
self,
|
|
||||||
armed: tuple[LockWindow, Arbiter],
|
|
||||||
mock_root: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""VT, backdrop, surfaces, grab, holder and the loops all come back."""
|
|
||||||
window, arbiter, strong = self._suspended(armed, tmp_path)
|
|
||||||
strong.release()
|
|
||||||
mock_root.grab_set_global.reset_mock()
|
|
||||||
with (
|
|
||||||
patch("gatelock._stepback.disable_vt_switching") as vt,
|
|
||||||
caplog.at_level(logging.WARNING),
|
|
||||||
):
|
|
||||||
_tick(window)
|
|
||||||
assert not window._stepback.suspended
|
|
||||||
assert "re-arms" in caplog.text
|
|
||||||
vt.assert_called_once_with()
|
|
||||||
mock_root.deiconify.assert_called_once_with()
|
|
||||||
mock_root.grab_set_global.assert_called_once_with()
|
|
||||||
assert arbiter.holds_screen
|
|
||||||
assert window.recovery.running
|
|
||||||
for surface in window.surfaces._surfaces.values():
|
|
||||||
surface.window.deiconify.assert_called()
|
|
||||||
|
|
||||||
def test_without_vt_policy_it_leaves_vt_alone(
|
|
||||||
self, mock_root: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""A lock that never disabled VT does not start now."""
|
|
||||||
arbiter = make_arbiter(tmp_path, "leetcode_guard", RANK_MID, vt=False)
|
|
||||||
grab = GrabPolicy(disable_vt=False)
|
|
||||||
config = LockConfig(mode="hard", grab=grab, rank=RANK_MID)
|
|
||||||
window = LockWindow(mock_root, config, MagicMock(), arbiter=arbiter)
|
|
||||||
window.setup()
|
|
||||||
window.grab_input()
|
|
||||||
strong = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER, vt=False)
|
|
||||||
_tick(window)
|
|
||||||
strong.release()
|
|
||||||
with patch("gatelock._stepback.disable_vt_switching") as vt:
|
|
||||||
_tick(window)
|
|
||||||
assert not window._stepback.suspended
|
|
||||||
vt.assert_not_called()
|
|
||||||
window.close()
|
|
||||||
|
|
||||||
def test_gives_up_waiting_after_the_deadline(
|
|
||||||
self,
|
|
||||||
armed: tuple[LockWindow, Arbiter],
|
|
||||||
tmp_path: Path,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""A stuck stronger claim cannot keep the machine unlocked forever."""
|
|
||||||
window, _, strong = self._suspended(armed, tmp_path)
|
|
||||||
step = window._stepback
|
|
||||||
step._since = step._clock() - _stepback.QUEUE_DEADLINE_SECONDS - 1
|
|
||||||
with caplog.at_level(logging.ERROR):
|
|
||||||
_tick(window)
|
|
||||||
assert not step.suspended
|
|
||||||
assert "gave up" in caplog.text
|
|
||||||
strong.release()
|
|
||||||
|
|
||||||
|
|
||||||
class TestRobustness:
|
|
||||||
"""A failing step must not leave the lock half-down."""
|
|
||||||
|
|
||||||
def test_tk_errors_are_logged_and_the_rest_still_happens(
|
|
||||||
self,
|
|
||||||
armed: tuple[LockWindow, Arbiter],
|
|
||||||
mock_root: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""A failed grab release is logged; hiding and the claim still follow."""
|
|
||||||
window, arbiter = armed
|
|
||||||
mock_root.grab_release.side_effect = tk.TclError("no grab")
|
|
||||||
strong = make_arbiter(tmp_path, "screen_locker", RANK_SCREEN_LOCKER)
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
_tick(window)
|
|
||||||
assert "grab release failed" in caplog.text
|
|
||||||
assert window._stepback.suspended
|
|
||||||
assert not arbiter.holds_screen
|
|
||||||
strong.release()
|
|
||||||
|
|
||||||
def test_a_raise_does_not_end_the_checking(
|
|
||||||
self, armed: tuple[LockWindow, Arbiter], caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""The reschedule is in ``finally``; one bad check is just logged."""
|
|
||||||
window, _ = armed
|
|
||||||
with (
|
|
||||||
patch("gatelock._stepback.outranking", side_effect=ValueError("boom")),
|
|
||||||
caplog.at_level(logging.ERROR),
|
|
||||||
):
|
|
||||||
_tick(window)
|
|
||||||
assert "step-back check raised" in caplog.text
|
|
||||||
assert window._stepback._job is not None
|
|
||||||
|
|
||||||
def test_close_stops_the_checking(self, armed: tuple[LockWindow, Arbiter]) -> None:
|
|
||||||
"""No timer outlives the window."""
|
|
||||||
window, _ = armed
|
|
||||||
window.close()
|
|
||||||
assert window._stepback._job is None
|
|
||||||
|
|
||||||
def test_stop_survives_a_dead_root(
|
|
||||||
self, armed: tuple[LockWindow, Arbiter], mock_root: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""Cancelling on a destroyed root is logged, not raised."""
|
|
||||||
window, _ = armed
|
|
||||||
mock_root.after_cancel.side_effect = tk.TclError("gone")
|
|
||||||
window._stepback.stop()
|
|
||||||
assert window._stepback._job is None
|
|
||||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "gatelock"
|
name = "gatelock"
|
||||||
version = "0.13.0"
|
version = "0.12.0"
|
||||||
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
|
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [] # pure stdlib — tkinter is bundled with Python
|
dependencies = [] # pure stdlib — tkinter is bundled with Python
|
||||||
|
|||||||
@ -1,101 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
# pylint: disable=missing-function-docstring
|
|
||||||
"""One stand-in locker for ``verify_all_lockers.py``: the real startup sequence.
|
|
||||||
|
|
||||||
Mirrors what leetcode_guard / screen_locker / diet_guard / book_guard /
|
|
||||||
focus_guard do: ``Arbiter`` -> ``publish`` -> ``wait_for_turn`` (windowless) ->
|
|
||||||
``acquire_holder`` -> ``LockWindow`` -> ``setup`` -> ``grab_input`` -> mainloop,
|
|
||||||
with the real app name and rank. Its only UI is one button; clicking it ends
|
|
||||||
the lock, like a solve does. Everything is reported on stdout, one line each:
|
|
||||||
``ARMED``, ``BTN x y`` (button centre, root coordinates), ``GRAB 0|1``,
|
|
||||||
``CLICKED``.
|
|
||||||
|
|
||||||
Usage: ``locker_child.py <app> <rank> <queue_poll_s>``
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import tkinter as tk
|
|
||||||
|
|
||||||
from gatelock import (
|
|
||||||
Arbiter,
|
|
||||||
GateRoot,
|
|
||||||
LockConfig,
|
|
||||||
LockWindow,
|
|
||||||
wait_for_turn,
|
|
||||||
)
|
|
||||||
|
|
||||||
REPORT_MS = 250
|
|
||||||
|
|
||||||
|
|
||||||
class _Child:
|
|
||||||
"""The hooks object, as each real locker is."""
|
|
||||||
|
|
||||||
def __init__(self, app: str, rank: int, poll: float) -> None:
|
|
||||||
self.app = app
|
|
||||||
self.button: tk.Button | None = None
|
|
||||||
config = LockConfig(mode="hard", app_name=app, rank=rank)
|
|
||||||
arbiter = Arbiter(
|
|
||||||
app,
|
|
||||||
rank,
|
|
||||||
grab=config.resolved_grab(),
|
|
||||||
disable_vt=False,
|
|
||||||
)
|
|
||||||
arbiter.publish()
|
|
||||||
wait_for_turn(arbiter, poll=poll)
|
|
||||||
arbiter.acquire_holder()
|
|
||||||
self.root = GateRoot(className=app)
|
|
||||||
self.lock = LockWindow(self.root, config, self, arbiter=arbiter)
|
|
||||||
self.lock.setup()
|
|
||||||
self.say("ARMED")
|
|
||||||
self.lock.grab_input()
|
|
||||||
self.root.after(REPORT_MS, self.report)
|
|
||||||
|
|
||||||
def say(self, text: str) -> None:
|
|
||||||
sys.stdout.write(f"{text}\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
def report(self) -> None:
|
|
||||||
self.say(f"GRAB {int(self.lock.recovery.holds_grab())}")
|
|
||||||
button = self.button
|
|
||||||
if button is not None and button.winfo_ismapped():
|
|
||||||
x = button.winfo_rootx() + button.winfo_width() // 2
|
|
||||||
y = button.winfo_rooty() + button.winfo_height() // 2
|
|
||||||
self.say(f"BTN {x} {y}")
|
|
||||||
self.root.after(REPORT_MS, self.report)
|
|
||||||
|
|
||||||
def clicked(self) -> None:
|
|
||||||
self.say("CLICKED")
|
|
||||||
self.lock.close()
|
|
||||||
|
|
||||||
# -- LockWindowHooks ---------------------------------------------------
|
|
||||||
def build_surface(self, parent: tk.Misc, surface: object) -> None:
|
|
||||||
del surface
|
|
||||||
self.button = tk.Button(parent, text=self.app, command=self.clicked)
|
|
||||||
self.button.place(relx=0.5, rely=0.5, width=240, height=90, anchor="center")
|
|
||||||
|
|
||||||
def teardown_surface(self, surface: object) -> None:
|
|
||||||
del surface
|
|
||||||
self.button = None
|
|
||||||
|
|
||||||
def on_focus_ready(self, surface: object) -> None:
|
|
||||||
del surface
|
|
||||||
|
|
||||||
def on_callback_error(self) -> None:
|
|
||||||
self.say("ERROR callback")
|
|
||||||
|
|
||||||
def on_close(self) -> None:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
|
||||||
"""Arm one locker and run its mainloop."""
|
|
||||||
child = _Child(argv[0], int(argv[1]), float(argv[2]))
|
|
||||||
child.lock.run()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
# pylint: disable=import-error
|
|
||||||
"""A second X client that holds the keyboard+pointer grab, like a credential prompt.
|
|
||||||
|
|
||||||
Started by ``verify_prompt_yield.py`` on a private display. Maps one window with
|
|
||||||
the given ``WM_CLASS`` (optionally inside an override-redirect frame, the way a
|
|
||||||
window manager reparents clients), takes ``XGrabKeyboard`` + ``XGrabPointer``,
|
|
||||||
prints ``READY``, then waits on stdin: ``release`` ungrabs and unmaps (prints
|
|
||||||
``RELEASED``), EOF exits.
|
|
||||||
|
|
||||||
Usage: ``prompt_grab_helper.py <wm_class> [--framed]``
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
from Xlib import X, display
|
|
||||||
|
|
||||||
OWNER_EVENTS = True
|
|
||||||
VIEWABLE_TRIES = 50
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
|
||||||
"""Grab, announce, wait for ``release``; returns a process exit status."""
|
|
||||||
wm_class = argv[0]
|
|
||||||
xdisplay = display.Display()
|
|
||||||
root = xdisplay.screen().root
|
|
||||||
depth = xdisplay.screen().root_depth
|
|
||||||
top = window = root.create_window(
|
|
||||||
10, 10, 400, 200, 0, depth, X.InputOutput, X.CopyFromParent
|
|
||||||
)
|
|
||||||
if "--framed" in argv:
|
|
||||||
top = root.create_window(
|
|
||||||
10, 10, 400, 200, 0, depth, X.InputOutput, X.CopyFromParent,
|
|
||||||
override_redirect=1,
|
|
||||||
) # fmt: skip
|
|
||||||
window.reparent(top, 0, 0)
|
|
||||||
window.set_wm_class(wm_class, wm_class.capitalize())
|
|
||||||
window.map()
|
|
||||||
top.map()
|
|
||||||
xdisplay.sync()
|
|
||||||
# A window manager maps asynchronously; grabbing a not-yet-viewable window
|
|
||||||
# fails with GrabNotViewable.
|
|
||||||
for _ in range(VIEWABLE_TRIES):
|
|
||||||
if window.get_attributes().map_state == X.IsViewable:
|
|
||||||
break
|
|
||||||
time.sleep(0.1)
|
|
||||||
modes = (X.GrabModeAsync, X.GrabModeAsync)
|
|
||||||
keyboard = window.grab_keyboard(OWNER_EVENTS, *modes, X.CurrentTime)
|
|
||||||
pointer = window.grab_pointer(
|
|
||||||
OWNER_EVENTS, X.ButtonPressMask, *modes, X.NONE, X.NONE, X.CurrentTime
|
|
||||||
)
|
|
||||||
if keyboard or pointer:
|
|
||||||
sys.stdout.write(f"grab failed keyboard={keyboard} pointer={pointer}\n")
|
|
||||||
return 2
|
|
||||||
sys.stdout.write("READY\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
for line in sys.stdin:
|
|
||||||
if line.strip() == "release":
|
|
||||||
xdisplay.ungrab_keyboard(X.CurrentTime)
|
|
||||||
xdisplay.ungrab_pointer(X.CurrentTime)
|
|
||||||
top.unmap()
|
|
||||||
xdisplay.sync()
|
|
||||||
sys.stdout.write("RELEASED\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
@ -1,249 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
# pylint: disable=missing-function-docstring,consider-using-with,import-error
|
|
||||||
r"""All lockers at once, on a real X server: one lock at a time, each clickable.
|
|
||||||
|
|
||||||
Starts the five lockers that fire together at login (real app names and ranks,
|
|
||||||
the real ``Arbiter`` + ``wait_for_turn`` startup, one process each, a private
|
|
||||||
``GATELOCK_RUNTIME_DIR``) with a ``gcr-prompter`` grab helper started first, as
|
|
||||||
on 2026-11-11. Asserts: never two locks on screen; once the prompt lets go the
|
|
||||||
lone lock holds the grab; an XTEST click on its button fires its callback; when
|
|
||||||
it closes the next one comes up the same way. The queue poll is 0.5 s (real:
|
|
||||||
2 s) to fit the 60 s cap. Argument: launch order -- mixed, strong-first,
|
|
||||||
weak-first, or late-strong (screen_locker starts 3 s after the rest, once a
|
|
||||||
weaker lock holds the grab). Xvfb only (XTEST); refuses ``:0``::
|
|
||||||
|
|
||||||
PYTHONPATH=. xvfb-run -a -s "-screen 0 1600x1200x24" \
|
|
||||||
python3 scripts/verify_all_lockers.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import select
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING, Final
|
|
||||||
|
|
||||||
from Xlib import X, display, error
|
|
||||||
from Xlib.ext import xtest
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
HERE: Final = Path(__file__).parent
|
|
||||||
LOCKERS: Final = { # app -> rank, as in each locker's own constants
|
|
||||||
"screen_locker": 200,
|
|
||||||
"leetcode_guard": 150,
|
|
||||||
"book_guard": 125,
|
|
||||||
"focus_guard": 120,
|
|
||||||
"diet_guard": 100,
|
|
||||||
}
|
|
||||||
_RANKED: Final = tuple(sorted(LOCKERS, key=lambda a: -LOCKERS[a]))
|
|
||||||
ORDERS: Final = { # launch order: how the five processes race to publish
|
|
||||||
"mixed": tuple(_RANKED[i] for i in (4, 1, 3, 0, 2)),
|
|
||||||
"strong-first": _RANKED,
|
|
||||||
"weak-first": _RANKED[::-1],
|
|
||||||
"late-strong": tuple(sorted(set(LOCKERS) - {"screen_locker"})),
|
|
||||||
}
|
|
||||||
LATE: Final = {"late-strong": ("screen_locker", 3.0, 2.0)} # app, start s, hold s
|
|
||||||
POLL_S, STEP_S, PROMPT_HOLD_S, SHOW_S, GRAB_S, CLICK_S = 0.5, 0.1, 6.0, 12.0, 8.0, 5.0
|
|
||||||
SETTLE_S = 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def _say(message: str) -> None:
|
|
||||||
sys.stdout.write(f"{message}\n")
|
|
||||||
|
|
||||||
|
|
||||||
class _Fleet:
|
|
||||||
"""The child processes and what they have reported so far."""
|
|
||||||
|
|
||||||
def __init__(self, env: dict[str, str], launch: tuple[str, ...]) -> None:
|
|
||||||
self.env = env
|
|
||||||
self.procs: dict[str, subprocess.Popen[bytes]] = {}
|
|
||||||
self.buffers = dict.fromkeys(LOCKERS, b"")
|
|
||||||
self.grab = dict.fromkeys(LOCKERS, 0)
|
|
||||||
self.button: dict[str, tuple[int, int]] = {}
|
|
||||||
self.clicked: set[str] = set()
|
|
||||||
self.armed: set[str] = set()
|
|
||||||
for app in launch:
|
|
||||||
self.launch(app)
|
|
||||||
|
|
||||||
def launch(self, app: str) -> None:
|
|
||||||
args = [sys.executable, str(HERE / "locker_child.py"), app]
|
|
||||||
args += [str(LOCKERS[app]), str(POLL_S)]
|
|
||||||
self.procs[app] = subprocess.Popen(args, stdout=subprocess.PIPE, env=self.env)
|
|
||||||
|
|
||||||
def drain(self) -> None:
|
|
||||||
fds = {p.stdout.fileno(): a for a, p in self.procs.items() if p.stdout}
|
|
||||||
for fd in select.select(list(fds), [], [], 0)[0]:
|
|
||||||
app = fds[fd]
|
|
||||||
self.buffers[app] += os.read(fd, 65536)
|
|
||||||
*lines, self.buffers[app] = self.buffers[app].split(b"\n")
|
|
||||||
for raw in lines:
|
|
||||||
self.note(app, raw.decode())
|
|
||||||
|
|
||||||
def note(self, app: str, line: str) -> None:
|
|
||||||
if line == "ARMED":
|
|
||||||
self.armed.add(app)
|
|
||||||
elif line == "CLICKED":
|
|
||||||
self.clicked.add(app)
|
|
||||||
elif m := re.fullmatch(r"GRAB (\d)", line):
|
|
||||||
self.grab[app] = int(m[1])
|
|
||||||
elif m := re.fullmatch(r"BTN (\d+) (\d+)", line):
|
|
||||||
self.button[app] = (int(m[1]), int(m[2]))
|
|
||||||
elif line.startswith("ERROR"):
|
|
||||||
_say(f"FAIL {app} reported {line}")
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
for proc in self.procs.values():
|
|
||||||
proc.terminate()
|
|
||||||
for proc in self.procs.values():
|
|
||||||
proc.wait(timeout=10)
|
|
||||||
|
|
||||||
|
|
||||||
class _Screen:
|
|
||||||
"""Which locks are on screen, and a synthetic mouse."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.display = display.Display()
|
|
||||||
self.root = self.display.screen().root
|
|
||||||
|
|
||||||
def shown(self) -> set[str]:
|
|
||||||
apps: set[str] = set()
|
|
||||||
try:
|
|
||||||
for child in self.root.query_tree().children:
|
|
||||||
if child.get_attributes().map_state != X.IsViewable:
|
|
||||||
continue
|
|
||||||
for win in (child, *child.query_tree().children):
|
|
||||||
found = win.get_wm_class()
|
|
||||||
if found and found[1].lower() in LOCKERS:
|
|
||||||
apps.add(found[1].lower())
|
|
||||||
except error.XError as exc: # a lock closed mid-walk: next sample
|
|
||||||
_say(f"note: window vanished mid-sample ({exc})")
|
|
||||||
return apps
|
|
||||||
|
|
||||||
def click(self, x: int, y: int) -> None:
|
|
||||||
# Approach from off-target: Tk learns which widget is under the pointer
|
|
||||||
# from Enter/Motion events, and the previous lock's button sat at this
|
|
||||||
# very spot, so a motion to the same coordinates would produce none.
|
|
||||||
for px, py in ((x - 30, y - 30), (x, y)):
|
|
||||||
xtest.fake_input(self.display, X.MotionNotify, x=px, y=py)
|
|
||||||
self.display.sync()
|
|
||||||
time.sleep(0.15)
|
|
||||||
xtest.fake_input(self.display, X.ButtonPress, 1)
|
|
||||||
xtest.fake_input(self.display, X.ButtonRelease, 1)
|
|
||||||
self.display.sync()
|
|
||||||
|
|
||||||
|
|
||||||
class _Run:
|
|
||||||
"""Pumps the fleet and samples the screen; remembers any double lock."""
|
|
||||||
|
|
||||||
def __init__(self, fleet: _Fleet, screen: _Screen) -> None:
|
|
||||||
self.fleet = fleet
|
|
||||||
self.screen = screen
|
|
||||||
self.doubles: list[list[str]] = []
|
|
||||||
|
|
||||||
def pump(self, seconds: float, until: Callable[[], bool] | None = None) -> bool:
|
|
||||||
deadline = time.monotonic() + seconds
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
self.fleet.drain()
|
|
||||||
shown = self.screen.shown()
|
|
||||||
if len(shown) > 1:
|
|
||||||
self.doubles.append(sorted(shown))
|
|
||||||
if until is not None and until():
|
|
||||||
return True
|
|
||||||
time.sleep(STEP_S)
|
|
||||||
return until() if until is not None else False
|
|
||||||
|
|
||||||
def lone(self, seen: list[str]) -> str | None:
|
|
||||||
"""Wait for exactly one new lock on screen; return its app."""
|
|
||||||
got = self.pump(
|
|
||||||
SHOW_S,
|
|
||||||
lambda: len(shown := self.screen.shown()) == 1 and not shown & set(seen),
|
|
||||||
)
|
|
||||||
return next(iter(self.screen.shown())) if got else None
|
|
||||||
|
|
||||||
|
|
||||||
def _drive(run: _Run, check: Callable[[object, str], None]) -> list[str]:
|
|
||||||
"""Click each lock that appears, in turn; returns the order seen."""
|
|
||||||
order: list[str] = []
|
|
||||||
fleet = run.fleet
|
|
||||||
for _ in LOCKERS:
|
|
||||||
app = run.lone(order)
|
|
||||||
check(app, f"lock #{len(order) + 1} came up alone ({app})")
|
|
||||||
if not app:
|
|
||||||
break
|
|
||||||
order.append(app)
|
|
||||||
ready = run.pump(GRAB_S, lambda a=app: fleet.grab[a] == 1 and a in fleet.button)
|
|
||||||
check(ready, f"{app}: holds the grab and shows its button")
|
|
||||||
if ready:
|
|
||||||
run.pump(SETTLE_S)
|
|
||||||
run.screen.click(*fleet.button[app])
|
|
||||||
check(
|
|
||||||
run.pump(CLICK_S, lambda a=app: a in fleet.clicked),
|
|
||||||
f"{app}: XTEST click fired the callback",
|
|
||||||
)
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
def main(mode: str) -> int:
|
|
||||||
"""Run the scenario on a private display; 0 when every check held."""
|
|
||||||
if os.environ.get("DISPLAY", "").split(".")[0] in {"", ":0"}:
|
|
||||||
_say("refusing to run: DISPLAY must be a private X server, never :0")
|
|
||||||
return 2
|
|
||||||
if mode not in ORDERS:
|
|
||||||
_say(f"usage: verify_all_lockers.py [{'|'.join(ORDERS)}]")
|
|
||||||
return 2
|
|
||||||
env = {**os.environ, "GATELOCK_RUNTIME_DIR": tempfile.mkdtemp(prefix="gl-lockers-")}
|
|
||||||
failures: list[str] = []
|
|
||||||
|
|
||||||
def check(ok: object, label: str) -> None:
|
|
||||||
_say(f"{'PASS' if ok else 'FAIL'} {label}")
|
|
||||||
if not ok:
|
|
||||||
failures.append(label)
|
|
||||||
|
|
||||||
helper = subprocess.Popen(
|
|
||||||
[sys.executable, str(HERE / "prompt_grab_helper.py"), "gcr-prompter"],
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
check(
|
|
||||||
helper.stdout is not None and helper.stdout.readline().strip() == "READY",
|
|
||||||
"prompt holds the grab",
|
|
||||||
)
|
|
||||||
fleet = _Fleet(env, ORDERS[mode])
|
|
||||||
run = _Run(fleet, _Screen())
|
|
||||||
try:
|
|
||||||
run.pump(LATE[mode][2] if mode in LATE else PROMPT_HOLD_S)
|
|
||||||
check(not run.doubles, "while the prompt held the grab: never two locks")
|
|
||||||
if helper.stdin:
|
|
||||||
helper.stdin.write("release\n")
|
|
||||||
helper.stdin.flush()
|
|
||||||
if mode in LATE:
|
|
||||||
late, at, hold = LATE[mode]
|
|
||||||
run.pump(at - hold)
|
|
||||||
check("leetcode_guard" in fleet.armed, "a weaker lock is armed first")
|
|
||||||
fleet.launch(late)
|
|
||||||
gone = run.pump(SHOW_S, lambda: "leetcode_guard" not in run.screen.shown())
|
|
||||||
check(gone, "the weaker lock stepped back")
|
|
||||||
order = _drive(run, check)
|
|
||||||
check(not run.doubles, f"never two locks on screen (saw {run.doubles[:3]})")
|
|
||||||
_say(f"order seen: {order}")
|
|
||||||
_say("rank order" if order == list(_RANKED) else "NOT rank order")
|
|
||||||
finally:
|
|
||||||
fleet.stop()
|
|
||||||
if helper.stdin:
|
|
||||||
helper.stdin.close()
|
|
||||||
helper.wait(timeout=10)
|
|
||||||
_say("OK" if not failures else f"{len(failures)} FAILED")
|
|
||||||
return 1 if failures else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "mixed"))
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
# pylint: disable=consider-using-with,import-error
|
|
||||||
r"""Prove the lock's Tk loop stays responsive while a foreign client holds the grab.
|
|
||||||
|
|
||||||
Tk's ``grab -global`` blocks the whole event loop for ~1.0 s on every FAILED
|
|
||||||
attempt, and the lock retries every 200 ms plus once a tick, so under a held
|
|
||||||
grab (2026-11-11: the keyring prompt) the loop was starved: no repaint, no
|
|
||||||
clicks, rows and buttons without text.
|
|
||||||
|
|
||||||
``prompt_grab_helper.py`` holds the grab (class ``steam_game``: not a prompt,
|
|
||||||
so no yielding muddies the measurement). A real ``mainloop`` runs for 5 s with
|
|
||||||
a 50 ms ``after`` heartbeat; the worst gap between beats must stay under
|
|
||||||
300 ms. After the helper lets go the lock must still take the grab.
|
|
||||||
|
|
||||||
PYTHONPATH=. xvfb-run -a -s "-screen 0 1600x1200x24" \
|
|
||||||
python3 scripts/verify_grab_responsive.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import select
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from gatelock import GateRoot, LockConfig, LockWindow
|
|
||||||
from gatelock._config import GrabPolicy
|
|
||||||
|
|
||||||
HELPER = Path(__file__).with_name("prompt_grab_helper.py")
|
|
||||||
BEAT_MS, RUN_S, MAX_GAP_S, WAIT_S = 50, 5.0, 0.3, 8.0
|
|
||||||
|
|
||||||
|
|
||||||
def _say(message: str) -> None:
|
|
||||||
sys.stdout.write(f"{message}\n")
|
|
||||||
|
|
||||||
|
|
||||||
def _line(proc: subprocess.Popen[str]) -> str:
|
|
||||||
out = proc.stdout
|
|
||||||
ready = select.select([out], [], [], WAIT_S)[0] if out else []
|
|
||||||
return out.readline().strip() if ready and out else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _worst_gap(root: GateRoot) -> float:
|
|
||||||
"""Run the real mainloop for RUN_S; return the largest heartbeat gap."""
|
|
||||||
last = [time.monotonic()]
|
|
||||||
worst = [0.0]
|
|
||||||
|
|
||||||
def beat() -> None:
|
|
||||||
now = time.monotonic()
|
|
||||||
worst[0] = max(worst[0], now - last[0])
|
|
||||||
last[0] = now
|
|
||||||
root.after(BEAT_MS, beat)
|
|
||||||
|
|
||||||
root.after(BEAT_MS, beat)
|
|
||||||
root.after(int(RUN_S * 1000), root.quit)
|
|
||||||
root.mainloop()
|
|
||||||
return worst[0]
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
"""Hold a foreign grab, measure the loop, then release; 0 when all held."""
|
|
||||||
if os.environ.get("DISPLAY", "").split(".")[0] in {"", ":0"}:
|
|
||||||
_say("refusing to run: DISPLAY must be a private X server, never :0")
|
|
||||||
return 2
|
|
||||||
helper = subprocess.Popen(
|
|
||||||
[sys.executable, str(HELPER), "steam_game"],
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
failed = 0
|
|
||||||
if _line(helper) != "READY":
|
|
||||||
_say("FAIL helper did not take the grab")
|
|
||||||
return 1
|
|
||||||
root = GateRoot()
|
|
||||||
config = LockConfig(mode="hard", grab=GrabPolicy(disable_vt=False, retry_ms=200))
|
|
||||||
lock = LockWindow(root, config, MagicMock())
|
|
||||||
try:
|
|
||||||
lock.setup()
|
|
||||||
lock.grab_input()
|
|
||||||
gap = _worst_gap(root)
|
|
||||||
ok = gap < MAX_GAP_S
|
|
||||||
_say(f"{'PASS' if ok else 'FAIL'} worst loop gap {gap:.3f}s (< {MAX_GAP_S}s)")
|
|
||||||
failed += not ok
|
|
||||||
grab_held = lock.recovery.holds_grab()
|
|
||||||
_say(f"{'FAIL' if grab_held else 'PASS'} helper still holds the grab")
|
|
||||||
failed += grab_held
|
|
||||||
if helper.stdin:
|
|
||||||
helper.stdin.write("release\n")
|
|
||||||
helper.stdin.flush()
|
|
||||||
_line(helper)
|
|
||||||
deadline = time.monotonic() + WAIT_S
|
|
||||||
while time.monotonic() < deadline and not lock.recovery.holds_grab():
|
|
||||||
root.update()
|
|
||||||
time.sleep(0.05)
|
|
||||||
got = lock.recovery.holds_grab()
|
|
||||||
_say(f"{'PASS' if got else 'FAIL'} lock took the grab once it was free")
|
|
||||||
failed += not got
|
|
||||||
finally:
|
|
||||||
lock.close()
|
|
||||||
if helper.stdin:
|
|
||||||
helper.stdin.close()
|
|
||||||
helper.wait(timeout=WAIT_S)
|
|
||||||
_say("OK" if not failed else f"{failed} FAILED")
|
|
||||||
return 1 if failed else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@ -1,250 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
# pylint: disable=missing-function-docstring,consider-using-with,import-error
|
|
||||||
r"""Prove, on a real X server, that the lock yields to a system credential prompt.
|
|
||||||
|
|
||||||
No unit test can: in the suite the Tk root is a MagicMock, so the grab never
|
|
||||||
fails and stacking is never real. The 2026-11-11 incident was exactly that.
|
|
||||||
|
|
||||||
``prompt_grab_helper.py`` (a second X client, so Tk really answers "another
|
|
||||||
application has grab") maps a window with a chosen ``WM_CLASS`` and takes the
|
|
||||||
keyboard+pointer grab; a ``LockWindow`` then arms over it. Cases: ``prompt``
|
|
||||||
(gcr-prompter must end up above backdrop and surfaces, stay there, and the lock
|
|
||||||
must retake the grab and the top once the helper lets go), ``framed`` (client
|
|
||||||
inside an override-redirect frame, like i3: the top-level ancestor is raised),
|
|
||||||
``game`` (steam_game is not allowlisted: lock stays on top), ``i3`` (like
|
|
||||||
``prompt`` under a real i3 with a private runtime dir).
|
|
||||||
|
|
||||||
Run one case on a throwaway display (never ``:0``)::
|
|
||||||
|
|
||||||
PYTHONPATH=. xvfb-run -a -s "-screen 0 1600x1200x24" \
|
|
||||||
python3 scripts/verify_prompt_yield.py prompt
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import _tkinter
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import select
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING, Final
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from Xlib import X, display
|
|
||||||
|
|
||||||
from gatelock import GateRoot, LockConfig, LockWindow
|
|
||||||
from gatelock._config import GrabPolicy
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
HELPER: Final = Path(__file__).with_name("prompt_grab_helper.py")
|
|
||||||
CASES: Final = { # case -> (WM_CLASS instance, framed)
|
|
||||||
"prompt": ("gcr-prompter", False),
|
|
||||||
"framed": ("gcr-prompter", True),
|
|
||||||
"game": ("steam_game", False),
|
|
||||||
"i3": ("gcr-prompter", False),
|
|
||||||
}
|
|
||||||
STEP_S, WAIT_S = 0.05, 8.0
|
|
||||||
|
|
||||||
|
|
||||||
def _say(message: str) -> None:
|
|
||||||
sys.stdout.write(f"{message}\n")
|
|
||||||
|
|
||||||
|
|
||||||
class _Helper:
|
|
||||||
"""The grab-holding subprocess, as seen from the checking side."""
|
|
||||||
|
|
||||||
def __init__(self, wm_class: str, *, framed: bool) -> None:
|
|
||||||
args = [sys.executable, str(HELPER), wm_class, *(["--framed"] * framed)]
|
|
||||||
self.proc = subprocess.Popen(
|
|
||||||
args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True
|
|
||||||
)
|
|
||||||
self._expect("READY")
|
|
||||||
|
|
||||||
def _expect(self, word: str) -> None:
|
|
||||||
out = self.proc.stdout
|
|
||||||
ready = select.select([out], [], [], WAIT_S)[0] if out else []
|
|
||||||
line = out.readline().strip() if ready and out else ""
|
|
||||||
if line != word:
|
|
||||||
msg = f"helper said {line!r}, wanted {word!r}"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
def release(self) -> None:
|
|
||||||
if self.proc.stdin:
|
|
||||||
self.proc.stdin.write("release\n")
|
|
||||||
self.proc.stdin.flush()
|
|
||||||
self._expect("RELEASED")
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
if self.proc.stdin:
|
|
||||||
self.proc.stdin.close()
|
|
||||||
self.proc.wait(timeout=WAIT_S)
|
|
||||||
|
|
||||||
|
|
||||||
class _Stack:
|
|
||||||
"""Real stacking order, read over the harness's own X connection."""
|
|
||||||
|
|
||||||
def __init__(self, lock_tops: list[int]) -> None:
|
|
||||||
self.display = display.Display()
|
|
||||||
self.root = self.display.screen().root
|
|
||||||
self.lock_ids = [self.top_of(i) for i in lock_tops]
|
|
||||||
self.prompt = 0
|
|
||||||
|
|
||||||
def top_of(self, window_id: int) -> int:
|
|
||||||
win = self.display.create_resource_object("window", window_id)
|
|
||||||
while (parent := win.query_tree().parent).id != self.root.id:
|
|
||||||
win = parent
|
|
||||||
return int(win.id)
|
|
||||||
|
|
||||||
def order(self) -> list[int]:
|
|
||||||
return [w.id for w in self.root.query_tree().children]
|
|
||||||
|
|
||||||
def viewable(self, window_id: int) -> bool:
|
|
||||||
win = self.display.create_resource_object("window", window_id)
|
|
||||||
return bool(win.get_attributes().map_state == X.IsViewable)
|
|
||||||
|
|
||||||
def find(self, wm_class: str) -> int | None:
|
|
||||||
"""The root child that is, or contains, the helper's window."""
|
|
||||||
for child in self.root.query_tree().children:
|
|
||||||
for win in (child, *child.query_tree().children):
|
|
||||||
found = win.get_wm_class()
|
|
||||||
if found and found[0] == wm_class:
|
|
||||||
self.prompt = int(child.id)
|
|
||||||
return self.prompt
|
|
||||||
return None
|
|
||||||
|
|
||||||
def covered(self) -> bool:
|
|
||||||
order = self.order()
|
|
||||||
return all(order.index(i) > order.index(self.prompt) for i in self.lock_ids)
|
|
||||||
|
|
||||||
def on_top(self) -> bool:
|
|
||||||
order = self.order()
|
|
||||||
return all(order.index(self.prompt) > order.index(i) for i in self.lock_ids)
|
|
||||||
|
|
||||||
def lock_topmost(self) -> bool:
|
|
||||||
"""The topmost *viewable* root child belongs to the lock."""
|
|
||||||
top = next(i for i in reversed(self.order()) if self.viewable(i))
|
|
||||||
return top in self.lock_ids
|
|
||||||
|
|
||||||
|
|
||||||
def _pump(root: tk.Tk, seconds: float, until: Callable[[], bool] | None = None) -> bool:
|
|
||||||
"""Run Tk's event loop for ``seconds`` or until ``until`` holds.
|
|
||||||
|
|
||||||
One event at a time: ``update()`` never returns while the lock's grab
|
|
||||||
retries (each blocks Tk for ~1 s) keep coming due.
|
|
||||||
"""
|
|
||||||
deadline = time.monotonic() + seconds
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
root.tk.dooneevent(_tkinter.DONT_WAIT)
|
|
||||||
if until is not None and until():
|
|
||||||
return True
|
|
||||||
time.sleep(STEP_S)
|
|
||||||
return until() if until is not None else False
|
|
||||||
|
|
||||||
|
|
||||||
def _start_i3() -> subprocess.Popen[bytes]:
|
|
||||||
"""Start i3 on the private display, with a private runtime dir."""
|
|
||||||
home = Path(tempfile.mkdtemp(prefix="gl-i3-"))
|
|
||||||
(home / "config").write_text("font pango:monospace 8\n", encoding="utf-8")
|
|
||||||
# No inherited I3SOCK: this i3 must never touch the live session's socket.
|
|
||||||
env = {k: v for k, v in os.environ.items() if k != "I3SOCK"}
|
|
||||||
env["XDG_RUNTIME_DIR"] = str(home)
|
|
||||||
proc = subprocess.Popen(["/usr/bin/i3", "-c", str(home / "config")], env=env)
|
|
||||||
probe = display.Display()
|
|
||||||
atom = probe.intern_atom("I3_SOCKET_PATH")
|
|
||||||
deadline = time.monotonic() + WAIT_S
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if probe.screen().root.get_full_property(atom, X.AnyPropertyType):
|
|
||||||
return proc
|
|
||||||
time.sleep(0.1)
|
|
||||||
proc.kill()
|
|
||||||
msg = "i3 never came up"
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def _checks(case: str, root: tk.Tk, lock: LockWindow, helper: _Helper) -> list[str]:
|
|
||||||
"""Drive the armed lock through one scenario; returns failed labels."""
|
|
||||||
wm_class = CASES[case][0]
|
|
||||||
failures: list[str] = []
|
|
||||||
|
|
||||||
def check(ok: object, label: str) -> None:
|
|
||||||
_say(f"{'PASS' if ok else 'FAIL'} {label}")
|
|
||||||
if not ok:
|
|
||||||
failures.append(label)
|
|
||||||
|
|
||||||
_pump(root, 0.3)
|
|
||||||
tops: list[tk.Wm] = [root]
|
|
||||||
tops += [w for w in root.winfo_children() if isinstance(w, tk.Toplevel)]
|
|
||||||
seen = _Stack([int(t.wm_frame(), 16) for t in tops])
|
|
||||||
prompt = seen.find(wm_class)
|
|
||||||
check(prompt is not None, "helper window found on the root")
|
|
||||||
if prompt is None:
|
|
||||||
return failures
|
|
||||||
_pump(root, 0.7)
|
|
||||||
check(seen.covered(), "t<1s: lock starts above the prompt (incident)")
|
|
||||||
check(not lock.recovery.holds_grab(), "t<1s: the helper holds the grab")
|
|
||||||
yielded = _pump(root, WAIT_S, seen.on_top)
|
|
||||||
if case == "game":
|
|
||||||
check(not yielded, "steam_game: lock stays on top (no yield)")
|
|
||||||
_pump(root, 2.5)
|
|
||||||
check(seen.covered(), "steam_game: still covered 2.5s later")
|
|
||||||
else:
|
|
||||||
check(yielded, "prompt raised above backdrop and every surface")
|
|
||||||
_pump(root, 2.5) # several enforce ticks
|
|
||||||
check(seen.on_top(), "prompt stays on top across ticks")
|
|
||||||
check(not lock.recovery.holds_grab(), "lock does not hold the grab yet")
|
|
||||||
check(all(seen.viewable(i) for i in seen.lock_ids), "lock still mapped")
|
|
||||||
helper.release()
|
|
||||||
got = _pump(root, WAIT_S, lock.recovery.holds_grab)
|
|
||||||
check(got, "after the helper let go, the lock took the grab")
|
|
||||||
_pump(root, 1.5)
|
|
||||||
check(seen.lock_topmost(), "lock is back on top of everything viewable")
|
|
||||||
return failures
|
|
||||||
|
|
||||||
|
|
||||||
def _run_case(case: str) -> list[str]:
|
|
||||||
"""Arm a lock over the helper's grab, run the scenario, tear it all down."""
|
|
||||||
wm_class, framed = CASES[case]
|
|
||||||
wm = _start_i3() if case == "i3" else None
|
|
||||||
helper = lock = None
|
|
||||||
try:
|
|
||||||
helper = _Helper(wm_class, framed=framed)
|
|
||||||
root = GateRoot()
|
|
||||||
config = LockConfig(
|
|
||||||
mode="hard", grab=GrabPolicy(disable_vt=False, retry_ms=200)
|
|
||||||
)
|
|
||||||
lock = LockWindow(root, config, MagicMock())
|
|
||||||
lock.setup()
|
|
||||||
lock.grab_input()
|
|
||||||
return _checks(case, root, lock, helper)
|
|
||||||
finally:
|
|
||||||
if lock is not None:
|
|
||||||
lock.close()
|
|
||||||
if helper is not None:
|
|
||||||
helper.stop()
|
|
||||||
if wm is not None:
|
|
||||||
wm.terminate()
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
|
||||||
"""Run the named case on a private display; 0 when every check held."""
|
|
||||||
xdisplay = os.environ.get("DISPLAY", "")
|
|
||||||
if not xdisplay or xdisplay.split(".")[0] == ":0":
|
|
||||||
_say("refusing to run: DISPLAY must be a private X server, never :0")
|
|
||||||
return 2
|
|
||||||
if len(argv) != 1 or argv[0] not in CASES:
|
|
||||||
_say(f"usage: verify_prompt_yield.py {{{'|'.join(CASES)}}}")
|
|
||||||
return 2
|
|
||||||
failed = _run_case(argv[0])
|
|
||||||
_say("OK" if not failed else f"{len(failed)} FAILED")
|
|
||||||
return 1 if failed else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
@ -64,57 +64,47 @@ class _DeviceCodeDialogState extends State<DeviceCodeDialog> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Glyphs in place of app-written words (kuhy's textgate rule); each old
|
|
||||||
// sentence is the spoken label. The user code is GitHub's data, typed
|
|
||||||
// by the user, so it stays visible.
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Icon(Icons.key, semanticLabel: 'Authorize on GitHub'),
|
title: const Text('Authorize on GitHub'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
const Text('Enter this code on GitHub:'),
|
||||||
children: [
|
const SizedBox(height: 8),
|
||||||
const Icon(
|
SelectableText(
|
||||||
Icons.keyboard,
|
widget.device.userCode,
|
||||||
semanticLabel: 'Enter this code on GitHub:',
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
SelectableText(
|
|
||||||
widget.device.userCode,
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (_error case final error?)
|
if (_error == null)
|
||||||
Icon(
|
const Row(
|
||||||
Icons.error,
|
children: [
|
||||||
color: Theme.of(context).colorScheme.error,
|
SizedBox(
|
||||||
semanticLabel: error,
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Expanded(child: Text('Waiting for authorization…')),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Semantics(
|
Text(
|
||||||
label: 'Waiting for authorization…',
|
_error!,
|
||||||
child: const SizedBox(
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton.icon(
|
||||||
onPressed: _openPage,
|
onPressed: _openPage,
|
||||||
child: const Icon(
|
icon: const Icon(Icons.open_in_new),
|
||||||
Icons.open_in_new,
|
label: const Text('Open GitHub & copy code'),
|
||||||
semanticLabel: 'Open GitHub & copy code',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,7 @@ description: >-
|
|||||||
modal that drives it. Extracted from four apps that each carried a copy,
|
modal that drives it. Extracted from four apps that each carried a copy,
|
||||||
two of them byte-identical.
|
two of them byte-identical.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.4.0
|
version: 0.3.2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|||||||
@ -67,9 +67,6 @@ Future<Object? Function()> _showDialog(
|
|||||||
);
|
);
|
||||||
await tester.tap(find.text('open'));
|
await tester.tap(find.text('open'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
// Let the dialog fade in: a transparent route has no semantics yet, and
|
|
||||||
// the dialog's words are spoken labels now.
|
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
|
||||||
return () => popped;
|
return () => popped;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,9 +74,9 @@ void main() {
|
|||||||
testWidgets('shows the user code and a waiting state', (tester) async {
|
testWidgets('shows the user code and a waiting state', (tester) async {
|
||||||
await _showDialog(tester, _pendingAuth());
|
await _showDialog(tester, _pendingAuth());
|
||||||
|
|
||||||
expect(find.bySemanticsLabel('Authorize on GitHub'), findsOneWidget);
|
expect(find.text('Authorize on GitHub'), findsOneWidget);
|
||||||
expect(find.text('ABCD-1234'), findsOneWidget);
|
expect(find.text('ABCD-1234'), findsOneWidget);
|
||||||
expect(find.bySemanticsLabel('Waiting for authorization…'), findsOneWidget);
|
expect(find.text('Waiting for authorization…'), findsOneWidget);
|
||||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -114,7 +111,7 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
expect(find.bySemanticsLabel(RegExp('access_denied')), findsOneWidget);
|
expect(find.textContaining('access_denied'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('surfaces an Error, not just an Exception', (tester) async {
|
testWidgets('surfaces an Error, not just an Exception', (tester) async {
|
||||||
@ -129,12 +126,12 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
expect(find.bySemanticsLabel(RegExp('No host specified')), findsOneWidget);
|
expect(find.textContaining('No host specified'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('cancel pops without a token', (tester) async {
|
testWidgets('cancel pops without a token', (tester) async {
|
||||||
final popped = await _showDialog(tester, _pendingAuth());
|
final popped = await _showDialog(tester, _pendingAuth());
|
||||||
await tester.tap(find.bySemanticsLabel('Cancel'));
|
await tester.tap(find.text('Cancel'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(AlertDialog), findsNothing);
|
expect(find.byType(AlertDialog), findsNothing);
|
||||||
@ -164,7 +161,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await _showDialog(tester, auth);
|
await _showDialog(tester, auth);
|
||||||
await tester.tap(find.bySemanticsLabel('Open GitHub & copy code'));
|
await tester.tap(find.text('Open GitHub & copy code'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(copied, 'ABCD-1234');
|
expect(copied, 'ABCD-1234');
|
||||||
|
|||||||
@ -93,14 +93,6 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with
|
|||||||
degrades to a pass, and the gate defers to that workflow when run inside
|
degrades to a pass, and the gate defers to that workflow when run inside
|
||||||
another GitHub Actions job.
|
another GitHub Actions job.
|
||||||
|
|
||||||
## Flutter apps: no Google non-free sign-in
|
|
||||||
|
|
||||||
A repo with a `pubspec.yaml` beside an `android/` dir (root or one level
|
|
||||||
down) also gets `scripts/check_no_gms_signin.sh` (a shim) and the always-run
|
|
||||||
`no-gms-signin` hook (`install_gms.py`). The shared gate fails on
|
|
||||||
google_sign_in / play-services-auth / googleid in pubspec.lock or gradle, or
|
|
||||||
in a built APK with `--apk`; apps use `google_account_token` instead.
|
|
||||||
|
|
||||||
## Flutter apps: the launcher rules
|
## Flutter apps: the launcher rules
|
||||||
|
|
||||||
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked
|
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked
|
||||||
|
|||||||
@ -25,7 +25,6 @@ from dep_freshness.install import NO_WORKFLOW_MARKER
|
|||||||
from repo_contract import bootstrap
|
from repo_contract import bootstrap
|
||||||
from repo_contract.check import violations
|
from repo_contract.check import violations
|
||||||
from repo_contract.infer import propose
|
from repo_contract.infer import propose
|
||||||
from repo_contract.install_gms import install_gms, plan_gms
|
|
||||||
from repo_contract.install_web import install_web, plan_web
|
from repo_contract.install_web import install_web, plan_web
|
||||||
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
||||||
from repo_contract.stacks import detect_stacks
|
from repo_contract.stacks import detect_stacks
|
||||||
@ -65,7 +64,6 @@ def plan(repo: Path) -> list[str]:
|
|||||||
|
|
||||||
A website (detected or declared in repo-contract.toml) also gets the
|
A website (detected or declared in repo-contract.toml) also gets the
|
||||||
AgentReady gate: config, shim, workflow and hook (see install_web.py).
|
AgentReady gate: config, shim, workflow and hook (see install_web.py).
|
||||||
A Flutter Android app also gets the no-GMS-sign-in gate (install_gms.py).
|
|
||||||
"""
|
"""
|
||||||
todo = []
|
todo = []
|
||||||
if _needs_shim(repo):
|
if _needs_shim(repo):
|
||||||
@ -74,7 +72,7 @@ def plan(repo: Path) -> list[str]:
|
|||||||
todo.append(str(WORKFLOW))
|
todo.append(str(WORKFLOW))
|
||||||
if needs_hook(repo, HOOK_ID):
|
if needs_hook(repo, HOOK_ID):
|
||||||
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||||
return todo + plan_web(repo) + plan_gms(repo)
|
return todo + plan_web(repo)
|
||||||
|
|
||||||
|
|
||||||
def install(repo: Path) -> list[str]:
|
def install(repo: Path) -> list[str]:
|
||||||
@ -94,7 +92,7 @@ def install(repo: Path) -> list[str]:
|
|||||||
if needs_hook(repo, HOOK_ID):
|
if needs_hook(repo, HOOK_ID):
|
||||||
write_hook(repo, _template("pre-commit-snippet.yaml"))
|
write_hook(repo, _template("pre-commit-snippet.yaml"))
|
||||||
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||||
return done + install_web(repo) + install_gms(repo)
|
return done + install_web(repo)
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_commands(repo: Path) -> list[str]:
|
def bootstrap_commands(repo: Path) -> list[str]:
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
"""The no-GMS-sign-in half of the installer: Flutter Android apps get the gate.
|
|
||||||
|
|
||||||
Why it rides on install_repo_contract_gate.sh: that installer is the one step
|
|
||||||
every new repo gets after `git init`, so a new Flutter app cannot come out of
|
|
||||||
scaffolding able to re-add google_sign_in (13 apps were migrated off it onto
|
|
||||||
utils/google_account_token in November 2026). Pieces: the
|
|
||||||
scripts/check_no_gms_signin.sh delegate and an always-run pre-commit hook.
|
|
||||||
Only repos with a pubspec.yaml next to an android/ dir (at the root or one
|
|
||||||
level down, e.g. tutor/app) get it; utils itself holds the real gate.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
|
||||||
|
|
||||||
TEMPLATES = Path(__file__).resolve().parent / "templates"
|
|
||||||
SHIM = Path("scripts/check_no_gms_signin.sh")
|
|
||||||
HOOK_ID = "no-gms-signin"
|
|
||||||
HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)"
|
|
||||||
SHIM_TEMPLATE = "check_no_gms_signin.shim.sh"
|
|
||||||
|
|
||||||
|
|
||||||
def _template(name: str) -> str:
|
|
||||||
return (TEMPLATES / name).read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def is_flutter_android(repo: Path) -> bool:
|
|
||||||
"""A pubspec.yaml beside an android/ dir, at the root or one level down."""
|
|
||||||
if (repo / "repo_contract" / "check.py").is_file():
|
|
||||||
return False # utils: SHIM is the real gate, never a delegate
|
|
||||||
pubspecs = [repo / "pubspec.yaml", *repo.glob("*/pubspec.yaml")]
|
|
||||||
return any(p.is_file() and (p.parent / "android").is_dir() for p in pubspecs)
|
|
||||||
|
|
||||||
|
|
||||||
def plan_gms(repo: Path) -> list[str]:
|
|
||||||
"""Which no-GMS-sign-in gate pieces are missing or drifted."""
|
|
||||||
if not is_flutter_android(repo):
|
|
||||||
return []
|
|
||||||
todo = []
|
|
||||||
shim = repo / SHIM
|
|
||||||
if not shim.is_file() or shim.read_text(encoding="utf-8") != _template(
|
|
||||||
SHIM_TEMPLATE
|
|
||||||
):
|
|
||||||
todo.append(str(SHIM))
|
|
||||||
if needs_hook(repo, HOOK_ID):
|
|
||||||
todo.append(HOOK_PIECE)
|
|
||||||
return todo
|
|
||||||
|
|
||||||
|
|
||||||
def install_gms(repo: Path) -> list[str]:
|
|
||||||
"""Write every missing or drifted no-GMS-sign-in piece. Returns them."""
|
|
||||||
todo = plan_gms(repo)
|
|
||||||
if str(SHIM) in todo:
|
|
||||||
target = repo / SHIM
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
target.write_text(_template(SHIM_TEMPLATE), encoding="utf-8")
|
|
||||||
target.chmod(0o755)
|
|
||||||
if HOOK_PIECE in todo:
|
|
||||||
write_hook(repo, _template("no-gms-signin-hook.yaml"))
|
|
||||||
return todo
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Fail if this app links Google's non-free sign-in (google_sign_in,
|
|
||||||
# play-services-auth, googleid) instead of google_account_token.
|
|
||||||
#
|
|
||||||
# Thin delegate to the shared gate in ~/src/utils, which owns the package
|
|
||||||
# list and the fix message. Copy this file to scripts/check_no_gms_signin.sh
|
|
||||||
# in a repo; never copy the shared gate itself.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_no_gms_signin.sh"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
if [[ ! -x "$SHARED_GATE" ]]; then
|
|
||||||
echo "Error: shared no-gms-signin gate not found at $SHARED_GATE" >&2
|
|
||||||
echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
|
|
||||||
echo " UTILS_ROOT to where it lives." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec bash "$SHARED_GATE" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh
|
|
||||||
# for any repo with a pubspec.yaml beside an android/ dir (a Flutter app).
|
|
||||||
# No Google non-free sign-in: google_sign_in links play-services-auth /
|
|
||||||
# googleid into the APK; apps use utils/google_account_token instead.
|
|
||||||
# always_run rather than `files:`: a poisoned pubspec.lock can arrive by
|
|
||||||
# pull or merge, and a staged-only trigger would never look again.
|
|
||||||
- id: no-gms-signin
|
|
||||||
name: no google_sign_in / GMS auth (use google_account_token)
|
|
||||||
entry: scripts/check_no_gms_signin.sh .
|
|
||||||
language: system
|
|
||||||
pass_filenames: false
|
|
||||||
always_run: true
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
"""A Flutter repo with an Android target gets the no-GMS-sign-in gate."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from repo_contract import install as inst
|
|
||||||
from repo_contract import install_gms as gms
|
|
||||||
|
|
||||||
from .conftest import write
|
|
||||||
|
|
||||||
|
|
||||||
def flutter_android(root: Path, sub: str = "") -> Path:
|
|
||||||
"""A Flutter project at `root/sub` with an android/ dir."""
|
|
||||||
base = f"{sub}/" if sub else ""
|
|
||||||
write(root, f"{base}pubspec.yaml", "name: x\n")
|
|
||||||
write(root, f"{base}android/app/build.gradle.kts", "plugins {}\n")
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_pubspec_means_no_gate(repo: Path) -> None:
|
|
||||||
assert gms.plan_gms(repo) == []
|
|
||||||
assert gms.install_gms(repo) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_pubspec_without_android_means_no_gate(repo: Path) -> None:
|
|
||||||
write(repo, "pubspec.yaml", "name: x\n")
|
|
||||||
assert gms.plan_gms(repo) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_root_flutter_android_gets_shim_and_hook(repo: Path) -> None:
|
|
||||||
flutter_android(repo)
|
|
||||||
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
|
||||||
assert inst.install(repo)[-2:] == [str(gms.SHIM), gms.HOOK_PIECE]
|
|
||||||
shim = repo / gms.SHIM
|
|
||||||
assert shim.stat().st_mode & stat.S_IXUSR
|
|
||||||
config = (repo / inst.PRECOMMIT).read_text(encoding="utf-8")
|
|
||||||
assert f"id: {gms.HOOK_ID}" in config
|
|
||||||
assert "always_run: true" in config
|
|
||||||
assert gms.plan_gms(repo) == []
|
|
||||||
assert gms.install_gms(repo) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_nested_app_dir_counts(repo: Path) -> None:
|
|
||||||
flutter_android(repo, "app")
|
|
||||||
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
|
||||||
|
|
||||||
|
|
||||||
def test_drifted_shim_is_rewritten_hook_kept(repo: Path) -> None:
|
|
||||||
flutter_android(repo)
|
|
||||||
gms.install_gms(repo)
|
|
||||||
write(repo, gms.SHIM.as_posix(), "#!/bin/bash\nexit 0\n")
|
|
||||||
assert gms.install_gms(repo) == [str(gms.SHIM)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_utils_itself_is_never_given_a_shim(repo: Path) -> None:
|
|
||||||
flutter_android(repo, "google_account_token")
|
|
||||||
write(repo, "repo_contract/check.py")
|
|
||||||
assert gms.plan_gms(repo) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_installed_shim_delegates_to_the_shared_gate(repo: Path) -> None:
|
|
||||||
flutter_android(repo)
|
|
||||||
write(repo, "pubspec.lock", "packages:\n google_sign_in_android:\n")
|
|
||||||
gms.install_gms(repo)
|
|
||||||
utils_root = Path(__file__).resolve().parents[2]
|
|
||||||
proc = subprocess.run(
|
|
||||||
["bash", str(repo / gms.SHIM), str(repo)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
env={"UTILS_ROOT": str(utils_root), "PATH": "/usr/bin:/bin"},
|
|
||||||
)
|
|
||||||
assert proc.returncode == 1
|
|
||||||
assert "google_account_token" in proc.stderr
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Fail if a Flutter/Android app links Google's non-free sign-in stack.
|
|
||||||
#
|
|
||||||
# Every app signs in through kuhy's FOSS plugin google_account_token
|
|
||||||
# (utils/google_account_token, Android AccountManager). google_sign_in pulls
|
|
||||||
# google_sign_in_android, which links play-services-auth / googleid into the
|
|
||||||
# APK. Nine apps were migrated off it on 2026-11-02 and four more on
|
|
||||||
# 2026-11-10; nothing else stops a later session from re-adding it.
|
|
||||||
#
|
|
||||||
# Lockfile mode (default) reads pubspec.lock package keys, plus the gradle
|
|
||||||
# files under each lock's sibling android/ dir. Web-only pieces
|
|
||||||
# (google_sign_in_web, google_sign_in_platform_interface and their transitive
|
|
||||||
# google_identity_services_web) are allowed while google_sign_in_android is
|
|
||||||
# absent: kartka's web build uses them for the GIS button and they never
|
|
||||||
# reach an APK. APK mode looks for the GMS auth library marker files in the
|
|
||||||
# built APK, the ground truth when a dependency arrives some other way.
|
|
||||||
#
|
|
||||||
# Usage: check_no_gms_signin.sh [pubspec.lock | dir ...]
|
|
||||||
# check_no_gms_signin.sh --apk app.apk
|
|
||||||
# A dir is searched for pubspec.lock files (build/, .dart_tool/ pruned).
|
|
||||||
# With no arguments, checks ./pubspec.lock (passes if there is none).
|
|
||||||
# Exit: 0 clean | 1 non-free sign-in found | 2 usage error
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_NAME="$(basename "$0")"
|
|
||||||
readonly SCRIPT_NAME
|
|
||||||
readonly BANNED=(google_sign_in google_sign_in_android google_sign_in_ios)
|
|
||||||
readonly WEB_ONLY=(google_sign_in_web google_sign_in_platform_interface
|
|
||||||
google_identity_services_web)
|
|
||||||
# Maven coordinates (and split-form catalog names) of the GMS sign-in stack.
|
|
||||||
# Anchored so com.google.gms:google-services (Firebase) and
|
|
||||||
# play-services-auth-api-phone do not trip it.
|
|
||||||
readonly GRADLE_PATTERN='com\.google\.android\.gms:play-services-(auth|identity-credentials)([^-[:alnum:]]|$)|com\.google\.android\.libraries\.identity\.googleid|androidx\.credentials:credentials-play-services-auth|name[[:space:]]*=[[:space:]]*"(play-services-auth|play-services-identity-credentials|credentials-play-services-auth|googleid)"'
|
|
||||||
# APK mode: library marker files AGP copies to the APK root (or META-INF),
|
|
||||||
# one per linked artifact. Dex strings are not used: play-services-base and
|
|
||||||
# -basement reference GoogleSignInAccount without linking play-services-auth.
|
|
||||||
# play-services-fido (passkeys) is deliberately allowed.
|
|
||||||
readonly APK_MARKERS=(
|
|
||||||
googleid.properties
|
|
||||||
play-services-auth.properties
|
|
||||||
play-services-auth-base.properties
|
|
||||||
play-services-auth-api-phone.properties
|
|
||||||
play-services-auth-blockstore.properties
|
|
||||||
play-services-identity-credentials.properties
|
|
||||||
META-INF/androidx.credentials_credentials-play-services-auth.version
|
|
||||||
)
|
|
||||||
readonly PRUNE=(-name build -o -name .dart_tool -o -name .git -o -name .gradle -o -name .claude
|
|
||||||
-o -name node_modules)
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "Usage: $SCRIPT_NAME [pubspec.lock | dir ...]"
|
|
||||||
echo " $SCRIPT_NAME --apk app.apk"
|
|
||||||
echo " Fails when an app links Google's non-free sign-in (GMS auth)."
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fix_hint() {
|
|
||||||
echo " Use google_account_token instead (FOSS, AccountManager):" >&2
|
|
||||||
echo " google_account_token:" >&2
|
|
||||||
echo " git:" >&2
|
|
||||||
echo " url: https://github.com/kuhyx/utils" >&2
|
|
||||||
echo " ref: google_account_token-v0.1.0" >&2
|
|
||||||
echo " path: google_account_token" >&2
|
|
||||||
echo " See utils/google_account_token/README.md." >&2
|
|
||||||
}
|
|
||||||
|
|
||||||
# Package keys of a pubspec.lock: the 2-space keys under `packages:`.
|
|
||||||
lock_packages() {
|
|
||||||
awk '
|
|
||||||
/^packages:[[:space:]]*$/ { in_packages = 1; next }
|
|
||||||
/^[^[:space:]]/ { in_packages = 0; next }
|
|
||||||
in_packages && /^ [A-Za-z0-9_]+:[[:space:]]*$/ {
|
|
||||||
sub(/^ /, ""); sub(/:.*/, ""); print
|
|
||||||
}
|
|
||||||
' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Prints the offending packages of one lock, one per line.
|
|
||||||
lock_offenders() {
|
|
||||||
local -A present=()
|
|
||||||
local name
|
|
||||||
while IFS= read -r name; do
|
|
||||||
present["$name"]=1
|
|
||||||
done < <(lock_packages "$1")
|
|
||||||
for name in "${BANNED[@]}"; do
|
|
||||||
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
|
||||||
done
|
|
||||||
if [[ -n "${present[google_sign_in_android]:-}" ]]; then
|
|
||||||
for name in "${WEB_ONLY[@]}"; do
|
|
||||||
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Prints file:line:text for every non-comment GMS sign-in coordinate under
|
|
||||||
# the lock's sibling android/ dir.
|
|
||||||
gradle_offenders() {
|
|
||||||
local android_dir="$1/android" file
|
|
||||||
[[ -d "$android_dir" ]] || return 0
|
|
||||||
while IFS= read -r -d '' file; do
|
|
||||||
grep -nE "$GRADLE_PATTERN" "$file" \
|
|
||||||
| grep -vE '^[0-9]+:[[:space:]]*(//|#|\*|/\*)' \
|
|
||||||
| sed "s|^|$file:|" || true
|
|
||||||
done < <(find "$android_dir" \( "${PRUNE[@]}" \) -prune -o -type f \
|
|
||||||
\( -name 'build.gradle*' -o -name '*.versions.toml' \) -print0)
|
|
||||||
}
|
|
||||||
|
|
||||||
indent() {
|
|
||||||
local line
|
|
||||||
while IFS= read -r line; do
|
|
||||||
echo " $line"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
check_lock() {
|
|
||||||
local lock="$1" offenders gradle
|
|
||||||
offenders="$(lock_offenders "$lock")"
|
|
||||||
gradle="$(gradle_offenders "$(dirname "$lock")")"
|
|
||||||
[[ -z "$offenders" && -z "$gradle" ]] && return 0
|
|
||||||
if [[ -n "$offenders" ]]; then
|
|
||||||
echo "Error: $lock links Google's non-free sign-in:" >&2
|
|
||||||
indent <<<"$offenders" >&2
|
|
||||||
fi
|
|
||||||
if [[ -n "$gradle" ]]; then
|
|
||||||
echo "Error: gradle names the GMS sign-in stack:" >&2
|
|
||||||
indent <<<"$gradle" >&2
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
check_apk() {
|
|
||||||
local apk="$1" entries found=()
|
|
||||||
local marker
|
|
||||||
[[ -f "$apk" ]] || { echo "Error: no such APK: $apk" >&2; exit 2; }
|
|
||||||
entries="$(unzip -Z1 "$apk")"
|
|
||||||
for marker in "${APK_MARKERS[@]}"; do
|
|
||||||
grep -qxF -- "$marker" <<<"$entries" && found+=("$marker")
|
|
||||||
done
|
|
||||||
[[ "${#found[@]}" -eq 0 ]] && return 0
|
|
||||||
echo "Error: $apk bundles Google's non-free sign-in libraries:" >&2
|
|
||||||
printf ' %s\n' "${found[@]}" >&2
|
|
||||||
fix_hint
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Expands the arguments into pubspec.lock paths, NUL-separated.
|
|
||||||
collect_locks() {
|
|
||||||
local arg
|
|
||||||
for arg in "$@"; do
|
|
||||||
if [[ -d "$arg" ]]; then
|
|
||||||
find "$arg" \( "${PRUNE[@]}" \) -prune -o -type f \
|
|
||||||
-name pubspec.lock -print0
|
|
||||||
else
|
|
||||||
printf '%s\0' "$arg"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
case "${1:-}" in
|
|
||||||
-h|--help) usage ;;
|
|
||||||
--apk)
|
|
||||||
[[ $# -eq 2 ]] || { echo "Usage: $SCRIPT_NAME --apk app.apk" >&2; exit 2; }
|
|
||||||
check_apk "$2"
|
|
||||||
return
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if [[ $# -eq 0 ]]; then
|
|
||||||
[[ -f pubspec.lock ]] || return 0
|
|
||||||
set -- pubspec.lock
|
|
||||||
fi
|
|
||||||
|
|
||||||
local -a locks=()
|
|
||||||
local arg lock failed=0
|
|
||||||
# Validated up front: collect_locks runs in a process substitution, whose
|
|
||||||
# exit status would be lost, and a typo'd path must not pass silently.
|
|
||||||
for arg in "$@"; do
|
|
||||||
[[ -e "$arg" ]] || { echo "Error: no such file or directory: $arg" >&2; exit 2; }
|
|
||||||
done
|
|
||||||
mapfile -d '' locks < <(collect_locks "$@")
|
|
||||||
for lock in "${locks[@]}"; do
|
|
||||||
check_lock "$lock" || failed=1
|
|
||||||
done
|
|
||||||
[[ "$failed" -eq 0 ]] || fix_hint
|
|
||||||
return "$failed"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Deploy the parts of utils that run on this machine, then prove they are live.
|
|
||||||
#
|
|
||||||
# utils is mostly libraries that apps pin by git tag, so "deploy" means:
|
|
||||||
# 1. every released library's tag for its current version is on origin
|
|
||||||
# (sickdays, sync_settings_ui: the apps' pins resolve to the code on main);
|
|
||||||
# 2. the sickdays CLI + launcher run the checkout's code, via
|
|
||||||
# sickdays/install.sh (its venv installs sickdays editable from here).
|
|
||||||
#
|
|
||||||
# Run it from the live checkout (~/src/utils), never from a worktree: install.sh
|
|
||||||
# installs editable from its own directory, and a worktree path would point the
|
|
||||||
# live venv at a directory that gets deleted.
|
|
||||||
#
|
|
||||||
# Usage: scripts/deploy.sh [--verify-only]
|
|
||||||
# Exits non-zero unless every health check passes. Idempotent.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_NAME="$(basename "$0")"
|
|
||||||
UTILS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
readonly SCRIPT_NAME UTILS_DIR
|
|
||||||
readonly VENV_DIR="${HOME}/.local/share/sickdays/venv"
|
|
||||||
readonly CLI_LINK="${HOME}/.local/bin/sickdays"
|
|
||||||
|
|
||||||
VERIFY_ONLY=0
|
|
||||||
FAILED=0
|
|
||||||
|
|
||||||
log() { printf 'deploy: %s\n' "$1" >&2; }
|
|
||||||
bad() { printf 'deploy: FAIL -- %s\n' "$1" >&2; FAILED=1; }
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "Usage: $SCRIPT_NAME [--verify-only]"
|
|
||||||
echo " --verify-only run the health checks without installing anything"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Prints the version field of a pyproject.toml or pubspec.yaml.
|
|
||||||
manifest_version() {
|
|
||||||
sed -nE 's/^version( =|:) *"?([^"]+)"?$/\2/p' "$1" | head -n 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# The tag <prefix>-v<version> for the version on disk must be on origin and
|
|
||||||
# hold exactly this checkout's <dir>: code past its tag never reaches the apps
|
|
||||||
# that pin it.
|
|
||||||
check_tag() {
|
|
||||||
local prefix="$1" dir="$2" manifest version tag
|
|
||||||
manifest="$UTILS_DIR/$dir/pyproject.toml"
|
|
||||||
[[ -f "$manifest" ]] || manifest="$UTILS_DIR/$dir/pubspec.yaml"
|
|
||||||
version="$(manifest_version "$manifest")"
|
|
||||||
tag="${prefix}-v${version}"
|
|
||||||
if ! git -C "$UTILS_DIR" fetch -q origin "+refs/tags/${tag}:refs/tags/${tag}" 2>/dev/null; then
|
|
||||||
bad "${tag} is not on origin: tag and push it so the app pins resolve"
|
|
||||||
elif [[ "$(git -C "$UTILS_DIR" rev-parse "${tag}:${dir}")" \
|
|
||||||
!= "$(git -C "$UTILS_DIR" rev-parse "HEAD:${dir}")" ]]; then
|
|
||||||
bad "${dir} changed after ${tag}: bump its version and release it"
|
|
||||||
else
|
|
||||||
log "ok: ${tag} is on origin and matches HEAD"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# The bare `sickdays` (what the launcher runs) must be the venv's CLI, and the
|
|
||||||
# venv must import the text-free sickdays from this checkout.
|
|
||||||
check_sickdays() {
|
|
||||||
local target module
|
|
||||||
target="$(readlink -f "$CLI_LINK" || true)"
|
|
||||||
if [[ "$target" != "$(readlink -f "$VENV_DIR/bin/sickdays")" ]]; then
|
|
||||||
bad "$CLI_LINK runs ${target:-nothing}, not the venv's sickdays"
|
|
||||||
fi
|
|
||||||
module="$("$VENV_DIR/bin/python" -I -c 'import sickdays; print(sickdays.__file__)' 2>/dev/null || true)"
|
|
||||||
if [[ "$module" != "$UTILS_DIR/sickdays/"* ]]; then
|
|
||||||
bad "the sickdays venv imports ${module:-nothing}, not $UTILS_DIR/sickdays"
|
|
||||||
elif [[ -e "$(dirname "$module")/gui/_text.py" ]]; then
|
|
||||||
bad "the live sickdays still has gui/_text.py (the pre-text-free GUI)"
|
|
||||||
else
|
|
||||||
log "ok: sickdays runs $module"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
verify() {
|
|
||||||
check_tag sickdays sickdays
|
|
||||||
check_tag sync_settings_ui sync_settings_ui
|
|
||||||
check_sickdays
|
|
||||||
if [[ "$FAILED" -ne 0 ]]; then
|
|
||||||
log "health check FAILED"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
log "healthy"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
if [[ "$VERIFY_ONLY" -eq 0 ]]; then
|
|
||||||
log "installing sickdays from $UTILS_DIR/sickdays"
|
|
||||||
"$UTILS_DIR/sickdays/install.sh"
|
|
||||||
fi
|
|
||||||
verify
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--verify-only) VERIFY_ONLY=1; shift ;;
|
|
||||||
-h|--help) usage ;;
|
|
||||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
main
|
|
||||||
@ -9,7 +9,6 @@
|
|||||||
# when the repo has .dep-freshness-no-workflow), Commands section. A website
|
# when the repo has .dep-freshness-no-workflow), Commands section. A website
|
||||||
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
|
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
|
||||||
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
|
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
|
||||||
# A Flutter app with android/ also gets the no-GMS-sign-in gate (shim + hook).
|
|
||||||
#
|
#
|
||||||
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
|
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
|
||||||
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo
|
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo
|
||||||
|
|||||||
@ -1,195 +0,0 @@
|
|||||||
"""check_no_gms_signin.sh: no app may link Google's non-free sign-in stack.
|
|
||||||
|
|
||||||
Each test writes a throwaway Flutter-shaped project (pubspec.lock, optional
|
|
||||||
android/ gradle files) or a fake APK built with zipfile, runs the real
|
|
||||||
script, and asserts on the exit code and the message.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
SCRIPT = Path(__file__).resolve().parent.parent / "check_no_gms_signin.sh"
|
|
||||||
|
|
||||||
# The package keys of kuhy's real kartka lock (web-only GIS button).
|
|
||||||
KARTKA_KEYS = (
|
|
||||||
"google_account_token",
|
|
||||||
"google_identity_services_web",
|
|
||||||
"google_sign_in_platform_interface",
|
|
||||||
"google_sign_in_web",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _lock(root: Path, *packages: str) -> Path:
|
|
||||||
"""Write a pubspec.lock under `root` with the given package keys."""
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
|
||||||
body = ["# Generated by pub", "packages:"]
|
|
||||||
for name in ("collection", *packages):
|
|
||||||
body += [
|
|
||||||
f" {name}:",
|
|
||||||
' dependency: "direct main"',
|
|
||||||
" description:",
|
|
||||||
f" name: {name}",
|
|
||||||
" source: hosted",
|
|
||||||
' version: "1.0.0"',
|
|
||||||
]
|
|
||||||
body += ["sdks:", ' dart: ">=3.0.0 <4.0.0"', ""]
|
|
||||||
lock = root / "pubspec.lock"
|
|
||||||
lock.write_text("\n".join(body), encoding="utf-8")
|
|
||||||
return lock
|
|
||||||
|
|
||||||
|
|
||||||
def _run(*args: str | Path) -> subprocess.CompletedProcess[str]:
|
|
||||||
return subprocess.run(
|
|
||||||
["bash", str(SCRIPT), *map(str, args)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_clean_lock_passes(tmp_path: Path) -> None:
|
|
||||||
lock = _lock(tmp_path, "google_account_token", "http")
|
|
||||||
assert _run(lock).returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_google_sign_in_android_fails_and_names_the_fix(tmp_path: Path) -> None:
|
|
||||||
lock = _lock(tmp_path, "google_sign_in_android")
|
|
||||||
proc = _run(lock)
|
|
||||||
assert proc.returncode == 1
|
|
||||||
assert str(lock) in proc.stderr
|
|
||||||
assert "google_sign_in_android" in proc.stderr
|
|
||||||
assert "google_account_token-v0.1.0" in proc.stderr
|
|
||||||
assert "google_account_token/README.md" in proc.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_facing_google_sign_in_fails(tmp_path: Path) -> None:
|
|
||||||
proc = _run(_lock(tmp_path, "google_sign_in"))
|
|
||||||
assert proc.returncode == 1
|
|
||||||
assert "google_sign_in " in proc.stderr or "google_sign_in\n" in proc.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def test_kartka_style_web_only_passes(tmp_path: Path) -> None:
|
|
||||||
assert _run(_lock(tmp_path, *KARTKA_KEYS)).returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_pieces_fail_once_android_is_linked(tmp_path: Path) -> None:
|
|
||||||
proc = _run(_lock(tmp_path, *KARTKA_KEYS, "google_sign_in_android"))
|
|
||||||
assert proc.returncode == 1
|
|
||||||
assert "google_sign_in_web" in proc.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def test_gradle_play_services_auth_fails(tmp_path: Path) -> None:
|
|
||||||
lock = _lock(tmp_path)
|
|
||||||
gradle = tmp_path / "android" / "app" / "build.gradle.kts"
|
|
||||||
gradle.parent.mkdir(parents=True)
|
|
||||||
gradle.write_text(
|
|
||||||
'dependencies {\n implementation("com.google.gms:google-services:4")\n'
|
|
||||||
' implementation("com.google.android.gms:play-services-auth:21.3.0")\n}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
proc = _run(lock)
|
|
||||||
assert proc.returncode == 1
|
|
||||||
assert str(gradle) in proc.stderr
|
|
||||||
assert "play-services-auth" in proc.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def test_versions_toml_googleid_fails(tmp_path: Path) -> None:
|
|
||||||
lock = _lock(tmp_path)
|
|
||||||
toml = tmp_path / "android" / "gradle" / "libs.versions.toml"
|
|
||||||
toml.parent.mkdir(parents=True)
|
|
||||||
toml.write_text(
|
|
||||||
'googleid = "com.google.android.libraries.identity.googleid:googleid:1.1"\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
assert _run(lock).returncode == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_firebase_google_services_plugin_passes(tmp_path: Path) -> None:
|
|
||||||
lock = _lock(tmp_path)
|
|
||||||
gradle = tmp_path / "android" / "build.gradle"
|
|
||||||
gradle.parent.mkdir(parents=True)
|
|
||||||
gradle.write_text(
|
|
||||||
"// play-services-auth was removed 2026-11-02\n"
|
|
||||||
"classpath 'com.google.gms:google-services:4.4.2'\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
assert _run(lock).returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_directory_mode_finds_nested_locks_and_prunes_build(tmp_path: Path) -> None:
|
|
||||||
_lock(tmp_path / "app", "google_account_token")
|
|
||||||
_lock(tmp_path / "app" / "build" / "x", "google_sign_in_android")
|
|
||||||
_lock(tmp_path / ".dart_tool" / "y", "google_sign_in_android")
|
|
||||||
# Agent worktrees are other checkouts, judged in their own repo.
|
|
||||||
_lock(tmp_path / ".claude" / "worktrees" / "z", "google_sign_in_android")
|
|
||||||
assert _run(tmp_path).returncode == 0
|
|
||||||
_lock(tmp_path / "other", "google_sign_in_ios")
|
|
||||||
proc = _run(tmp_path)
|
|
||||||
assert proc.returncode == 1
|
|
||||||
assert "google_sign_in_ios" in proc.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_explicit_file_is_a_usage_error(tmp_path: Path) -> None:
|
|
||||||
assert _run(tmp_path / "nope.lock").returncode == 2
|
|
||||||
|
|
||||||
|
|
||||||
CREDENTIALS_MARKER = "META-INF/androidx.credentials_credentials-play-services-auth.version"
|
|
||||||
DEX_REF = b"\x00Lcom/google/android/gms/auth/api/signin/GoogleSignInAccount;\x00" * 2
|
|
||||||
|
|
||||||
|
|
||||||
def _apk(path: Path, dex: bytes = b"", markers: tuple[str, ...] = ()) -> Path:
|
|
||||||
"""Fake APK: dex strings plus library marker files AGP copies in."""
|
|
||||||
with zipfile.ZipFile(path, "w") as apk:
|
|
||||||
apk.writestr("AndroidManifest.xml", b"\x03\x00")
|
|
||||||
apk.writestr("classes.dex", b"dex\n035\x00" + dex)
|
|
||||||
apk.writestr("classes2.dex", b"dex\n035\x00Lkotlin/Unit;\x00")
|
|
||||||
for name in markers:
|
|
||||||
apk.writestr(name, b"version=1")
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def test_apk_with_only_dex_references_passes(tmp_path: Path) -> None:
|
|
||||||
"""play-services-base references GoogleSignInAccount without linking auth."""
|
|
||||||
markers = (
|
|
||||||
"play-services-base.properties",
|
|
||||||
"play-services-basement.properties",
|
|
||||||
"play-services-tasks.properties",
|
|
||||||
"play-services-fido.properties",
|
|
||||||
)
|
|
||||||
proc = _run("--apk", _apk(tmp_path / "ok.apk", DEX_REF, markers))
|
|
||||||
assert proc.returncode == 0, proc.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def test_apk_without_markers_passes(tmp_path: Path) -> None:
|
|
||||||
assert _run("--apk", _apk(tmp_path / "clean.apk")).returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_apk_banned_markers_fail_and_are_named(tmp_path: Path) -> None:
|
|
||||||
banned = (
|
|
||||||
"play-services-auth.properties",
|
|
||||||
"googleid.properties",
|
|
||||||
"play-services-auth-base.properties",
|
|
||||||
"play-services-auth-api-phone.properties",
|
|
||||||
"play-services-auth-blockstore.properties",
|
|
||||||
"play-services-identity-credentials.properties",
|
|
||||||
CREDENTIALS_MARKER,
|
|
||||||
)
|
|
||||||
for marker in banned:
|
|
||||||
apk = _apk(tmp_path / "bad.apk", DEX_REF, ("play-services-base.properties", marker))
|
|
||||||
proc = _run("--apk", apk)
|
|
||||||
assert proc.returncode == 1, marker
|
|
||||||
assert marker in proc.stderr
|
|
||||||
assert "google_account_token" in proc.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def test_apk_nested_marker_name_is_not_a_root_marker(tmp_path: Path) -> None:
|
|
||||||
"""Only APK-root files count; a same-named file in a subdir is not AGP's."""
|
|
||||||
apk = _apk(tmp_path / "nested.apk", markers=("assets/play-services-auth.properties",))
|
|
||||||
assert _run("--apk", apk).returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_apk_missing_file_is_a_usage_error(tmp_path: Path) -> None:
|
|
||||||
assert _run("--apk", tmp_path / "nope.apk").returncode == 2
|
|
||||||
@ -70,35 +70,22 @@ refused (past day, already sick, not sick, a free day, sync unavailable),
|
|||||||
## The picker window (0.3.0)
|
## The picker window (0.3.0)
|
||||||
|
|
||||||
`sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on
|
`sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on
|
||||||
Tk -- calendar, a six-switch panel per day, the days ahead. Past days are
|
Tk -- calendar, a five-switch panel per day, the days ahead. Past days are
|
||||||
inert; a free day is refused; each write starts `sync_quietly()` on a
|
inert; a free day is refused; each write starts `sync_quietly()` on a
|
||||||
non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed
|
non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed
|
||||||
just the panel (`import sickdays` never imports tkinter):
|
just the panel (`import sickdays` never imports tkinter):
|
||||||
|
|
||||||
No words inside the window (textgate): dates are ISO numerals (`2026-11`,
|
|
||||||
`2026-11-27`), each locker is a drawn pictogram (dumbbell = workout, `</>` =
|
|
||||||
LeetCode, open book, apple = diet, alarm clock, target = focus), switches are
|
|
||||||
knob-right-on-accent / knob-left-on-dark, buttons are check (mark / save),
|
|
||||||
red bin (clear) and cross (cancel). A refusal is an icon in the status slot:
|
|
||||||
padlock (past), no-entry (free day), warning (cannot check, or a refused
|
|
||||||
write; the detail goes to the log), hourglass (a sync is still running).
|
|
||||||
Icons are Canvas geometry, not font glyphs: the venv's Tk uses core X fonts,
|
|
||||||
where non-ASCII glyphs render as nothing. The title bar keeps "Sick days"
|
|
||||||
(an app's name, textgate settled decision 4). The guard is
|
|
||||||
`tests/test_gui_no_words.py`: no letter on any widget, in any state.
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call
|
from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call
|
||||||
KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack()
|
KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack()
|
||||||
```
|
```
|
||||||
|
|
||||||
Normal day: six switches, all off (off = stands down), `actions.mark`; sick
|
Normal day: five switches, all off (off = stands down), "Mark sick"; sick
|
||||||
day: its switches, `actions.save`, `actions.clear`. Past/free/uncheckable
|
day: its switches, "Save", "Clear sick day". Past/free/uncheckable days show
|
||||||
days show their icon and offer no write; a refusal (or a click mid-sync)
|
why and offer no write; a refusal (or a click mid-sync) shows in the panel.
|
||||||
shows in the panel's `status` slot, named by `panel.reason`.
|
|
||||||
`on_saved(SickDay | None)` fires after the write and sync start: the entry on
|
`on_saved(SickDay | None)` fires after the write and sync start: the entry on
|
||||||
mark / a changed save (`is_sick=True`) or clear (`is_sick=False`), `None`
|
Mark sick / a changed Save (`is_sick=True`) or Clear (`is_sick=False`), `None`
|
||||||
on an unchanged save (nothing written). The panel never destroys itself.
|
on an unchanged Save (nothing written). The panel never destroys itself.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sickdays"
|
name = "sickdays"
|
||||||
version = "0.5.0"
|
version = "0.4.0"
|
||||||
description = "Shared sick-day pool: per-date, uncapped, synced; every gate respects it"
|
description = "Shared sick-day pool: per-date, uncapped, synced; every gate respects it"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
# Deliberately NOT gatelock (its __init__ imports tkinter); the HMAC lives in
|
# Deliberately NOT gatelock (its __init__ imports tkinter); the HMAC lives in
|
||||||
@ -120,9 +120,6 @@ disable = []
|
|||||||
min-public-methods = 0
|
min-public-methods = 0
|
||||||
max-module-lines = 1000
|
max-module-lines = 1000
|
||||||
max-attributes = 10
|
max-attributes = 10
|
||||||
# A tk.Canvas subclass (gui/_controls.py) has 9 ancestors by construction:
|
|
||||||
# Canvas mixes in XView and YView on top of Widget's chain.
|
|
||||||
max-parents = 9
|
|
||||||
|
|
||||||
[tool.pylint.typecheck]
|
[tool.pylint.typecheck]
|
||||||
generated-members = [
|
generated-members = [
|
||||||
|
|||||||
@ -1,33 +1,29 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""The month grid: Monday-first, one button per day, prev/next month chevrons.
|
"""The month grid: Monday-first, one button per day, prev/next month arrows.
|
||||||
|
|
||||||
The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a
|
The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a
|
||||||
sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past
|
sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past
|
||||||
days keep their mark but faded and inert. Today stays clickable -- a sick
|
days keep their mark but faded and inert. Today stays clickable -- a sick
|
||||||
day can be marked or cleared for today. Deciding what a click *means* stays
|
day can be marked or cleared for today. Deciding what a click *means* stays
|
||||||
with the window; this only reports the date. No words: the month reads
|
with the window; this only reports the date.
|
||||||
``2026-11`` and the weekday row is dots, with rings on Saturday and Sunday.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING, Final
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sickdays._day import to_iso
|
from sickdays._day import to_iso
|
||||||
from sickdays.gui import _dates
|
from sickdays.gui import _text
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
from sickdays.gui import _widgets as ui
|
from sickdays.gui import _widgets as ui
|
||||||
from sickdays.gui._controls import IconButton, IconSlot
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
#: The weekday marks over the grid, in px.
|
_PREV = "\N{SINGLE LEFT-POINTING ANGLE QUOTATION MARK}"
|
||||||
_MARK_PX: Final = 10
|
_NEXT = "\N{SINGLE RIGHT-POINTING ANGLE QUOTATION MARK}"
|
||||||
#: The keep-a-locker dot inside a sick day's cell, in px.
|
|
||||||
_DOT_PX: Final = 8
|
|
||||||
|
|
||||||
|
|
||||||
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
|
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
|
||||||
@ -60,7 +56,7 @@ class MonthCalendar(tk.Frame):
|
|||||||
self._is_sick = is_sick
|
self._is_sick = is_sick
|
||||||
self._has_dot = has_dot
|
self._has_dot = has_dot
|
||||||
self._on_day = on_day
|
self._on_day = on_day
|
||||||
self._first, self._last = _dates.month_bounds(today)
|
self._first, self._last = _text.month_bounds(today)
|
||||||
self.month = today.replace(day=1)
|
self.month = today.replace(day=1)
|
||||||
self._header = tk.Frame(self, name="header", bg=self.cget("bg"))
|
self._header = tk.Frame(self, name="header", bg=self.cget("bg"))
|
||||||
self._header.pack(fill="x")
|
self._header.pack(fill="x")
|
||||||
@ -70,7 +66,7 @@ class MonthCalendar(tk.Frame):
|
|||||||
|
|
||||||
def step(self, months: int) -> None:
|
def step(self, months: int) -> None:
|
||||||
"""Move ``months`` months (within the phone's range) and redraw."""
|
"""Move ``months`` months (within the phone's range) and redraw."""
|
||||||
target = _dates.shift_month(self.month, months)
|
target = _text.shift_month(self.month, months)
|
||||||
if self._first <= target <= self._last:
|
if self._first <= target <= self._last:
|
||||||
self.month = target
|
self.month = target
|
||||||
self.redraw()
|
self.redraw()
|
||||||
@ -81,12 +77,11 @@ class MonthCalendar(tk.Frame):
|
|||||||
for child in frame.winfo_children():
|
for child in frame.winfo_children():
|
||||||
child.destroy()
|
child.destroy()
|
||||||
self._draw_header()
|
self._draw_header()
|
||||||
for column in range(7):
|
for column, initial in enumerate(_text.WEEKDAY_INITIALS):
|
||||||
mark = "ring" if _dates.is_weekend_column(column) else "dot"
|
ui.label(self._grid, initial, "caption", fg=tok.MUTED_ON_DARK).grid(
|
||||||
IconSlot(self._grid, size=_MARK_PX).show(mark, tok.MUTED_ON_DARK).grid(
|
row=0, column=column
|
||||||
row=0, column=column, pady=(0, tok.XS)
|
|
||||||
)
|
)
|
||||||
for row, week in enumerate(_dates.month_weeks(self.month), start=1):
|
for row, week in enumerate(_text.month_weeks(self.month), start=1):
|
||||||
for column, day in enumerate(week):
|
for column, day in enumerate(week):
|
||||||
if day is not None:
|
if day is not None:
|
||||||
self._cell(day).grid(
|
self._cell(day).grid(
|
||||||
@ -94,20 +89,20 @@ class MonthCalendar(tk.Frame):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _draw_header(self) -> None:
|
def _draw_header(self) -> None:
|
||||||
back = IconButton(self._header, "prev", lambda: self.step(-1), name="prev")
|
back = ui.button(self._header, _PREV, lambda: self.step(-1), name="prev")
|
||||||
forward = IconButton(self._header, "next", lambda: self.step(1), name="next")
|
forward = ui.button(self._header, _NEXT, lambda: self.step(1), name="next")
|
||||||
back.set_enabled(self.month > self._first)
|
back.configure(state="normal" if self.month > self._first else "disabled")
|
||||||
forward.set_enabled(self.month < self._last)
|
forward.configure(state="normal" if self.month < self._last else "disabled")
|
||||||
back.pack(side="left")
|
back.pack(side="left")
|
||||||
forward.pack(side="right")
|
forward.pack(side="right")
|
||||||
ui.label(self._header, _dates.month_title(self.month), "subtitle").pack(
|
ui.label(self._header, _text.month_title(self.month), "subtitle").pack(
|
||||||
side="top", pady=tok.XS
|
side="top", pady=tok.XS
|
||||||
)
|
)
|
||||||
|
|
||||||
def _cell(self, day: date) -> tk.Button:
|
def _cell(self, day: date) -> tk.Button:
|
||||||
sick = self._is_sick(day)
|
sick = self._is_sick(day)
|
||||||
past = day < self._today
|
past = day < self._today
|
||||||
fill, text = _cell_colours(sick=sick, past=past, workday=_dates.is_workday(day))
|
fill, text = _cell_colours(sick=sick, past=past, workday=_text.is_workday(day))
|
||||||
cell = tk.Button(
|
cell = tk.Button(
|
||||||
self._grid,
|
self._grid,
|
||||||
name=f"day-{to_iso(day)}",
|
name=f"day-{to_iso(day)}",
|
||||||
@ -129,6 +124,7 @@ class MonthCalendar(tk.Frame):
|
|||||||
cursor="arrow" if past else "hand2",
|
cursor="arrow" if past else "hand2",
|
||||||
)
|
)
|
||||||
if sick and self._has_dot(day):
|
if sick and self._has_dot(day):
|
||||||
dot = IconSlot(cell, name="dot", size=_DOT_PX).show("dot", text)
|
dot = tk.Label(cell, name="dot", text="●", fg=text, bg=fill)
|
||||||
dot.place(relx=1.0, x=-2, y=2, anchor="ne")
|
dot.configure(font=tok.font(ui.family(self), "caption"), padx=0, pady=0)
|
||||||
|
dot.place(relx=1.0, x=-2, y=0, anchor="ne")
|
||||||
return cell
|
return cell
|
||||||
|
|||||||
@ -1,171 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Word-free controls: an icon button, an on/off switch and an icon slot.
|
|
||||||
|
|
||||||
Canvases, because a Tk Button or Checkbutton can only show its state as a
|
|
||||||
word or a font glyph (see :mod:`sickdays.gui._icons`). Each keeps Tk's own
|
|
||||||
``state`` option and offers ``invoke()``, so callers and tests drive them
|
|
||||||
like the widgets they replace. Every state has two channels: shape (icon,
|
|
||||||
knob position) plus colour.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING, Final, Literal
|
|
||||||
|
|
||||||
from sickdays.gui import _tokens as tok
|
|
||||||
from sickdays.gui._icons import TAG, draw
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
ButtonKind = Literal["primary", "danger", "plain"]
|
|
||||||
|
|
||||||
#: (fill, icon colour) per button kind; danger keeps its red on a dark fill.
|
|
||||||
_BUTTON_COLOURS: Final[dict[ButtonKind, tuple[str, str]]] = {
|
|
||||||
"primary": (tok.ACCENT, tok.ON_FILL),
|
|
||||||
"danger": (tok.INK_RAISED_2, tok.DANGER),
|
|
||||||
"plain": (tok.INK_RAISED_2, tok.TEXT_ON_DARK),
|
|
||||||
}
|
|
||||||
ICON_PX: Final = 20
|
|
||||||
_TRACK_W: Final = 44
|
|
||||||
_TRACK_H: Final = 24
|
|
||||||
|
|
||||||
|
|
||||||
def _enabled(widget: tk.Canvas) -> bool:
|
|
||||||
return str(widget.cget("state")) != "disabled"
|
|
||||||
|
|
||||||
|
|
||||||
class IconButton(tk.Canvas):
|
|
||||||
"""A flat button showing ``icon``; ``invoke()`` runs ``command`` unless disabled."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent: tk.Misc,
|
|
||||||
icon: str,
|
|
||||||
command: Callable[[], object],
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
kind: ButtonKind = "plain",
|
|
||||||
) -> None:
|
|
||||||
"""Draw the button; a click anywhere on it invokes it."""
|
|
||||||
fill, self._colour = _BUTTON_COLOURS[kind]
|
|
||||||
super().__init__(
|
|
||||||
parent,
|
|
||||||
name=name,
|
|
||||||
width=ICON_PX + 2 * tok.MD,
|
|
||||||
height=ICON_PX + 2 * tok.SM,
|
|
||||||
bg=fill,
|
|
||||||
highlightthickness=0,
|
|
||||||
borderwidth=0,
|
|
||||||
cursor="hand2",
|
|
||||||
)
|
|
||||||
#: Which pictogram this button shows.
|
|
||||||
self.icon = icon
|
|
||||||
self._command = command
|
|
||||||
self.bind("<Button-1>", lambda _event: self.invoke())
|
|
||||||
self._paint()
|
|
||||||
|
|
||||||
def set_enabled(self, enabled: bool) -> None: # noqa: FBT001
|
|
||||||
"""Grey the icon out and ignore clicks while disabled."""
|
|
||||||
self.configure(
|
|
||||||
state="normal" if enabled else "disabled",
|
|
||||||
cursor="hand2" if enabled else "arrow",
|
|
||||||
)
|
|
||||||
self._paint()
|
|
||||||
|
|
||||||
def invoke(self) -> None:
|
|
||||||
"""Run the command, as a click would."""
|
|
||||||
if _enabled(self):
|
|
||||||
self._command()
|
|
||||||
|
|
||||||
def _paint(self) -> None:
|
|
||||||
self.delete(TAG)
|
|
||||||
colour = self._colour if _enabled(self) else tok.MUTED_ON_DARK
|
|
||||||
draw(self, self.icon, size=ICON_PX, colour=colour, at=(tok.MD, tok.SM))
|
|
||||||
|
|
||||||
|
|
||||||
class Toggle(tk.Canvas):
|
|
||||||
"""An on/off switch for ``variable``: knob right on accent, left on dark."""
|
|
||||||
|
|
||||||
def __init__(self, parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> None:
|
|
||||||
"""Draw the switch in ``variable``'s current state."""
|
|
||||||
super().__init__(
|
|
||||||
parent,
|
|
||||||
name=name,
|
|
||||||
width=_TRACK_W,
|
|
||||||
height=_TRACK_H,
|
|
||||||
bg=parent.cget("bg"),
|
|
||||||
highlightthickness=0,
|
|
||||||
borderwidth=0,
|
|
||||||
cursor="hand2",
|
|
||||||
)
|
|
||||||
self._variable = variable
|
|
||||||
self.bind("<Button-1>", lambda _event: self.invoke())
|
|
||||||
self.paint()
|
|
||||||
|
|
||||||
def set_enabled(self, enabled: bool) -> None: # noqa: FBT001
|
|
||||||
"""Fade the switch and ignore clicks while disabled."""
|
|
||||||
self.configure(
|
|
||||||
state="normal" if enabled else "disabled",
|
|
||||||
cursor="hand2" if enabled else "arrow",
|
|
||||||
)
|
|
||||||
self.paint()
|
|
||||||
|
|
||||||
def invoke(self) -> None:
|
|
||||||
"""Flip the switch, as a click would."""
|
|
||||||
if _enabled(self):
|
|
||||||
self._variable.set(not self._variable.get())
|
|
||||||
self.paint()
|
|
||||||
|
|
||||||
def is_on(self) -> bool:
|
|
||||||
"""Whether the locker this switch stands for still applies."""
|
|
||||||
return bool(self._variable.get())
|
|
||||||
|
|
||||||
def paint(self) -> None:
|
|
||||||
"""Redraw the track and knob from the variable."""
|
|
||||||
self.delete(TAG)
|
|
||||||
on = self.is_on()
|
|
||||||
track, knob = (
|
|
||||||
(tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.MUTED_ON_DARK)
|
|
||||||
)
|
|
||||||
if not _enabled(self):
|
|
||||||
track = tok.blend(track, tok.INK, tok.PAST_ALPHA)
|
|
||||||
knob = tok.blend(knob, tok.INK, tok.PAST_ALPHA)
|
|
||||||
h, w = _TRACK_H, _TRACK_W
|
|
||||||
self.create_oval(0, 0, h, h, fill=track, outline=track, tags=TAG)
|
|
||||||
self.create_oval(w - h, 0, w, h, fill=track, outline=track, tags=TAG)
|
|
||||||
self.create_rectangle(
|
|
||||||
h // 2, 0, w - h // 2, h, fill=track, outline=track, tags=TAG
|
|
||||||
)
|
|
||||||
left = w - h + 3 if on else 3
|
|
||||||
self.create_oval(
|
|
||||||
left, 3, left + h - 6, h - 3, fill=knob, outline=knob, tags=TAG
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class IconSlot(tk.Canvas):
|
|
||||||
"""One icon, or nothing: a status mark, a locker's pictogram, a marker."""
|
|
||||||
|
|
||||||
def __init__(self, parent: tk.Misc, *, name: str = "", size: int = ICON_PX) -> None:
|
|
||||||
"""An empty slot ``size`` px square on ``parent``'s background."""
|
|
||||||
super().__init__(
|
|
||||||
parent,
|
|
||||||
name=name,
|
|
||||||
width=size,
|
|
||||||
height=size,
|
|
||||||
bg=parent.cget("bg"),
|
|
||||||
highlightthickness=0,
|
|
||||||
borderwidth=0,
|
|
||||||
)
|
|
||||||
self._size = size
|
|
||||||
#: The pictogram shown, or ``None`` when the slot is empty.
|
|
||||||
self.icon: str | None = None
|
|
||||||
|
|
||||||
def show(self, icon: str | None, colour: str = tok.TEXT_ON_DARK) -> IconSlot:
|
|
||||||
"""Show ``icon`` in ``colour`` (``None`` clears the slot)."""
|
|
||||||
self.delete(TAG)
|
|
||||||
self.icon = icon
|
|
||||||
if icon is not None:
|
|
||||||
draw(self, icon, size=self._size, colour=colour)
|
|
||||||
return self
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Calendar arithmetic and the free-day check for the picker -- no Tk in here.
|
|
||||||
|
|
||||||
The picker shows no words (kuhy's textgate rule): dates are ISO numerals and
|
|
||||||
a refusal is a reason *code* the widgets turn into an icon. The library's
|
|
||||||
own message for an unavailable check goes to the log, not the screen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import calendar
|
|
||||||
from datetime import date
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING, Final, Literal
|
|
||||||
|
|
||||||
from sickdays._conflict import free_day_predicate
|
|
||||||
from sickdays._constants import LOCKERS
|
|
||||||
from sickdays._errors import ConflictCheckUnavailableError
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterable
|
|
||||||
|
|
||||||
_LOG = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
#: Why a day cannot be marked sick: it is a free day, or that cannot be checked.
|
|
||||||
Block = Literal["free", "unavailable"]
|
|
||||||
|
|
||||||
#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``).
|
|
||||||
_WORKDAYS: Final = frozenset({1, 2, 3})
|
|
||||||
#: Sat/Sun, marked with a ring instead of a dot over the grid.
|
|
||||||
_WEEKEND: Final = frozenset({5, 6})
|
|
||||||
|
|
||||||
|
|
||||||
def kept_in_order(kept: Iterable[str]) -> tuple[str, ...]:
|
|
||||||
"""The kept lockers in ``sickdays.LOCKERS`` order, not the set's."""
|
|
||||||
chosen = set(kept)
|
|
||||||
return tuple(locker for locker in LOCKERS if locker in chosen)
|
|
||||||
|
|
||||||
|
|
||||||
def free_day_block(day: date) -> Block | None:
|
|
||||||
"""Why ``day`` cannot be marked sick for the free-day pool, or None.
|
|
||||||
|
|
||||||
"Could not check" refuses too: it must never count as "not a free day".
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
is_free = free_day_predicate()
|
|
||||||
except ConflictCheckUnavailableError as exc:
|
|
||||||
_LOG.warning("free-day check unavailable: %s", exc)
|
|
||||||
return "unavailable"
|
|
||||||
return "free" if is_free(day) else None
|
|
||||||
|
|
||||||
|
|
||||||
def is_workday(day: date) -> bool:
|
|
||||||
"""Whether ``day`` is a weekly workday (Tue/Wed/Thu), shaded on the grid."""
|
|
||||||
return day.weekday() in _WORKDAYS
|
|
||||||
|
|
||||||
|
|
||||||
def is_weekend_column(column: int) -> bool:
|
|
||||||
"""Whether grid ``column`` (0 = Monday) is Saturday or Sunday."""
|
|
||||||
return column in _WEEKEND
|
|
||||||
|
|
||||||
|
|
||||||
def month_title(month: date) -> str:
|
|
||||||
"""``"2026-10"``: the month as numerals, never a month name."""
|
|
||||||
return f"{month.year}-{month.month:02d}"
|
|
||||||
|
|
||||||
|
|
||||||
def month_weeks(month: date) -> list[list[date | None]]:
|
|
||||||
"""The month as Monday-first weeks, ``None`` padding either end."""
|
|
||||||
return [
|
|
||||||
[None if not cell else date(month.year, month.month, cell) for cell in week]
|
|
||||||
for week in calendar.Calendar(firstweekday=0).monthdayscalendar(
|
|
||||||
month.year, month.month
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def shift_month(month: date, step: int) -> date:
|
|
||||||
"""The first of the month ``step`` months after ``month``."""
|
|
||||||
index = month.year * 12 + month.month - 1 + step
|
|
||||||
return date(index // 12, index % 12 + 1, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def month_bounds(reference: date) -> tuple[date, date]:
|
|
||||||
"""January this year to December next year, the phone's range."""
|
|
||||||
return date(reference.year, 1, 1), date(reference.year + 1, 12, 1)
|
|
||||||
@ -1,235 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Pictograms drawn as Canvas geometry, so the picker needs no words.
|
|
||||||
|
|
||||||
Drawn rather than typed: the venv's Tk falls back to core X fonts, where
|
|
||||||
every non-ASCII glyph (angle quotes, check marks) renders as nothing; the
|
|
||||||
old month arrows were blank buttons. Each icon is plotted on a unit square,
|
|
||||||
scaled to ``size`` pixels, and tagged ``icon`` so a repaint can drop it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Final
|
|
||||||
|
|
||||||
from sickdays._constants import (
|
|
||||||
LOCKER_ALARM,
|
|
||||||
LOCKER_BOOK,
|
|
||||||
LOCKER_DIET,
|
|
||||||
LOCKER_FOCUS,
|
|
||||||
LOCKER_LEETCODE,
|
|
||||||
LOCKER_WORKOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
import tkinter as tk
|
|
||||||
|
|
||||||
TAG: Final = "icon"
|
|
||||||
|
|
||||||
|
|
||||||
class Pen:
|
|
||||||
"""Draws unit-square coordinates onto ``canvas`` at ``(x, y)``, ``size`` wide."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, canvas: tk.Canvas, *, size: int, colour: str, at: tuple[int, int]
|
|
||||||
) -> None:
|
|
||||||
"""Remember where and how large to draw."""
|
|
||||||
self._canvas = canvas
|
|
||||||
self._size = size
|
|
||||||
self._colour = colour
|
|
||||||
self._x, self._y = at
|
|
||||||
self.width = max(2, round(size / 10))
|
|
||||||
|
|
||||||
def _xy(self, points: tuple[float, ...]) -> list[float]:
|
|
||||||
return [
|
|
||||||
(self._y if i % 2 else self._x) + value * self._size
|
|
||||||
for i, value in enumerate(points)
|
|
||||||
]
|
|
||||||
|
|
||||||
def line(self, *points: float) -> None:
|
|
||||||
"""A stroke through ``points`` (x0, y0, x1, y1, ...)."""
|
|
||||||
self._canvas.create_line(
|
|
||||||
*self._xy(points),
|
|
||||||
fill=self._colour,
|
|
||||||
width=self.width,
|
|
||||||
capstyle="round",
|
|
||||||
joinstyle="round",
|
|
||||||
tags=TAG,
|
|
||||||
)
|
|
||||||
|
|
||||||
def oval(self, *box: float, filled: bool = False) -> None:
|
|
||||||
"""An ellipse in ``box`` (x0, y0, x1, y1), outlined or filled."""
|
|
||||||
self._canvas.create_oval(
|
|
||||||
*self._xy(box),
|
|
||||||
outline=self._colour,
|
|
||||||
fill=self._colour if filled else "",
|
|
||||||
width=self.width,
|
|
||||||
tags=TAG,
|
|
||||||
)
|
|
||||||
|
|
||||||
def shape(self, *points: float, filled: bool = False) -> None:
|
|
||||||
"""A closed polygon through ``points``, outlined or filled."""
|
|
||||||
self._canvas.create_polygon(
|
|
||||||
*self._xy(points),
|
|
||||||
outline=self._colour,
|
|
||||||
fill=self._colour if filled else "",
|
|
||||||
width=self.width,
|
|
||||||
joinstyle="round",
|
|
||||||
tags=TAG,
|
|
||||||
)
|
|
||||||
|
|
||||||
def arch(self, *box: float) -> None:
|
|
||||||
"""The top half of the ellipse in ``box`` (a padlock's shackle)."""
|
|
||||||
self._canvas.create_arc(
|
|
||||||
*self._xy(box),
|
|
||||||
start=0,
|
|
||||||
extent=180,
|
|
||||||
style="arc",
|
|
||||||
outline=self._colour,
|
|
||||||
width=self.width,
|
|
||||||
tags=TAG,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _check(pen: Pen) -> None:
|
|
||||||
pen.line(0.2, 0.55, 0.42, 0.76, 0.82, 0.28)
|
|
||||||
|
|
||||||
|
|
||||||
def _cross(pen: Pen) -> None:
|
|
||||||
pen.line(0.25, 0.25, 0.75, 0.75)
|
|
||||||
pen.line(0.75, 0.25, 0.25, 0.75)
|
|
||||||
|
|
||||||
|
|
||||||
def _trash(pen: Pen) -> None:
|
|
||||||
pen.line(0.18, 0.26, 0.82, 0.26)
|
|
||||||
pen.line(0.4, 0.26, 0.42, 0.14, 0.58, 0.14, 0.6, 0.26)
|
|
||||||
pen.shape(0.27, 0.34, 0.73, 0.34, 0.68, 0.88, 0.32, 0.88)
|
|
||||||
|
|
||||||
|
|
||||||
def _prev(pen: Pen) -> None:
|
|
||||||
pen.line(0.62, 0.22, 0.36, 0.5, 0.62, 0.78)
|
|
||||||
|
|
||||||
|
|
||||||
def _next(pen: Pen) -> None:
|
|
||||||
pen.line(0.38, 0.22, 0.64, 0.5, 0.38, 0.78)
|
|
||||||
|
|
||||||
|
|
||||||
def _workout(pen: Pen) -> None:
|
|
||||||
pen.line(0.2, 0.5, 0.8, 0.5)
|
|
||||||
pen.shape(0.17, 0.28, 0.29, 0.28, 0.29, 0.72, 0.17, 0.72, filled=True)
|
|
||||||
pen.shape(0.71, 0.28, 0.83, 0.28, 0.83, 0.72, 0.71, 0.72, filled=True)
|
|
||||||
pen.line(0.08, 0.5, 0.17, 0.5)
|
|
||||||
pen.line(0.83, 0.5, 0.92, 0.5)
|
|
||||||
|
|
||||||
|
|
||||||
def _leetcode(pen: Pen) -> None:
|
|
||||||
pen.line(0.36, 0.28, 0.12, 0.5, 0.36, 0.72)
|
|
||||||
pen.line(0.64, 0.28, 0.88, 0.5, 0.64, 0.72)
|
|
||||||
pen.line(0.57, 0.2, 0.43, 0.8)
|
|
||||||
|
|
||||||
|
|
||||||
def _book(pen: Pen) -> None:
|
|
||||||
pen.shape(0.5, 0.3, 0.1, 0.2, 0.1, 0.76, 0.5, 0.86)
|
|
||||||
pen.shape(0.5, 0.3, 0.9, 0.2, 0.9, 0.76, 0.5, 0.86)
|
|
||||||
|
|
||||||
|
|
||||||
def _diet(pen: Pen) -> None:
|
|
||||||
pen.oval(0.2, 0.34, 0.8, 0.9, filled=True)
|
|
||||||
pen.line(0.5, 0.34, 0.54, 0.14)
|
|
||||||
pen.oval(0.56, 0.12, 0.78, 0.26, filled=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _alarm(pen: Pen) -> None:
|
|
||||||
pen.oval(0.18, 0.24, 0.82, 0.88)
|
|
||||||
pen.line(0.5, 0.38, 0.5, 0.56, 0.63, 0.64)
|
|
||||||
pen.oval(0.08, 0.1, 0.3, 0.3, filled=True)
|
|
||||||
pen.oval(0.7, 0.1, 0.92, 0.3, filled=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _focus(pen: Pen) -> None:
|
|
||||||
pen.oval(0.1, 0.1, 0.9, 0.9)
|
|
||||||
pen.oval(0.3, 0.3, 0.7, 0.7)
|
|
||||||
pen.oval(0.44, 0.44, 0.56, 0.56, filled=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _padlock(pen: Pen) -> None:
|
|
||||||
pen.arch(0.3, 0.12, 0.7, 0.62)
|
|
||||||
pen.shape(0.22, 0.44, 0.78, 0.44, 0.78, 0.88, 0.22, 0.88, filled=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _no_entry(pen: Pen) -> None:
|
|
||||||
pen.oval(0.14, 0.14, 0.86, 0.86)
|
|
||||||
pen.line(0.28, 0.28, 0.72, 0.72)
|
|
||||||
|
|
||||||
|
|
||||||
def _warning(pen: Pen) -> None:
|
|
||||||
pen.shape(0.5, 0.12, 0.9, 0.86, 0.1, 0.86)
|
|
||||||
pen.line(0.5, 0.38, 0.5, 0.6)
|
|
||||||
pen.oval(0.47, 0.71, 0.53, 0.77, filled=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _hourglass(pen: Pen) -> None:
|
|
||||||
pen.line(0.22, 0.12, 0.78, 0.12)
|
|
||||||
pen.line(0.22, 0.88, 0.78, 0.88)
|
|
||||||
pen.shape(0.28, 0.12, 0.72, 0.12, 0.28, 0.88, 0.72, 0.88)
|
|
||||||
|
|
||||||
|
|
||||||
def _sick(pen: Pen) -> None:
|
|
||||||
pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85, filled=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _normal(pen: Pen) -> None:
|
|
||||||
pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85)
|
|
||||||
|
|
||||||
|
|
||||||
def _dot(pen: Pen) -> None:
|
|
||||||
pen.oval(0.3, 0.3, 0.7, 0.7, filled=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _ring(pen: Pen) -> None:
|
|
||||||
pen.oval(0.25, 0.25, 0.75, 0.75)
|
|
||||||
|
|
||||||
|
|
||||||
ICONS: Final[dict[str, Callable[[Pen], None]]] = {
|
|
||||||
"check": _check,
|
|
||||||
"cross": _cross,
|
|
||||||
"trash": _trash,
|
|
||||||
"prev": _prev,
|
|
||||||
"next": _next,
|
|
||||||
"workout": _workout,
|
|
||||||
"leetcode": _leetcode,
|
|
||||||
"book": _book,
|
|
||||||
"diet": _diet,
|
|
||||||
"alarm": _alarm,
|
|
||||||
"focus": _focus,
|
|
||||||
"padlock": _padlock,
|
|
||||||
"no_entry": _no_entry,
|
|
||||||
"warning": _warning,
|
|
||||||
"hourglass": _hourglass,
|
|
||||||
"sick": _sick,
|
|
||||||
"normal": _normal,
|
|
||||||
"dot": _dot,
|
|
||||||
"ring": _ring,
|
|
||||||
}
|
|
||||||
|
|
||||||
#: The pictogram each locker is shown as, in place of its name.
|
|
||||||
LOCKER_ICONS: Final = {
|
|
||||||
LOCKER_WORKOUT: "workout",
|
|
||||||
LOCKER_LEETCODE: "leetcode",
|
|
||||||
LOCKER_BOOK: "book",
|
|
||||||
LOCKER_DIET: "diet",
|
|
||||||
LOCKER_ALARM: "alarm",
|
|
||||||
LOCKER_FOCUS: "focus",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def draw(
|
|
||||||
canvas: tk.Canvas,
|
|
||||||
icon: str,
|
|
||||||
*,
|
|
||||||
size: int,
|
|
||||||
colour: str,
|
|
||||||
at: tuple[int, int] = (0, 0),
|
|
||||||
) -> None:
|
|
||||||
"""Draw ``icon`` on ``canvas`` in a ``size`` px square whose corner is ``at``."""
|
|
||||||
ICONS[icon](Pen(canvas, size=size, colour=colour, at=at))
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""KeepPanel: a switch per locker for one day, and the buttons that write them.
|
"""KeepPanel: the five switches for one day, and the buttons that write them.
|
||||||
|
|
||||||
The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no
|
The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no
|
||||||
mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call,
|
mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call,
|
||||||
@ -7,34 +7,29 @@ so screen-locker can drop it onto its fullscreen surface in-process, under
|
|||||||
its own X grab (a separate window could not get input there).
|
its own X grab (a separate window could not get input there).
|
||||||
|
|
||||||
On is "this locker still applies", off is "stands down", all off by default
|
On is "this locker still applies", off is "stands down", all off by default
|
||||||
-- the phone's ``SickDaySheet``. A normal day offers "mark sick" (check); a
|
-- the phone's ``SickDaySheet``. A normal day offers "Mark sick"; a sick day
|
||||||
sick day offers "save" (check, a keep-only edit) and "clear" (red bin). A
|
offers "Save" (a keep-only edit) and "Clear sick day". A past day, a free
|
||||||
past day (padlock), a free day (no-entry sign) or a free-day pool that cannot
|
day, or a free-day pool that cannot be checked shows why and offers no
|
||||||
be checked (warning) shows its icon and offers no write. A refused write is
|
write. A refused write is shown in the panel, never raised: an exception in a
|
||||||
shown in the panel as an icon, never raised: an exception in a Tk callback
|
Tk callback only reaches stderr, which nobody sees under a lock.
|
||||||
only reaches stderr, which nobody sees under a lock. The panel shows no
|
|
||||||
words (kuhy's textgate rule): a locker is its pictogram, the day its ISO date.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import threading
|
import threading
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING, Final
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day
|
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day
|
||||||
from sickdays._constants import LOCKERS
|
from sickdays._constants import LOCKERS
|
||||||
from sickdays._day import to_iso, today
|
from sickdays._day import today
|
||||||
from sickdays._errors import SickDayError
|
from sickdays._errors import SickDayError
|
||||||
from sickdays._keep_api import kept_lockers, set_kept_lockers
|
from sickdays._keep_api import kept_lockers, set_kept_lockers
|
||||||
from sickdays._paths import resolve_paths
|
from sickdays._paths import resolve_paths
|
||||||
from sickdays._sync import sync_quietly
|
from sickdays._sync import sync_quietly
|
||||||
from sickdays.gui import _dates
|
from sickdays.gui import _text
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
from sickdays.gui import _widgets as ui
|
from sickdays.gui import _widgets as ui
|
||||||
from sickdays.gui._controls import IconButton, IconSlot, Toggle
|
|
||||||
from sickdays.gui._icons import LOCKER_ICONS
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@ -44,17 +39,6 @@ if TYPE_CHECKING:
|
|||||||
from sickdays._paths import Paths
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
|
|
||||||
_LOG = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
#: (icon, colour) for each reason the panel shows, keyed by that reason.
|
|
||||||
STATUS_ICONS: Final = {
|
|
||||||
"past": ("padlock", tok.MUTED_ON_DARK),
|
|
||||||
"free": ("no_entry", tok.DANGER),
|
|
||||||
"unavailable": ("warning", tok.DANGER),
|
|
||||||
"refused": ("warning", tok.DANGER),
|
|
||||||
"busy": ("hourglass", tok.DANGER),
|
|
||||||
}
|
|
||||||
|
|
||||||
#: Held by every background sync, and tried by every write. ``sync()`` reads
|
#: Held by every background sync, and tried by every write. ``sync()`` reads
|
||||||
#: the pool, waits on the network, then writes its merged copy back, so a
|
#: the pool, waits on the network, then writes its merged copy back, so a
|
||||||
#: write landing in between would be silently overwritten. A write that finds
|
#: write landing in between would be silently overwritten. A write that finds
|
||||||
@ -89,17 +73,16 @@ class KeepPanel(tk.Frame):
|
|||||||
``on_saved`` is called once per successful click, after the write and
|
``on_saved`` is called once per successful click, after the write and
|
||||||
after the background sync has started:
|
after the background sync has started:
|
||||||
|
|
||||||
* ``actions.mark`` -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
|
* "Mark sick" -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
|
||||||
* ``actions.save`` with a changed set -> the stored entry (``is_sick=True``);
|
* "Save" with a changed set -> the stored entry (``is_sick=True``);
|
||||||
* ``actions.save`` with nothing changed -> ``None``: nothing written, no sync;
|
* "Save" with nothing changed -> ``None``: nothing written, no sync;
|
||||||
* ``actions.clear`` -> the cleared entry (``is_sick=False``).
|
* "Clear sick day" -> the cleared entry (``is_sick=False``).
|
||||||
|
|
||||||
A refused write (:class:`~sickdays.SickDayError`, or a click while the
|
A refused write (:class:`~sickdays.SickDayError`, or a click while the
|
||||||
previous write's sync is still running) is shown in the panel's
|
previous write's sync is still running) is shown in the panel and
|
||||||
``status`` slot and ``on_saved`` is not called; :attr:`reason` names it
|
``on_saved`` is not called. ``on_cancel``, when given, adds a
|
||||||
(a :data:`STATUS_ICONS` key). ``on_cancel``, when given, adds an
|
"Cancel" button that calls it. The panel never destroys itself; the
|
||||||
``actions.cancel`` cross that calls it. The panel never destroys itself;
|
callbacks decide what happens to it.
|
||||||
the callbacks decide what happens to it.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -124,15 +107,12 @@ class KeepPanel(tk.Frame):
|
|||||||
}
|
}
|
||||||
#: The last background sync, for callers (and tests) that wait on it.
|
#: The last background sync, for callers (and tests) that wait on it.
|
||||||
self.sync_thread: threading.Thread | None = None
|
self.sync_thread: threading.Thread | None = None
|
||||||
#: Why the panel refuses, a :data:`STATUS_ICONS` key, or ``None``.
|
|
||||||
self.reason: str | None = None
|
|
||||||
blocked = self._blocked_reason()
|
blocked = self._blocked_reason()
|
||||||
self._draw_switches(editable=blocked is None)
|
self._draw_switches(editable=blocked is None)
|
||||||
self._status = IconSlot(self, name="status")
|
self._status = ui.label(
|
||||||
self._status.grid(
|
self, blocked or "", "label", fg=tok.DANGER, name="status"
|
||||||
row=len(LOCKERS) + 1, column=0, columnspan=2, sticky="w", pady=(tok.SM, 0)
|
|
||||||
)
|
)
|
||||||
self._show(blocked)
|
self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w")
|
||||||
self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
|
self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
|
||||||
|
|
||||||
def kept(self) -> frozenset[str]:
|
def kept(self) -> frozenset[str]:
|
||||||
@ -141,54 +121,49 @@ class KeepPanel(tk.Frame):
|
|||||||
|
|
||||||
def _blocked_reason(self) -> str | None:
|
def _blocked_reason(self) -> str | None:
|
||||||
if self._day < today():
|
if self._day < today():
|
||||||
return "past"
|
return _text.PAST_NOTE
|
||||||
return None if self._sick else _dates.free_day_block(self._day)
|
return None if self._sick else _text.free_day_refusal(self._day)
|
||||||
|
|
||||||
def _show(self, reason: str | None) -> None:
|
|
||||||
self.reason = reason
|
|
||||||
if reason is None:
|
|
||||||
self._status.show(None)
|
|
||||||
return
|
|
||||||
icon, colour = STATUS_ICONS[reason]
|
|
||||||
self._status.show(icon, colour)
|
|
||||||
|
|
||||||
def _draw_switches(self, *, editable: bool) -> None:
|
def _draw_switches(self, *, editable: bool) -> None:
|
||||||
head = tk.Frame(self, name="head", bg=self.cget("bg"))
|
title = _text.panel_title(self._day, sick=self._sick)
|
||||||
head.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, tok.SM))
|
ui.label(self, title, "subtitle", name="title").grid(
|
||||||
IconSlot(head, name="state").show(
|
row=0, column=0, columnspan=2, sticky="w"
|
||||||
"sick" if self._sick else "normal", tok.SICK
|
)
|
||||||
).pack(side="left", padx=(0, tok.SM))
|
ui.label(self, _text.SHEET_HELP, "caption", fg=tok.MUTED_ON_DARK).grid(
|
||||||
ui.label(head, to_iso(self._day), "subtitle", name="title").pack(side="left")
|
row=1, column=0, columnspan=2, sticky="w", pady=(tok.XS, tok.SM)
|
||||||
for row, locker in enumerate(LOCKERS, start=1):
|
)
|
||||||
IconSlot(self, name=f"icon-{locker}").show(LOCKER_ICONS[locker]).grid(
|
for row, locker in enumerate(LOCKERS, start=2):
|
||||||
|
ui.label(self, _text.LOCKER_LABELS[locker], "body").grid(
|
||||||
row=row, column=0, sticky="w", pady=tok.XS
|
row=row, column=0, sticky="w", pady=tok.XS
|
||||||
)
|
)
|
||||||
switch = Toggle(self, self._vars[locker], name=f"keep-{locker}")
|
switch = ui.toggle(self, self._vars[locker], name=f"keep-{locker}")
|
||||||
switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS)
|
switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS)
|
||||||
if not editable:
|
if not editable:
|
||||||
switch.set_enabled(False)
|
switch.configure(state="disabled")
|
||||||
|
|
||||||
def _draw_actions(
|
def _draw_actions(
|
||||||
self, *, writable: bool, on_cancel: Callable[[], None] | None
|
self, *, writable: bool, on_cancel: Callable[[], None] | None
|
||||||
) -> None:
|
) -> None:
|
||||||
actions = tk.Frame(self, name="actions", bg=self.cget("bg"))
|
actions = tk.Frame(self, name="actions", bg=self.cget("bg"))
|
||||||
actions.grid(
|
actions.grid(
|
||||||
row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
|
row=len(LOCKERS) + 3, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
|
||||||
)
|
)
|
||||||
buttons: list[IconButton] = []
|
buttons: list[tk.Button] = []
|
||||||
if writable and self._sick:
|
if writable and self._sick:
|
||||||
buttons.append(
|
buttons.append(
|
||||||
IconButton(actions, "check", self._save, kind="primary", name="save")
|
ui.button(actions, "Save", self._save, kind="primary", name="save")
|
||||||
)
|
)
|
||||||
buttons.append(
|
buttons.append(
|
||||||
IconButton(actions, "trash", self._clear, kind="danger", name="clear")
|
ui.button(
|
||||||
|
actions, "Clear sick day", self._clear, kind="danger", name="clear"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif writable:
|
elif writable:
|
||||||
buttons.append(
|
buttons.append(
|
||||||
IconButton(actions, "check", self._mark, kind="primary", name="mark")
|
ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark")
|
||||||
)
|
)
|
||||||
if on_cancel is not None:
|
if on_cancel is not None:
|
||||||
buttons.append(IconButton(actions, "cross", on_cancel, name="cancel"))
|
buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel"))
|
||||||
for button in buttons:
|
for button in buttons:
|
||||||
button.pack(side="left", padx=(0, tok.SM))
|
button.pack(side="left", padx=(0, tok.SM))
|
||||||
|
|
||||||
@ -212,13 +187,12 @@ class KeepPanel(tk.Frame):
|
|||||||
# Non-blocking on purpose (no ``with``): waiting would freeze Tk for
|
# Non-blocking on purpose (no ``with``): waiting would freeze Tk for
|
||||||
# the whole network round trip.
|
# the whole network round trip.
|
||||||
if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with
|
if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with
|
||||||
self._show("busy")
|
self._status.configure(text=_text.BUSY_NOTE, fg=tok.DANGER)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
entry = action()
|
entry = action()
|
||||||
except SickDayError as exc:
|
except SickDayError as exc:
|
||||||
_LOG.warning("sick-day write refused: %s", exc)
|
self._status.configure(text=str(exc), fg=tok.DANGER)
|
||||||
self._show("refused")
|
|
||||||
return
|
return
|
||||||
finally:
|
finally:
|
||||||
_SYNC_LOCK.release()
|
_SYNC_LOCK.release()
|
||||||
|
|||||||
125
sickdays/sickdays/gui/_text.py
Normal file
125
sickdays/sickdays/gui/_text.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Wording and calendar arithmetic for the picker -- no Tk in here.
|
||||||
|
|
||||||
|
Every string is the phone's (``wake-alarm/phone_app/lib/screens/
|
||||||
|
sick_day_sheet.dart`` and ``sick_days_body.dart``), so the two pickers read
|
||||||
|
the same. Kept here, apart from the widgets, so it is testable without a
|
||||||
|
display.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
from datetime import date
|
||||||
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
|
from sickdays._conflict import free_day_predicate
|
||||||
|
from sickdays._constants import (
|
||||||
|
LOCKER_ALARM,
|
||||||
|
LOCKER_BOOK,
|
||||||
|
LOCKER_DIET,
|
||||||
|
LOCKER_FOCUS,
|
||||||
|
LOCKER_LEETCODE,
|
||||||
|
LOCKER_WORKOUT,
|
||||||
|
)
|
||||||
|
from sickdays._day import to_iso
|
||||||
|
from sickdays._errors import ConflictCheckUnavailableError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
#: What each locker is called on screen, in ``sickdays.LOCKERS`` order.
|
||||||
|
LOCKER_LABELS: Final = {
|
||||||
|
LOCKER_WORKOUT: "Workout lock",
|
||||||
|
LOCKER_LEETCODE: "LeetCode",
|
||||||
|
LOCKER_BOOK: "Book",
|
||||||
|
LOCKER_DIET: "Diet",
|
||||||
|
LOCKER_ALARM: "Wake alarm",
|
||||||
|
LOCKER_FOCUS: "Focus",
|
||||||
|
}
|
||||||
|
|
||||||
|
SHEET_HELP: Final = (
|
||||||
|
"Off = stands down for the day. Switch on any locker that should still "
|
||||||
|
"apply; it then behaves exactly as on a normal day."
|
||||||
|
)
|
||||||
|
INTRO: Final = (
|
||||||
|
"Click a day to mark it sick and choose which lockers still apply; click "
|
||||||
|
"a sick day to change them or clear it. Today or later only. No limit and "
|
||||||
|
"no reason needed."
|
||||||
|
)
|
||||||
|
INTRO_DETAIL: Final = (
|
||||||
|
"On a sick day every locker left off stands down: no alarm, no lock, and "
|
||||||
|
"work left undone is dropped rather than carried forward. A locker "
|
||||||
|
"switched on behaves as on a normal day; a dot marks a sick day that "
|
||||||
|
"keeps one."
|
||||||
|
)
|
||||||
|
PAST_NOTE: Final = "This day has passed; it can no longer be changed."
|
||||||
|
NONE_AHEAD: Final = "None ahead."
|
||||||
|
BUSY_NOTE: Final = "Syncing \u2014 try again in a moment."
|
||||||
|
WEEKDAY_INITIALS: Final = ("M", "T", "W", "T", "F", "S", "S")
|
||||||
|
|
||||||
|
_WEEKDAYS: Final = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||||
|
#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``).
|
||||||
|
_WORKDAYS: Final = frozenset({1, 2, 3})
|
||||||
|
|
||||||
|
|
||||||
|
def day_label(day: date) -> str:
|
||||||
|
"""``"Tue 2026-10-20"``."""
|
||||||
|
return f"{_WEEKDAYS[day.weekday()]} {to_iso(day)}"
|
||||||
|
|
||||||
|
|
||||||
|
def kept_label(kept: Iterable[str]) -> str:
|
||||||
|
"""The kept lockers as one line, in :data:`LOCKER_LABELS` order."""
|
||||||
|
chosen = set(kept)
|
||||||
|
return ", ".join(label for key, label in LOCKER_LABELS.items() if key in chosen)
|
||||||
|
|
||||||
|
|
||||||
|
def panel_title(day: date, *, sick: bool) -> str:
|
||||||
|
"""``"Mark sick: Tue 2026-10-20"``, or ``"Sick day: ..."`` when editing."""
|
||||||
|
return f"{'Sick day' if sick else 'Mark sick'}: {day_label(day)}"
|
||||||
|
|
||||||
|
|
||||||
|
def free_day_refusal(day: date) -> str | None:
|
||||||
|
"""Why ``day`` cannot be marked sick for the free-day pool, or None.
|
||||||
|
|
||||||
|
"Could not check" refuses too, with the library's own message: it must
|
||||||
|
never count as "not a free day".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
is_free = free_day_predicate()
|
||||||
|
except ConflictCheckUnavailableError as exc:
|
||||||
|
return str(exc)
|
||||||
|
if not is_free(day):
|
||||||
|
return None
|
||||||
|
return f"{to_iso(day)} is a free day — release it in Free days first."
|
||||||
|
|
||||||
|
|
||||||
|
def is_workday(day: date) -> bool:
|
||||||
|
"""Whether ``day`` is a weekly workday (Tue/Wed/Thu), shaded on the grid."""
|
||||||
|
return day.weekday() in _WORKDAYS
|
||||||
|
|
||||||
|
|
||||||
|
def month_title(month: date) -> str:
|
||||||
|
"""``"October 2026"``."""
|
||||||
|
return f"{calendar.month_name[month.month]} {month.year}"
|
||||||
|
|
||||||
|
|
||||||
|
def month_weeks(month: date) -> list[list[date | None]]:
|
||||||
|
"""The month as Monday-first weeks, ``None`` padding either end."""
|
||||||
|
return [
|
||||||
|
[None if not cell else date(month.year, month.month, cell) for cell in week]
|
||||||
|
for week in calendar.Calendar(firstweekday=0).monthdayscalendar(
|
||||||
|
month.year, month.month
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def shift_month(month: date, step: int) -> date:
|
||||||
|
"""The first of the month ``step`` months after ``month``."""
|
||||||
|
index = month.year * 12 + month.month - 1 + step
|
||||||
|
return date(index // 12, index % 12 + 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def month_bounds(reference: date) -> tuple[date, date]:
|
||||||
|
"""January this year to December next year, the phone's range."""
|
||||||
|
return date(reference.year, 1, 1), date(reference.year + 1, 12, 1)
|
||||||
@ -1,20 +1,33 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""Token-styled Tk labels for the numerals the picker shows (dates, months).
|
"""Token-styled Tk building blocks shared by the panel and the window.
|
||||||
|
|
||||||
Every colour comes from :mod:`sickdays.gui._tokens`. Buttons, switches and
|
Every colour comes from :mod:`sickdays.gui._tokens`, and the text colour of a
|
||||||
icons are word-free Canvases in :mod:`sickdays.gui._controls`.
|
filled control is picked from its fill (``on-fill`` on the accent, never the
|
||||||
|
near-white body text), the bug the design system's README warns about.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import font as tkfont
|
from tkinter import font as tkfont
|
||||||
from typing import Final
|
from typing import TYPE_CHECKING, Final, Literal
|
||||||
|
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
ButtonKind = Literal["primary", "danger", "plain"]
|
||||||
_BOLD_ROLES: Final = frozenset({"title", "subtitle"})
|
_BOLD_ROLES: Final = frozenset({"title", "subtitle"})
|
||||||
|
|
||||||
|
#: (background, foreground) per button kind. Danger is a text button, as on
|
||||||
|
#: the phone, so its red sits on the dark surface rather than under ink.
|
||||||
|
_BUTTON_COLOURS: Final[dict[ButtonKind, tuple[str, str]]] = {
|
||||||
|
"primary": (tok.ACCENT, tok.ON_FILL),
|
||||||
|
"danger": (tok.INK_RAISED_2, tok.DANGER),
|
||||||
|
"plain": (tok.INK_RAISED_2, tok.TEXT_ON_DARK),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def family(widget: tk.Misc) -> str:
|
def family(widget: tk.Misc) -> str:
|
||||||
"""The family Tk's default font actually resolves to on this display."""
|
"""The family Tk's default font actually resolves to on this display."""
|
||||||
@ -29,7 +42,10 @@ def label(
|
|||||||
fg: str = tok.TEXT_ON_DARK,
|
fg: str = tok.TEXT_ON_DARK,
|
||||||
name: str = "",
|
name: str = "",
|
||||||
) -> tk.Label:
|
) -> tk.Label:
|
||||||
"""A label on ``parent``'s own background; titles and subtitles are bold."""
|
"""A label on ``parent``'s own background; prose wraps at :data:`tok.WRAP`.
|
||||||
|
|
||||||
|
Titles and subtitles are bold, everything else regular.
|
||||||
|
"""
|
||||||
return tk.Label(
|
return tk.Label(
|
||||||
parent,
|
parent,
|
||||||
name=name,
|
name=name,
|
||||||
@ -39,4 +55,71 @@ def label(
|
|||||||
font=tok.font(family(parent), role, bold=role in _BOLD_ROLES),
|
font=tok.font(family(parent), role, bold=role in _BOLD_ROLES),
|
||||||
justify="left",
|
justify="left",
|
||||||
anchor="w",
|
anchor="w",
|
||||||
|
wraplength=tok.WRAP,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def button(
|
||||||
|
parent: tk.Misc,
|
||||||
|
text: str,
|
||||||
|
command: Callable[[], object],
|
||||||
|
*,
|
||||||
|
kind: ButtonKind = "plain",
|
||||||
|
name: str = "",
|
||||||
|
) -> tk.Button:
|
||||||
|
"""A flat button whose text colour follows its fill."""
|
||||||
|
bg, fg = _BUTTON_COLOURS[kind]
|
||||||
|
return tk.Button(
|
||||||
|
parent,
|
||||||
|
name=name,
|
||||||
|
text=text,
|
||||||
|
command=command,
|
||||||
|
bg=bg,
|
||||||
|
fg=fg,
|
||||||
|
activebackground=bg,
|
||||||
|
activeforeground=fg,
|
||||||
|
font=tok.font(family(parent), "body", bold=kind == "primary"),
|
||||||
|
relief="flat",
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
padx=tok.MD,
|
||||||
|
pady=tok.SM,
|
||||||
|
cursor="hand2",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def paint_toggle(switch: tk.Checkbutton, *, on: bool) -> None:
|
||||||
|
"""Draw ``switch`` as a toggle: accent fill reading "On", or a dark "Off"."""
|
||||||
|
bg, fg = (tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.TEXT_ON_DARK)
|
||||||
|
switch.configure(
|
||||||
|
text="On" if on else "Off",
|
||||||
|
bg=bg,
|
||||||
|
selectcolor=bg,
|
||||||
|
activebackground=bg,
|
||||||
|
fg=fg,
|
||||||
|
activeforeground=fg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def toggle(parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> tk.Checkbutton:
|
||||||
|
"""A Checkbutton drawn as an On/Off switch, repainted on every change."""
|
||||||
|
switch = tk.Checkbutton(
|
||||||
|
parent,
|
||||||
|
name=name,
|
||||||
|
variable=variable,
|
||||||
|
onvalue=True,
|
||||||
|
offvalue=False,
|
||||||
|
indicatoron=False,
|
||||||
|
width=4,
|
||||||
|
font=tok.font(family(parent), "label", bold=True),
|
||||||
|
relief="flat",
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=0,
|
||||||
|
padx=tok.SM,
|
||||||
|
pady=tok.XS,
|
||||||
|
disabledforeground=tok.MUTED_ON_DARK,
|
||||||
|
cursor="hand2",
|
||||||
|
)
|
||||||
|
switch.configure(command=lambda: paint_toggle(switch, on=variable.get()))
|
||||||
|
paint_toggle(switch, on=variable.get())
|
||||||
|
return switch
|
||||||
|
|||||||
@ -2,33 +2,26 @@
|
|||||||
"""``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk.
|
"""``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk.
|
||||||
|
|
||||||
The calendar on the left, :class:`~sickdays.gui.KeepPanel` on the right for
|
The calendar on the left, :class:`~sickdays.gui.KeepPanel` on the right for
|
||||||
the clicked day, the sick days still ahead underneath (each an ISO date and
|
the clicked day, the sick days still ahead underneath. The window class is
|
||||||
the pictograms of the lockers it keeps). The window class is ``sickdays``
|
``sickdays`` (Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3
|
||||||
(Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3 ``for_window``
|
``for_window`` rule can float it; ``Escape`` closes it.
|
||||||
rule can float it; ``Escape`` closes it.
|
|
||||||
|
|
||||||
No words inside the window (kuhy's textgate rule). The title bar keeps the
|
|
||||||
app's name: an OS-level app name is a proper noun (textgate settled
|
|
||||||
decision 4), the same as ``sickdays.desktop``'s ``Name=``.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING, Final
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sickdays._api import is_sick_day, load
|
from sickdays._api import is_sick_day, load
|
||||||
from sickdays._day import to_iso, today
|
from sickdays._day import today
|
||||||
from sickdays._keep_api import kept_lockers
|
from sickdays._keep_api import kept_lockers
|
||||||
from sickdays._model import sick_entries
|
from sickdays._model import sick_entries
|
||||||
from sickdays._paths import resolve_paths
|
from sickdays._paths import resolve_paths
|
||||||
from sickdays.gui import _dates
|
from sickdays.gui import _text
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
from sickdays.gui import _widgets as ui
|
from sickdays.gui import _widgets as ui
|
||||||
from sickdays.gui._calendar import MonthCalendar
|
from sickdays.gui._calendar import MonthCalendar
|
||||||
from sickdays.gui._controls import IconSlot
|
from sickdays.gui._panel import KeepPanel
|
||||||
from sickdays.gui._icons import LOCKER_ICONS
|
|
||||||
from sickdays.gui._panel import STATUS_ICONS, KeepPanel
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@ -37,18 +30,19 @@ if TYPE_CHECKING:
|
|||||||
from sickdays._paths import Paths
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
WINDOW_CLASS = "sickdays"
|
WINDOW_CLASS = "sickdays"
|
||||||
WINDOW_TITLE = "Sick days"
|
|
||||||
#: A kept locker's pictogram in the list of days ahead, in px.
|
|
||||||
_KEPT_PX: Final = 16
|
|
||||||
|
|
||||||
|
|
||||||
def saved_icon(entry: SickDay | None) -> str | None:
|
def saved_message(entry: SickDay | None, day: date) -> str:
|
||||||
"""The status mark after the panel closed with ``entry``.
|
"""The status line after the panel closed with ``entry``."""
|
||||||
|
label = _text.day_label(day)
|
||||||
A check for any write (the grid and the list show what it was); nothing
|
if entry is None:
|
||||||
when the panel was saved unchanged.
|
return f"{label}: nothing changed"
|
||||||
"""
|
if not entry.is_sick:
|
||||||
return None if entry is None else "check"
|
return f"{label} is a normal day again"
|
||||||
|
kept = _text.kept_label(entry.keep)
|
||||||
|
return f"{label} is a sick day — " + (
|
||||||
|
f"kept: {kept}" if kept else "every locker stands down"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SickDaysApp:
|
class SickDaysApp:
|
||||||
@ -58,7 +52,7 @@ class SickDaysApp:
|
|||||||
"""Style ``root`` and draw the current pool."""
|
"""Style ``root`` and draw the current pool."""
|
||||||
self._root = root
|
self._root = root
|
||||||
self._paths = resolve_paths(paths)
|
self._paths = resolve_paths(paths)
|
||||||
root.title(WINDOW_TITLE)
|
root.title("Sick days")
|
||||||
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
|
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
|
||||||
root.bind("<Escape>", self.close)
|
root.bind("<Escape>", self.close)
|
||||||
left = tk.Frame(root, bg=tok.INK)
|
left = tk.Frame(root, bg=tok.INK)
|
||||||
@ -66,9 +60,13 @@ class SickDaysApp:
|
|||||||
#: Where the clicked day's panel goes.
|
#: Where the clicked day's panel goes.
|
||||||
self.host = tk.Frame(root, name="host", bg=tok.INK)
|
self.host = tk.Frame(root, name="host", bg=tok.INK)
|
||||||
self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0))
|
self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0))
|
||||||
#: The last outcome's mark: a check, or the panel's refusal icons.
|
ui.label(left, "Sick days", "title").pack(anchor="w")
|
||||||
self.status = IconSlot(left, name="status")
|
ui.label(left, _text.INTRO, "body").pack(anchor="w", pady=(tok.SM, 0))
|
||||||
self.status.pack(anchor="w")
|
ui.label(left, _text.INTRO_DETAIL, "caption", fg=tok.MUTED_ON_DARK).pack(
|
||||||
|
anchor="w", pady=(tok.SM, 0)
|
||||||
|
)
|
||||||
|
self.status = ui.label(left, "", "label", name="status")
|
||||||
|
self.status.pack(anchor="w", pady=(tok.SM, 0))
|
||||||
log = self._paths.log
|
log = self._paths.log
|
||||||
self.calendar = MonthCalendar(
|
self.calendar = MonthCalendar(
|
||||||
left,
|
left,
|
||||||
@ -77,8 +75,8 @@ class SickDaysApp:
|
|||||||
has_dot=lambda day: bool(kept_lockers(day, log_path=log)),
|
has_dot=lambda day: bool(kept_lockers(day, log_path=log)),
|
||||||
on_day=self.select,
|
on_day=self.select,
|
||||||
)
|
)
|
||||||
self.calendar.pack(anchor="w", pady=(tok.SM, 0))
|
self.calendar.pack(anchor="w", pady=(tok.MD, 0))
|
||||||
IconSlot(left, name="ahead").show("sick", tok.SICK).pack(
|
ui.label(left, "Marked sick", "subtitle").pack(
|
||||||
anchor="w", pady=(tok.LG, tok.SM)
|
anchor="w", pady=(tok.LG, tok.SM)
|
||||||
)
|
)
|
||||||
self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
|
self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
|
||||||
@ -90,25 +88,25 @@ class SickDaysApp:
|
|||||||
self._root.destroy()
|
self._root.destroy()
|
||||||
|
|
||||||
def select(self, day: date) -> None:
|
def select(self, day: date) -> None:
|
||||||
"""Open the panel for ``day``, or refuse a free day in the status mark."""
|
"""Open the panel for ``day``, or refuse a free day in the status line."""
|
||||||
self._close_panel()
|
self._close_panel()
|
||||||
if not is_sick_day(day, log_path=self._paths.log):
|
if not is_sick_day(day, log_path=self._paths.log):
|
||||||
block = _dates.free_day_block(day)
|
refusal = _text.free_day_refusal(day)
|
||||||
if block is not None:
|
if refusal is not None:
|
||||||
self.status.show(*STATUS_ICONS[block])
|
self._set_status(refusal, failed=True)
|
||||||
return
|
return
|
||||||
self.status.show(None)
|
self._set_status("", failed=False)
|
||||||
KeepPanel(
|
KeepPanel(
|
||||||
self.host,
|
self.host,
|
||||||
day,
|
day,
|
||||||
on_saved=self._saved,
|
on_saved=lambda entry: self._saved(entry, day),
|
||||||
on_cancel=self._close_panel,
|
on_cancel=self._close_panel,
|
||||||
paths=self._paths,
|
paths=self._paths,
|
||||||
).pack(anchor="n")
|
).pack(anchor="n")
|
||||||
|
|
||||||
def _saved(self, entry: SickDay | None) -> None:
|
def _saved(self, entry: SickDay | None, day: date) -> None:
|
||||||
self._close_panel()
|
self._close_panel()
|
||||||
self.status.show(saved_icon(entry), tok.SUCCESS)
|
self._set_status(saved_message(entry, day), failed=False)
|
||||||
self.calendar.redraw()
|
self.calendar.redraw()
|
||||||
self._draw_upcoming()
|
self._draw_upcoming()
|
||||||
|
|
||||||
@ -116,20 +114,29 @@ class SickDaysApp:
|
|||||||
for child in self.host.winfo_children():
|
for child in self.host.winfo_children():
|
||||||
child.destroy()
|
child.destroy()
|
||||||
|
|
||||||
|
def _set_status(self, text: str, *, failed: bool) -> None:
|
||||||
|
self.status.configure(text=text, fg=tok.DANGER if failed else tok.SUCCESS)
|
||||||
|
|
||||||
def _draw_upcoming(self) -> None:
|
def _draw_upcoming(self) -> None:
|
||||||
for child in self._list.winfo_children():
|
for child in self._list.winfo_children():
|
||||||
child.destroy()
|
child.destroy()
|
||||||
reference = today()
|
reference = today()
|
||||||
for entry in sick_entries(load(self._paths.log)):
|
upcoming = [
|
||||||
if entry.day < reference:
|
entry
|
||||||
continue
|
for entry in sick_entries(load(self._paths.log))
|
||||||
row = tk.Frame(self._list, name=f"day-{to_iso(entry.day)}", bg=tok.INK)
|
if entry.day >= reference
|
||||||
row.pack(anchor="w", pady=(0, tok.XS))
|
]
|
||||||
ui.label(row, to_iso(entry.day), "body").pack(side="left")
|
if not upcoming:
|
||||||
for locker in _dates.kept_in_order(entry.keep):
|
ui.label(self._list, _text.NONE_AHEAD, "body", fg=tok.MUTED_ON_DARK).pack(
|
||||||
IconSlot(row, name=f"kept-{locker}", size=_KEPT_PX).show(
|
anchor="w"
|
||||||
LOCKER_ICONS[locker], tok.MUTED_ON_DARK
|
)
|
||||||
).pack(side="left", padx=(tok.SM, 0))
|
for entry in upcoming:
|
||||||
|
ui.label(self._list, _text.day_label(entry.day), "body").pack(anchor="w")
|
||||||
|
kept = _text.kept_label(entry.keep)
|
||||||
|
if kept:
|
||||||
|
ui.label(
|
||||||
|
self._list, f"Kept: {kept}", "label", fg=tok.MUTED_ON_DARK
|
||||||
|
).pack(anchor="w", pady=(0, tok.XS))
|
||||||
|
|
||||||
|
|
||||||
def run(*, paths: Paths | None = None) -> int:
|
def run(*, paths: Paths | None = None) -> int:
|
||||||
|
|||||||
@ -61,7 +61,7 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|||||||
for module in _MODULES:
|
for module in _MODULES:
|
||||||
monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
|
monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
|
||||||
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free)
|
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free)
|
||||||
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", lambda: _never_free)
|
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", lambda: _never_free)
|
||||||
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)
|
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""The word-free controls and the drawn icons, against a real Tk."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tkinter as tk
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from sickdays._constants import LOCKERS
|
|
||||||
from sickdays.gui import _tokens as tok
|
|
||||||
from sickdays.gui._controls import IconButton, IconSlot, Toggle
|
|
||||||
from sickdays.gui._icons import ICONS, LOCKER_ICONS, TAG
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def shown(tk_root: tk.Tk) -> tk.Tk:
|
|
||||||
"""The root mapped, so real clicks reach the canvases."""
|
|
||||||
tk_root.deiconify()
|
|
||||||
tk_root.update()
|
|
||||||
return tk_root
|
|
||||||
|
|
||||||
|
|
||||||
def _click(widget: tk.Canvas) -> None:
|
|
||||||
widget.pack()
|
|
||||||
widget.update()
|
|
||||||
widget.event_generate("<Button-1>", x=2, y=2)
|
|
||||||
widget.update()
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_icon_draws_something(tk_root: tk.Tk) -> None:
|
|
||||||
for icon in ICONS:
|
|
||||||
slot = IconSlot(tk_root, size=32).show(icon, tok.DANGER)
|
|
||||||
assert slot.icon == icon
|
|
||||||
assert slot.find_withtag(TAG), icon
|
|
||||||
assert slot.show(None).icon is None
|
|
||||||
assert slot.find_withtag(TAG) == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_locker_has_an_icon() -> None:
|
|
||||||
assert set(LOCKER_ICONS) == set(LOCKERS)
|
|
||||||
assert set(LOCKER_ICONS.values()) <= set(ICONS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_click_invokes_the_button_unless_disabled(shown: tk.Tk) -> None:
|
|
||||||
calls: list[int] = []
|
|
||||||
button = IconButton(shown, "check", lambda: calls.append(1), name="ok")
|
|
||||||
button.set_enabled(False)
|
|
||||||
assert str(button.cget("cursor")) == "arrow"
|
|
||||||
_click(button)
|
|
||||||
button.invoke()
|
|
||||||
assert calls == []
|
|
||||||
button.set_enabled(True)
|
|
||||||
_click(button)
|
|
||||||
assert calls == [1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_click_flips_the_switch_unless_disabled(shown: tk.Tk) -> None:
|
|
||||||
on = tk.BooleanVar(shown, value=False)
|
|
||||||
switch = Toggle(shown, on, name="switch")
|
|
||||||
_click(switch)
|
|
||||||
assert switch.is_on()
|
|
||||||
assert on.get()
|
|
||||||
switch.set_enabled(False)
|
|
||||||
_click(switch)
|
|
||||||
switch.invoke()
|
|
||||||
assert switch.is_on()
|
|
||||||
switch.set_enabled(True)
|
|
||||||
switch.invoke()
|
|
||||||
assert not switch.is_on()
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""The picker's calendar arithmetic, free-day check and tokens -- no display."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sickdays._errors import ConflictCheckUnavailableError
|
|
||||||
from sickdays.gui import _dates
|
|
||||||
from sickdays.gui import _tokens as tok
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
DAY = date(2026, 10, 20)
|
|
||||||
|
|
||||||
|
|
||||||
def test_kept_lockers_follow_locker_order_not_the_alphabet() -> None:
|
|
||||||
assert _dates.kept_in_order({"alarm", "book", "workout"}) == (
|
|
||||||
"workout",
|
|
||||||
"book",
|
|
||||||
"alarm",
|
|
||||||
)
|
|
||||||
assert _dates.kept_in_order(frozenset()) == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_free_day_block(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
assert _dates.free_day_block(DAY) is None
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
|
||||||
)
|
|
||||||
assert _dates.free_day_block(DAY) == "free"
|
|
||||||
|
|
||||||
def unavailable() -> None:
|
|
||||||
msg = "freedays is not installed"
|
|
||||||
raise ConflictCheckUnavailableError(msg)
|
|
||||||
|
|
||||||
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
assert _dates.free_day_block(DAY) == "unavailable"
|
|
||||||
assert "freedays is not installed" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_month_grid_is_monday_first() -> None:
|
|
||||||
weeks = _dates.month_weeks(date(2026, 10, 1))
|
|
||||||
assert weeks[0] == [None, None, None, *(date(2026, 10, d) for d in (1, 2, 3, 4))]
|
|
||||||
assert weeks[-1][-1] is None
|
|
||||||
assert _dates.month_title(date(2026, 10, 1)) == "2026-10"
|
|
||||||
assert _dates.month_title(date(2027, 3, 1)) == "2027-03"
|
|
||||||
assert [_dates.is_weekend_column(c) for c in range(7)] == [
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
True,
|
|
||||||
True,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_month_arithmetic_and_range() -> None:
|
|
||||||
assert _dates.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1)
|
|
||||||
assert _dates.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1)
|
|
||||||
assert _dates.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1))
|
|
||||||
|
|
||||||
|
|
||||||
def test_workdays_are_tuesday_to_thursday() -> None:
|
|
||||||
assert [_dates.is_workday(date(2026, 10, d)) for d in range(19, 26)] == [
|
|
||||||
False,
|
|
||||||
True,
|
|
||||||
True,
|
|
||||||
True,
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_tokens() -> None:
|
|
||||||
assert tok.font("Fake Sans", "body") == ("Fake Sans", -16, "normal")
|
|
||||||
assert tok.font("Fake Sans", "title", bold=True) == ("Fake Sans", -24, "bold")
|
|
||||||
assert tok.blend("#FFFFFF", "#000000", 0.5) == "#808080"
|
|
||||||
assert tok.blend(tok.SICK, tok.INK, 1.0) == tok.SICK
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""The textgate guard: no letter on any widget of the picker, in any state.
|
|
||||||
|
|
||||||
Everything this window shows is chrome except numerals and ISO dates (day
|
|
||||||
numbers, ``2026-11``, ``2026-11-13``), so the guard bans every letter, not
|
|
||||||
just words. The window title is the app's proper noun (textgate settled
|
|
||||||
decision 4) and is checked on its own.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
import re
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sickdays._api import add_sick_day
|
|
||||||
from sickdays._day import today
|
|
||||||
from sickdays._errors import ConflictCheckUnavailableError
|
|
||||||
from sickdays.gui import KeepPanel, SickDaysApp, _panel
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
FUTURE = today() + timedelta(days=3)
|
|
||||||
LETTER = re.compile(r"[^\W\d_]")
|
|
||||||
|
|
||||||
|
|
||||||
def _shown(widget: tk.Misc) -> list[str]:
|
|
||||||
"""Every string ``widget`` and its descendants paint."""
|
|
||||||
found: list[str] = []
|
|
||||||
for child in widget.winfo_children():
|
|
||||||
if isinstance(child, tk.Canvas):
|
|
||||||
# Raw Tcl: typeshed types Canvas.type()/itemcget() too loosely.
|
|
||||||
path = str(child)
|
|
||||||
found.extend(
|
|
||||||
str(child.tk.call(path, "itemcget", item, "-text"))
|
|
||||||
for item in child.find_all()
|
|
||||||
if str(child.tk.call(path, "type", item)) == "text"
|
|
||||||
)
|
|
||||||
elif isinstance(child, (tk.Label, tk.Button, tk.Checkbutton)):
|
|
||||||
found.append(str(child.cget("text")))
|
|
||||||
found.extend(_shown(child))
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_no_letters(widget: tk.Misc) -> None:
|
|
||||||
shown = _shown(widget)
|
|
||||||
assert shown, "the guard walked no widget"
|
|
||||||
worded = [text for text in shown if LETTER.search(text)]
|
|
||||||
assert worded == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_window_title_is_the_app_name(tk_root: tk.Tk) -> None:
|
|
||||||
SickDaysApp(tk_root)
|
|
||||||
assert tk_root.title() == "Sick days"
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_window_shows_no_letters(tk_root: tk.Tk) -> None:
|
|
||||||
add_sick_day(FUTURE, keep=["alarm", "workout"])
|
|
||||||
app = SickDaysApp(tk_root)
|
|
||||||
_assert_no_letters(tk_root)
|
|
||||||
app.select(FUTURE + timedelta(days=1))
|
|
||||||
_assert_no_letters(tk_root)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_window_after_a_refusal_shows_no_letters(
|
|
||||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
|
||||||
)
|
|
||||||
SickDaysApp(tk_root).select(FUTURE)
|
|
||||||
_assert_no_letters(tk_root)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_window_after_a_save_shows_no_letters(tk_root: tk.Tk) -> None:
|
|
||||||
app = SickDaysApp(tk_root)
|
|
||||||
app.select(FUTURE)
|
|
||||||
(panel,) = app.host.winfo_children()
|
|
||||||
panel.nametowidget("actions.mark").invoke()
|
|
||||||
assert isinstance(panel, KeepPanel)
|
|
||||||
assert panel.sync_thread is not None
|
|
||||||
panel.sync_thread.join(timeout=5)
|
|
||||||
_assert_no_letters(tk_root)
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_panel_state_shows_no_letters(
|
|
||||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
add_sick_day(FUTURE, keep=["book"])
|
|
||||||
for day in (FUTURE, FUTURE + timedelta(days=1), today() - timedelta(days=1)):
|
|
||||||
_assert_no_letters(
|
|
||||||
KeepPanel(tk_root, day, on_saved=lambda _e: None, on_cancel=lambda: None)
|
|
||||||
)
|
|
||||||
|
|
||||||
def unavailable() -> None:
|
|
||||||
msg = "cannot check the free-day pool"
|
|
||||||
raise ConflictCheckUnavailableError(msg)
|
|
||||||
|
|
||||||
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
|
|
||||||
_assert_no_letters(KeepPanel(tk_root, FUTURE + timedelta(days=2), on_saved=print))
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_refused_or_busy_write_shows_no_letters(tk_root: tk.Tk) -> None:
|
|
||||||
refused = KeepPanel(tk_root, FUTURE + timedelta(days=4), on_saved=print)
|
|
||||||
add_sick_day(FUTURE + timedelta(days=4))
|
|
||||||
refused.nametowidget("actions.mark").invoke()
|
|
||||||
_assert_no_letters(refused)
|
|
||||||
busy = KeepPanel(tk_root, FUTURE + timedelta(days=5), on_saved=print)
|
|
||||||
with _panel._SYNC_LOCK:
|
|
||||||
busy.nametowidget("actions.mark").invoke()
|
|
||||||
_assert_no_letters(busy)
|
|
||||||
@ -1,27 +1,26 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""KeepPanel: the six switches for one day, against a real (unmapped) Tk."""
|
"""KeepPanel: the five switches for one day, against a real (unmapped) Tk."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
|
||||||
import threading
|
import threading
|
||||||
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from sickdays._api import add_sick_day, is_sick_day, load
|
from sickdays._api import add_sick_day, is_sick_day, load
|
||||||
from sickdays._day import to_iso, today
|
from sickdays._day import today
|
||||||
from sickdays._errors import ConflictCheckUnavailableError
|
from sickdays._errors import ConflictCheckUnavailableError
|
||||||
from sickdays._keep_api import kept_lockers
|
from sickdays._keep_api import kept_lockers
|
||||||
from sickdays._paths import Paths
|
from sickdays._paths import Paths
|
||||||
from sickdays._store import write_pool
|
from sickdays._store import write_pool
|
||||||
from sickdays.gui import KeepPanel
|
from sickdays.gui import KeepPanel, _text
|
||||||
from sickdays.gui._controls import IconButton, IconSlot, Toggle
|
from sickdays.gui import _tokens as tok
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tkinter as tk
|
|
||||||
|
|
||||||
from sickdays._model import SickDay
|
from sickdays._model import SickDay
|
||||||
|
|
||||||
@ -41,22 +40,20 @@ def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]:
|
|||||||
return calls
|
return calls
|
||||||
|
|
||||||
|
|
||||||
def _button(panel: KeepPanel, name: str) -> IconButton:
|
def _button(panel: KeepPanel, name: str) -> tk.Button:
|
||||||
widget = panel.nametowidget(f"actions.{name}")
|
widget = panel.nametowidget(f"actions.{name}")
|
||||||
assert isinstance(widget, IconButton)
|
assert isinstance(widget, tk.Button)
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
|
||||||
def _toggle(panel: KeepPanel, locker: str) -> Toggle:
|
def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton:
|
||||||
widget = panel.nametowidget(f"keep-{locker}")
|
widget = panel.nametowidget(f"keep-{locker}")
|
||||||
assert isinstance(widget, Toggle)
|
assert isinstance(widget, tk.Checkbutton)
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
|
||||||
def _status(panel: KeepPanel) -> str | None:
|
def _status(panel: KeepPanel) -> str:
|
||||||
slot = panel.nametowidget("status")
|
return str(panel.nametowidget("status").cget("text"))
|
||||||
assert isinstance(slot, IconSlot)
|
|
||||||
return slot.icon
|
|
||||||
|
|
||||||
|
|
||||||
def _actions(panel: KeepPanel) -> set[str]:
|
def _actions(panel: KeepPanel) -> set[str]:
|
||||||
@ -75,11 +72,11 @@ def test_mark_sick_keeping_leetcode(tk_root: tk.Tk, synced: list[Paths | None])
|
|||||||
assert panel.kept() == frozenset()
|
assert panel.kept() == frozenset()
|
||||||
assert _actions(panel) == {"mark"}
|
assert _actions(panel) == {"mark"}
|
||||||
toggle = _toggle(panel, "leetcode")
|
toggle = _toggle(panel, "leetcode")
|
||||||
assert not toggle.is_on()
|
assert toggle.cget("text") == "Off"
|
||||||
toggle.invoke()
|
toggle.invoke()
|
||||||
assert toggle.is_on()
|
assert toggle.cget("text") == "On"
|
||||||
assert _button(panel, "mark").icon == "check"
|
assert str(toggle.cget("bg")).upper() == tok.ACCENT
|
||||||
assert _status(panel) is None
|
assert str(toggle.cget("fg")).upper() == tok.ON_FILL
|
||||||
_button(panel, "mark").invoke()
|
_button(panel, "mark").invoke()
|
||||||
_join(panel)
|
_join(panel)
|
||||||
assert is_sick_day(FUTURE)
|
assert is_sick_day(FUTURE)
|
||||||
@ -96,14 +93,8 @@ def test_an_existing_sick_day_opens_with_its_switches(tk_root: tk.Tk) -> None:
|
|||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None)
|
||||||
assert panel.kept() == frozenset({"workout"})
|
assert panel.kept() == frozenset({"workout"})
|
||||||
assert _actions(panel) == {"save", "clear"}
|
assert _actions(panel) == {"save", "clear"}
|
||||||
assert panel.nametowidget("head.title").cget("text") == to_iso(FUTURE)
|
title = panel.nametowidget("title").cget("text")
|
||||||
state = panel.nametowidget("head.state")
|
assert title == f"Sick day: {_text.day_label(FUTURE)}"
|
||||||
assert isinstance(state, IconSlot)
|
|
||||||
assert state.icon == "sick"
|
|
||||||
assert _button(panel, "clear").icon == "trash"
|
|
||||||
icon = panel.nametowidget("icon-workout")
|
|
||||||
assert isinstance(icon, IconSlot)
|
|
||||||
assert icon.icon == "workout"
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
|
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
|
||||||
@ -148,27 +139,20 @@ def test_clear_makes_the_day_normal(tk_root: tk.Tk, synced: list[Paths | None])
|
|||||||
|
|
||||||
def test_a_past_day_is_shown_but_not_editable(tk_root: tk.Tk) -> None:
|
def test_a_past_day_is_shown_but_not_editable(tk_root: tk.Tk) -> None:
|
||||||
panel = KeepPanel(tk_root, today() - timedelta(days=1), on_saved=lambda _e: None)
|
panel = KeepPanel(tk_root, today() - timedelta(days=1), on_saved=lambda _e: None)
|
||||||
assert panel.reason == "past"
|
assert _status(panel) == _text.PAST_NOTE
|
||||||
assert _status(panel) == "padlock"
|
|
||||||
assert _actions(panel) == set()
|
assert _actions(panel) == set()
|
||||||
diet = _toggle(panel, "diet")
|
assert str(_toggle(panel, "diet").cget("state")) == "disabled"
|
||||||
assert str(diet.cget("state")) == "disabled"
|
|
||||||
diet.invoke()
|
|
||||||
assert not diet.is_on()
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_free_day_is_refused_with_the_phones_message(
|
def test_a_free_day_is_refused_with_the_phones_message(
|
||||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
||||||
)
|
)
|
||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
||||||
assert panel.reason == "free"
|
expected = "is a free day — release it in Free days first."
|
||||||
assert _status(panel) == "no_entry"
|
assert _status(panel).endswith(expected)
|
||||||
state = panel.nametowidget("head.state")
|
|
||||||
assert isinstance(state, IconSlot)
|
|
||||||
assert state.icon == "normal"
|
|
||||||
assert _actions(panel) == set()
|
assert _actions(panel) == set()
|
||||||
|
|
||||||
|
|
||||||
@ -179,24 +163,20 @@ def test_an_uncheckable_free_pool_refuses(
|
|||||||
msg = "cannot check the free-day pool"
|
msg = "cannot check the free-day pool"
|
||||||
raise ConflictCheckUnavailableError(msg)
|
raise ConflictCheckUnavailableError(msg)
|
||||||
|
|
||||||
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
|
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
|
||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
|
||||||
assert panel.reason == "unavailable"
|
assert _status(panel) == "cannot check the free-day pool"
|
||||||
assert _status(panel) == "warning"
|
|
||||||
assert _actions(panel) == set()
|
assert _actions(panel) == set()
|
||||||
|
|
||||||
|
|
||||||
def test_a_refused_write_is_shown_not_raised(
|
def test_a_refused_write_is_shown_not_raised(
|
||||||
tk_root: tk.Tk, synced: list[Paths | None], caplog: pytest.LogCaptureFixture
|
tk_root: tk.Tk, synced: list[Paths | None]
|
||||||
) -> None:
|
) -> None:
|
||||||
saved: list[SickDay | None] = []
|
saved: list[SickDay | None] = []
|
||||||
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
|
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
|
||||||
add_sick_day(FUTURE) # marked elsewhere while the panel was open
|
add_sick_day(FUTURE) # marked elsewhere while the panel was open
|
||||||
with caplog.at_level(logging.WARNING):
|
_button(panel, "mark").invoke()
|
||||||
_button(panel, "mark").invoke()
|
assert "already a sick day" in _status(panel)
|
||||||
assert panel.reason == "refused"
|
|
||||||
assert _status(panel) == "warning"
|
|
||||||
assert "already a sick day" in caplog.text
|
|
||||||
assert saved == []
|
assert saved == []
|
||||||
assert synced == []
|
assert synced == []
|
||||||
|
|
||||||
@ -209,9 +189,7 @@ def test_cancel_calls_back(tk_root: tk.Tk) -> None:
|
|||||||
on_saved=lambda _e: None,
|
on_saved=lambda _e: None,
|
||||||
on_cancel=lambda: cancelled.append(True),
|
on_cancel=lambda: cancelled.append(True),
|
||||||
)
|
)
|
||||||
cancel = _button(panel, "cancel")
|
_button(panel, "cancel").invoke()
|
||||||
assert cancel.icon == "cross"
|
|
||||||
cancel.invoke()
|
|
||||||
assert cancelled == [True]
|
assert cancelled == [True]
|
||||||
|
|
||||||
|
|
||||||
@ -264,5 +242,4 @@ def test_a_write_during_a_sync_is_refused_not_lost(
|
|||||||
for entry in saved:
|
for entry in saved:
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert is_sick_day(entry.day), f"{entry.day} was written, then lost"
|
assert is_sick_day(entry.day), f"{entry.day} was written, then lost"
|
||||||
assert second.reason == "busy"
|
assert _status(second) == _text.BUSY_NOTE
|
||||||
assert _status(second) == "hourglass"
|
|
||||||
|
|||||||
86
sickdays/sickdays/tests/test_gui_text.py
Normal file
86
sickdays/sickdays/tests/test_gui_text.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The picker's wording, calendar arithmetic and tokens -- no display needed."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sickdays._errors import ConflictCheckUnavailableError
|
||||||
|
from sickdays.gui import _text
|
||||||
|
from sickdays.gui import _tokens as tok
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
DAY = date(2026, 10, 20)
|
||||||
|
|
||||||
|
|
||||||
|
def test_labels_match_the_phone() -> None:
|
||||||
|
assert _text.day_label(DAY) == "Tue 2026-10-20"
|
||||||
|
assert _text.panel_title(DAY, sick=False) == "Mark sick: Tue 2026-10-20"
|
||||||
|
assert _text.panel_title(DAY, sick=True) == "Sick day: Tue 2026-10-20"
|
||||||
|
assert list(_text.LOCKER_LABELS.values()) == [
|
||||||
|
"Workout lock",
|
||||||
|
"LeetCode",
|
||||||
|
"Book",
|
||||||
|
"Diet",
|
||||||
|
"Wake alarm",
|
||||||
|
"Focus",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kept_label_follows_locker_order_not_the_alphabet() -> None:
|
||||||
|
assert _text.kept_label({"alarm", "book", "workout"}) == (
|
||||||
|
"Workout lock, Book, Wake alarm"
|
||||||
|
)
|
||||||
|
assert _text.kept_label(frozenset()) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_free_day_refusal(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
assert _text.free_day_refusal(DAY) is None
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
||||||
|
)
|
||||||
|
assert _text.free_day_refusal(DAY) == (
|
||||||
|
"2026-10-20 is a free day — release it in Free days first."
|
||||||
|
)
|
||||||
|
|
||||||
|
def unavailable() -> None:
|
||||||
|
msg = "freedays is not installed"
|
||||||
|
raise ConflictCheckUnavailableError(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
|
||||||
|
assert _text.free_day_refusal(DAY) == "freedays is not installed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_grid_is_monday_first() -> None:
|
||||||
|
weeks = _text.month_weeks(date(2026, 10, 1))
|
||||||
|
assert weeks[0] == [None, None, None, *(date(2026, 10, d) for d in (1, 2, 3, 4))]
|
||||||
|
assert weeks[-1][-1] is None
|
||||||
|
assert _text.month_title(date(2026, 10, 1)) == "October 2026"
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_arithmetic_and_range() -> None:
|
||||||
|
assert _text.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1)
|
||||||
|
assert _text.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1)
|
||||||
|
assert _text.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_workdays_are_tuesday_to_thursday() -> None:
|
||||||
|
assert [_text.is_workday(date(2026, 10, d)) for d in range(19, 26)] == [
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tokens() -> None:
|
||||||
|
assert tok.font("Fake Sans", "body") == ("Fake Sans", -16, "normal")
|
||||||
|
assert tok.font("Fake Sans", "title", bold=True) == ("Fake Sans", -24, "bold")
|
||||||
|
assert tok.blend("#FFFFFF", "#000000", 0.5) == "#808080"
|
||||||
|
assert tok.blend(tok.SICK, tok.INK, 1.0) == tok.SICK
|
||||||
@ -10,9 +10,8 @@ from typing import TYPE_CHECKING
|
|||||||
from sickdays._api import add_sick_day
|
from sickdays._api import add_sick_day
|
||||||
from sickdays._day import to_iso, today
|
from sickdays._day import to_iso, today
|
||||||
from sickdays._model import SickDay
|
from sickdays._model import SickDay
|
||||||
from sickdays.gui import KeepPanel, SickDaysApp, _dates, _window
|
from sickdays.gui import KeepPanel, SickDaysApp, _text, _window
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
from sickdays.gui._controls import IconButton, IconSlot
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import pytest
|
import pytest
|
||||||
@ -20,19 +19,13 @@ if TYPE_CHECKING:
|
|||||||
FUTURE = today() + timedelta(days=3)
|
FUTURE = today() + timedelta(days=3)
|
||||||
|
|
||||||
|
|
||||||
def _upcoming(app: SickDaysApp) -> dict[str, list[str]]:
|
def _texts(widget: tk.Misc) -> list[str]:
|
||||||
"""Each listed day's ISO date -> the locker icons it keeps."""
|
found: list[str] = []
|
||||||
rows = app.calendar.master.nametowidget("upcoming").winfo_children()
|
for child in widget.winfo_children():
|
||||||
listed: dict[str, list[str]] = {}
|
if isinstance(child, tk.Label):
|
||||||
for row in rows:
|
found.append(str(child.cget("text")))
|
||||||
icons = [
|
found.extend(_texts(child))
|
||||||
str(slot.icon)
|
return found
|
||||||
for slot in row.winfo_children()
|
|
||||||
if isinstance(slot, IconSlot)
|
|
||||||
]
|
|
||||||
(date_label,) = [c for c in row.winfo_children() if isinstance(c, tk.Label)]
|
|
||||||
listed[str(date_label.cget("text"))] = icons
|
|
||||||
return listed
|
|
||||||
|
|
||||||
|
|
||||||
def _cell(app: SickDaysApp, day: date) -> tk.Button:
|
def _cell(app: SickDaysApp, day: date) -> tk.Button:
|
||||||
@ -47,17 +40,14 @@ def _panel(app: SickDaysApp) -> KeepPanel:
|
|||||||
return panel
|
return panel
|
||||||
|
|
||||||
|
|
||||||
def test_an_empty_pool_lists_nothing_ahead(tk_root: tk.Tk) -> None:
|
def test_an_empty_pool_says_none_ahead(tk_root: tk.Tk) -> None:
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
assert tk_root.title() == "Sick days"
|
assert tk_root.title() == "Sick days"
|
||||||
assert _upcoming(app) == {}
|
assert _text.NONE_AHEAD in _texts(tk_root)
|
||||||
assert app.status.icon is None
|
|
||||||
assert app.host.winfo_children() == []
|
assert app.host.winfo_children() == []
|
||||||
|
|
||||||
|
|
||||||
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
||||||
later = FUTURE + timedelta(days=1)
|
|
||||||
add_sick_day(later)
|
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
app.calendar.month = FUTURE.replace(day=1)
|
app.calendar.month = FUTURE.replace(day=1)
|
||||||
app.calendar.redraw()
|
app.calendar.redraw()
|
||||||
@ -68,8 +58,10 @@ def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
|
|||||||
assert panel.sync_thread is not None
|
assert panel.sync_thread is not None
|
||||||
panel.sync_thread.join(timeout=5)
|
panel.sync_thread.join(timeout=5)
|
||||||
assert app.host.winfo_children() == []
|
assert app.host.winfo_children() == []
|
||||||
assert app.status.icon == "check"
|
assert app.status.cget("text") == _window.saved_message(
|
||||||
assert _upcoming(app) == {to_iso(FUTURE): ["leetcode"], to_iso(later): []}
|
SickDay(FUTURE, is_sick=True, keep=frozenset({"leetcode"})), FUTURE
|
||||||
|
)
|
||||||
|
assert "Kept: LeetCode" in _texts(tk_root)
|
||||||
cell = _cell(app, FUTURE)
|
cell = _cell(app, FUTURE)
|
||||||
assert str(cell.cget("bg")).upper() == tok.SICK
|
assert str(cell.cget("bg")).upper() == tok.SICK
|
||||||
assert cell.nametowidget("dot").winfo_exists()
|
assert cell.nametowidget("dot").winfo_exists()
|
||||||
@ -86,12 +78,13 @@ def test_a_free_day_is_refused_before_the_panel_opens(
|
|||||||
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
||||||
)
|
)
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
app.select(FUTURE)
|
app.select(FUTURE)
|
||||||
assert app.host.winfo_children() == []
|
assert app.host.winfo_children() == []
|
||||||
assert app.status.icon == "no_entry"
|
assert str(app.status.cget("text")).endswith("release it in Free days first.")
|
||||||
|
assert str(app.status.cget("fg")).upper() == tok.DANGER
|
||||||
|
|
||||||
|
|
||||||
def test_a_sick_day_on_a_free_day_still_opens(
|
def test_a_sick_day_on_a_free_day_still_opens(
|
||||||
@ -99,7 +92,7 @@ def test_a_sick_day_on_a_free_day_still_opens(
|
|||||||
) -> None:
|
) -> None:
|
||||||
add_sick_day(FUTURE)
|
add_sick_day(FUTURE)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
|
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
|
||||||
)
|
)
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
app.select(FUTURE)
|
app.select(FUTURE)
|
||||||
@ -109,31 +102,19 @@ def test_a_sick_day_on_a_free_day_still_opens(
|
|||||||
def test_the_list_shows_upcoming_days_with_kept_lockers(tk_root: tk.Tk) -> None:
|
def test_the_list_shows_upcoming_days_with_kept_lockers(tk_root: tk.Tk) -> None:
|
||||||
add_sick_day(FUTURE, keep=["alarm", "workout"])
|
add_sick_day(FUTURE, keep=["alarm", "workout"])
|
||||||
add_sick_day(FUTURE + timedelta(days=1))
|
add_sick_day(FUTURE + timedelta(days=1))
|
||||||
past = today() - timedelta(days=1)
|
texts = _texts(SickDaysApp(tk_root).calendar.master)
|
||||||
add_sick_day(past, now=past)
|
assert _text.day_label(FUTURE) in texts
|
||||||
assert _upcoming(SickDaysApp(tk_root)) == {
|
assert "Kept: Workout lock, Wake alarm" in texts
|
||||||
to_iso(FUTURE): ["workout", "alarm"],
|
assert _text.NONE_AHEAD not in texts
|
||||||
to_iso(FUTURE + timedelta(days=1)): [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_saved_icon() -> None:
|
def test_saved_message_wording() -> None:
|
||||||
assert _window.saved_icon(None) is None
|
sick = SickDay(FUTURE, is_sick=True)
|
||||||
assert _window.saved_icon(SickDay(FUTURE, is_sick=True)) == "check"
|
label = _text.day_label(FUTURE)
|
||||||
assert _window.saved_icon(SickDay(FUTURE, is_sick=False)) == "check"
|
assert _window.saved_message(None, FUTURE) == f"{label}: nothing changed"
|
||||||
|
assert _window.saved_message(sick, FUTURE).endswith("every locker stands down")
|
||||||
|
cleared = SickDay(FUTURE, is_sick=False)
|
||||||
def test_the_month_reads_as_numerals_with_weekday_marks(tk_root: tk.Tk) -> None:
|
assert _window.saved_message(cleared, FUTURE) == f"{label} is a normal day again"
|
||||||
app = SickDaysApp(tk_root)
|
|
||||||
title = app.calendar.nametowidget("header").winfo_children()[-1]
|
|
||||||
assert isinstance(title, tk.Label)
|
|
||||||
assert title.cget("text") == _dates.month_title(today().replace(day=1))
|
|
||||||
marks = [
|
|
||||||
child.icon
|
|
||||||
for child in app.calendar.nametowidget("days").winfo_children()
|
|
||||||
if isinstance(child, IconSlot)
|
|
||||||
]
|
|
||||||
assert marks == ["dot"] * 5 + ["ring"] * 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
||||||
@ -147,7 +128,7 @@ def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
|
|||||||
|
|
||||||
def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
||||||
app = SickDaysApp(tk_root)
|
app = SickDaysApp(tk_root)
|
||||||
first, last = _dates.month_bounds(today())
|
first, last = _text.month_bounds(today())
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
app.calendar.step(1)
|
app.calendar.step(1)
|
||||||
assert app.calendar.month == last
|
assert app.calendar.month == last
|
||||||
@ -155,8 +136,6 @@ def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
|
|||||||
app.calendar.step(-1)
|
app.calendar.step(-1)
|
||||||
assert app.calendar.month == first
|
assert app.calendar.month == first
|
||||||
back = app.calendar.nametowidget("header.prev")
|
back = app.calendar.nametowidget("header.prev")
|
||||||
assert isinstance(back, IconButton)
|
|
||||||
assert back.icon == "prev"
|
|
||||||
assert str(back.cget("state")) == "disabled"
|
assert str(back.cget("state")) == "disabled"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
part of 'sync_settings_screen.dart';
|
|
||||||
|
|
||||||
/// The screen's glyph vocabulary, in place of written headings and status
|
|
||||||
/// sentences (kuhy's textgate rule). Every old sentence survives as the
|
|
||||||
/// spoken label, so screen readers and widget tests still find it.
|
|
||||||
|
|
||||||
/// A result: a glyph, the old sentence (spoken only), and whether it is a
|
|
||||||
/// failure. Colour is the second channel: danger hue for failures.
|
|
||||||
class _Cue {
|
|
||||||
const _Cue(this.icon, this.spoken) : error = false;
|
|
||||||
|
|
||||||
const _Cue.error(this.icon, this.spoken) : error = true;
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String spoken;
|
|
||||||
final bool error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paints a [_Cue] as a large glyph.
|
|
||||||
class _CueView extends StatelessWidget {
|
|
||||||
const _CueView(this.cue);
|
|
||||||
|
|
||||||
final _Cue cue;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
return Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Icon(
|
|
||||||
cue.icon,
|
|
||||||
size: 32,
|
|
||||||
color: cue.error ? scheme.error : scheme.primary,
|
|
||||||
semanticLabel: cue.spoken,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A section heading as a glyph, spoken as [spoken].
|
|
||||||
class _Heading extends StatelessWidget {
|
|
||||||
const _Heading(this.icon, this.spoken);
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String spoken;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
semanticLabel: spoken,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user