Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ dependencies:
# installed at CI runtime by ci.yml's auto-derive step from .gpr.py
# requires_mod — single source of truth. Keep only the stable base
# here (Gramps + orjson for plugin registration).
#
# conda-forge has no gramps 6.1 yet, so on a maintenance/gramps61 (or later)
# branch this resolves to 6.0.x — i.e. the Windows lane validates addons
# against conda-forge's newest in-range gramps, not the branch's exact series
# (the Linux CI image git-builds the exact series; conda-Windows cannot — see
# ci.yml's "Report gramps-vs-branch series" step). When 6.1 reaches
# conda-forge the pin picks it up automatically.
- pip:
- "gramps>=6.0,<6.1"
- orjson
209 changes: 209 additions & 0 deletions .github/scripts/addon_system_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Single source of truth for addon *system* dependencies in CI.

Addons declare three dependency kinds in their ``.gpr.py``:

* ``requires_mod`` — importable Python modules. pip-installable; ci.yml already
auto-derives these from the ``.gpr.py`` files. Nothing to do here.
* ``requires_gi`` — GObject-introspection typelibs (e.g. ``GooCanvas``).
* ``requires_exe`` — system executables (e.g. ``dot`` from graphviz).

The latter two are *system* packages: not pip-installable, named differently per
platform, and Gramps' own ``Requirements`` only *checks* them (never installs).
This module maps each declared ``requires_gi`` namespace / ``requires_exe`` name
to its package on each CI platform and scans the addons for what they declare,
so ci.yml derives the install list from one place instead of a hand-kept list.

Platform availability is asymmetric and encoded here: the GTK 3 addon libs
(goocanvas, osm-gps-map, gexiv2) exist on Debian/apt but **not on conda-forge**,
so the conda (Windows) lane cannot install them — addons needing them skip there
by necessity. A ``conda`` value of ``None`` records that.

Pure stdlib so it runs anywhere in CI without bootstrapping.

CLI::

addon_system_deps.py --platform apt # space-separated install list
addon_system_deps.py --platform conda # (only packages available there)
addon_system_deps.py --unmapped . # declared deps with no map entry; exit 1 if any
"""

# ------------------------
# Python modules
# ------------------------
from __future__ import annotations

import argparse
import ast
import glob
import os
import re
import sys

# ---------------------------------------------------------------------------
# The map. Keys are what addons declare; values give the package per platform.
# A None value means "no package provides this on that platform" (so it is not
# installed there and an addon needing it is expected to skip).
# ---------------------------------------------------------------------------

# requires_gi namespace -> package providing the typelib, per platform.
GI_PACKAGES: dict[str, dict[str, str | None]] = {
"GExiv2": {"apt": "gir1.2-gexiv2-0.10", "conda": None},
"GooCanvas": {"apt": "gir1.2-goocanvas-2.0", "conda": None},
"OsmGpsMap": {"apt": "gir1.2-osmgpsmap-1.0", "conda": None},
# PlaceCoordinateGramplet declares GeocodeGlib 1.0, but modern distros ship
# only the 2.0 typelib and conda-forge ships none; the addon has no tests.
# Recorded so the drift-guard recognises the namespace; not installed.
"GeocodeGlib": {"apt": None, "conda": None},
}

# requires_exe executable -> package providing it, per platform.
EXE_PACKAGES: dict[str, dict[str, str | None]] = {
"dot": {"apt": "graphviz", "conda": "graphviz"},
}

PLATFORMS = ("apt", "conda")


# ------------------------------------------------------------
#
# scanning
#
# ------------------------------------------------------------
_GI_RE = re.compile(r"requires_gi\s*=\s*(\[[^\]]*\])")
_EXE_RE = re.compile(r"requires_exe\s*=\s*(\[[^\]]*\])")


def _gpr_files(root: str) -> list[str]:
return sorted(glob.glob(os.path.join(root, "*", "*.gpr.py")))


def _literal(src: str):
try:
return ast.literal_eval(src)
except (ValueError, SyntaxError):
return []


def _scan(root: str, pattern: re.Pattern, first_of_tuple: bool) -> set[str]:
found: set[str] = set()
for path in _gpr_files(root):
try:
text = open(path, encoding="utf-8").read()
except OSError:
continue
for match in pattern.finditer(text):
for entry in _literal(match.group(1)):
if first_of_tuple and isinstance(entry, (tuple, list)):
entry = entry[0] if entry else None
if entry:
found.add(entry)
return found


def scan_gi_namespaces(root: str) -> set[str]:
return _scan(root, _GI_RE, first_of_tuple=True)


def scan_executables(root: str) -> set[str]:
return _scan(root, _EXE_RE, first_of_tuple=False)


def addon_requirements(addon_dir: str) -> tuple[set[str], set[str]]:
"""Return (gi_namespaces, executables) declared by a single addon dir."""
gi: set[str] = set()
exe: set[str] = set()
for path in sorted(glob.glob(os.path.join(addon_dir, "*.gpr.py"))):
try:
text = open(path, encoding="utf-8").read()
except OSError:
continue
for match in _GI_RE.finditer(text):
for entry in _literal(match.group(1)):
ns = entry[0] if isinstance(entry, (tuple, list)) else entry
if ns:
gi.add(ns)
for match in _EXE_RE.finditer(text):
for entry in _literal(match.group(1)):
if entry:
exe.add(entry)
return gi, exe


# ------------------------------------------------------------
#
# derivation
#
# ------------------------------------------------------------
def packages(platform: str) -> list[str]:
"""All install-by-name packages available for a platform (full mapped set)."""
pkgs: list[str] = []
for table in (GI_PACKAGES, EXE_PACKAGES):
for entry in table.values():
pkg = entry.get(platform)
if pkg:
pkgs.append(pkg)
return sorted(set(pkgs))


def unmapped(root: str) -> tuple[set[str], set[str]]:
"""Declared deps with no entry in the maps at all (drift)."""
return (
scan_gi_namespaces(root) - set(GI_PACKAGES),
scan_executables(root) - set(EXE_PACKAGES),
)


def addon_satisfiable_on(addon_dir: str, platform: str) -> bool:
"""
True if every system dep the addon declares has a package on this platform.

Used by the test runner to tell an *expected* platform skip (a declared dep
that simply is not packaged here, e.g. goocanvas on conda) from a suspicious
all-skip that should fail.
"""
gi, exe = addon_requirements(addon_dir)
for ns in gi:
entry = GI_PACKAGES.get(ns)
if entry is None or entry.get(platform) is None:
return False
for name in exe:
entry = EXE_PACKAGES.get(name)
if entry is None or entry.get(platform) is None:
return False
return True


# ------------------------------------------------------------
#
# CLI
#
# ------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--platform", choices=PLATFORMS)
parser.add_argument(
"--unmapped",
metavar="ROOT",
help="print declared GI/exe deps with no map entry; exit 1 if any",
)
args = parser.parse_args(argv)

if args.unmapped is not None:
gi, exe = unmapped(args.unmapped)
for ns in sorted(gi):
print(f"gi:{ns}")
for name in sorted(exe):
print(f"exe:{name}")
return 1 if (gi or exe) else 0

if args.platform:
print(" ".join(packages(args.platform)))
return 0

parser.error("nothing to do: pass --platform or --unmapped")
return 2


if __name__ == "__main__":
sys.exit(main())
29 changes: 29 additions & 0 deletions .github/scripts/gi_bootstrap/sitecustomize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Pin the GObject-introspection versions, like the Gramps GUI launcher.

Put this directory on ``PYTHONPATH`` for a test step and the interpreter imports
this ``sitecustomize`` at startup — before any test (or subprocess it spawns)
imports a ``gramps.gui`` module.

Why: gramps pins its GI versions in the GUI launcher (``gramps/gui/grampsgui.py``
calls ``gi.require_version`` for Pango/PangoCairo/Gtk at import). A test that
imports a ``gramps.gui.*`` module directly never runs that launcher, so Gtk/Pango
get imported with no version pinned first — emitting a ``PyGIWarning`` and, on a
host where GTK 4 is the default, risking the wrong stack. This shim performs the
same bootstrap, so tests run under the supported GTK 3 stack.

Used for the discover-based / subprocess-loading steps (e.g. plugin
registration), where the bootstrap must be inherited via ``PYTHONPATH`` by every
spawned interpreter. The in-process unit/integration runner
(``run_addon_tests.py``) does the same ``require_version`` itself.
"""

try:
import gi

for _ns, _ver in (("Pango", "1.0"), ("PangoCairo", "1.0"), ("Gtk", "3.0")):
try:
gi.require_version(_ns, _ver)
except (ValueError, AttributeError):
pass
except ImportError:
pass
Loading
Loading