Skip to content

Commit c208f73

Browse files
authored
ci: forward-port current pipeline (harness + timeout) to gramps61 (#40)
Bring maintenance/gramps61's .github/ up to feature/ci-cd-pipeline-upstream. The branch carried an early (2026-05-19) branch-neutral snapshot that ran tests with a bare 'python3 -m unittest' — no per-module timeout, no silent-skip detection, no GI bootstrap. The harness added later (run_addon_tests.py, 2026-05-29) only landed on the feature branch and was never forward-ported here, so a hanging addon test (e.g. TMGimporter's real-DB import on Windows) ran unbounded toward the 6h job default. Adds run_addon_tests.py (per-module subprocess timeout + honest skip accounting), addon_system_deps.py, gi_bootstrap/sitecustomize.py, and the matching ci.yml/environment.yml wiring. The ci.yml stays branch-neutral. Forward-port only; no addon changes.
1 parent dfd437d commit c208f73

5 files changed

Lines changed: 568 additions & 5 deletions

File tree

.github/environment.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ dependencies:
1010
# installed at CI runtime by ci.yml's auto-derive step from .gpr.py
1111
# requires_mod — single source of truth. Keep only the stable base
1212
# here (Gramps + orjson for plugin registration).
13+
#
14+
# conda-forge has no gramps 6.1 yet, so on a maintenance/gramps61 (or later)
15+
# branch this resolves to 6.0.x — i.e. the Windows lane validates addons
16+
# against conda-forge's newest in-range gramps, not the branch's exact series
17+
# (the Linux CI image git-builds the exact series; conda-Windows cannot — see
18+
# ci.yml's "Report gramps-vs-branch series" step). When 6.1 reaches
19+
# conda-forge the pin picks it up automatically.
1320
- pip:
1421
- "gramps>=6.0,<6.1"
1522
- orjson
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
#!/usr/bin/env python3
2+
"""Single source of truth for addon *system* dependencies in CI.
3+
4+
Addons declare three dependency kinds in their ``.gpr.py``:
5+
6+
* ``requires_mod`` — importable Python modules. pip-installable; ci.yml already
7+
auto-derives these from the ``.gpr.py`` files. Nothing to do here.
8+
* ``requires_gi`` — GObject-introspection typelibs (e.g. ``GooCanvas``).
9+
* ``requires_exe`` — system executables (e.g. ``dot`` from graphviz).
10+
11+
The latter two are *system* packages: not pip-installable, named differently per
12+
platform, and Gramps' own ``Requirements`` only *checks* them (never installs).
13+
This module maps each declared ``requires_gi`` namespace / ``requires_exe`` name
14+
to its package on each CI platform and scans the addons for what they declare,
15+
so ci.yml derives the install list from one place instead of a hand-kept list.
16+
17+
Platform availability is asymmetric and encoded here: the GTK 3 addon libs
18+
(goocanvas, osm-gps-map, gexiv2) exist on Debian/apt but **not on conda-forge**,
19+
so the conda (Windows) lane cannot install them — addons needing them skip there
20+
by necessity. A ``conda`` value of ``None`` records that.
21+
22+
Pure stdlib so it runs anywhere in CI without bootstrapping.
23+
24+
CLI::
25+
26+
addon_system_deps.py --platform apt # space-separated install list
27+
addon_system_deps.py --platform conda # (only packages available there)
28+
addon_system_deps.py --unmapped . # declared deps with no map entry; exit 1 if any
29+
"""
30+
31+
# ------------------------
32+
# Python modules
33+
# ------------------------
34+
from __future__ import annotations
35+
36+
import argparse
37+
import ast
38+
import glob
39+
import os
40+
import re
41+
import sys
42+
43+
# ---------------------------------------------------------------------------
44+
# The map. Keys are what addons declare; values give the package per platform.
45+
# A None value means "no package provides this on that platform" (so it is not
46+
# installed there and an addon needing it is expected to skip).
47+
# ---------------------------------------------------------------------------
48+
49+
# requires_gi namespace -> package providing the typelib, per platform.
50+
GI_PACKAGES: dict[str, dict[str, str | None]] = {
51+
"GExiv2": {"apt": "gir1.2-gexiv2-0.10", "conda": None},
52+
"GooCanvas": {"apt": "gir1.2-goocanvas-2.0", "conda": None},
53+
"OsmGpsMap": {"apt": "gir1.2-osmgpsmap-1.0", "conda": None},
54+
# PlaceCoordinateGramplet declares GeocodeGlib 1.0, but modern distros ship
55+
# only the 2.0 typelib and conda-forge ships none; the addon has no tests.
56+
# Recorded so the drift-guard recognises the namespace; not installed.
57+
"GeocodeGlib": {"apt": None, "conda": None},
58+
}
59+
60+
# requires_exe executable -> package providing it, per platform.
61+
EXE_PACKAGES: dict[str, dict[str, str | None]] = {
62+
"dot": {"apt": "graphviz", "conda": "graphviz"},
63+
}
64+
65+
PLATFORMS = ("apt", "conda")
66+
67+
68+
# ------------------------------------------------------------
69+
#
70+
# scanning
71+
#
72+
# ------------------------------------------------------------
73+
_GI_RE = re.compile(r"requires_gi\s*=\s*(\[[^\]]*\])")
74+
_EXE_RE = re.compile(r"requires_exe\s*=\s*(\[[^\]]*\])")
75+
76+
77+
def _gpr_files(root: str) -> list[str]:
78+
return sorted(glob.glob(os.path.join(root, "*", "*.gpr.py")))
79+
80+
81+
def _literal(src: str):
82+
try:
83+
return ast.literal_eval(src)
84+
except (ValueError, SyntaxError):
85+
return []
86+
87+
88+
def _scan(root: str, pattern: re.Pattern, first_of_tuple: bool) -> set[str]:
89+
found: set[str] = set()
90+
for path in _gpr_files(root):
91+
try:
92+
text = open(path, encoding="utf-8").read()
93+
except OSError:
94+
continue
95+
for match in pattern.finditer(text):
96+
for entry in _literal(match.group(1)):
97+
if first_of_tuple and isinstance(entry, (tuple, list)):
98+
entry = entry[0] if entry else None
99+
if entry:
100+
found.add(entry)
101+
return found
102+
103+
104+
def scan_gi_namespaces(root: str) -> set[str]:
105+
return _scan(root, _GI_RE, first_of_tuple=True)
106+
107+
108+
def scan_executables(root: str) -> set[str]:
109+
return _scan(root, _EXE_RE, first_of_tuple=False)
110+
111+
112+
def addon_requirements(addon_dir: str) -> tuple[set[str], set[str]]:
113+
"""Return (gi_namespaces, executables) declared by a single addon dir."""
114+
gi: set[str] = set()
115+
exe: set[str] = set()
116+
for path in sorted(glob.glob(os.path.join(addon_dir, "*.gpr.py"))):
117+
try:
118+
text = open(path, encoding="utf-8").read()
119+
except OSError:
120+
continue
121+
for match in _GI_RE.finditer(text):
122+
for entry in _literal(match.group(1)):
123+
ns = entry[0] if isinstance(entry, (tuple, list)) else entry
124+
if ns:
125+
gi.add(ns)
126+
for match in _EXE_RE.finditer(text):
127+
for entry in _literal(match.group(1)):
128+
if entry:
129+
exe.add(entry)
130+
return gi, exe
131+
132+
133+
# ------------------------------------------------------------
134+
#
135+
# derivation
136+
#
137+
# ------------------------------------------------------------
138+
def packages(platform: str) -> list[str]:
139+
"""All install-by-name packages available for a platform (full mapped set)."""
140+
pkgs: list[str] = []
141+
for table in (GI_PACKAGES, EXE_PACKAGES):
142+
for entry in table.values():
143+
pkg = entry.get(platform)
144+
if pkg:
145+
pkgs.append(pkg)
146+
return sorted(set(pkgs))
147+
148+
149+
def unmapped(root: str) -> tuple[set[str], set[str]]:
150+
"""Declared deps with no entry in the maps at all (drift)."""
151+
return (
152+
scan_gi_namespaces(root) - set(GI_PACKAGES),
153+
scan_executables(root) - set(EXE_PACKAGES),
154+
)
155+
156+
157+
def addon_satisfiable_on(addon_dir: str, platform: str) -> bool:
158+
"""
159+
True if every system dep the addon declares has a package on this platform.
160+
161+
Used by the test runner to tell an *expected* platform skip (a declared dep
162+
that simply is not packaged here, e.g. goocanvas on conda) from a suspicious
163+
all-skip that should fail.
164+
"""
165+
gi, exe = addon_requirements(addon_dir)
166+
for ns in gi:
167+
entry = GI_PACKAGES.get(ns)
168+
if entry is None or entry.get(platform) is None:
169+
return False
170+
for name in exe:
171+
entry = EXE_PACKAGES.get(name)
172+
if entry is None or entry.get(platform) is None:
173+
return False
174+
return True
175+
176+
177+
# ------------------------------------------------------------
178+
#
179+
# CLI
180+
#
181+
# ------------------------------------------------------------
182+
def main(argv: list[str] | None = None) -> int:
183+
parser = argparse.ArgumentParser(description=__doc__)
184+
parser.add_argument("--platform", choices=PLATFORMS)
185+
parser.add_argument(
186+
"--unmapped",
187+
metavar="ROOT",
188+
help="print declared GI/exe deps with no map entry; exit 1 if any",
189+
)
190+
args = parser.parse_args(argv)
191+
192+
if args.unmapped is not None:
193+
gi, exe = unmapped(args.unmapped)
194+
for ns in sorted(gi):
195+
print(f"gi:{ns}")
196+
for name in sorted(exe):
197+
print(f"exe:{name}")
198+
return 1 if (gi or exe) else 0
199+
200+
if args.platform:
201+
print(" ".join(packages(args.platform)))
202+
return 0
203+
204+
parser.error("nothing to do: pass --platform or --unmapped")
205+
return 2
206+
207+
208+
if __name__ == "__main__":
209+
sys.exit(main())
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Pin the GObject-introspection versions, like the Gramps GUI launcher.
2+
3+
Put this directory on ``PYTHONPATH`` for a test step and the interpreter imports
4+
this ``sitecustomize`` at startup — before any test (or subprocess it spawns)
5+
imports a ``gramps.gui`` module.
6+
7+
Why: gramps pins its GI versions in the GUI launcher (``gramps/gui/grampsgui.py``
8+
calls ``gi.require_version`` for Pango/PangoCairo/Gtk at import). A test that
9+
imports a ``gramps.gui.*`` module directly never runs that launcher, so Gtk/Pango
10+
get imported with no version pinned first — emitting a ``PyGIWarning`` and, on a
11+
host where GTK 4 is the default, risking the wrong stack. This shim performs the
12+
same bootstrap, so tests run under the supported GTK 3 stack.
13+
14+
Used for the discover-based / subprocess-loading steps (e.g. plugin
15+
registration), where the bootstrap must be inherited via ``PYTHONPATH`` by every
16+
spawned interpreter. The in-process unit/integration runner
17+
(``run_addon_tests.py``) does the same ``require_version`` itself.
18+
"""
19+
20+
try:
21+
import gi
22+
23+
for _ns, _ver in (("Pango", "1.0"), ("PangoCairo", "1.0"), ("Gtk", "3.0")):
24+
try:
25+
gi.require_version(_ns, _ver)
26+
except (ValueError, AttributeError):
27+
pass
28+
except ImportError:
29+
pass

0 commit comments

Comments
 (0)