Skip to content

Commit c206d1f

Browse files
committed
ci-scripts: fix same-named-addon-module shadowing; honour module-level SkipTest
Two real defects the fork CI run surfaced, both pre-existing (the original runner failed identically) but newly hit by the post-merge addon set: 1. Same-name shadowing. The worker appends the addon's directory to sys.path so its tests can import sibling modules by bare name (WebSearch's 'from models import …'). But most addons also ship <Addon>/<Addon>.py, and once <Addon>/ is on sys.path that regular module wins the bare name over the namespace-package directory — so loading the dotted test name <Addon>.tests.test_x died with "module '<Addon>' has no attribute 'tests'". 11 real addons failed this way (CalculateEstimatedDates, WebSearch, libaccess, …). Import the addon package from the repo root FIRST, pinning it in sys.modules, before the addon dir joins sys.path; the dotted load then resolves and the bare sibling imports still work. Reproduced and killed with a synthetic same-named-module fixture. 2. A module-level 'raise SkipTest(...)' — an addon's own "needs a display / PyGObject" guard — was classified as a code bug and failed the run. It is an explicit opt-out and is now honoured as a skip on every platform, regardless of declared-dep satisfiability. The load classifier gained a third outcome (skip | dep | other) and now reads the terminal exception out of unittest's wrapped-ImportError message, so a wrapped SkipTest/SyntaxError is classified by what actually happened.
1 parent 2a2f778 commit c206d1f

2 files changed

Lines changed: 88 additions & 6 deletions

File tree

.github/scripts/run_addon_tests.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
lives in ``addon_system_deps.py``). A module that fails to LOAD is excused as
2929
a platform skip only when the failure is dependency-shaped (ImportError, or
3030
gi's absent-typelib ValueError); a SyntaxError or a bug in the addon's own
31-
import-time code is a real defect and always FAILS, on every platform.
31+
import-time code is a real defect and always FAILS, on every platform. A
32+
module that raises ``SkipTest`` at import is opting out explicitly and is
33+
always honoured as a skip.
3234
3335
Usage::
3436
@@ -46,6 +48,7 @@
4648
from __future__ import annotations
4749

4850
import argparse
51+
import importlib
4952
import os
5053
import re
5154
import signal
@@ -85,14 +88,28 @@ def _module_timeout() -> int:
8588

8689
def _terminal_exc_name(text: str) -> str | None:
8790
"""The exception class name on the last ``Type: message`` line of a
88-
traceback string (``ModuleNotFoundError``, ``SyntaxError``, …), or None."""
91+
traceback string (``ModuleNotFoundError``, ``SyntaxError``, ``SkipTest``…),
92+
or None. Scans from the end, so the first match is the terminal line."""
8993
for line in reversed(text.strip().splitlines()):
90-
m = re.match(r"^([A-Za-z_][\w.]*(?:Error|Exception|Warning)):", line.strip())
94+
m = re.match(r"^([A-Za-z_][\w.]*)\s*:", line.strip())
9195
if m:
9296
return m.group(1).rsplit(".", 1)[-1]
9397
return None
9498

9599

100+
def _is_skip_request(exc: BaseException) -> bool:
101+
"""Whether a module opted out at import time via ``raise SkipTest(...)``.
102+
103+
A module-level SkipTest is an explicit, deliberate opt-out (the addon's own
104+
guard for "this needs a display / PyGObject / a backend I don't have"), so it
105+
is honoured as a skip on every platform — never a failure, and never
106+
dependent on the addon's declared system deps."""
107+
if isinstance(exc, unittest.SkipTest):
108+
return True
109+
# unittest's loader may wrap it (see _dep_shaped) — check the terminal type.
110+
return isinstance(exc, ImportError) and _terminal_exc_name(str(exc)) == "SkipTest"
111+
112+
96113
def _dep_shaped(exc: BaseException) -> bool:
97114
"""Whether a module load failure is a missing-dependency shape.
98115
@@ -121,6 +138,13 @@ def _dep_shaped(exc: BaseException) -> bool:
121138
return False
122139

123140

141+
def _load_kind(exc: BaseException) -> str:
142+
"""Classify a module load failure: skip | dep | other."""
143+
if _is_skip_request(exc):
144+
return "skip"
145+
return "dep" if _dep_shaped(exc) else "other"
146+
147+
124148
def _load_failure_exception(suite: unittest.TestSuite):
125149
"""The exception behind a deferred import failure, or None.
126150
@@ -187,6 +211,20 @@ def _run_worker(modname: str, root: str = ".") -> int:
187211
# working too.
188212
addon = modname.split(".", 1)[0]
189213
addon_dir = os.path.join(root, addon)
214+
# Resolve the addon PACKAGE (its directory, from the repo root) before the
215+
# addon dir joins sys.path. Many addons ship a top-level module named after
216+
# the addon itself (<Addon>/<Addon>.py); the moment <Addon>/ is on sys.path
217+
# that regular module wins the bare name <Addon> over the namespace package,
218+
# and the dotted test name <Addon>.tests.test_x then dies with "module
219+
# '<Addon>' has no attribute 'tests'". Importing the package first pins it in
220+
# sys.modules so the dotted load resolves, while the addon dir added below
221+
# still serves the tests' bare sibling imports (e.g. `from models import …`).
222+
try:
223+
importlib.import_module(addon)
224+
except Exception:
225+
# Not importable as a package (single-file addon, or its __init__ needs
226+
# deps): leave it; the dotted load below reports the real failure.
227+
pass
190228
if addon_dir not in sys.path:
191229
sys.path.append(addon_dir)
192230
# Load via unittest (NOT a bare import_module probe): an addon whose
@@ -202,12 +240,12 @@ def _run_worker(modname: str, root: str = ".") -> int:
202240
try:
203241
suite = unittest.defaultTestLoader.loadTestsFromName(modname)
204242
except Exception as exc: # raised import-time failure
205-
kind = "dep" if _dep_shaped(exc) else "other"
243+
kind = _load_kind(exc)
206244
print(f"{_LOADERROR} kind={kind} {exc!r}", flush=True)
207245
return 0
208246
load_exc = _load_failure_exception(suite) # deferred (_FailedTest) failure
209247
if load_exc is not None:
210-
kind = "dep" if _dep_shaped(load_exc) else "other"
248+
kind = _load_kind(load_exc)
211249
print(f"{_LOADERROR} kind={kind} {load_exc!r}", flush=True)
212250
return 0
213251
result = unittest.TextTestRunner(verbosity=2).run(suite)
@@ -276,7 +314,12 @@ def _classify(modname: str, platform: str, root: str) -> tuple[bool, str]:
276314

277315
if result_line.startswith(_LOADERROR):
278316
kind_m = re.search(r"\bkind=(\w+)", result_line)
279-
dep_shaped = bool(kind_m) and kind_m.group(1) == "dep"
317+
kind = kind_m.group(1) if kind_m else ""
318+
if kind == "skip":
319+
# The module raised SkipTest at import: an explicit opt-out, honoured
320+
# on every platform regardless of declared-dep satisfiability.
321+
return False, f" skip {modname} — module opted out (SkipTest at import)"
322+
dep_shaped = kind == "dep"
280323
if not dep_shaped:
281324
# A non-dependency load failure (SyntaxError, a bug in the addon's
282325
# import-time code) is a real defect on every platform — never

tests/test_run_addon_tests_paths.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,45 @@ def test_non_integer_timeout_env_is_tolerated(self) -> None:
228228
self.assertEqual(result.returncode, 0, out)
229229
self.assertIn("ignoring non-integer", result.stderr)
230230

231+
def test_same_named_addon_module_does_not_shadow_package(self) -> None:
232+
# Regression: many addons ship <Addon>/<Addon>.py. Once <Addon>/ is on
233+
# sys.path (for the tests' bare sibling imports) that regular module wins
234+
# the bare name over the namespace-package directory, and the dotted test
235+
# name <Addon>.tests.test_x died with "module '<Addon>' has no attribute
236+
# 'tests'" — 11 real addons failed this way on CI.
237+
self._write("ShadowAddon/shadowaddon.gpr.py", 'register(GRAMPLET, id="s")\n')
238+
self._write("ShadowAddon/ShadowAddon.py", "MAIN = 'addon main module'\n")
239+
self._write("ShadowAddon/sibling.py", "SIB = 5\n")
240+
self._write("ShadowAddon/tests/__init__.py", "")
241+
self._write(
242+
"ShadowAddon/tests/test_x.py",
243+
"import unittest\n"
244+
"from sibling import SIB\n" # bare sibling import needs addon dir
245+
"\n"
246+
"class T(unittest.TestCase):\n"
247+
" def test_sibling(self):\n"
248+
" self.assertEqual(SIB, 5)\n",
249+
)
250+
result = self._run("ShadowAddon.tests.test_x", platform="apt")
251+
out = result.stdout + result.stderr
252+
self.assertEqual(result.returncode, 0, out)
253+
self.assertIn("ok ShadowAddon.tests.test_x", result.stdout, out)
254+
255+
def test_module_level_skiptest_is_honored(self) -> None:
256+
# A module that raises SkipTest at import is explicitly opting out (the
257+
# addon's own "needs a display / PyGObject" guard) — honour it as a skip
258+
# on every platform, never a failure.
259+
self._write("SkipAddon/skipaddon.gpr.py", 'register(GRAMPLET, id="s")\n')
260+
self._write("SkipAddon/tests/__init__.py", "")
261+
self._write(
262+
"SkipAddon/tests/test_x.py",
263+
"import unittest\nraise unittest.SkipTest('no display here')\n",
264+
)
265+
result = self._run("SkipAddon.tests.test_x", platform="apt")
266+
out = result.stdout + result.stderr
267+
self.assertEqual(result.returncode, 0, out)
268+
self.assertIn("opted out", out)
269+
231270
@unittest.skipUnless(os.name == "posix", "process-group kill is POSIX-only")
232271
def test_timeout_reaps_grandchild_holding_stdout(self) -> None:
233272
# A test that spawns a long-lived child inheriting the worker's stdout

0 commit comments

Comments
 (0)