From 39e52d07c201ced165a2daa7467643d743cbfbe5 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 26 Jul 2026 15:08:41 +0200 Subject: [PATCH] Stop the recovery tick stealing our own grab, and fix pre-commit for the monorepo `_holds_grab` asked `grab_current() is self._root`, but our own transient children take the grab for themselves: a posted Tk menu grabs the menu window, so the test read "the grab was lost" and `grab_set_global()` took it back a second later, off our own widget mid-interaction. That is what made screen-locker's sport selector unusable while locked on 2026-07-26. The predicate is now `grab_current() is not None` -- any window we own means the lock still has pointer and keyboard -- and it is public, `holds_grab`, so an embedder can ask the same question instead of re-deriving it. Anything that does not resolve against our widget map (a Tcl-created `ttk.Combobox` popdown, a stale name) raises `KeyError` and counts as lost: fail closed, because losing a popdown beats believing we hold a grab we do not. The blind spot for a second `tk.Tk()` reporting its root as "." is unchanged from the old test, and now written down. `_verify` and `_drain` scheduled the next tick *after* running it, so any exception ended all re-assertion of coverage, grab and VT for the rest of the lock -- the exact fail-open this module exists to prevent, in a file whose docstring is a monotonicity proof. The reschedule moves into `finally`, and the warnings only fire when the reason changes: these states can last the whole lock, and a 1Hz journal stream is not the alternative to silence. The pre-commit side: both subproject configs were written as if cwd were the subproject, but pre-commit runs hooks from the git root -- which is ~/utils, not gatelock. Every pushed run since at least 2026-07-11 has been red, and for compounding reasons: pylint and bandit could not find their config, bandit's tests exclusion never matched, the pytest hook ran with no config at all, and with no `files:` filter each config linted the *whole* monorepo, so gatelock's mypy was type-checking crdt-sync. All paths are now root-relative, each config is scoped to its own subproject, and the pytest hook goes through a shared `scripts/run_subproject_tests.sh` that supplies the missing `cd` and fails loudly if the subproject is gone. Scoping via `files:` has one sharp edge -- rename the directory and the regex matches nothing, so every hook passes on zero files, green and useless. An `always_run` scope-guard hook turns that silent pass into a failure. bandit 1.7.10 threw inside its AST visitor on Python 3.14 and *skipped* every file while still reporting zero issues; 1.9.4 actually scans. Codespell was also flagging a real typo the noise had buried. Housekeeping the above turned up: `gatelock/.github/` and `crdt-sync/.github/` were dead -- GitHub only reads the root `.github/workflows`, which already has all four jobs -- and the two subproject `.gitignore`s were byte-identical to each other and subsumed by the new root one. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 17 ++++ crdt-sync/.github/workflows/pre-commit.yml | 19 ----- crdt-sync/.github/workflows/python-tests.yml | 28 ------ crdt-sync/.gitignore | 12 --- crdt-sync/.pre-commit-config.yaml | 28 ++++-- gatelock/.github/workflows/pre-commit.yml | 19 ----- gatelock/.github/workflows/python-tests.yml | 28 ------ gatelock/.gitignore | 12 --- gatelock/.pre-commit-config.yaml | 28 ++++-- gatelock/gatelock/_recovery.py | 90 +++++++++++++++++--- gatelock/gatelock/tests/test_outputs.py | 2 +- gatelock/gatelock/tests/test_recovery.py | 85 +++++++++++++++++- scripts/check_subproject_scope.sh | 40 +++++++++ scripts/run_subproject_tests.sh | 44 ++++++++++ 14 files changed, 307 insertions(+), 145 deletions(-) create mode 100644 .gitignore delete mode 100644 crdt-sync/.github/workflows/pre-commit.yml delete mode 100644 crdt-sync/.github/workflows/python-tests.yml delete mode 100644 crdt-sync/.gitignore delete mode 100644 gatelock/.github/workflows/pre-commit.yml delete mode 100644 gatelock/.github/workflows/python-tests.yml delete mode 100644 gatelock/.gitignore create mode 100755 scripts/check_subproject_scope.sh create mode 100755 scripts/run_subproject_tests.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14002ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python build/test artifacts, across every subproject in this monorepo. +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ + +# Coverage output. Each subproject writes these next to its own pyproject.toml, +# and a run from the repo root drops a pair here too. +.coverage +.coverage.* +coverage.lcov +htmlcov/ diff --git a/crdt-sync/.github/workflows/pre-commit.yml b/crdt-sync/.github/workflows/pre-commit.yml deleted file mode 100644 index 98911e8..0000000 --- a/crdt-sync/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: pre-commit - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - pre-commit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install dependencies - run: pip install -r requirements.txt - - uses: pre-commit/action@v3.0.1 diff --git a/crdt-sync/.github/workflows/python-tests.yml b/crdt-sync/.github/workflows/python-tests.yml deleted file mode 100644 index 66e5931..0000000 --- a/crdt-sync/.github/workflows/python-tests.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10", "3.11", "3.12"] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: pip install -r requirements.txt - - - name: Run tests with coverage - run: python -m pytest -v diff --git a/crdt-sync/.gitignore b/crdt-sync/.gitignore deleted file mode 100644 index eec3a7f..0000000 --- a/crdt-sync/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -__pycache__/ -*.pyc -.venv/ -.mypy_cache/ -.ruff_cache/ -.pytest_cache/ -.coverage -coverage.lcov -htmlcov/ -dist/ -build/ -*.egg-info/ diff --git a/crdt-sync/.pre-commit-config.yaml b/crdt-sync/.pre-commit-config.yaml index 3b6f069..372daac 100644 --- a/crdt-sync/.pre-commit-config.yaml +++ b/crdt-sync/.pre-commit-config.yaml @@ -3,6 +3,14 @@ # Run: pre-commit run --all-files # Update: pre-commit autoupdate +# NOTE: this subproject lives inside the ~/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 crdt-sync/.pre-commit-config.yaml --all-files +files: ^crdt-sync/ + default_language_version: python: python3 default_stages: [pre-commit] @@ -32,6 +40,14 @@ repos: - repo: local hooks: + - id: subproject-scope + name: subproject scope regex still matches + entry: scripts/check_subproject_scope.sh crdt-sync + 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)' @@ -86,7 +102,7 @@ repos: hooks: - id: pylint args: - - --rcfile=pyproject.toml + - --rcfile=crdt-sync/pyproject.toml - --fail-under=8.0 - --jobs=4 additional_dependencies: @@ -94,24 +110,24 @@ repos: - requests - repo: https://github.com/PyCQA/bandit - rev: 1.7.10 + rev: 1.9.4 hooks: - id: bandit args: - -c - - pyproject.toml + - crdt-sync/pyproject.toml - --severity-level=high - --confidence-level=medium - --skip=B113 additional_dependencies: ["bandit[toml]"] - exclude: ^(tests/|.*test.*\.py$) + exclude: ^(crdt-sync/crdt_sync/tests/|.*test.*\.py$) - repo: local hooks: - id: pytest-coverage name: pytest with coverage enforcement - entry: python -m pytest - language: system + entry: scripts/run_subproject_tests.sh crdt-sync + language: script types: [python] pass_filenames: false require_serial: true diff --git a/gatelock/.github/workflows/pre-commit.yml b/gatelock/.github/workflows/pre-commit.yml deleted file mode 100644 index 98911e8..0000000 --- a/gatelock/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: pre-commit - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - pre-commit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install dependencies - run: pip install -r requirements.txt - - uses: pre-commit/action@v3.0.1 diff --git a/gatelock/.github/workflows/python-tests.yml b/gatelock/.github/workflows/python-tests.yml deleted file mode 100644 index 66e5931..0000000 --- a/gatelock/.github/workflows/python-tests.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10", "3.11", "3.12"] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: pip install -r requirements.txt - - - name: Run tests with coverage - run: python -m pytest -v diff --git a/gatelock/.gitignore b/gatelock/.gitignore deleted file mode 100644 index eec3a7f..0000000 --- a/gatelock/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -__pycache__/ -*.pyc -.venv/ -.mypy_cache/ -.ruff_cache/ -.pytest_cache/ -.coverage -coverage.lcov -htmlcov/ -dist/ -build/ -*.egg-info/ diff --git a/gatelock/.pre-commit-config.yaml b/gatelock/.pre-commit-config.yaml index 670b0d3..c28e2ea 100644 --- a/gatelock/.pre-commit-config.yaml +++ b/gatelock/.pre-commit-config.yaml @@ -3,6 +3,14 @@ # Run: pre-commit run --all-files # Update: pre-commit autoupdate +# NOTE: this subproject lives inside the ~/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 gatelock/.pre-commit-config.yaml --all-files +files: ^gatelock/ + default_language_version: python: python3 default_stages: [pre-commit] @@ -32,6 +40,14 @@ repos: - repo: local hooks: + - id: subproject-scope + name: subproject scope regex still matches + entry: scripts/check_subproject_scope.sh gatelock + 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)' @@ -84,31 +100,31 @@ repos: hooks: - id: pylint args: - - --rcfile=pyproject.toml + - --rcfile=gatelock/pyproject.toml - --fail-under=8.0 - --jobs=4 additional_dependencies: - pytest - repo: https://github.com/PyCQA/bandit - rev: 1.7.10 + rev: 1.9.4 hooks: - id: bandit args: - -c - - pyproject.toml + - gatelock/pyproject.toml - --severity-level=high - --confidence-level=medium - --skip=B113 additional_dependencies: ["bandit[toml]"] - exclude: ^(tests/|.*test.*\.py$) + exclude: ^(gatelock/gatelock/tests/|.*test.*\.py$) - repo: local hooks: - id: pytest-coverage name: pytest with coverage enforcement - entry: python -m pytest - language: system + entry: scripts/run_subproject_tests.sh gatelock + language: script types: [python] pass_filenames: false require_serial: true diff --git a/gatelock/gatelock/_recovery.py b/gatelock/gatelock/_recovery.py index 15161d5..95bc89d 100644 --- a/gatelock/gatelock/_recovery.py +++ b/gatelock/gatelock/_recovery.py @@ -90,6 +90,7 @@ class RecoveryLoop: self._ticks = 0 self._drain_job: str | None = None self._verify_job: str | None = None + self._last_grab_warning: str | None = None @property def ticks(self) -> int: @@ -126,15 +127,32 @@ class RecoveryLoop: def _drain(self) -> None: """Run a full tick only if a push signal arrived. Cheap otherwise.""" - if self._detector.take_pending(): - _logger.debug("output-change signal received; re-asserting the lock") - self.tick() - self._schedule_drain() + try: + if self._detector.take_pending(): + _logger.debug("output-change signal received; re-asserting the lock") + self.tick() + # Same fail-open reasoning as _verify. + except Exception: + _logger.exception("drain tick raised; the lock loop keeps running") + finally: + self._schedule_drain() def _verify(self) -> None: - """Run a full tick unconditionally.""" - self.tick() - self._schedule_verify() + """Run a full tick unconditionally. + + The reschedule is in ``finally`` because it is the only thing keeping + the loop alive: a Tk ``after`` callback that raises just logs and + returns, so scheduling *after* ``tick()`` meant one unexpected + exception silently ended all re-assertion of coverage, grab and VT for + the rest of the lock. Fail-open is the one outcome this module exists + to prevent, so a broken tick must still leave the next one queued. + """ + try: + self.tick() + except Exception: + _logger.exception("verify tick raised; the lock loop keeps running") + finally: + self._schedule_verify() def tick(self) -> RecoveryReport: """Re-assert coverage, placement, grab and VT. Never weakens any of them. @@ -198,7 +216,7 @@ class RecoveryLoop: """ if self._config.resolved_grab() != "global": return False - if self._holds_grab(): + if self.holds_grab(): return False with contextlib.suppress(tk.TclError): self._root.grab_set_global() @@ -206,13 +224,59 @@ class RecoveryLoop: return True return False - def _holds_grab(self) -> bool: - """Whether our root still owns the grab.""" + def holds_grab(self) -> bool: + """Whether the grab is held by a window this application owns. + + Deliberately *not* ``grab_current() is self._root``. Our own transient + children take the grab for themselves: a posted Tk menu grabs the menu + window, and the old identity test read that as "the grab was lost" and + called ``grab_set_global()`` a second later, stealing it back off our + own widget mid-interaction. That is what made the screen-locker sport + selector unusable while locked on 2026-07-26. Any window we own still + means the lock has the pointer and keyboard. + + ``grab_current`` resolves the name Tcl gives it against our own widget + map, so anything that does not resolve -- a Tcl-created popdown such as + a ``ttk.Combobox``'s, or a stale name -- raises ``KeyError`` and is NOT + provably ours. That counts as lost and the grab is re-taken: fail + closed, because this module exists to keep the lock, and losing a + popdown beats believing we hold a grab we do not. + + Known limitation: a second ``tk.Tk()`` in the same process reports its + root as ``"."`` too, which is indistinguishable from ours. The old + identity test had exactly the same blind spot. ``_heat_skip``'s + throwaway root is the only other one in screen-locker, and it is + destroyed before the lock window exists. + + Public so an embedder (or a verification harness) can ask the same + question the loop asks, rather than re-implementing it and drifting. + """ try: - current = self._root.grab_current() - except tk.TclError: + held = self._root.grab_current() is not None + except KeyError as exc: + self._warn_once( + f"the grab is held by {exc.args[0]!r}, not one of our windows" + ) return False - return current is self._root + except tk.TclError: + self._warn_once("could not read the current grab") + return False + if held: + self._last_grab_warning = None + return held + + def _warn_once(self, reason: str) -> None: + """Warn about a lost grab, but only when the reason changes. + + These states can persist for the whole lock (a dark display, a popdown + that keeps re-grabbing), and this runs once a second -- warning every + tick would push a line per second into the journal for as long as it + lasts. Silence is not the alternative: the first occurrence and every + change of cause are still logged at WARNING. + """ + if reason != self._last_grab_warning: + _logger.warning("%s; treating it as lost and re-taking it", reason) + self._last_grab_warning = reason def _reassert_vt(self) -> bool: """Periodically re-disable VT switching. Never re-enables it. diff --git a/gatelock/gatelock/tests/test_outputs.py b/gatelock/gatelock/tests/test_outputs.py index 1f0456a..63accd3 100644 --- a/gatelock/gatelock/tests/test_outputs.py +++ b/gatelock/gatelock/tests/test_outputs.py @@ -133,7 +133,7 @@ class TestParseXrandrQuery: assert output.rect is None def test_garbage_and_empty(self) -> None: - """Unparseable input yields no outputs rather than raising.""" + """Unparsable input yields no outputs rather than raising.""" assert parse_xrandr_query(XRANDR_GARBAGE) == () assert parse_xrandr_query("") == () diff --git a/gatelock/gatelock/tests/test_recovery.py b/gatelock/gatelock/tests/test_recovery.py index a42939b..32e6b87 100644 --- a/gatelock/gatelock/tests/test_recovery.py +++ b/gatelock/gatelock/tests/test_recovery.py @@ -8,6 +8,7 @@ lock never weakens, and per-branch coverage. from __future__ import annotations import ast +import logging from pathlib import Path import tkinter as tk from unittest.mock import MagicMock, patch @@ -190,6 +191,57 @@ class TestGrabReassertion: mock_root.grab_current.return_value = mock_root assert recovery.tick().grab_reasserted is False + def test_noop_when_our_own_child_holds_the_grab( + self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock + ) -> None: + """A posted menu owns the grab; we must not steal it back. + + Regression: the old ``grab_current() is root`` test read a Tk menu's + own grab as "lost" and re-grabbed a second later, which made the + screen-locker sport selector unusable while locked. + """ + recovery, enumerator, _surfaces = loop + enumerator.scan.return_value = BOTH + mock_root.grab_current.return_value = MagicMock(name="posted menu") + assert recovery.tick().grab_reasserted is False + mock_root.grab_set_global.assert_not_called() + + def test_reasserts_when_the_holder_is_not_one_of_our_windows( + self, + loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], + mock_root: MagicMock, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Fail closed: a name we cannot resolve is not provably ours. + + A ``ttk.Combobox`` popdown is a Tcl-created toplevel absent from + Tkinter's widget map, so ``nametowidget`` raises ``KeyError``. Keeping + the lock beats keeping a banned widget's popdown. + """ + recovery, enumerator, _surfaces = loop + enumerator.scan.return_value = BOTH + mock_root.grab_current.side_effect = KeyError("!popdown") + with caplog.at_level(logging.WARNING): + assert recovery.tick().grab_reasserted is True + mock_root.grab_set_global.assert_called() + assert "not one of our windows" in caplog.text + + def test_a_persistent_lost_grab_warns_once_not_once_per_tick( + self, + loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], + mock_root: MagicMock, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A state that lasts the whole lock must not stream to the journal.""" + recovery, enumerator, _surfaces = loop + enumerator.scan.return_value = BOTH + mock_root.grab_current.side_effect = tk.TclError("gone") + with caplog.at_level(logging.WARNING): + for _ in range(5): + recovery.tick() + lost = [r for r in caplog.records if "current grab" in r.message] + assert len(lost) == 1, f"warned {len(lost)} times over 5 ticks" + def test_tclerror_while_regrabbing_is_swallowed( self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock ) -> None: @@ -200,7 +252,7 @@ class TestGrabReassertion: mock_root.grab_set_global.side_effect = tk.TclError("held") assert recovery.tick().grab_reasserted is False - def test_grab_current_tclerror( + def test_grab_query_tclerror( self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock ) -> None: """A TclError querying the grab counts as not holding it.""" @@ -286,6 +338,37 @@ class TestScheduling: recovery._verify() assert recovery.ticks == 1 + def test_verify_reschedules_even_when_the_tick_raises( + self, + loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], + mock_root: MagicMock, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A broken tick must not end the loop -- that would fail open.""" + recovery, enumerator, _surfaces = loop + enumerator.scan.side_effect = RuntimeError("boom") + recovery._running = True + with caplog.at_level(logging.ERROR): + recovery._verify() + mock_root.after.assert_called_once() + assert "verify tick raised" in caplog.text + + def test_drain_reschedules_even_when_the_tick_raises( + self, + loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], + mock_root: MagicMock, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Same for the cheap cadence.""" + recovery, enumerator, _surfaces = loop + enumerator.scan.side_effect = RuntimeError("boom") + recovery._detector.take_pending.return_value = True + recovery._running = True + with caplog.at_level(logging.ERROR): + recovery._drain() + mock_root.after.assert_called_once() + assert "drain tick raised" in caplog.text + def test_stop_cancels_and_halts( self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock ) -> None: diff --git a/scripts/check_subproject_scope.sh b/scripts/check_subproject_scope.sh new file mode 100755 index 0000000..795667e --- /dev/null +++ b/scripts/check_subproject_scope.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# ============================================================================ +# Fail if a subproject's pre-commit config is scoped to a directory that has +# moved or vanished. +# +# Each subproject config carries `files: ^/`. If the directory is +# renamed, that regex matches nothing and EVERY hook in the config reports +# "no files to check" -- green CI with no linting at all. This hook runs with +# always_run, so the top-level filter cannot skip it, and turns that silent +# pass into a loud failure. +# +# Usage: scripts/check_subproject_scope.sh +# ============================================================================ + +set -euo pipefail + +readonly REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +main() { + if [[ $# -ne 1 ]]; then + echo "Usage: $(basename "$0") " >&2 + exit 1 + fi + local subproject="$1" + local config="$REPO_ROOT/$subproject/.pre-commit-config.yaml" + + if [[ ! -f "$config" ]]; then + echo "Error: $subproject/.pre-commit-config.yaml is missing." >&2 + echo " Every hook scoped to '^$subproject/' would silently pass." >&2 + exit 1 + fi + if ! grep -qx "files: \^$subproject/" "$config"; then + echo "Error: $config does not scope itself to '^$subproject/'." >&2 + echo " Hooks would run over the whole monorepo, or over nothing." >&2 + exit 1 + fi +} + +main "$@" diff --git a/scripts/run_subproject_tests.sh b/scripts/run_subproject_tests.sh new file mode 100755 index 0000000..8435032 --- /dev/null +++ b/scripts/run_subproject_tests.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# ============================================================================ +# Run one subproject's test suite from that subproject's directory. +# +# Every subproject here keeps its own pyproject.toml, and its pytest config +# (testpaths, --cov=..., coverage source) is relative to that directory -- +# but pre-commit always runs hooks from the monorepo's git root. This wrapper +# supplies the missing `cd`, and fails loudly if the subproject is gone, +# rather than silently testing nothing. +# +# Usage: scripts/run_subproject_tests.sh [pytest args...] +# ============================================================================ + +set -euo pipefail + +readonly REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly SCRIPT_NAME="$(basename "$0")" + +usage() { + echo "Usage: $SCRIPT_NAME [pytest args...]" + echo " directory under $REPO_ROOT holding a pyproject.toml" + exit 1 +} + +main() { + if [[ $# -lt 1 ]]; then + usage + fi + local subproject="$1" + shift + local project_dir="$REPO_ROOT/$subproject" + + if [[ ! -f "$project_dir/pyproject.toml" ]]; then + echo "Error: no $subproject/pyproject.toml under $REPO_ROOT" >&2 + echo " (was the subproject renamed or removed?)" >&2 + exit 1 + fi + + cd "$project_dir" + exec python -m pytest "$@" +} + +main "$@"