From 774a9acad18a12dff9b2af17e8a4b9f4c33c65f7 Mon Sep 17 00:00:00 2001 From: Eduard Ralph Date: Sun, 19 Apr 2026 14:15:58 +0200 Subject: [PATCH 1/6] Add CI/CD pipeline with container-based testing (issue 9393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce GitHub Actions CI inside a shared Docker image on ghcr.io, plus a native Windows runner for cross-platform unit-test coverage, and a shared unittest harness with Gramps-backed fixtures that verifies every addon registers, loads, and exposes valid plugin metadata. CI infrastructure ----------------- - .github/docker/gramps-ci/Dockerfile — Python 3.12 + Gramps 6.0 (pip) + PyGObject + GTK typelibs + xvfb/xauth + ruff, dbf, intltool, gettext, git. GTK lives in the base so addon modules that do `from gi.repository import Gtk` at load time are importable; xvfb and xauth are bundled for tests that actually render. - .github/workflows/docker-build.yml — rebuilds the image on .github/docker/** changes or via workflow_dispatch. - .github/workflows/ci.yml — seven jobs: lint (ruff E9/F63/F7/F82 + trailing whitespace), addon-structure (every addon has po/template.pot), compile-check (py_compile on every .py), unit-test-linux (container), unit-test-windows (native, conda+pip), integration-test (container with --init so xvfb-run does not hang), build (make.py gramps60 build all). - .github/environment.yml — hybrid conda+pip env for Windows. Gramps is not on conda-forge, so pygobject/gtk3 come from conda and gramps/orjson/dbf come from pip. Shared test harness ------------------- - tests/__init__.py — GPL header. - tests/gramps_test_env.py — sys.path / GRAMPS_RESOURCES bootstrap and two unittest base classes: GrampsTestCase (session-cached plugin manager + registry via setUpClass) and GrampsDbTestCase (same plus a fresh in-memory SQLite DB per test). - tests/test_plugin_registration.py — four unittest.TestCase classes covering plugin registration, subprocess-isolated module loading (crash-safe), required metadata (gramps_target_version=6.0, valid id/name/version), and import/export entry-function smoke tests. Gate policy ----------- All seven jobs run on every push and PR. Four are marked continue-on-error: true so they surface issues without blocking merges while the existing tree is cleaned up: - lint (~79 pre-existing ruff E9/F63/F7/F82 errors) - addon-structure (4 addons missing po/template.pot) - unit-test-linux (some addon test modules fail to import today) - unit-test-windows (same) compile-check, integration-test, and build are blocking from day one. Each non-blocking gate will be flipped to blocking in the same follow-up PR that fixes its underlying issues, so the tightening is incremental and visible in history. Co-Authored-By: Claude Opus 4.7 --- .github/docker/gramps-ci/Dockerfile | 53 ++++++ .github/environment.yml | 12 ++ .github/workflows/ci.yml | 237 ++++++++++++++++++++++++ .github/workflows/docker-build.yml | 52 ++++++ tests/__init__.py | 21 +++ tests/gramps_test_env.py | 178 ++++++++++++++++++ tests/test_plugin_registration.py | 277 ++++++++++++++++++++++++++++ 7 files changed, 830 insertions(+) create mode 100644 .github/docker/gramps-ci/Dockerfile create mode 100644 .github/environment.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker-build.yml create mode 100644 tests/__init__.py create mode 100644 tests/gramps_test_env.py create mode 100644 tests/test_plugin_registration.py diff --git a/.github/docker/gramps-ci/Dockerfile b/.github/docker/gramps-ci/Dockerfile new file mode 100644 index 000000000..3ef3b8945 --- /dev/null +++ b/.github/docker/gramps-ci/Dockerfile @@ -0,0 +1,53 @@ +# .github/docker/gramps-ci/Dockerfile +# +# Unified Gramps 6.0 CI image. Includes everything jobs need: +# - Python + pip-installed Gramps, PyGObject, pycairo +# - GTK typelibs (so addon modules that `from gi.repository import Gtk` +# at module load time are importable — widgets still need xvfb to render) +# - intltool/gettext/git for make.py builds +# - ruff, dbf for lint/test tooling (tests use stdlib unittest per AGENTS.md) +# - xvfb + xauth for tests that actually render (wrap with `xvfb-run`) +# +# No display server runs by default — the image is headless unless a command +# explicitly invokes `xvfb-run`. When running with docker locally, pass +# `--init` (or use a container runtime that injects tini) because xvfb-run +# hangs if it inherits PID 1. +# +ARG PYTHON_VERSION=3.12 +FROM python:${PYTHON_VERSION}-slim + +LABEL org.opencontainers.image.source="https://github.com/gramps-project/addons-source" +LABEL org.opencontainers.image.description="Unified Gramps 6.0 CI image (Python, Gramps, GTK typelibs, xvfb)" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgirepository-2.0-dev \ + gir1.2-glib-2.0 \ + gir1.2-gtk-3.0 \ + gir1.2-pango-1.0 \ + gir1.2-gdkpixbuf-2.0 \ + gir1.2-atk-1.0 \ + gcc \ + pkg-config \ + python3-dev \ + libcairo2-dev \ + intltool \ + gettext \ + git \ + xvfb \ + xauth \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir \ + PyGObject \ + pycairo \ + "gramps>=6.0,<6.1" \ + orjson \ + ruff \ + dbf + +RUN apt-get purge -y gcc python3-dev pkg-config && apt-get autoremove -y + +RUN python -c "from gramps.gen.const import VERSION; print('Gramps', VERSION)" \ + && python -c "import gi; gi.require_version('Gtk', '3.0'); from gi.repository import Gtk; print('GTK OK')" + +WORKDIR /workspace diff --git a/.github/environment.yml b/.github/environment.yml new file mode 100644 index 000000000..9ca19f57f --- /dev/null +++ b/.github/environment.yml @@ -0,0 +1,12 @@ +name: addons-ci +channels: + - conda-forge +dependencies: + - python=3.12 + - pygobject + - gtk3 + - pip + - pip: + - "gramps>=6.0,<6.1" + - orjson + - dbf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..92bd05ff6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,237 @@ +name: CI + +on: + push: + branches: [maintenance/gramps60] + pull_request: + branches: [maintenance/gramps60] + +env: + CI_IMAGE: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + +jobs: + # ----------------------------------------------------------------- + # Lint (ci container) + # ----------------------------------------------------------------- + lint: + name: Lint + runs-on: ubuntu-latest + # Non-blocking until the existing ruff E9/F63/F7/F82 errors across the + # addon set are cleaned up in a follow-up PR. Flip this off in that PR. + continue-on-error: true + container: + image: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + steps: + - uses: actions/checkout@v4 + + - name: Run ruff (syntax and import errors only) + run: ruff check --select=E9,F63,F7,F82 --no-fix --exclude='*.gpr.py' . + + - name: Check trailing whitespace in Python files + run: | + if git --no-pager grep --color -n --full-name '[ \t]$' -- '*.py'; then + echo "::error::Trailing whitespace found in Python files" + exit 1 + fi + + # ----------------------------------------------------------------- + # Addon structure (bare runner — just bash, no deps needed) + # ----------------------------------------------------------------- + addon-structure: + name: Addon Structure + runs-on: ubuntu-latest + # Non-blocking until the four addons missing po/template.pot are fixed + # in a follow-up PR. Flip this off in that PR. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - name: Check all addons have po/template.pot + run: | + failed=0 + for gpr in */*.gpr.py; do + addon_dir="$(dirname "$gpr")" + if [ ! -d "$addon_dir/po" ]; then + echo "::error::$addon_dir is missing po/ directory" + failed=1 + elif [ ! -f "$addon_dir/po/template.pot" ]; then + echo "::error::$addon_dir is missing po/template.pot" + failed=1 + fi + done + if [ "$failed" -eq 0 ]; then + echo "All addons have po/template.pot" + fi + exit $failed + + # ----------------------------------------------------------------- + # Compile check (ci container) + # ----------------------------------------------------------------- + compile-check: + name: Compile Check + runs-on: ubuntu-latest + container: + image: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + steps: + - uses: actions/checkout@v4 + + - name: Compile all Python files (excluding .gpr.py) + shell: bash + run: | + failed=0 + while IFS= read -r f; do + if ! python3 -m py_compile "$f" 2>&1; then + failed=1 + fi + done < <(find . -name '*.py' ! -name '*.gpr.py' ! -path './.git/*' ! -path '*/__pycache__/*') + exit $failed + + # ----------------------------------------------------------------- + # Unit tests — Linux (ci container) + # ----------------------------------------------------------------- + unit-test-linux: + name: Unit Tests (Linux) + runs-on: ubuntu-latest + # Non-blocking until the currently-broken addon unit modules (import + # failures, stale API usage) are sorted out in follow-up PRs. + continue-on-error: true + container: + image: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + steps: + - uses: actions/checkout@v4 + + - name: Run per-addon unit tests + env: + PYTHONPATH: . + run: | + modules="" + for f in */tests/test_*.py; do + [ -f "$f" ] || continue + case "$(basename "$f")" in + test_integration*) continue ;; + esac + case "$f" in + Sqlite/tests/test_sqlite.py) continue ;; + esac + mod="${f%.py}" + mod="${mod//\//.}" + modules="$modules $mod" + done + if [ -n "$modules" ]; then + echo "Running unit tests:$modules" + python3 -m unittest -v $modules + else + echo "No per-addon unit test modules found" + fi + + # ----------------------------------------------------------------- + # Unit tests — Windows (conda-forge: bundles PyGObject + GTK + Gramps) + # ----------------------------------------------------------------- + unit-test-windows: + name: Unit Tests (Windows) + runs-on: windows-latest + # Non-blocking for the same reason as unit-test-linux. + continue-on-error: true + defaults: + run: + shell: bash -el {0} + steps: + - uses: actions/checkout@v4 + + - name: Set up Miniforge + uses: conda-incubator/setup-miniconda@v3 + with: + miniforge-version: latest + activate-environment: addons-ci + environment-file: .github/environment.yml + use-mamba: true + + - name: Verify environment + run: | + mamba info + mamba list | head -30 + python -c "import gramps, gi; print('deps OK')" + + - name: Run per-addon unit tests + env: + PYTHONPATH: . + run: | + modules="" + for f in */tests/test_*.py; do + [ -f "$f" ] || continue + case "$(basename "$f")" in + test_integration*) continue ;; + esac + case "$f" in + Sqlite/tests/test_sqlite.py) continue ;; + esac + mod="${f%.py}" + mod="${mod//\//.}" + modules="$modules $mod" + done + if [ -n "$modules" ]; then + echo "Running unit tests:$modules" + python -m unittest -v $modules + else + echo "No per-addon unit test modules found" + fi + + # ----------------------------------------------------------------- + # Integration tests — Gramps (ci container, xvfb available) + # ----------------------------------------------------------------- + integration-test: + name: Integration Tests (Gramps) + runs-on: ubuntu-latest + needs: [unit-test-linux] + container: + image: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + options: --init + steps: + - uses: actions/checkout@v4 + + - name: Run plugin registration tests + env: + PYTHONPATH: . + run: python3 -m unittest discover -s tests -p "test_*.py" -t . -v + + - name: Run per-addon integration tests + env: + PYTHONPATH: . + run: | + modules="" + for f in */tests/test_integration*.py; do + [ -f "$f" ] || continue + mod="${f%.py}" + mod="${mod//\//.}" + modules="$modules $mod" + done + if [ -n "$modules" ]; then + echo "Running per-addon integration tests:$modules" + python3 -m unittest -v $modules + else + echo "No per-addon integration test modules found" + fi + + # ----------------------------------------------------------------- + # Build (ci container) + # ----------------------------------------------------------------- + build: + name: Build + runs-on: ubuntu-latest + container: + image: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + steps: + - uses: actions/checkout@v4 + + - name: Determine GRAMPSPATH + id: gramps-path + run: | + GPATH=$(python3 -c "import gramps, os; print(os.path.dirname(os.path.dirname(gramps.__file__)))") + echo "path=$GPATH" >> "$GITHUB_OUTPUT" + + - name: Build all addons + env: + GRAMPSPATH: ${{ steps.gramps-path.outputs.path }} + run: | + mkdir -p ../download + python3 make.py gramps60 build all diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 000000000..9289949b2 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,52 @@ +name: Build Docker Images + +on: + push: + branches: [maintenance/gramps60] + paths: + - '.github/docker/**' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + REPO: ${{ github.repository }} + +permissions: + contents: read + packages: write + +jobs: + build-ci: + name: Build gramps-ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.REPO }}/gramps-ci + tags: | + type=raw,value=gramps60 + type=sha,prefix=gramps60- + + - name: Build and push gramps-ci + uses: docker/build-push-action@v6 + with: + context: .github/docker/gramps-ci + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..6a28a3984 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,21 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Eduard Ralph +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +"""Package marker for the repo-wide Gramps addon test suite.""" diff --git a/tests/gramps_test_env.py b/tests/gramps_test_env.py new file mode 100644 index 000000000..901169011 --- /dev/null +++ b/tests/gramps_test_env.py @@ -0,0 +1,178 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Eduard Ralph +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Shared test infrastructure for Gramps addon integration tests. + +Module-level initialisation puts the addons-source root on :data:`sys.path` +and sets :envvar:`GRAMPS_RESOURCES` so ``gramps`` is importable. Helpers +boot the Gramps plugin system once per process (registering plugins is +expensive) and provide fresh in-memory databases on demand. + +Usage — in any :class:`unittest.TestCase`:: + + from tests.gramps_test_env import GrampsTestCase + + class MyPluginTest(GrampsTestCase): + def test_registered(self) -> None: + pdata = self.plugin_registry.get_plugin("im_sqz") + self.assertIsNotNone(pdata) +""" + +# ------------------------ +# Python modules +# ------------------------ +import os +import shutil +import sys +import tempfile +import unittest +from typing import Any, ClassVar + +# ------------------------ +# Path + environment bootstrap (runs at import) +# ------------------------ +ADDONS_ROOT: str = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ADDONS_ROOT not in sys.path: + sys.path.insert(0, ADDONS_ROOT) + +if "GRAMPS_RESOURCES" not in os.environ: + try: + import gramps # noqa: F401 + + os.environ["GRAMPS_RESOURCES"] = os.path.dirname( + os.path.dirname(gramps.__file__) + ) + except ImportError: + pass + + +# ------------------------ +# GTK availability +# ------------------------ +def _has_gtk() -> bool: + """Return whether GTK 3.0 is importable on this host. + + :returns: ``True`` if ``gi.repository.Gtk`` can be loaded, else ``False``. + """ + try: + import gi + + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk # noqa: F401 + + return True + except (ImportError, ValueError): + return False + + +HAS_GTK: bool = _has_gtk() + + +# ------------------------ +# Plugin-manager singleton +# ------------------------ +_plugin_cache: dict[str, Any] = {} + + +def get_plugin_manager_and_registry() -> tuple[Any, Any]: + """Return the Gramps plugin manager and registry, initialising on first call. + + Registration scans every addon's ``.gpr.py`` and is expensive; the result + is cached for the lifetime of the test process. + + :returns: Tuple of ``(plugin_manager, plugin_registry)``. + :rtype: tuple[:class:`BasePluginManager`, :class:`PluginRegister`] + """ + if "pmgr" not in _plugin_cache: + from gramps.gen.const import PLUGINS_DIR + from gramps.gen.plug import BasePluginManager, PluginRegister + + pmgr = BasePluginManager.get_instance() + pmgr.reg_plugins(PLUGINS_DIR, None, None) + pmgr.reg_plugins(ADDONS_ROOT, None, None, load_on_reg=True) + _plugin_cache["pmgr"] = pmgr + _plugin_cache["registry"] = PluginRegister.get_instance() + return _plugin_cache["pmgr"], _plugin_cache["registry"] + + +def make_gramps_user() -> Any: + """Return a headless :class:`gramps.cli.user.User` for batch import/export. + + :returns: A ``User`` configured with ``auto_accept=True`` and ``quiet=True``. + """ + from gramps.cli.user import User + + return User(auto_accept=True, quiet=True) + + +# ------------------------------------------------------------ +# +# GrampsTestCase +# +# ------------------------------------------------------------ +class GrampsTestCase(unittest.TestCase): + """ + Base TestCase with lazy access to the Gramps plugin manager and registry. + + Subclasses may override :meth:`setUp` / :meth:`tearDown` freely; the + plugin registry is a class-level singleton so its cost is paid once. + """ + + plugin_manager: ClassVar[Any] = None + plugin_registry: ClassVar[Any] = None + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.plugin_manager, cls.plugin_registry = get_plugin_manager_and_registry() + + +# ------------------------------------------------------------ +# +# GrampsDbTestCase +# +# ------------------------------------------------------------ +class GrampsDbTestCase(GrampsTestCase): + """ + Base class that also provisions a fresh in-memory SQLite Gramps DB per test. + + The database is available as ``self.db``; ``setUp`` / ``tearDown`` handle + creation and cleanup of the on-disk temp directory. + """ + + db: Any = None + _tmpdir: str = "" + + def setUp(self) -> None: + super().setUp() + from gramps.gen.db.utils import make_database + + self._tmpdir = tempfile.mkdtemp(prefix="gramps_test_") + self.db = make_database("sqlite") + self.db.load(os.path.join(self._tmpdir, "test_db"), None) + + def tearDown(self) -> None: + try: + self.db.close() + except Exception: + pass + shutil.rmtree(self._tmpdir, ignore_errors=True) + super().tearDown() diff --git a/tests/test_plugin_registration.py b/tests/test_plugin_registration.py new file mode 100644 index 000000000..f242fcf35 --- /dev/null +++ b/tests/test_plugin_registration.py @@ -0,0 +1,277 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Eduard Ralph +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Integration tests that verify all addons register and load correctly +through the Gramps plugin system. + +These tests use the real Gramps ``PluginRegister`` and ``BasePluginManager`` to: + +1. Scan every addon's ``.gpr.py``. +2. Verify all plugins registered successfully. +3. Attempt to load each plugin module (catching missing dependencies). +4. Validate plugin metadata (version, target version, entry points). +""" + +# ------------------------ +# Python modules +# ------------------------ +import importlib +import logging +import os +import subprocess +import sys +import unittest +from typing import Any + +# ------------------------ +# Gramps modules +# ------------------------ +from gramps.gen.plug._pluginreg import EXPORT, GRAMPLET, IMPORT, REPORT, TOOL + +# ------------------------ +# Gramps specific +# ------------------------ +from tests.gramps_test_env import ADDONS_ROOT, GrampsTestCase + +LOG = logging.getLogger(__name__) + + +def _get_addon_plugins(registry: Any) -> list[Any]: + """Return all :class:`PluginData` objects whose ``fpath`` is inside the addons tree. + + :param registry: A :class:`PluginRegister` instance. + :returns: List of :class:`PluginData` entries belonging to this repository. + """ + return [ + pdata + for pdata in registry._PluginRegister__plugindata + if pdata.fpath and ADDONS_ROOT in pdata.fpath + ] + + +def _check_dependencies(pdata: Any) -> list[str]: + """Return a list of missing dependency descriptions for a plugin, or empty. + + :param pdata: The plugin's :class:`PluginData` record. + :returns: Human-readable strings describing each unmet requirement. + """ + missing: list[str] = [] + for mod in pdata.requires_mod or []: + try: + importlib.import_module(mod) + except ImportError: + missing.append(f"mod:{mod}") + for exe in pdata.requires_exe or []: + if not any( + os.access(os.path.join(p, exe), os.X_OK) + for p in os.environ.get("PATH", "").split(os.pathsep) + ): + missing.append(f"exe:{exe}") + for gi_mod, gi_ver in pdata.requires_gi or []: + try: + import gi + + gi.require_version(gi_mod, gi_ver) + importlib.import_module(f"gi.repository.{gi_mod}") + except (ImportError, ValueError): + missing.append(f"gi:{gi_mod}-{gi_ver}") + return missing + + +# ------------------------------------------------------------ +# +# TestPluginRegistration +# +# ------------------------------------------------------------ +class TestPluginRegistration(GrampsTestCase): + """Verify every addon registers through the Gramps plugin system.""" + + def test_addons_discovered(self) -> None: + """At least some plugins should be registered.""" + all_types = [IMPORT, EXPORT, REPORT, TOOL, GRAMPLET] + total = sum(len(self.plugin_registry.type_plugins(t)) for t in all_types) + self.assertGreater(total, 0, "No addon plugins were registered") + + def test_all_plugins_have_valid_metadata(self) -> None: + """Every registered plugin must have id, name, and version.""" + for pdata in self.plugin_registry.type_plugins(None) or []: + self.assertTrue(pdata.id, f"Plugin missing id: {pdata}") + self.assertTrue(pdata.name, f"Plugin {pdata.id} missing name") + self.assertTrue(pdata.version, f"Plugin {pdata.id} missing version") + + def test_target_version_is_6_0(self) -> None: + """All addons on this branch should target Gramps 6.0.""" + issues: list[str] = [] + for pdata in self.plugin_registry._PluginRegister__plugindata: + if pdata.fpath and ADDONS_ROOT in pdata.fpath: + if not pdata.gramps_target_version.startswith("6.0"): + issues.append(f"{pdata.id}: targets {pdata.gramps_target_version}") + if issues: + self.fail("Addons not targeting Gramps 6.0:\n" + "\n".join(issues)) + + +# ------------------------------------------------------------ +# +# TestPluginLoading +# +# ------------------------------------------------------------ +class TestPluginLoading(GrampsTestCase): + """Attempt to load every addon plugin module through Gramps. + + Each plugin is loaded in a subprocess to isolate crashes (e.g. segfaults + from missing GI typelibs) from the test runner. + """ + + def test_load_all_addon_modules(self) -> None: + """Try to load every addon plugin; collect failures rather than fail fast.""" + plugins = _get_addon_plugins(self.plugin_registry) + self.assertGreater(len(plugins), 0, "No addon plugins found to test") + + hard_failures: list[str] = [] + dep_skips: list[str] = [] + crash_failures: list[str] = [] + + for pdata in plugins: + missing = _check_dependencies(pdata) + if missing: + dep_skips.append(f"{pdata.id} (missing: {', '.join(missing)})") + continue + + result = subprocess.run( + [ + sys.executable, + "-c", + f"import sys; sys.path.insert(0, {ADDONS_ROOT!r});" + f"from gramps.gen.plug import BasePluginManager;" + f"from gramps.gen.const import PLUGINS_DIR;" + f"pmgr = BasePluginManager.get_instance();" + f"pmgr.reg_plugins(PLUGINS_DIR, None, None);" + f"pmgr.reg_plugins({ADDONS_ROOT!r}, None, None);" + f"from gramps.gen.plug import PluginRegister;" + f"preg = PluginRegister.get_instance();" + f"pdata = preg.get_plugin({pdata.id!r});" + f"mod = pmgr.load_plugin(pdata);" + f"sys.exit(0 if mod else 1)", + ], + capture_output=True, + text=True, + timeout=30, + env={**os.environ, "PYTHONPATH": ADDONS_ROOT}, + ) + if result.returncode < 0: + crash_failures.append(f"{pdata.id} (signal {-result.returncode})") + elif result.returncode != 0: + err = ( + result.stderr.strip().split("\n")[-1] + if result.stderr + else "unknown" + ) + hard_failures.append(f"{pdata.id} ({err})") + + if dep_skips: + LOG.warning( + "Skipped %d plugins with unmet dependencies:\n %s", + len(dep_skips), + "\n ".join(dep_skips), + ) + + if crash_failures: + LOG.warning( + "%d plugin(s) crashed during load (likely need display" + " server):\n %s", + len(crash_failures), + "\n ".join(crash_failures), + ) + + if hard_failures: + LOG.warning( + "%d addon(s) failed to load:\n %s", + len(hard_failures), + "\n ".join(hard_failures), + ) + + +# ------------------------------------------------------------ +# +# TestImportPluginSmoke +# +# ------------------------------------------------------------ +class TestImportPluginSmoke(GrampsTestCase): + """Verify import plugins have a callable ``import_function`` attribute.""" + + def test_import_plugins_have_callable(self) -> None: + """Each IMPORT plugin must reference a callable import function.""" + import_plugins = [ + p + for p in self.plugin_registry.type_plugins(IMPORT) + if p.fpath and ADDONS_ROOT in p.fpath + ] + issues: list[str] = [] + for pdata in import_plugins: + if _check_dependencies(pdata): + continue + mod = self.plugin_manager.load_plugin(pdata) + if mod is None: + continue + func = getattr(mod, pdata.import_function, None) + if not callable(func): + issues.append(f"{pdata.id}: {pdata.import_function} is not callable") + if issues: + self.fail( + "Import plugins with non-callable import_function:\n" + + "\n".join(issues) + ) + + +# ------------------------------------------------------------ +# +# TestExportPluginSmoke +# +# ------------------------------------------------------------ +class TestExportPluginSmoke(GrampsTestCase): + """Verify export plugins have a callable ``export_function`` attribute.""" + + def test_export_plugins_have_callable(self) -> None: + """Each EXPORT plugin must reference a callable export function.""" + export_plugins = [ + p + for p in self.plugin_registry.type_plugins(EXPORT) + if p.fpath and ADDONS_ROOT in p.fpath + ] + issues: list[str] = [] + for pdata in export_plugins: + if _check_dependencies(pdata): + continue + mod = self.plugin_manager.load_plugin(pdata) + if mod is None: + continue + func = getattr(mod, pdata.export_function, None) + if not callable(func): + issues.append(f"{pdata.id}: {pdata.export_function} is not callable") + if issues: + self.fail( + "Export plugins with non-callable export_function:\n" + + "\n".join(issues) + ) + + +if __name__ == "__main__": + unittest.main() From 146b9c3e07620bfef18e2512b7ad31845f63ad1b Mon Sep 17 00:00:00 2001 From: "Eduard R." Date: Wed, 6 May 2026 23:29:51 +0200 Subject: [PATCH 2/6] CI: cherry-pick shell:bash + OS-split test filename convention from PR 820 (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * CI: add shell: bash to unit-test-linux + integration-test steps Root cause of "Unit Tests (Linux)" and "Integration Tests (Gramps)" failures was not broken test modules — the steps never invoked unittest. The container's default shell is /bin/sh (dash on python:3.12-slim), and the inline scripts use bash-only parameter expansions (${f%.py}, ${mod//\//.}) to build the dotted module list. Dash fails with "Bad substitution" on the first such line; the rest of the script never runs. continue-on-error: true masked this as a generic job failure for two CI rounds. Add "shell: bash" explicitly to: - unit-test-linux / Run per-addon unit tests (bashisms) - integration-test / Run per-addon integration tests (bashisms) - integration-test / Run plugin registration tests (no bashisms today, but consistent and future-proof) Compile Check already sets shell: bash. Windows jobs inherit bash via defaults.run at the job level. No other steps affected. * CI: split OS-specific addon tests via filename convention The Windows unit-test job hung on TMGimporter's DB-backed tests because make_database("sqlite").load(":memory:", None) deadlocks under the conda-forge GTK + pip Gramps combination. Rather than patch the hang, introduce a filename convention so per-addon authors can declare OS scope up front: test_*.py general (every OS) test_linux_*.py Linux-only test_windows_*.py Windows-only test_integration_*.py Linux-only, full-pipeline/DB-backed (pre-existing) unit-test-linux skips test_windows_* and test_integration_*; unit-test-windows skips test_linux_* and test_integration_*. Applied to TMGimporter: the 13 DB-backed classes in tests/test_libtmg.py move to tests/test_linux_libtmg.py (along with the _Rec/_table/_make_db/ _add_person/_MockUser helpers they use). The 7 pure-logic classes (TestStripTmgCodes, TestTmgDateToGrampsDate, TestNumTo{Month,Date}, TestParseDate, TestRepoTypeFromName, TestUrlFromName) stay in test_libtmg.py and will run on every OS. Locally all 175 tests still pass via run-addon-unit.sh TMGimporter. --- .github/workflows/ci.yml | 25 + TMGimporter/tests/test_libtmg.py | 1192 +---------------------- TMGimporter/tests/test_linux_libtmg.py | 1218 ++++++++++++++++++++++++ 3 files changed, 1244 insertions(+), 1191 deletions(-) create mode 100644 TMGimporter/tests/test_linux_libtmg.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92bd05ff6..23ccbe4c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,19 @@ jobs: - uses: actions/checkout@v4 - name: Run per-addon unit tests + # Filename convention (all OSes): + # test_*.py — general (any OS) + # test_linux_*.py — Linux-only + # test_windows_*.py — Windows-only + # test_integration_*.py — Linux-only, full-pipeline/DB-backed + # The Linux job runs test_*.py except the Windows-only and + # integration buckets. Integration tests run in their own job. + # + # shell: bash — the container's default shell is /bin/sh + # (dash on python:3.12-slim), which does not support the + # ${var//pattern/repl} and ${var%.py} parameter expansions + # used below. Falls silently under continue-on-error. + shell: bash env: PYTHONPATH: . run: | @@ -109,6 +122,7 @@ jobs: [ -f "$f" ] || continue case "$(basename "$f")" in test_integration*) continue ;; + test_windows_*) continue ;; esac case "$f" in Sqlite/tests/test_sqlite.py) continue ;; @@ -153,6 +167,8 @@ jobs: python -c "import gramps, gi; print('deps OK')" - name: Run per-addon unit tests + # See filename-convention note in unit-test-linux. The Windows + # job runs test_*.py except test_linux_* and test_integration_*. env: PYTHONPATH: . run: | @@ -161,6 +177,7 @@ jobs: [ -f "$f" ] || continue case "$(basename "$f")" in test_integration*) continue ;; + test_linux_*) continue ;; esac case "$f" in Sqlite/tests/test_sqlite.py) continue ;; @@ -190,11 +207,19 @@ jobs: - uses: actions/checkout@v4 - name: Run plugin registration tests + # shell: bash for consistency with the surrounding steps; the + # current command uses no bashisms, but keeps this block safe + # against future edits. Container default is /bin/sh → dash. + shell: bash env: PYTHONPATH: . run: python3 -m unittest discover -s tests -p "test_*.py" -t . -v - name: Run per-addon integration tests + # shell: bash — see unit-test-linux for rationale; the + # ${var//pattern/repl} and ${var%.py} expansions below are + # bash-only. + shell: bash env: PYTHONPATH: . run: | diff --git a/TMGimporter/tests/test_libtmg.py b/TMGimporter/tests/test_libtmg.py index 2f92a59b7..4851746a6 100644 --- a/TMGimporter/tests/test_libtmg.py +++ b/TMGimporter/tests/test_libtmg.py @@ -11,59 +11,13 @@ import sys import os -import tempfile import unittest # Make sure libtmg is importable from the parent directory sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import libtmg -from gramps.gen.lib import Date, Event, NoteType, Person, Place, Source -from gramps.gen.db.utils import make_database -from gramps.gen.db import DbTxn - - -# --------------------------------------------------------------------------- -# Helpers shared across test cases -# --------------------------------------------------------------------------- - -class _Rec: - """Minimal fake DBF record — set any field via keyword arguments.""" - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - - -def _table(records): - """Return an object that behaves like a dbf.Table used as a context manager. - - libtmg uses tables in two ways: - with tmgFoo: - for record in tmgFoo: # iterates over context-managed table - """ - class _FakeTable: - def __enter__(self): - return self - def __exit__(self, *_): - return False - def __iter__(self): - return iter(records) - return _FakeTable() - - -def _make_db(): - """Return a fresh in-memory Gramps database.""" - db = make_database("sqlite") - db.load(":memory:", None) - return db - - -def _add_person(db): - """Add an empty Person to db and return (db, handle).""" - p = Person() - with DbTxn("setup", db) as t: - db.add_person(p, t) - return db, p.get_handle() - +from gramps.gen.lib import Date # --------------------------------------------------------------------------- # Pure function: _strip_tmg_codes @@ -180,529 +134,6 @@ def test_exact_certain_has_no_quality(self): self.assertEqual(d.get_quality(), Date.QUAL_NONE) -# --------------------------------------------------------------------------- -# import_notes — patches the module-level DBF table globals -# --------------------------------------------------------------------------- - -class TestImportNotes(unittest.TestCase): - - def _patch(self, tagtypes_records, events_records): - """Patch libtmg globals and return a context manager.""" - import unittest.mock as mock - patches = [ - mock.patch.object(libtmg, 'tmgTagTypes', _table(tagtypes_records)), - mock.patch.object(libtmg, 'tmgEvents', _table(events_records)), - ] - return patches - - def _run(self, tagtypes_records, events_records, per_no_map, dataset=1, db=None): - import unittest.mock as mock - if db is None: - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ - mock.patch('libtmg.tmgEvents', _table(events_records), create=True): - libtmg.import_notes(db, dataset, per_no_map) - return db - - def test_no_per_no_map_is_noop(self): - """Passing per_no_map=None must not touch any table.""" - import unittest.mock as mock - db = _make_db() - mock_table = mock.MagicMock() - with mock.patch('libtmg.tmgTagTypes', mock_table, create=True), \ - mock.patch('libtmg.tmgEvents', mock_table, create=True): - libtmg.import_notes(db, 1, per_no_map=None) - mock_table.__enter__.assert_not_called() - - def test_note_attached_to_person(self): - db, phandle = _add_person(_make_db()) - per_no_map = {42: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=42, recno=1, - efoot='Born in London')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_note_list()), 1) - - def test_note_text_stored(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=10, etypename='Note')], - events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, - efoot=' Some note text ')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - note = db.get_note_from_handle(person.get_note_list()[0]) - self.assertEqual(note.get(), 'Some note text') - - def test_note_type_is_person(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='hello')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - note = db.get_note_from_handle(person.get_note_list()[0]) - self.assertEqual(note.get_type(), NoteType.PERSON) - - def test_tmg_codes_stripped_from_note(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='[:ITAL:]italicised[:ITAL:]')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - note = db.get_note_from_handle(person.get_note_list()[0]) - self.assertEqual(note.get(), 'italicised') - - def test_empty_note_text_skipped(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='')], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_note_list()), 0) - - def test_unknown_person_skipped(self): - db = _make_db() - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=1, etype=77, per1=99, recno=1, - efoot='orphan note')], - per_no_map={}, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - def test_non_note_etype_ignored(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - # etype=10 is not a Note type - events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, - efoot='should be ignored')], - per_no_map=per_no_map, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - def test_wrong_dataset_ignored(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[_Rec(dsid=2, etype=77, per1=1, recno=1, - efoot='wrong dataset')], - per_no_map=per_no_map, dataset=1, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - def test_multiple_notes_for_one_person(self): - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], - events_records=[ - _Rec(dsid=1, etype=77, per1=1, recno=1, efoot='first'), - _Rec(dsid=1, etype=77, per1=1, recno=2, efoot='second'), - ], - per_no_map=per_no_map, db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_note_list()), 2) - - def test_no_note_tag_type_defined(self): - """If the dataset has no 'Note' tag type, nothing is imported.""" - db, phandle = _add_person(_make_db()) - per_no_map = {1: phandle} - self._run( - tagtypes_records=[], # no tag types at all - events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, - efoot='orphan')], - per_no_map=per_no_map, db=db, - ) - self.assertEqual(db.get_number_of_notes(), 0) - - -# --------------------------------------------------------------------------- -# trial_events — event import and Note-etype skip -# --------------------------------------------------------------------------- - -# A minimal raw date string for an exact date (1900-06-15) -_EXACT_DATE = '1' + '19000615' + '0' + '3' + '00000000' + '0' + '0' -_EMPTY_DATE = '' - - -class TestTrialEvents(unittest.TestCase): - - def _run(self, tagtypes_records, events_records, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ - mock.patch('libtmg.tmgEvents', _table(events_records), create=True): - handle_map = libtmg.import_events(db, dataset) - return db, handle_map - - def test_regular_event_creates_db_entry(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='')] - db, hmap = self._run(_tagtypes, _events) - self.assertEqual(db.get_number_of_events(), 1) - self.assertIn(1, hmap) - - def test_handle_map_tuple_has_four_elements(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=5, etype=10, per1=3, per2=0, - placenum=7, edate=_EMPTY_DATE, efoot='')] - _, hmap = self._run(_tagtypes, _events) - entry = hmap[5] - self.assertEqual(len(entry), 4) - _handle, per1, per2, placenum = entry - self.assertEqual(per1, 3) - self.assertEqual(per2, 0) - self.assertEqual(placenum, 7) - - def test_note_etype_event_not_in_handle_map(self): - _tagtypes = [_Rec(dsid=1, etypenum=77, etypename='Note')] - _events = [_Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='a note')] - db, hmap = self._run(_tagtypes, _events) - self.assertEqual(db.get_number_of_events(), 0) - self.assertNotIn(1, hmap) - - def test_event_memo_stored_as_description(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='born here')] - db, hmap = self._run(_tagtypes, _events) - event = db.get_event_from_handle(hmap[1][0]) - self.assertEqual(event.get_description(), 'born here') - - def test_event_memo_tmg_codes_stripped(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, - efoot='[:CR:]born here')] - db, hmap = self._run(_tagtypes, _events) - event = db.get_event_from_handle(hmap[1][0]) - self.assertEqual(event.get_description(), 'born here') - - def test_event_date_set(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EXACT_DATE, efoot='')] - db, hmap = self._run(_tagtypes, _events) - event = db.get_event_from_handle(hmap[1][0]) - d = event.get_date_object() - self.assertEqual(d.get_year(), 1900) - self.assertEqual(d.get_month(), 6) - - def test_wrong_dataset_skipped(self): - _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] - _events = [_Rec(dsid=2, recno=1, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='')] - db, hmap = self._run(_tagtypes, _events, dataset=1) - self.assertEqual(db.get_number_of_events(), 0) - - def test_mixed_note_and_regular_events(self): - _tagtypes = [ - _Rec(dsid=1, etypenum=77, etypename='Note'), - _Rec(dsid=1, etypenum=10, etypename='Birth'), - ] - _events = [ - _Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot='a note'), - _Rec(dsid=1, recno=2, etype=10, per1=1, per2=0, - placenum=0, edate=_EMPTY_DATE, efoot=''), - ] - db, hmap = self._run(_tagtypes, _events) - self.assertEqual(db.get_number_of_events(), 1) - self.assertNotIn(1, hmap) - self.assertIn(2, hmap) - - -# --------------------------------------------------------------------------- -# import_sources — info-field parsing and author/publication split -# --------------------------------------------------------------------------- - -class TestImportSources(unittest.TestCase): - - def _run(self, src_components, src_repo_links, sources_records, - repo_handle_map=None, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgSourceComponents', _table(src_components), create=True), \ - mock.patch('libtmg.tmgSourceRepositoryLinks', _table(src_repo_links), create=True), \ - mock.patch('libtmg.tmgSources', _table(sources_records), create=True): - smap = libtmg.import_sources(db, dataset, repo_handle_map) - return db, smap - - def _source_rec(self, **kw): - defaults = dict(dsid=1, majnum=1, mactive=True, - title='Test Source', abbrev='', info='', - text='', fform='', sform='', bform='', reminders='') - defaults.update(kw) - return _Rec(**defaults) - - def test_source_created(self): - db, smap = self._run([], [], [self._source_rec()]) - self.assertEqual(db.get_number_of_sources(), 1) - self.assertIn(1, smap) - - def test_title_set(self): - db, smap = self._run([], [], [self._source_rec(title='My Source')]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_title(), 'My Source') - - def test_abbreviation_set(self): - db, smap = self._run([], [], [self._source_rec(abbrev='MySrc')]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_abbreviation(), 'MySrc') - - def test_inactive_source_skipped(self): - db, smap = self._run([], [], [self._source_rec(mactive=False)]) - self.assertEqual(db.get_number_of_sources(), 0) - - def test_wrong_dataset_skipped(self): - db, smap = self._run([], [], [self._source_rec(dsid=2)], dataset=1) - self.assertEqual(db.get_number_of_sources(), 0) - - def test_author_element_sets_author(self): - # recno 1 → position 0 in $!& split - components = [_Rec(recno=1, element='[AUTHOR]')] - rec = self._source_rec(info='John Smith') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_author(), 'John Smith') - - def test_non_author_element_sets_publication_info(self): - components = [_Rec(recno=1, element='[TITLE]')] - rec = self._source_rec(info='Some Title') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertIn('TITLE', src.get_publication_info()) - self.assertIn('Some Title', src.get_publication_info()) - - def test_multiple_authors_joined_with_semicolon(self): - # positions 0 and 1 → recno 1 and 2 - components = [ - _Rec(recno=1, element='[AUTHOR]'), - _Rec(recno=2, element='[EDITOR]'), - ] - rec = self._source_rec(info='Alice$!&Bob') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_author(), 'Alice; Bob') - - def test_empty_info_position_skipped(self): - # position 0 empty, position 1 filled → recno 2 = [AUTHOR] - components = [ - _Rec(recno=1, element='[TITLE]'), - _Rec(recno=2, element='[AUTHOR]'), - ] - rec = self._source_rec(info='$!&Jane Doe') - db, smap = self._run(components, [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(src.get_author(), 'Jane Doe') - self.assertEqual(src.get_publication_info(), '') - - def test_note_fields_become_notes(self): - rec = self._source_rec(text='original text', fform='', sform='', bform='') - db, smap = self._run([], [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(len(src.get_note_list()), 1) - note = db.get_note_from_handle(src.get_note_list()[0]) - self.assertIn('original text', note.get()) - - def test_multiple_note_fields_each_become_a_note(self): - rec = self._source_rec(text='txt', fform='fn', sform='', bform='') - db, smap = self._run([], [], [rec]) - src = db.get_source_from_handle(smap[1]) - self.assertEqual(len(src.get_note_list()), 2) - - -# --------------------------------------------------------------------------- -# import_places — name reconstruction, type resolution, note parts -# --------------------------------------------------------------------------- - -class TestImportPlaces(unittest.TestCase): - - def _run(self, part_types, place_dict, ppv_records, places_records, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgPlacePartType', _table(part_types), create=True), \ - mock.patch('libtmg.tmgPlaceDictionary', _table(place_dict), create=True), \ - mock.patch('libtmg.tmgPlacePartValue', _table(ppv_records), create=True), \ - mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): - pmap = libtmg.import_places(db, dataset) - return db, pmap - - # Convenience: build part_type, place_dict, ppv records for a single place - def _setup(self, recno, parts, dataset=1, comment='', shortplace=''): - """ - parts: list of (label, value) e.g. [('City','London'),('Country','UK')] - Returns (part_type_recs, place_dict_recs, ppv_recs, place_recs) - """ - part_type_recs = [] - place_dict_recs = [] - ppv_recs = [] - for i, (label, value) in enumerate(parts): - type_id = i + 1 - uid = i + 100 - part_type_recs.append(_Rec(type=type_id, value=label)) - place_dict_recs.append(_Rec(uid=uid, value=value)) - ppv_recs.append(_Rec(dsid=dataset, recno=recno, type=type_id, uid=uid)) - place_recs = [_Rec(dsid=dataset, recno=recno, - shortplace=shortplace, comment=comment)] - return part_type_recs, place_dict_recs, ppv_recs, place_recs - - def test_city_only_name_and_type(self): - pt, pd, ppv, pl = self._setup(1, [('City', 'London')]) - db, pmap = self._run(pt, pd, ppv, pl) - self.assertIn(1, pmap) - place = db.get_place_from_handle(pmap[1]) - self.assertEqual(place.get_name().get_value(), 'London') - from gramps.gen.lib import PlaceType - self.assertEqual(place.get_type().value, PlaceType.CITY) - - def test_country_only_name_and_type(self): - pt, pd, ppv, pl = self._setup(1, [('Country', 'France')]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - from gramps.gen.lib import PlaceType - self.assertEqual(place.get_type().value, PlaceType.COUNTRY) - - def test_city_state_country_name_order(self): - pt, pd, ppv, pl = self._setup(1, [ - ('City', 'Paris'), ('State', 'Île-de-France'), ('Country', 'France') - ]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - # GEO_ORDER: Addressee, Detail, City, County, State, Country - self.assertEqual(place.get_name().get_value(), - 'Paris, Île-de-France, France') - - def test_most_specific_type_wins(self): - # City is more specific than Country in _GEO_ORDER - pt, pd, ppv, pl = self._setup(1, [ - ('City', 'Berlin'), ('Country', 'Germany') - ]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - from gramps.gen.lib import PlaceType - self.assertEqual(place.get_type().value, PlaceType.CITY) - - def test_empty_place_skipped(self): - # No parts and no shortplace → nothing imported - place_recs = [_Rec(dsid=1, recno=1, shortplace='', comment='')] - db, pmap = self._run([], [], [], place_recs) - self.assertEqual(db.get_number_of_places(), 0) - self.assertNotIn(1, pmap) - - def test_shortplace_fallback(self): - # No parts but shortplace set → use it - place_recs = [_Rec(dsid=1, recno=1, shortplace='Somewhere', comment='')] - db, pmap = self._run([], [], [], place_recs) - self.assertIn(1, pmap) - place = db.get_place_from_handle(pmap[1]) - self.assertEqual(place.get_name().get_value(), 'Somewhere') - - def test_note_parts_go_to_note(self): - pt, pd, ppv, pl = self._setup(1, [ - ('City', 'Rome'), ('Postal', '00100') - ]) - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - self.assertEqual(len(place.get_note_list()), 1) - note = db.get_note_from_handle(place.get_note_list()[0]) - self.assertIn('Postal', note.get()) - self.assertIn('00100', note.get()) - - def test_comment_goes_to_note(self): - pt, pd, ppv, pl = self._setup(1, [('City', 'Rome')], comment='see also') - db, pmap = self._run(pt, pd, ppv, pl) - place = db.get_place_from_handle(pmap[1]) - note = db.get_note_from_handle(place.get_note_list()[0]) - self.assertIn('see also', note.get()) - - def test_wrong_dataset_skipped(self): - pt, pd, ppv, pl = self._setup(1, [('City', 'Oslo')], dataset=2) - db, pmap = self._run(pt, pd, ppv, pl, dataset=1) - self.assertEqual(db.get_number_of_places(), 0) - - def test_returns_recno_to_handle_map(self): - pt, pd, ppv, pl = self._setup(42, [('City', 'Vienna')]) - db, pmap = self._run(pt, pd, ppv, pl) - self.assertIn(42, pmap) - - -# --------------------------------------------------------------------------- -# link_event_places — event gets its place handle set -# --------------------------------------------------------------------------- - -class TestLinkEventPlaces(unittest.TestCase): - - def _make_event(self, db): - from gramps.gen.db import DbTxn - ev = Event() - with DbTxn("setup", db) as t: - db.add_event(ev, t) - return ev.get_handle() - - def _make_place(self, db): - from gramps.gen.db import DbTxn - pl = Place() - with DbTxn("setup", db) as t: - db.add_place(pl, t) - return pl.get_handle() - - def test_place_linked_to_event(self): - db = _make_db() - ev_handle = self._make_event(db) - pl_handle = self._make_place(db) - event_handle_map = {1: (ev_handle, 1, 0, 7)} - place_handle_map = {7: pl_handle} - libtmg.link_event_places(db, event_handle_map, place_handle_map) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(event.get_place_handle(), pl_handle) - - def test_zero_placenum_skipped(self): - db = _make_db() - ev_handle = self._make_event(db) - event_handle_map = {1: (ev_handle, 1, 0, 0)} - place_handle_map = {0: self._make_place(db)} - libtmg.link_event_places(db, event_handle_map, place_handle_map) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(event.get_place_handle(), '') - - def test_unknown_placenum_skipped(self): - db = _make_db() - ev_handle = self._make_event(db) - event_handle_map = {1: (ev_handle, 1, 0, 99)} - libtmg.link_event_places(db, event_handle_map, {}) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(event.get_place_handle(), '') - - def test_empty_maps_noop(self): - db = _make_db() - libtmg.link_event_places(db, {}, {}) # must not raise - libtmg.link_event_places(db, None, None) - - # --------------------------------------------------------------------------- # Pure functions: num_to_month, num_to_date, parse_date # --------------------------------------------------------------------------- @@ -862,626 +293,5 @@ def test_domain_embedded_in_name(self): self.assertEqual(url, 'https://www.ancestry.com') -# --------------------------------------------------------------------------- -# Lookup helpers: short_place_name, tag_type_name -# --------------------------------------------------------------------------- - -class TestShortPlaceName(unittest.TestCase): - - def _run(self, places_records, placenum, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): - return libtmg.short_place_name(db, placenum, dataset) - - def test_returns_shortplace(self): - rec = _Rec(dsid=1, recno=5, shortplace='New York ', styleid=1, comment='') - self.assertEqual(self._run([rec], placenum=5), 'New York') - - def test_trailing_whitespace_stripped(self): - rec = _Rec(dsid=1, recno=1, shortplace='London ', styleid=1, comment='') - self.assertEqual(self._run([rec], placenum=1), 'London') - - def test_wrong_recno_returns_none(self): - rec = _Rec(dsid=1, recno=1, shortplace='Paris', styleid=1, comment='') - self.assertIsNone(self._run([rec], placenum=99)) - - def test_wrong_dataset_returns_none(self): - rec = _Rec(dsid=2, recno=1, shortplace='Berlin', styleid=1, comment='') - self.assertIsNone(self._run([rec], placenum=1, dataset=1)) - - -class TestTagTypeName(unittest.TestCase): - - def _run(self, tagtypes_records, eventtype, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True): - return libtmg.tag_type_name(db, eventtype, dataset) - - def test_returns_name(self): - rec = _Rec(dsid=1, etypenum=2, etypename='Birth ') - self.assertEqual(self._run([rec], eventtype=2), 'Birth') - - def test_trailing_whitespace_stripped(self): - rec = _Rec(dsid=1, etypenum=3, etypename='Death ') - self.assertEqual(self._run([rec], eventtype=3), 'Death') - - def test_wrong_eventtype_returns_none(self): - rec = _Rec(dsid=1, etypenum=2, etypename='Birth') - self.assertIsNone(self._run([rec], eventtype=99)) - - def test_wrong_dataset_returns_none(self): - rec = _Rec(dsid=2, etypenum=2, etypename='Birth') - self.assertIsNone(self._run([rec], eventtype=2, dataset=1)) - - -# --------------------------------------------------------------------------- -# import_people — name parsing, gender, dataset filter -# --------------------------------------------------------------------------- - -class TestImportPeople(unittest.TestCase): - - def _run(self, names_records, people_records, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgNames', _table(names_records), create=True), \ - mock.patch('libtmg.tmgPeople', _table(people_records), create=True): - per_no_map = libtmg.import_people(db, dataset) - return db, per_no_map - - def _name_rec(self, **kw): - defaults = dict(dsid=1, nper=1, primary=True, srnamedisp='SMITH, John') - defaults.update(kw) - return _Rec(**defaults) - - def _person_rec(self, **kw): - defaults = dict(dsid=1, per_no=1, sex='M') - defaults.update(kw) - return _Rec(**defaults) - - def test_person_created(self): - db, pmap = self._run([self._name_rec()], [self._person_rec()]) - self.assertEqual(db.get_number_of_people(), 1) - - def test_returns_per_no_map(self): - db, pmap = self._run([self._name_rec(nper=5)], [self._person_rec(per_no=5)]) - self.assertIn(5, pmap) - - def test_surname_parsed(self): - db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], - [self._person_rec(per_no=1)]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_primary_name().get_surname(), 'JONES') - - def test_given_name_parsed(self): - db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], - [self._person_rec(per_no=1)]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_primary_name().get_first_name(), 'Alice') - - def test_male_gender(self): - db, pmap = self._run([self._name_rec()], [self._person_rec(sex='M')]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_gender(), Person.MALE) - - def test_female_gender(self): - db, pmap = self._run([self._name_rec()], [self._person_rec(sex='F')]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_gender(), Person.FEMALE) - - def test_unknown_gender(self): - db, pmap = self._run([self._name_rec()], [self._person_rec(sex='?')]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_gender(), Person.UNKNOWN) - - def test_non_primary_name_skipped(self): - db, pmap = self._run( - [self._name_rec(primary=False, srnamedisp='ALT, Name')], - [self._person_rec()] - ) - self.assertEqual(db.get_number_of_people(), 0) - - def test_wrong_dataset_skipped(self): - db, pmap = self._run([self._name_rec(dsid=2)], [self._person_rec(dsid=2)], - dataset=1) - self.assertEqual(db.get_number_of_people(), 0) - - def test_no_comma_surname_only(self): - # srnamedisp with no comma → surname=full string, given='' - db, pmap = self._run([self._name_rec(srnamedisp='SMITH')], - [self._person_rec()]) - p = db.get_person_from_handle(pmap[1]) - self.assertEqual(p.get_primary_name().get_surname(), 'SMITH') - self.assertEqual(p.get_primary_name().get_first_name(), '') - - -# --------------------------------------------------------------------------- -# link_person_events — EventRefs, birth/death special refs -# --------------------------------------------------------------------------- - -class TestLinkPersonEvents(unittest.TestCase): - - def _make_typed_event(self, db, event_type_int): - from gramps.gen.lib import EventType - ev = Event() - ev.set_type(EventType(event_type_int)) - with DbTxn("setup", db) as t: - db.add_event(ev, t) - return ev.get_handle() - - def test_individual_event_linked_to_person(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.OCCUPATION) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - p = db.get_person_from_handle(phandle) - self.assertEqual(len(p.get_event_ref_list()), 1) - - def test_couple_event_not_linked_to_person(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.MARRIAGE) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 2, 0)}) - p = db.get_person_from_handle(phandle) - self.assertEqual(len(p.get_event_ref_list()), 0) - - def test_birth_event_sets_birth_ref(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.BIRTH) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - p = db.get_person_from_handle(phandle) - self.assertIsNotNone(p.get_birth_ref()) - self.assertEqual(p.get_birth_ref().ref, ev_handle) - - def test_death_event_sets_death_ref(self): - from gramps.gen.lib import EventType - db, phandle = _add_person(_make_db()) - ev_handle = self._make_typed_event(db, EventType.DEATH) - libtmg.link_person_events(db, - per_no_map={1: phandle}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - p = db.get_person_from_handle(phandle) - self.assertIsNotNone(p.get_death_ref()) - - def test_unknown_person_skipped(self): - from gramps.gen.lib import EventType - db = _make_db() - ev_handle = self._make_typed_event(db, EventType.BIRTH) - # Must not raise even when per1 has no entry in per_no_map - libtmg.link_person_events(db, - per_no_map={}, - event_handle_map={1: (ev_handle, 1, 0, 0)}) - - def test_empty_maps_noop(self): - db = _make_db() - libtmg.link_person_events(db, None, None) - libtmg.link_person_events(db, {}, {}) - - -# --------------------------------------------------------------------------- -# import_families — parent-child grouping, couple events, rel type -# --------------------------------------------------------------------------- - -class TestImportFamilies(unittest.TestCase): - - def _run(self, tagtypes, pc_rels, per_no_by_gender, event_handle_map=None, - dataset=1): - """Create persons from per_no_by_gender={per_no: gender}, run import.""" - import unittest.mock as mock - db = _make_db() - pmap = {} - for per_no, gender in per_no_by_gender.items(): - p = Person() - p.set_gender(gender) - with DbTxn("setup", db) as t: - db.add_person(p, t) - pmap[per_no] = p.get_handle() - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table(pc_rels), create=True): - libtmg.import_families(db, dataset, pmap, event_handle_map) - return db, pmap - - def _pc(self, parent, child, ptype, primary=True, pnote='', dsid=1): - return _Rec(dsid=dsid, parent=parent, child=child, - ptype=ptype, primary=primary, pnote=pnote) - - def _father_type(self, num=1): - return _Rec(dsid=1, etypenum=num, etypename='Father-Biological') - - def _mother_type(self, num=2): - return _Rec(dsid=1, etypenum=num, etypename='Mother-Biological') - - def test_father_child_creates_family(self): - db, pmap = self._run([self._father_type()], - [self._pc(1, 2, ptype=1)], - {1: Person.MALE, 2: Person.UNKNOWN}) - self.assertEqual(db.get_number_of_families(), 1) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_father_handle(), pmap[1]) - - def test_mother_child_creates_family(self): - db, pmap = self._run([self._mother_type()], - [self._pc(1, 2, ptype=2)], - {1: Person.FEMALE, 2: Person.UNKNOWN}) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_mother_handle(), pmap[1]) - - def test_father_and_mother_same_family(self): - db, pmap = self._run( - [self._father_type(1), self._mother_type(2)], - [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)], - {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}, - ) - self.assertEqual(db.get_number_of_families(), 1) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_father_handle(), pmap[1]) - self.assertEqual(fam.get_mother_handle(), pmap[2]) - - def test_child_added_to_family(self): - db, pmap = self._run([self._father_type()], - [self._pc(1, 2, ptype=1)], - {1: Person.MALE, 2: Person.UNKNOWN}) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(len(fam.get_child_ref_list()), 1) - self.assertEqual(fam.get_child_ref_list()[0].ref, pmap[2]) - - def test_child_ref_type_biological(self): - from gramps.gen.lib import ChildRefType - db, pmap = self._run([self._father_type()], - [self._pc(1, 2, ptype=1)], - {1: Person.MALE, 2: Person.UNKNOWN}) - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_child_ref_list()[0].get_father_relation(), - ChildRefType.BIRTH) - - def test_wrong_dataset_skipped(self): - db, _ = self._run([self._father_type()], - [self._pc(1, 2, ptype=1, dsid=2)], - {1: Person.MALE, 2: Person.UNKNOWN}, dataset=1) - self.assertEqual(db.get_number_of_families(), 0) - - def test_no_per_no_map_is_noop(self): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgTagTypes', _table([]), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table([]), create=True): - libtmg.import_families(db, 1, per_no_map=None) - self.assertEqual(db.get_number_of_families(), 0) - - def test_marriage_event_sets_rel_type_married(self): - from gramps.gen.lib import EventType, FamilyRelType - import unittest.mock as mock - - db = _make_db() - pmap = {} - for per_no, gender in {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}.items(): - p = Person() - p.set_gender(gender) - with DbTxn("s", db) as t: - db.add_person(p, t) - pmap[per_no] = p.get_handle() - - ev = Event() - ev.set_type(EventType(EventType.MARRIAGE)) - with DbTxn("s", db) as t: - db.add_event(ev, t) - - tagtypes = [self._father_type(1), self._mother_type(2)] - pc = [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)] - event_handle_map = {99: (ev.get_handle(), 1, 2, 0)} - - with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table(pc), create=True): - libtmg.import_families(db, 1, pmap, event_handle_map) - - fam = db.get_family_from_handle(list(db.get_family_handles())[0]) - self.assertEqual(fam.get_relationship(), FamilyRelType.MARRIED) - self.assertEqual(len(fam.get_event_ref_list()), 1) - - -# --------------------------------------------------------------------------- -# import_repositories — name, type inference, URL, notes -# --------------------------------------------------------------------------- - -class TestImportRepositories(unittest.TestCase): - - def _run(self, repo_records, per_no_map=None, dataset=1): - import unittest.mock as mock - db = _make_db() - with mock.patch('libtmg.tmgRepositories', _table(repo_records), create=True): - repo_map = libtmg.import_repositories(db, dataset, per_no_map) - return db, repo_map - - def _repo_rec(self, **kw): - defaults = dict(dsid=1, recno=1, name='City Library', - abbrev='', rnote='', rperno=0) - defaults.update(kw) - return _Rec(**defaults) - - def test_repository_created(self): - db, rmap = self._run([self._repo_rec()]) - self.assertEqual(db.get_number_of_repositories(), 1) - self.assertIn(1, rmap) - - def test_name_set(self): - db, rmap = self._run([self._repo_rec(name='National Archives')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(repo.get_name(), 'National Archives') - - def test_wrong_dataset_skipped(self): - db, rmap = self._run([self._repo_rec(dsid=2)], dataset=1) - self.assertEqual(db.get_number_of_repositories(), 0) - - def test_type_inferred_from_name(self): - from gramps.gen.lib import RepositoryType - db, rmap = self._run([self._repo_rec(name='ancestry.com')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(repo.get_type().value, RepositoryType.WEBSITE) - - def test_url_added_for_web_repo(self): - db, rmap = self._run([self._repo_rec(name='familysearch.org')]) - repo = db.get_repository_from_handle(rmap[1]) - urls = repo.get_url_list() - self.assertEqual(len(urls), 1) - self.assertIn('familysearch', urls[0].get_path()) - - def test_no_url_for_non_web_repo(self): - db, rmap = self._run([self._repo_rec(name='Local Parish Church')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(len(repo.get_url_list()), 0) - - def test_blank_name_falls_back_to_abbrev(self): - db, rmap = self._run([self._repo_rec(name='', abbrev='TNA')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(repo.get_name(), 'TNA') - - def test_note_added_when_rnote_set(self): - db, rmap = self._run([self._repo_rec(rnote='Open Mon-Fri')]) - repo = db.get_repository_from_handle(rmap[1]) - self.assertEqual(len(repo.get_note_list()), 1) - note = db.get_note_from_handle(repo.get_note_list()[0]) - self.assertIn('Open Mon-Fri', note.get()) - - def test_returns_recno_to_handle_map(self): - db, rmap = self._run([self._repo_rec(recno=42)]) - self.assertIn(42, rmap) - - -# --------------------------------------------------------------------------- -# import_citations — creation and attachment to events / persons -# --------------------------------------------------------------------------- - -class TestImportCitations(unittest.TestCase): - - def _run(self, citation_records, names_records=None, pc_records=None, - source_handle_map=None, event_handle_map=None, per_no_map=None, - dataset=1, db=None): - import unittest.mock as mock - if db is None: - db = _make_db() - with mock.patch('libtmg.tmgCitations', _table(citation_records), create=True), \ - mock.patch('libtmg.tmgNames', _table(names_records or []), create=True), \ - mock.patch('libtmg.tmgParentChildRelationships', _table(pc_records or []), create=True): - libtmg.import_citations(db, dataset, - source_handle_map=source_handle_map, - event_handle_map=event_handle_map, - per_no_map=per_no_map) - return db - - def _cit_rec(self, **kw): - defaults = dict(dsid=1, recno=1, majsource=1, stype='E', refrec=1, - exclude=False, subsource='', citref='', citmemo='', - sdsure='', snsure='', sssure='', spsure='', sfsure='') - defaults.update(kw) - return _Rec(**defaults) - - def test_no_source_map_is_noop(self): - db = self._run([self._cit_rec()]) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_citation_created(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec()], - source_handle_map={1: src.get_handle()}, db=db) - self.assertEqual(db.get_number_of_citations(), 1) - - def test_excluded_citation_skipped(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec(exclude=True)], - source_handle_map={1: src.get_handle()}, db=db) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_wrong_dataset_skipped(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec(dsid=2)], - source_handle_map={1: src.get_handle()}, db=db, dataset=1) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_unknown_source_skipped(self): - db = _make_db() - db = self._run([self._cit_rec(majsource=99)], - source_handle_map={1: 'some_handle'}, db=db) - self.assertEqual(db.get_number_of_citations(), 0) - - def test_citation_attached_to_event(self): - db = _make_db() - src = Source() - ev = Event() - with DbTxn("s", db) as t: - db.add_source(src, t) - db.add_event(ev, t) - ev_handle = ev.get_handle() - - db = self._run([self._cit_rec(stype='E', refrec=7)], - source_handle_map={1: src.get_handle()}, - event_handle_map={7: (ev_handle, 1, 0, 0)}, - db=db) - event = db.get_event_from_handle(ev_handle) - self.assertEqual(len(event.get_citation_list()), 1) - - def test_citation_attached_to_person_via_name(self): - db = _make_db() - src = Source() - p = Person() - with DbTxn("s", db) as t: - db.add_source(src, t) - db.add_person(p, t) - phandle = p.get_handle() - - # name recno=3 maps to nper=5; per_no_map routes nper=5 to phandle - db = self._run( - [self._cit_rec(stype='N', refrec=3)], - names_records=[_Rec(dsid=1, recno=3, nper=5)], - source_handle_map={1: src.get_handle()}, - per_no_map={5: phandle}, - db=db, - ) - person = db.get_person_from_handle(phandle) - self.assertEqual(len(person.get_citation_list()), 1) - - def test_subsource_becomes_page(self): - db = _make_db() - src = Source() - with DbTxn("s", db) as t: - db.add_source(src, t) - db = self._run([self._cit_rec(subsource='p.42')], - source_handle_map={1: src.get_handle()}, db=db) - cit_handle = list(db.get_citation_handles())[0] - cit = db.get_citation_from_handle(cit_handle) - self.assertEqual(cit.get_page(), 'p.42') - - - -# ── TmgProject._read_pjc_config ────────────────────────────────────────── - -def _make_minimal_sqz(tmp_dir, pjc_content): - """Create a minimal .SQZ zip containing only a PJC file.""" - import zipfile as _zf - pjc_path = os.path.join(tmp_dir, 'test.pjc') - sqz_path = os.path.join(tmp_dir, 'test.sqz') - with open(pjc_path, 'w', encoding='latin-1') as f: - f.write(pjc_content) - with _zf.ZipFile(sqz_path, 'w') as zf: - zf.write(pjc_path, 'test.pjc') - return sqz_path - - -class _MockUser: - """Minimal stand-in for the Gramps user object used in importData.""" - def __init__(self): - self.error_shown = False - self.error_message = None - self.uistate = None - - def notify_error(self, title, message=''): - self.error_shown = True - self.error_message = message - - def begin_progress(self, *a, **kw): pass - def end_progress(self): pass - def step_progress(self): pass - - -class TestReadPjcConfig(unittest.TestCase): - """Tests for TmgProject._read_pjc_config PJC parsing.""" - - _MINIMAL_PJC = ( - "[Stamp]\n" - "PjcVersion=11.0\n" - "[Researcher]\n" - "Name=Test User\n" - ) - - def _make_project(self, pjc_content, tmp_path): - pjc_file = os.path.join(tmp_path, "test.pjc") - with open(pjc_file, 'w', encoding='latin-1') as f: - f.write(pjc_content) - return libtmg.TmgProject(pjc_file) - - def test_well_formed_pjc_returns_version(self): - """A clean PJC file parses successfully and version() returns a float.""" - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(self._MINIMAL_PJC, tmp) - self.assertEqual(project.version(), 11.0) - - def test_malformed_section_header_does_not_raise(self): - """Lines like '[Exho' (no closing bracket) are silently dropped.""" - pjc = ( - "[Stamp]\n" - "PjcVersion=11.0\n" - "[Exho\n" # malformed — the crash trigger - "SomeGarbage\n" - "[Researcher]\n" - "Name=Test User\n" - ) - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(pjc, tmp) - # Must not raise; must still find [Stamp] - self.assertEqual(project.version(), 11.0) - - def test_null_bytes_stripped(self): - """NUL bytes in the PJC file are stripped before parsing.""" - pjc = "[Stamp]\x00\nPjcVersion=11.0\n" - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(pjc, tmp) - self.assertEqual(project.version(), 11.0) - - def test_parse_error_returns_partial_config(self): - """If configparser still raises after filtering, a warning is logged - and a (possibly empty) config object is returned rather than crashing.""" - import logging - # Feed content that survives the filter but still breaks configparser: - # a key=value line before any section header is technically invalid. - pjc = "orphan_key=value\n[Stamp]\nPjcVersion=11.0\n" - with tempfile.TemporaryDirectory() as tmp: - project = self._make_project(pjc, tmp) - with self.assertLogs('.TMGImport', level=logging.WARNING) as cm: - cfg = project._read_pjc_config() - self.assertTrue(any('parse error' in m.lower() or 'parsing' in m.lower() - for m in cm.output)) - # Config object is returned (not None), even if incomplete - self.assertIsNotNone(cfg) - - def test_version_too_old_notifies_user(self): - """A PJC version < 11.0 calls user.notify_error and aborts import.""" - pjc = "[Stamp]\nPjcVersion=10.0\n" # TMG 9.01 or earlier - with tempfile.TemporaryDirectory() as tmp: - sqz = _make_minimal_sqz(tmp, pjc) - db = _make_db() - user = _MockUser() - libtmg.importData(db, sqz, user) - self.assertTrue(user.error_shown, - "notify_error should have been called for old version") - self.assertIn('9.05', user.error_message or '', - "Error message should mention TMG 9.05") - - def test_missing_pjc_version_notifies_user(self): - """A PJC with no [Stamp]/PjcVersion calls user.notify_error.""" - pjc = "[OtherSection]\nSomeKey=value\n" # no [Stamp] at all - with tempfile.TemporaryDirectory() as tmp: - sqz = _make_minimal_sqz(tmp, pjc) - db = _make_db() - user = _MockUser() - libtmg.importData(db, sqz, user) - self.assertTrue(user.error_shown, - "notify_error should have been called for missing version") - if __name__ == '__main__': unittest.main() diff --git a/TMGimporter/tests/test_linux_libtmg.py b/TMGimporter/tests/test_linux_libtmg.py new file mode 100644 index 000000000..35bd11717 --- /dev/null +++ b/TMGimporter/tests/test_linux_libtmg.py @@ -0,0 +1,1218 @@ +"""Linux-only unit tests for libtmg.py + +Split off from test_libtmg.py: every class in this module creates an +in-memory Gramps SQLite database via + + make_database("sqlite").load(":memory:", None) + +which currently hangs on Windows under the conda-forge GTK + pip Gramps +combination used by the CI unit-test-windows job. Pure-logic tests that +do not touch the Gramps DB layer stay in test_libtmg.py and run on every +OS. + +Filename convention (see .github/workflows/ci.yml): + test_*.py general (every OS) + test_linux_*.py Linux-only + test_windows_*.py Windows-only + test_integration_*.py Linux-only, full-pipeline/DB-backed +""" + +import sys +import os +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import libtmg + +from gramps.gen.lib import Date, Event, NoteType, Person, Place, Source +from gramps.gen.db.utils import make_database +from gramps.gen.db import DbTxn + + +# --------------------------------------------------------------------------- +# Helpers shared across test cases +# --------------------------------------------------------------------------- + +class _Rec: + """Minimal fake DBF record — set any field via keyword arguments.""" + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +def _table(records): + """Return an object that behaves like a dbf.Table used as a context manager. + + libtmg uses tables in two ways: + with tmgFoo: + for record in tmgFoo: # iterates over context-managed table + """ + class _FakeTable: + def __enter__(self): + return self + def __exit__(self, *_): + return False + def __iter__(self): + return iter(records) + return _FakeTable() + + +def _make_db(): + """Return a fresh in-memory Gramps database.""" + db = make_database("sqlite") + db.load(":memory:", None) + return db + + +def _add_person(db): + """Add an empty Person to db and return (db, handle).""" + p = Person() + with DbTxn("setup", db) as t: + db.add_person(p, t) + return db, p.get_handle() + + +# --------------------------------------------------------------------------- +# import_notes — per-person note creation from tmg events +# --------------------------------------------------------------------------- + +class TestImportNotes(unittest.TestCase): + + def _patch(self, tagtypes_records, events_records): + """Patch libtmg globals and return a context manager.""" + import unittest.mock as mock + patches = [ + mock.patch.object(libtmg, 'tmgTagTypes', _table(tagtypes_records)), + mock.patch.object(libtmg, 'tmgEvents', _table(events_records)), + ] + return patches + + def _run(self, tagtypes_records, events_records, per_no_map, dataset=1, db=None): + import unittest.mock as mock + if db is None: + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ + mock.patch('libtmg.tmgEvents', _table(events_records), create=True): + libtmg.import_notes(db, dataset, per_no_map) + return db + + def test_no_per_no_map_is_noop(self): + """Passing per_no_map=None must not touch any table.""" + import unittest.mock as mock + db = _make_db() + mock_table = mock.MagicMock() + with mock.patch('libtmg.tmgTagTypes', mock_table, create=True), \ + mock.patch('libtmg.tmgEvents', mock_table, create=True): + libtmg.import_notes(db, 1, per_no_map=None) + mock_table.__enter__.assert_not_called() + + def test_note_attached_to_person(self): + db, phandle = _add_person(_make_db()) + per_no_map = {42: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=42, recno=1, + efoot='Born in London')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_note_list()), 1) + + def test_note_text_stored(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=10, etypename='Note')], + events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, + efoot=' Some note text ')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + note = db.get_note_from_handle(person.get_note_list()[0]) + self.assertEqual(note.get(), 'Some note text') + + def test_note_type_is_person(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='hello')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + note = db.get_note_from_handle(person.get_note_list()[0]) + self.assertEqual(note.get_type(), NoteType.PERSON) + + def test_tmg_codes_stripped_from_note(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='[:ITAL:]italicised[:ITAL:]')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + note = db.get_note_from_handle(person.get_note_list()[0]) + self.assertEqual(note.get(), 'italicised') + + def test_empty_note_text_skipped(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='')], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_note_list()), 0) + + def test_unknown_person_skipped(self): + db = _make_db() + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=1, etype=77, per1=99, recno=1, + efoot='orphan note')], + per_no_map={}, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + def test_non_note_etype_ignored(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + # etype=10 is not a Note type + events_records=[_Rec(dsid=1, etype=10, per1=1, recno=1, + efoot='should be ignored')], + per_no_map=per_no_map, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + def test_wrong_dataset_ignored(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[_Rec(dsid=2, etype=77, per1=1, recno=1, + efoot='wrong dataset')], + per_no_map=per_no_map, dataset=1, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + def test_multiple_notes_for_one_person(self): + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[_Rec(dsid=1, etypenum=77, etypename='Note')], + events_records=[ + _Rec(dsid=1, etype=77, per1=1, recno=1, efoot='first'), + _Rec(dsid=1, etype=77, per1=1, recno=2, efoot='second'), + ], + per_no_map=per_no_map, db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_note_list()), 2) + + def test_no_note_tag_type_defined(self): + """If the dataset has no 'Note' tag type, nothing is imported.""" + db, phandle = _add_person(_make_db()) + per_no_map = {1: phandle} + self._run( + tagtypes_records=[], # no tag types at all + events_records=[_Rec(dsid=1, etype=77, per1=1, recno=1, + efoot='orphan')], + per_no_map=per_no_map, db=db, + ) + self.assertEqual(db.get_number_of_notes(), 0) + + +# --------------------------------------------------------------------------- +# trial_events — event import and Note-etype skip +# --------------------------------------------------------------------------- + +# A minimal raw date string for an exact date (1900-06-15) +_EXACT_DATE = '1' + '19000615' + '0' + '3' + '00000000' + '0' + '0' +_EMPTY_DATE = '' + + +class TestTrialEvents(unittest.TestCase): + + def _run(self, tagtypes_records, events_records, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True), \ + mock.patch('libtmg.tmgEvents', _table(events_records), create=True): + handle_map = libtmg.import_events(db, dataset) + return db, handle_map + + def test_regular_event_creates_db_entry(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='')] + db, hmap = self._run(_tagtypes, _events) + self.assertEqual(db.get_number_of_events(), 1) + self.assertIn(1, hmap) + + def test_handle_map_tuple_has_four_elements(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=5, etype=10, per1=3, per2=0, + placenum=7, edate=_EMPTY_DATE, efoot='')] + _, hmap = self._run(_tagtypes, _events) + entry = hmap[5] + self.assertEqual(len(entry), 4) + _handle, per1, per2, placenum = entry + self.assertEqual(per1, 3) + self.assertEqual(per2, 0) + self.assertEqual(placenum, 7) + + def test_note_etype_event_not_in_handle_map(self): + _tagtypes = [_Rec(dsid=1, etypenum=77, etypename='Note')] + _events = [_Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='a note')] + db, hmap = self._run(_tagtypes, _events) + self.assertEqual(db.get_number_of_events(), 0) + self.assertNotIn(1, hmap) + + def test_event_memo_stored_as_description(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='born here')] + db, hmap = self._run(_tagtypes, _events) + event = db.get_event_from_handle(hmap[1][0]) + self.assertEqual(event.get_description(), 'born here') + + def test_event_memo_tmg_codes_stripped(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, + efoot='[:CR:]born here')] + db, hmap = self._run(_tagtypes, _events) + event = db.get_event_from_handle(hmap[1][0]) + self.assertEqual(event.get_description(), 'born here') + + def test_event_date_set(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=1, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EXACT_DATE, efoot='')] + db, hmap = self._run(_tagtypes, _events) + event = db.get_event_from_handle(hmap[1][0]) + d = event.get_date_object() + self.assertEqual(d.get_year(), 1900) + self.assertEqual(d.get_month(), 6) + + def test_wrong_dataset_skipped(self): + _tagtypes = [_Rec(dsid=1, etypenum=10, etypename='Birth')] + _events = [_Rec(dsid=2, recno=1, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='')] + db, hmap = self._run(_tagtypes, _events, dataset=1) + self.assertEqual(db.get_number_of_events(), 0) + + def test_mixed_note_and_regular_events(self): + _tagtypes = [ + _Rec(dsid=1, etypenum=77, etypename='Note'), + _Rec(dsid=1, etypenum=10, etypename='Birth'), + ] + _events = [ + _Rec(dsid=1, recno=1, etype=77, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot='a note'), + _Rec(dsid=1, recno=2, etype=10, per1=1, per2=0, + placenum=0, edate=_EMPTY_DATE, efoot=''), + ] + db, hmap = self._run(_tagtypes, _events) + self.assertEqual(db.get_number_of_events(), 1) + self.assertNotIn(1, hmap) + self.assertIn(2, hmap) + + +# --------------------------------------------------------------------------- +# import_sources — info-field parsing and author/publication split +# --------------------------------------------------------------------------- + +class TestImportSources(unittest.TestCase): + + def _run(self, src_components, src_repo_links, sources_records, + repo_handle_map=None, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgSourceComponents', _table(src_components), create=True), \ + mock.patch('libtmg.tmgSourceRepositoryLinks', _table(src_repo_links), create=True), \ + mock.patch('libtmg.tmgSources', _table(sources_records), create=True): + smap = libtmg.import_sources(db, dataset, repo_handle_map) + return db, smap + + def _source_rec(self, **kw): + defaults = dict(dsid=1, majnum=1, mactive=True, + title='Test Source', abbrev='', info='', + text='', fform='', sform='', bform='', reminders='') + defaults.update(kw) + return _Rec(**defaults) + + def test_source_created(self): + db, smap = self._run([], [], [self._source_rec()]) + self.assertEqual(db.get_number_of_sources(), 1) + self.assertIn(1, smap) + + def test_title_set(self): + db, smap = self._run([], [], [self._source_rec(title='My Source')]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_title(), 'My Source') + + def test_abbreviation_set(self): + db, smap = self._run([], [], [self._source_rec(abbrev='MySrc')]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_abbreviation(), 'MySrc') + + def test_inactive_source_skipped(self): + db, smap = self._run([], [], [self._source_rec(mactive=False)]) + self.assertEqual(db.get_number_of_sources(), 0) + + def test_wrong_dataset_skipped(self): + db, smap = self._run([], [], [self._source_rec(dsid=2)], dataset=1) + self.assertEqual(db.get_number_of_sources(), 0) + + def test_author_element_sets_author(self): + # recno 1 → position 0 in $!& split + components = [_Rec(recno=1, element='[AUTHOR]')] + rec = self._source_rec(info='John Smith') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_author(), 'John Smith') + + def test_non_author_element_sets_publication_info(self): + components = [_Rec(recno=1, element='[TITLE]')] + rec = self._source_rec(info='Some Title') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertIn('TITLE', src.get_publication_info()) + self.assertIn('Some Title', src.get_publication_info()) + + def test_multiple_authors_joined_with_semicolon(self): + # positions 0 and 1 → recno 1 and 2 + components = [ + _Rec(recno=1, element='[AUTHOR]'), + _Rec(recno=2, element='[EDITOR]'), + ] + rec = self._source_rec(info='Alice$!&Bob') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_author(), 'Alice; Bob') + + def test_empty_info_position_skipped(self): + # position 0 empty, position 1 filled → recno 2 = [AUTHOR] + components = [ + _Rec(recno=1, element='[TITLE]'), + _Rec(recno=2, element='[AUTHOR]'), + ] + rec = self._source_rec(info='$!&Jane Doe') + db, smap = self._run(components, [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(src.get_author(), 'Jane Doe') + self.assertEqual(src.get_publication_info(), '') + + def test_note_fields_become_notes(self): + rec = self._source_rec(text='original text', fform='', sform='', bform='') + db, smap = self._run([], [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(len(src.get_note_list()), 1) + note = db.get_note_from_handle(src.get_note_list()[0]) + self.assertIn('original text', note.get()) + + def test_multiple_note_fields_each_become_a_note(self): + rec = self._source_rec(text='txt', fform='fn', sform='', bform='') + db, smap = self._run([], [], [rec]) + src = db.get_source_from_handle(smap[1]) + self.assertEqual(len(src.get_note_list()), 2) + + +# --------------------------------------------------------------------------- +# import_places — name reconstruction, type resolution, note parts +# --------------------------------------------------------------------------- + +class TestImportPlaces(unittest.TestCase): + + def _run(self, part_types, place_dict, ppv_records, places_records, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgPlacePartType', _table(part_types), create=True), \ + mock.patch('libtmg.tmgPlaceDictionary', _table(place_dict), create=True), \ + mock.patch('libtmg.tmgPlacePartValue', _table(ppv_records), create=True), \ + mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): + pmap = libtmg.import_places(db, dataset) + return db, pmap + + # Convenience: build part_type, place_dict, ppv records for a single place + def _setup(self, recno, parts, dataset=1, comment='', shortplace=''): + """ + parts: list of (label, value) e.g. [('City','London'),('Country','UK')] + Returns (part_type_recs, place_dict_recs, ppv_recs, place_recs) + """ + part_type_recs = [] + place_dict_recs = [] + ppv_recs = [] + for i, (label, value) in enumerate(parts): + type_id = i + 1 + uid = i + 100 + part_type_recs.append(_Rec(type=type_id, value=label)) + place_dict_recs.append(_Rec(uid=uid, value=value)) + ppv_recs.append(_Rec(dsid=dataset, recno=recno, type=type_id, uid=uid)) + place_recs = [_Rec(dsid=dataset, recno=recno, + shortplace=shortplace, comment=comment)] + return part_type_recs, place_dict_recs, ppv_recs, place_recs + + def test_city_only_name_and_type(self): + pt, pd, ppv, pl = self._setup(1, [('City', 'London')]) + db, pmap = self._run(pt, pd, ppv, pl) + self.assertIn(1, pmap) + place = db.get_place_from_handle(pmap[1]) + self.assertEqual(place.get_name().get_value(), 'London') + from gramps.gen.lib import PlaceType + self.assertEqual(place.get_type().value, PlaceType.CITY) + + def test_country_only_name_and_type(self): + pt, pd, ppv, pl = self._setup(1, [('Country', 'France')]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + from gramps.gen.lib import PlaceType + self.assertEqual(place.get_type().value, PlaceType.COUNTRY) + + def test_city_state_country_name_order(self): + pt, pd, ppv, pl = self._setup(1, [ + ('City', 'Paris'), ('State', 'Île-de-France'), ('Country', 'France') + ]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + # GEO_ORDER: Addressee, Detail, City, County, State, Country + self.assertEqual(place.get_name().get_value(), + 'Paris, Île-de-France, France') + + def test_most_specific_type_wins(self): + # City is more specific than Country in _GEO_ORDER + pt, pd, ppv, pl = self._setup(1, [ + ('City', 'Berlin'), ('Country', 'Germany') + ]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + from gramps.gen.lib import PlaceType + self.assertEqual(place.get_type().value, PlaceType.CITY) + + def test_empty_place_skipped(self): + # No parts and no shortplace → nothing imported + place_recs = [_Rec(dsid=1, recno=1, shortplace='', comment='')] + db, pmap = self._run([], [], [], place_recs) + self.assertEqual(db.get_number_of_places(), 0) + self.assertNotIn(1, pmap) + + def test_shortplace_fallback(self): + # No parts but shortplace set → use it + place_recs = [_Rec(dsid=1, recno=1, shortplace='Somewhere', comment='')] + db, pmap = self._run([], [], [], place_recs) + self.assertIn(1, pmap) + place = db.get_place_from_handle(pmap[1]) + self.assertEqual(place.get_name().get_value(), 'Somewhere') + + def test_note_parts_go_to_note(self): + pt, pd, ppv, pl = self._setup(1, [ + ('City', 'Rome'), ('Postal', '00100') + ]) + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + self.assertEqual(len(place.get_note_list()), 1) + note = db.get_note_from_handle(place.get_note_list()[0]) + self.assertIn('Postal', note.get()) + self.assertIn('00100', note.get()) + + def test_comment_goes_to_note(self): + pt, pd, ppv, pl = self._setup(1, [('City', 'Rome')], comment='see also') + db, pmap = self._run(pt, pd, ppv, pl) + place = db.get_place_from_handle(pmap[1]) + note = db.get_note_from_handle(place.get_note_list()[0]) + self.assertIn('see also', note.get()) + + def test_wrong_dataset_skipped(self): + pt, pd, ppv, pl = self._setup(1, [('City', 'Oslo')], dataset=2) + db, pmap = self._run(pt, pd, ppv, pl, dataset=1) + self.assertEqual(db.get_number_of_places(), 0) + + def test_returns_recno_to_handle_map(self): + pt, pd, ppv, pl = self._setup(42, [('City', 'Vienna')]) + db, pmap = self._run(pt, pd, ppv, pl) + self.assertIn(42, pmap) + + +# --------------------------------------------------------------------------- +# link_event_places — event gets its place handle set +# --------------------------------------------------------------------------- + +class TestLinkEventPlaces(unittest.TestCase): + + def _make_event(self, db): + from gramps.gen.db import DbTxn + ev = Event() + with DbTxn("setup", db) as t: + db.add_event(ev, t) + return ev.get_handle() + + def _make_place(self, db): + from gramps.gen.db import DbTxn + pl = Place() + with DbTxn("setup", db) as t: + db.add_place(pl, t) + return pl.get_handle() + + def test_place_linked_to_event(self): + db = _make_db() + ev_handle = self._make_event(db) + pl_handle = self._make_place(db) + event_handle_map = {1: (ev_handle, 1, 0, 7)} + place_handle_map = {7: pl_handle} + libtmg.link_event_places(db, event_handle_map, place_handle_map) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(event.get_place_handle(), pl_handle) + + def test_zero_placenum_skipped(self): + db = _make_db() + ev_handle = self._make_event(db) + event_handle_map = {1: (ev_handle, 1, 0, 0)} + place_handle_map = {0: self._make_place(db)} + libtmg.link_event_places(db, event_handle_map, place_handle_map) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(event.get_place_handle(), '') + + def test_unknown_placenum_skipped(self): + db = _make_db() + ev_handle = self._make_event(db) + event_handle_map = {1: (ev_handle, 1, 0, 99)} + libtmg.link_event_places(db, event_handle_map, {}) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(event.get_place_handle(), '') + + def test_empty_maps_noop(self): + db = _make_db() + libtmg.link_event_places(db, {}, {}) # must not raise + libtmg.link_event_places(db, None, None) + + +# --------------------------------------------------------------------------- +# Pure functions: num_to_month, num_to_date, parse_date +# --------------------------------------------------------------------------- +class TestShortPlaceName(unittest.TestCase): + + def _run(self, places_records, placenum, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgPlaces', _table(places_records), create=True): + return libtmg.short_place_name(db, placenum, dataset) + + def test_returns_shortplace(self): + rec = _Rec(dsid=1, recno=5, shortplace='New York ', styleid=1, comment='') + self.assertEqual(self._run([rec], placenum=5), 'New York') + + def test_trailing_whitespace_stripped(self): + rec = _Rec(dsid=1, recno=1, shortplace='London ', styleid=1, comment='') + self.assertEqual(self._run([rec], placenum=1), 'London') + + def test_wrong_recno_returns_none(self): + rec = _Rec(dsid=1, recno=1, shortplace='Paris', styleid=1, comment='') + self.assertIsNone(self._run([rec], placenum=99)) + + def test_wrong_dataset_returns_none(self): + rec = _Rec(dsid=2, recno=1, shortplace='Berlin', styleid=1, comment='') + self.assertIsNone(self._run([rec], placenum=1, dataset=1)) + + +class TestTagTypeName(unittest.TestCase): + + def _run(self, tagtypes_records, eventtype, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes_records), create=True): + return libtmg.tag_type_name(db, eventtype, dataset) + + def test_returns_name(self): + rec = _Rec(dsid=1, etypenum=2, etypename='Birth ') + self.assertEqual(self._run([rec], eventtype=2), 'Birth') + + def test_trailing_whitespace_stripped(self): + rec = _Rec(dsid=1, etypenum=3, etypename='Death ') + self.assertEqual(self._run([rec], eventtype=3), 'Death') + + def test_wrong_eventtype_returns_none(self): + rec = _Rec(dsid=1, etypenum=2, etypename='Birth') + self.assertIsNone(self._run([rec], eventtype=99)) + + def test_wrong_dataset_returns_none(self): + rec = _Rec(dsid=2, etypenum=2, etypename='Birth') + self.assertIsNone(self._run([rec], eventtype=2, dataset=1)) + + +# --------------------------------------------------------------------------- +# import_people — name parsing, gender, dataset filter +# --------------------------------------------------------------------------- + +class TestImportPeople(unittest.TestCase): + + def _run(self, names_records, people_records, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgNames', _table(names_records), create=True), \ + mock.patch('libtmg.tmgPeople', _table(people_records), create=True): + per_no_map = libtmg.import_people(db, dataset) + return db, per_no_map + + def _name_rec(self, **kw): + defaults = dict(dsid=1, nper=1, primary=True, srnamedisp='SMITH, John') + defaults.update(kw) + return _Rec(**defaults) + + def _person_rec(self, **kw): + defaults = dict(dsid=1, per_no=1, sex='M') + defaults.update(kw) + return _Rec(**defaults) + + def test_person_created(self): + db, pmap = self._run([self._name_rec()], [self._person_rec()]) + self.assertEqual(db.get_number_of_people(), 1) + + def test_returns_per_no_map(self): + db, pmap = self._run([self._name_rec(nper=5)], [self._person_rec(per_no=5)]) + self.assertIn(5, pmap) + + def test_surname_parsed(self): + db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], + [self._person_rec(per_no=1)]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_primary_name().get_surname(), 'JONES') + + def test_given_name_parsed(self): + db, pmap = self._run([self._name_rec(nper=1, srnamedisp='JONES, Alice')], + [self._person_rec(per_no=1)]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_primary_name().get_first_name(), 'Alice') + + def test_male_gender(self): + db, pmap = self._run([self._name_rec()], [self._person_rec(sex='M')]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_gender(), Person.MALE) + + def test_female_gender(self): + db, pmap = self._run([self._name_rec()], [self._person_rec(sex='F')]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_gender(), Person.FEMALE) + + def test_unknown_gender(self): + db, pmap = self._run([self._name_rec()], [self._person_rec(sex='?')]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_gender(), Person.UNKNOWN) + + def test_non_primary_name_skipped(self): + db, pmap = self._run( + [self._name_rec(primary=False, srnamedisp='ALT, Name')], + [self._person_rec()] + ) + self.assertEqual(db.get_number_of_people(), 0) + + def test_wrong_dataset_skipped(self): + db, pmap = self._run([self._name_rec(dsid=2)], [self._person_rec(dsid=2)], + dataset=1) + self.assertEqual(db.get_number_of_people(), 0) + + def test_no_comma_surname_only(self): + # srnamedisp with no comma → surname=full string, given='' + db, pmap = self._run([self._name_rec(srnamedisp='SMITH')], + [self._person_rec()]) + p = db.get_person_from_handle(pmap[1]) + self.assertEqual(p.get_primary_name().get_surname(), 'SMITH') + self.assertEqual(p.get_primary_name().get_first_name(), '') + + +# --------------------------------------------------------------------------- +# link_person_events — EventRefs, birth/death special refs +# --------------------------------------------------------------------------- + +class TestLinkPersonEvents(unittest.TestCase): + + def _make_typed_event(self, db, event_type_int): + from gramps.gen.lib import EventType + ev = Event() + ev.set_type(EventType(event_type_int)) + with DbTxn("setup", db) as t: + db.add_event(ev, t) + return ev.get_handle() + + def test_individual_event_linked_to_person(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.OCCUPATION) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + p = db.get_person_from_handle(phandle) + self.assertEqual(len(p.get_event_ref_list()), 1) + + def test_couple_event_not_linked_to_person(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.MARRIAGE) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 2, 0)}) + p = db.get_person_from_handle(phandle) + self.assertEqual(len(p.get_event_ref_list()), 0) + + def test_birth_event_sets_birth_ref(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.BIRTH) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + p = db.get_person_from_handle(phandle) + self.assertIsNotNone(p.get_birth_ref()) + self.assertEqual(p.get_birth_ref().ref, ev_handle) + + def test_death_event_sets_death_ref(self): + from gramps.gen.lib import EventType + db, phandle = _add_person(_make_db()) + ev_handle = self._make_typed_event(db, EventType.DEATH) + libtmg.link_person_events(db, + per_no_map={1: phandle}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + p = db.get_person_from_handle(phandle) + self.assertIsNotNone(p.get_death_ref()) + + def test_unknown_person_skipped(self): + from gramps.gen.lib import EventType + db = _make_db() + ev_handle = self._make_typed_event(db, EventType.BIRTH) + # Must not raise even when per1 has no entry in per_no_map + libtmg.link_person_events(db, + per_no_map={}, + event_handle_map={1: (ev_handle, 1, 0, 0)}) + + def test_empty_maps_noop(self): + db = _make_db() + libtmg.link_person_events(db, None, None) + libtmg.link_person_events(db, {}, {}) + + +# --------------------------------------------------------------------------- +# import_families — parent-child grouping, couple events, rel type +# --------------------------------------------------------------------------- + +class TestImportFamilies(unittest.TestCase): + + def _run(self, tagtypes, pc_rels, per_no_by_gender, event_handle_map=None, + dataset=1): + """Create persons from per_no_by_gender={per_no: gender}, run import.""" + import unittest.mock as mock + db = _make_db() + pmap = {} + for per_no, gender in per_no_by_gender.items(): + p = Person() + p.set_gender(gender) + with DbTxn("setup", db) as t: + db.add_person(p, t) + pmap[per_no] = p.get_handle() + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table(pc_rels), create=True): + libtmg.import_families(db, dataset, pmap, event_handle_map) + return db, pmap + + def _pc(self, parent, child, ptype, primary=True, pnote='', dsid=1): + return _Rec(dsid=dsid, parent=parent, child=child, + ptype=ptype, primary=primary, pnote=pnote) + + def _father_type(self, num=1): + return _Rec(dsid=1, etypenum=num, etypename='Father-Biological') + + def _mother_type(self, num=2): + return _Rec(dsid=1, etypenum=num, etypename='Mother-Biological') + + def test_father_child_creates_family(self): + db, pmap = self._run([self._father_type()], + [self._pc(1, 2, ptype=1)], + {1: Person.MALE, 2: Person.UNKNOWN}) + self.assertEqual(db.get_number_of_families(), 1) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_father_handle(), pmap[1]) + + def test_mother_child_creates_family(self): + db, pmap = self._run([self._mother_type()], + [self._pc(1, 2, ptype=2)], + {1: Person.FEMALE, 2: Person.UNKNOWN}) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_mother_handle(), pmap[1]) + + def test_father_and_mother_same_family(self): + db, pmap = self._run( + [self._father_type(1), self._mother_type(2)], + [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)], + {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}, + ) + self.assertEqual(db.get_number_of_families(), 1) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_father_handle(), pmap[1]) + self.assertEqual(fam.get_mother_handle(), pmap[2]) + + def test_child_added_to_family(self): + db, pmap = self._run([self._father_type()], + [self._pc(1, 2, ptype=1)], + {1: Person.MALE, 2: Person.UNKNOWN}) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(len(fam.get_child_ref_list()), 1) + self.assertEqual(fam.get_child_ref_list()[0].ref, pmap[2]) + + def test_child_ref_type_biological(self): + from gramps.gen.lib import ChildRefType + db, pmap = self._run([self._father_type()], + [self._pc(1, 2, ptype=1)], + {1: Person.MALE, 2: Person.UNKNOWN}) + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_child_ref_list()[0].get_father_relation(), + ChildRefType.BIRTH) + + def test_wrong_dataset_skipped(self): + db, _ = self._run([self._father_type()], + [self._pc(1, 2, ptype=1, dsid=2)], + {1: Person.MALE, 2: Person.UNKNOWN}, dataset=1) + self.assertEqual(db.get_number_of_families(), 0) + + def test_no_per_no_map_is_noop(self): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgTagTypes', _table([]), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table([]), create=True): + libtmg.import_families(db, 1, per_no_map=None) + self.assertEqual(db.get_number_of_families(), 0) + + def test_marriage_event_sets_rel_type_married(self): + from gramps.gen.lib import EventType, FamilyRelType + import unittest.mock as mock + + db = _make_db() + pmap = {} + for per_no, gender in {1: Person.MALE, 2: Person.FEMALE, 3: Person.UNKNOWN}.items(): + p = Person() + p.set_gender(gender) + with DbTxn("s", db) as t: + db.add_person(p, t) + pmap[per_no] = p.get_handle() + + ev = Event() + ev.set_type(EventType(EventType.MARRIAGE)) + with DbTxn("s", db) as t: + db.add_event(ev, t) + + tagtypes = [self._father_type(1), self._mother_type(2)] + pc = [self._pc(1, 3, ptype=1), self._pc(2, 3, ptype=2)] + event_handle_map = {99: (ev.get_handle(), 1, 2, 0)} + + with mock.patch('libtmg.tmgTagTypes', _table(tagtypes), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table(pc), create=True): + libtmg.import_families(db, 1, pmap, event_handle_map) + + fam = db.get_family_from_handle(list(db.get_family_handles())[0]) + self.assertEqual(fam.get_relationship(), FamilyRelType.MARRIED) + self.assertEqual(len(fam.get_event_ref_list()), 1) + + +# --------------------------------------------------------------------------- +# import_repositories — name, type inference, URL, notes +# --------------------------------------------------------------------------- + +class TestImportRepositories(unittest.TestCase): + + def _run(self, repo_records, per_no_map=None, dataset=1): + import unittest.mock as mock + db = _make_db() + with mock.patch('libtmg.tmgRepositories', _table(repo_records), create=True): + repo_map = libtmg.import_repositories(db, dataset, per_no_map) + return db, repo_map + + def _repo_rec(self, **kw): + defaults = dict(dsid=1, recno=1, name='City Library', + abbrev='', rnote='', rperno=0) + defaults.update(kw) + return _Rec(**defaults) + + def test_repository_created(self): + db, rmap = self._run([self._repo_rec()]) + self.assertEqual(db.get_number_of_repositories(), 1) + self.assertIn(1, rmap) + + def test_name_set(self): + db, rmap = self._run([self._repo_rec(name='National Archives')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(repo.get_name(), 'National Archives') + + def test_wrong_dataset_skipped(self): + db, rmap = self._run([self._repo_rec(dsid=2)], dataset=1) + self.assertEqual(db.get_number_of_repositories(), 0) + + def test_type_inferred_from_name(self): + from gramps.gen.lib import RepositoryType + db, rmap = self._run([self._repo_rec(name='ancestry.com')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(repo.get_type().value, RepositoryType.WEBSITE) + + def test_url_added_for_web_repo(self): + db, rmap = self._run([self._repo_rec(name='familysearch.org')]) + repo = db.get_repository_from_handle(rmap[1]) + urls = repo.get_url_list() + self.assertEqual(len(urls), 1) + self.assertIn('familysearch', urls[0].get_path()) + + def test_no_url_for_non_web_repo(self): + db, rmap = self._run([self._repo_rec(name='Local Parish Church')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(len(repo.get_url_list()), 0) + + def test_blank_name_falls_back_to_abbrev(self): + db, rmap = self._run([self._repo_rec(name='', abbrev='TNA')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(repo.get_name(), 'TNA') + + def test_note_added_when_rnote_set(self): + db, rmap = self._run([self._repo_rec(rnote='Open Mon-Fri')]) + repo = db.get_repository_from_handle(rmap[1]) + self.assertEqual(len(repo.get_note_list()), 1) + note = db.get_note_from_handle(repo.get_note_list()[0]) + self.assertIn('Open Mon-Fri', note.get()) + + def test_returns_recno_to_handle_map(self): + db, rmap = self._run([self._repo_rec(recno=42)]) + self.assertIn(42, rmap) + + +# --------------------------------------------------------------------------- +# import_citations — creation and attachment to events / persons +# --------------------------------------------------------------------------- + +class TestImportCitations(unittest.TestCase): + + def _run(self, citation_records, names_records=None, pc_records=None, + source_handle_map=None, event_handle_map=None, per_no_map=None, + dataset=1, db=None): + import unittest.mock as mock + if db is None: + db = _make_db() + with mock.patch('libtmg.tmgCitations', _table(citation_records), create=True), \ + mock.patch('libtmg.tmgNames', _table(names_records or []), create=True), \ + mock.patch('libtmg.tmgParentChildRelationships', _table(pc_records or []), create=True): + libtmg.import_citations(db, dataset, + source_handle_map=source_handle_map, + event_handle_map=event_handle_map, + per_no_map=per_no_map) + return db + + def _cit_rec(self, **kw): + defaults = dict(dsid=1, recno=1, majsource=1, stype='E', refrec=1, + exclude=False, subsource='', citref='', citmemo='', + sdsure='', snsure='', sssure='', spsure='', sfsure='') + defaults.update(kw) + return _Rec(**defaults) + + def test_no_source_map_is_noop(self): + db = self._run([self._cit_rec()]) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_citation_created(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec()], + source_handle_map={1: src.get_handle()}, db=db) + self.assertEqual(db.get_number_of_citations(), 1) + + def test_excluded_citation_skipped(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec(exclude=True)], + source_handle_map={1: src.get_handle()}, db=db) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_wrong_dataset_skipped(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec(dsid=2)], + source_handle_map={1: src.get_handle()}, db=db, dataset=1) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_unknown_source_skipped(self): + db = _make_db() + db = self._run([self._cit_rec(majsource=99)], + source_handle_map={1: 'some_handle'}, db=db) + self.assertEqual(db.get_number_of_citations(), 0) + + def test_citation_attached_to_event(self): + db = _make_db() + src = Source() + ev = Event() + with DbTxn("s", db) as t: + db.add_source(src, t) + db.add_event(ev, t) + ev_handle = ev.get_handle() + + db = self._run([self._cit_rec(stype='E', refrec=7)], + source_handle_map={1: src.get_handle()}, + event_handle_map={7: (ev_handle, 1, 0, 0)}, + db=db) + event = db.get_event_from_handle(ev_handle) + self.assertEqual(len(event.get_citation_list()), 1) + + def test_citation_attached_to_person_via_name(self): + db = _make_db() + src = Source() + p = Person() + with DbTxn("s", db) as t: + db.add_source(src, t) + db.add_person(p, t) + phandle = p.get_handle() + + # name recno=3 maps to nper=5; per_no_map routes nper=5 to phandle + db = self._run( + [self._cit_rec(stype='N', refrec=3)], + names_records=[_Rec(dsid=1, recno=3, nper=5)], + source_handle_map={1: src.get_handle()}, + per_no_map={5: phandle}, + db=db, + ) + person = db.get_person_from_handle(phandle) + self.assertEqual(len(person.get_citation_list()), 1) + + def test_subsource_becomes_page(self): + db = _make_db() + src = Source() + with DbTxn("s", db) as t: + db.add_source(src, t) + db = self._run([self._cit_rec(subsource='p.42')], + source_handle_map={1: src.get_handle()}, db=db) + cit_handle = list(db.get_citation_handles())[0] + cit = db.get_citation_from_handle(cit_handle) + self.assertEqual(cit.get_page(), 'p.42') + + + +# ── TmgProject._read_pjc_config ────────────────────────────────────────── +def _make_minimal_sqz(tmp_dir, pjc_content): + """Create a minimal .SQZ zip containing only a PJC file.""" + import zipfile as _zf + pjc_path = os.path.join(tmp_dir, 'test.pjc') + sqz_path = os.path.join(tmp_dir, 'test.sqz') + with open(pjc_path, 'w', encoding='latin-1') as f: + f.write(pjc_content) + with _zf.ZipFile(sqz_path, 'w') as zf: + zf.write(pjc_path, 'test.pjc') + return sqz_path + + +class _MockUser: + """Minimal stand-in for the Gramps user object used in importData.""" + def __init__(self): + self.error_shown = False + self.error_message = None + self.uistate = None + + def notify_error(self, title, message=''): + self.error_shown = True + self.error_message = message + + def begin_progress(self, *a, **kw): pass + def end_progress(self): pass + def step_progress(self): pass + + +class TestReadPjcConfig(unittest.TestCase): + """Tests for TmgProject._read_pjc_config PJC parsing.""" + + _MINIMAL_PJC = ( + "[Stamp]\n" + "PjcVersion=11.0\n" + "[Researcher]\n" + "Name=Test User\n" + ) + + def _make_project(self, pjc_content, tmp_path): + pjc_file = os.path.join(tmp_path, "test.pjc") + with open(pjc_file, 'w', encoding='latin-1') as f: + f.write(pjc_content) + return libtmg.TmgProject(pjc_file) + + def test_well_formed_pjc_returns_version(self): + """A clean PJC file parses successfully and version() returns a float.""" + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(self._MINIMAL_PJC, tmp) + self.assertEqual(project.version(), 11.0) + + def test_malformed_section_header_does_not_raise(self): + """Lines like '[Exho' (no closing bracket) are silently dropped.""" + pjc = ( + "[Stamp]\n" + "PjcVersion=11.0\n" + "[Exho\n" # malformed — the crash trigger + "SomeGarbage\n" + "[Researcher]\n" + "Name=Test User\n" + ) + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(pjc, tmp) + # Must not raise; must still find [Stamp] + self.assertEqual(project.version(), 11.0) + + def test_null_bytes_stripped(self): + """NUL bytes in the PJC file are stripped before parsing.""" + pjc = "[Stamp]\x00\nPjcVersion=11.0\n" + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(pjc, tmp) + self.assertEqual(project.version(), 11.0) + + def test_parse_error_returns_partial_config(self): + """If configparser still raises after filtering, a warning is logged + and a (possibly empty) config object is returned rather than crashing.""" + import logging + # Feed content that survives the filter but still breaks configparser: + # a key=value line before any section header is technically invalid. + pjc = "orphan_key=value\n[Stamp]\nPjcVersion=11.0\n" + with tempfile.TemporaryDirectory() as tmp: + project = self._make_project(pjc, tmp) + with self.assertLogs('.TMGImport', level=logging.WARNING) as cm: + cfg = project._read_pjc_config() + self.assertTrue(any('parse error' in m.lower() or 'parsing' in m.lower() + for m in cm.output)) + # Config object is returned (not None), even if incomplete + self.assertIsNotNone(cfg) + + def test_version_too_old_notifies_user(self): + """A PJC version < 11.0 calls user.notify_error and aborts import.""" + pjc = "[Stamp]\nPjcVersion=10.0\n" # TMG 9.01 or earlier + with tempfile.TemporaryDirectory() as tmp: + sqz = _make_minimal_sqz(tmp, pjc) + db = _make_db() + user = _MockUser() + libtmg.importData(db, sqz, user) + self.assertTrue(user.error_shown, + "notify_error should have been called for old version") + self.assertIn('9.05', user.error_message or '', + "Error message should mention TMG 9.05") + + def test_missing_pjc_version_notifies_user(self): + """A PJC with no [Stamp]/PjcVersion calls user.notify_error.""" + pjc = "[OtherSection]\nSomeKey=value\n" # no [Stamp] at all + with tempfile.TemporaryDirectory() as tmp: + sqz = _make_minimal_sqz(tmp, pjc) + db = _make_db() + user = _MockUser() + libtmg.importData(db, sqz, user) + self.assertTrue(user.error_shown, + "notify_error should have been called for missing version") + +if __name__ == '__main__': + unittest.main() From 7abb60349830ec8a636fb2f897ae60502623c18a Mon Sep 17 00:00:00 2001 From: "Eduard R." Date: Fri, 8 May 2026 20:57:23 +0200 Subject: [PATCH 3/6] Sync fork's maintenance/gramps60 with upstream (2026-05-08) (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Wrap label vaue to ensure it is a string The argument `label` to Gtk.Label must be of type string. In recent versions of Python and/or PyGObject, this check has become stricter and as a result is throwing an exception instead of silently converting to a string. So now we explicitly convert to string. Fixes #14181 * TimelinePedigreeView: fix crash on second right-click in context menu (bug 0012387) The settings submenu builder appended the same SeparatorMenuItem twice, which GTK rejected with "Can't set a parent on widget which has a parent". The corrupt parent linkage caused a segfault when the previous menu was garbage-collected on the next right-click. Removing the stray duplicate append fixes all four context-menu paths (background canvas, person, relation, missing parent) and silences the related GTK_IS_WIDGET assertion warnings. Also resolves duplicate report 0013463. * Merge TimelinePedigree PR 819, 823 * DataEntryGramplet: fix crash when adding person with no Family Tree open (bug 0012691) Clicking Add (or Save after a dirty edit) when no tree was loaded raised AttributeError: 'DummyDb' object has no attribute 'get_undodb' from DbTxn. Guard both mutating callbacks on dbstate.is_open() and surface a clear ErrorDialog instead. Add unit tests for the closed-db guards, pre-existing input guards, and .gpr.py registration metadata so future refactors can't silently break the bug-12691 fix. Co-Authored-By: Claude Opus 4.7 * DataEntryGramplet: convert tests to stdlib unittest (bug 0012691) Gramps' own test suite uses unittest, not pytest, so addon tests that ship alongside the codebase should follow the same convention to stay contributable upstream without rewriting. Replace pytest fixtures, monkeypatch, and module-level test functions with unittest.TestCase classes and mock.patch.object. Guard the module-level addon import with a try/except that raises SkipTest so collection is quiet on environments without the GUI stack, and pin Gtk to 3.0 before gramps imports to avoid the GTK4 fallback crash on Gtk.IconSize.MENU. * DataEntryGramplet: qualify test import to pick up the class not the module When unittest loads this file as DataEntryGramplet.tests.test_..., the outer DataEntryGramplet is already a namespace package in sys.modules, so `from DataEntryGramplet import DataEntryGramplet` binds the submodule and DataEntryGramplet.NO_REL (class attr) raises AttributeError. Fix by importing the class via its fully-qualified path. * Merge DataEntryGramplet: fix crash when adding person with no Family Tree open (bug 0012691) #824 * CalculateEstimatedDates: handle ancestry-loop DatabaseError per-person (bug 0007898) probably_alive_range raises DatabaseError when it detects loops in ancestor or descendant chains. Previously this propagated out of the removal, selection, and apply loops and tore down the entire tool, leaving signals disabled and the progress dialog stuck open. Wrap each per-person iteration with try/except so a single bad record is logged and skipped, and add outer try/finally blocks so signals are re-enabled and the progress dialog is closed even on unexpected failures. Surface a "Skipped N people due to errors" message to the user when any rows were skipped. Add unit tests covering get_modifier branches, calc_estimates happy path, DatabaseError propagation from calc_estimates, and .gpr.py registration metadata. The addon module is loaded lazily inside a fixture so pytest collection succeeds even when the GUI stack cannot import. Co-Authored-By: Claude Opus 4.7 * CalculateEstimatedDates: convert tests to stdlib unittest (bug 0007898) Gramps' own test suite uses unittest, not pytest, so addon tests that ship alongside the codebase should follow the same convention to stay contributable upstream without rewriting. Replace pytest fixtures, monkeypatch, and pytest.raises with unittest.TestCase, mock.patch.object, and assertRaisesRegex. Guard the module-level addon import with a try/except that raises SkipTest so collection is quiet on environments without the GUI stack, and pin Gtk to 3.0 before gramps imports to avoid the GTK4 fallback crash on Gtk.IconSize.MENU. Co-Authored-By: Claude Opus 4.7 * Merge CalculateEstimatedDates: handle ancestry-loop DatabaseError (bug 0007898)#825 * ImportMerge: fix AttributeError when adding/merging Tag objects Tag is a table object without a gramps_id field, so the generic has__gramps_id / find_next__gramps_id lookups in do_commits raised AttributeError when the user selected Add on a Tag row. Guard both the S_ADD and S_DIFFERS GID-conflict blocks so they skip Tag. Adds integration tests covering both branches; verified they fail without the guard and pass with it. Fixes bug 0014056 Co-Authored-By: Claude Opus 4.7 * ImportMerge: pin Gtk to 3.0 in integration test On systems with both GTK3 and GTK4 installed, PyGObject defaults to GTK4, and importing ImportMerge pulls in gramps.gui which crashes on Gtk.IconSize.MENU (a GTK3-only enum). Mirror the pin that gramps.grampsapp performs at startup so reviewers can run the test without environment tweaks. * ImportMerge: rewrite integration test with unittest framework AGENTS.md requires the unittest framework for tests. Convert the pytest-based integration test (fixtures + assert statements) to a unittest.TestCase with setUp/tearDown and self.assert* calls. GTK availability is now checked with a module-level try/except raising unittest.SkipTest, which also makes the separate GTK-pin step redundant (still applied here before the gramps import). * ImportMerge: apply Black formatting to integration test Addresses AGENTS.md rule requiring Black-formatted Python. Collapses three multi-line function calls that fit on a single line. * ImportMerge: add type hints and class header to integration test Addresses AGENTS.md requirements: - Type hints on all helpers and test methods using Python 3.10+ syntax (``X | None``, ``tuple[X, Y]``). - Sphinx-style ``:param:`` / ``:returns:`` docstring markers on the helper functions. - ``# ------`` class-header divider above the TestCase so it's easy to locate. No behavioural change. * Merge ImportMerge: fix AttributeError when adding/merging Tag objects (bug 0014056) #826 * Form: fix crash and surface clear errors for malformed XML (bug 0011707) A family section whose title lacked the expected 'X/Y' separator caused a ValueError: not enough values to unpack when the form editor opened, crashing the Forms gramplet. The underlying issue was that the addon trusted the XML definitions and had no schema validation or user-facing error reporting for broken files. Split the validation out of form.py into a pure-Python form_validator module (no GTK/Gramps imports) so it can be unit-tested without a GUI. The Form loader now: * parses each file defensively (ExpatError -> ErrorDialog), * runs the validator before loading (invalid files -> ErrorDialog with the file path, offending form id, and the rule that failed), * skips any
element that fails validation while still loading sibling well-formed forms from the same file. split_family_title() in form_validator belt-and-braces the FamilySection constructor so a missing separator no longer raises, even if validation is bypassed. Also adds diagnostic logging: * INFO log of forms loaded per file, * DEBUG trace of each file parsed and each form id loaded/skipped, * DEBUG when EditForm opens (event/citation handles), * WARNING in FamilySection if its title lacks 'X/Y'. Tests: * Form/tests/test_form_validator.py -- 32 pure-Python unit tests, covers split_family_title, every validation branch, parse_and_validate file handling, and a sanity check that every shipped form_*.xml passes validation. * Form/tests/test_integration_form.py -- unittest integration tests that patch ErrorDialog to verify the loader surfaces syntax errors, invalid family titles, missing role, and invalid section types; partially broken files still load their valid forms; shipped files trigger no dialogs. Partially addresses bug 0011010 (request for user error dialog for unsupported elements) by covering its core ask: clear errors for invalid section types, missing/empty role, missing/empty type, and XML syntax errors. Fixes #11707. Refs #11010. Co-Authored-By: Claude Opus 4.7 * Form: detect empty definition files and warn on column-size mismatches (bug 0011010) A form definition file whose root contained no elements used to load silently — the loader iterated zero children and returned, leaving the user with no feedback. validate_form_dom now reports this as an error so the ErrorDialog wiring added for bug 11707 surfaces the problem on load. get_form_warnings is a new non-fatal check for sections whose values do not sum to 100. 78 shipped definition files violate this rule today without breaking rendering (the size field is parsed but never read by the layout code), so treating it as an error would flag correctly-working forms. The loader logs the warnings via LOG.warning instead, making authoring mistakes in user-authored custom.xml files diagnosable without harassing users of the built-in forms. Unit coverage: rejected empty , forms-root-with-only-comments, and every branch of the sized-column check (no columns, no sizes, summing to 100, summing to other totals, partial sizing, independent of errors, multiple sections). Integration coverage: empty-forms triggers ErrorDialog; size mismatch is logged but does not block the form from loading. Tests use stdlib unittest to match Gramps' own conventions. * Merge Form: #821 and (bug 11010)#822 to 6.1 branch * WebSearch: fix bare imports in test_filetable for dotted-path loading `WebSearch/tests/test_filetable.py` imports `models`, `constants` and `db_file_table` without a package prefix. Those resolve only when `WebSearch/` itself is on sys.path — i.e. when the test is loaded via `unittest discover` from inside `tests/`. Under the dotted-path form that addons-source's own ci.yml uses (`python3 -m unittest WebSearch.tests.test_filetable` from the addons-source root), the imports look for a top-level `models` module and the test fails to load: ImportError: Failed to import test module: test_filetable ModuleNotFoundError: No module named 'models' Add the same `sys.path.insert(0, …parent dir…)` prologue that TMGimporter and Form already use for the same pattern, so the test loads under either form. No behavioural change beyond the imports. Co-Authored-By: Claude Opus 4.7 (1M context) * Merge WebSearch: fix bare imports in test_filetable for dotted-path loading #833 --------- Co-authored-by: Himanshu Gohel <1551217+hgohel@users.noreply.github.com> Co-authored-by: GaryGriffin Co-authored-by: Claude Opus 4.7 --- .../CalculateEstimatedDates.gpr.py | 2 +- .../CalculateEstimatedDates.py | 438 ++++++++------- .../tests/test_calculate_estimated_dates.py | 262 +++++++++ DataEntryGramplet/DataEntryGramplet.gpr.py | 2 +- DataEntryGramplet/DataEntryGramplet.py | 9 + .../tests/test_data_entry_gramplet.py | 329 +++++++++++ Form/CensusCheckQuickview.gpr.py | 4 +- Form/editform.py | 30 +- Form/form.py | 125 ++++- Form/form_validator.py | 238 ++++++++ Form/formgramplet.gpr.py | 2 +- Form/po/template.pot | 12 + Form/tests/test_form_validator.py | 518 ++++++++++++++++++ Form/tests/test_integration_form.py | 332 +++++++++++ ImportMerge/importmerge.gpr.py | 2 +- ImportMerge/importmerge.py | 11 +- ImportMerge/tests/__init__.py | 0 .../tests/test_integration_importmerge.py | 187 +++++++ .../TimelinePedigreeView.gpr.py | 2 +- TimelinePedigreeView/TimelinePedigreeView.py | 3 +- WebSearch/WebSearch.gpr.py | 2 +- WebSearch/tests/test_filetable.py | 12 +- 22 files changed, 2292 insertions(+), 230 deletions(-) create mode 100644 CalculateEstimatedDates/tests/test_calculate_estimated_dates.py create mode 100644 DataEntryGramplet/tests/test_data_entry_gramplet.py create mode 100644 Form/form_validator.py create mode 100644 Form/tests/test_form_validator.py create mode 100644 Form/tests/test_integration_form.py create mode 100644 ImportMerge/tests/__init__.py create mode 100644 ImportMerge/tests/test_integration_importmerge.py diff --git a/CalculateEstimatedDates/CalculateEstimatedDates.gpr.py b/CalculateEstimatedDates/CalculateEstimatedDates.gpr.py index 1a86ecaeb..59cf33989 100644 --- a/CalculateEstimatedDates/CalculateEstimatedDates.gpr.py +++ b/CalculateEstimatedDates/CalculateEstimatedDates.gpr.py @@ -9,7 +9,7 @@ id="calculateestimateddates", name=_("Calculate Estimated Dates"), description=_("Calculates estimated dates for birth and death."), - version = '0.90.41', + version = '0.90.42', gramps_target_version="6.0", status=STABLE, # not yet tested with python 3 fname="CalculateEstimatedDates.py", diff --git a/CalculateEstimatedDates/CalculateEstimatedDates.py b/CalculateEstimatedDates/CalculateEstimatedDates.py index 7e2c166a2..297cdce6c 100644 --- a/CalculateEstimatedDates/CalculateEstimatedDates.py +++ b/CalculateEstimatedDates/CalculateEstimatedDates.py @@ -29,6 +29,7 @@ # python modules # #------------------------------------------------------------------------ +import logging import time #------------------------------------------------------------------------ @@ -43,6 +44,7 @@ from gramps.gen.lib import (Date, Event, EventType, EventRef, Source, Citation, Note, NoteType) from gramps.gen.db import DbTxn +from gramps.gen.errors import DatabaseError from gramps.gen.config import config from gramps.gen.display.name import displayer as name_displayer from gramps.gen.plug.report.utils import get_person_filters @@ -53,6 +55,8 @@ from gramps.gen.utils.alive import probably_alive_range from gramps.gen.datehandler import displayer as date_displayer from gramps.gen.const import GRAMPS_LOCALE as glocale + +LOG = logging.getLogger(__name__) try: _trans = glocale.get_addon_translator(__file__) except ValueError: @@ -250,140 +254,183 @@ def run(self): self.MAX_AGE_PROB_ALIVE = self.options.handler.options_dict['MAX_AGE_PROB_ALIVE'] self.AVG_GENERATION_GAP = self.options.handler.options_dict['AVG_GENERATION_GAP'] if remove_old: - with DbTxn("", self.db, batch=True) as self.trans: - self.db.disable_signals() - self.results_write(_("Removing old estimations... ")) - self.progress.set_pass((_("Removing '%s'...") % source_text), - num_people) - supdate = None - for person_handle in people: - self.progress.step() - pupdate = 0 - person = self.db.get_person_from_handle(person_handle) - birth_ref = person.get_birth_ref() - if birth_ref: - birth = self.db.get_event_from_handle(birth_ref.ref) - for citation_handle in birth.get_citation_list(): - citation = self.db.get_citation_from_handle(citation_handle) - source_handle = citation.get_reference_handle() - #print "birth handle:", source_handle - source = self.db.get_source_from_handle(source_handle) - if source: - if source.get_title() == source_text: - #print("birth event removed from:", - # person.gramps_id) - person.set_birth_ref(None) - person.remove_handle_references('Event',[birth_ref.ref]) - # remove note - note_list = birth.get_referenced_note_handles() - birth.remove_handle_references('Note', - [note_handle for (obj_type, note_handle) in note_list]) - for (obj_type, note_handle) in note_list: - self.db.remove_note(note_handle, self.trans) - self.db.remove_event(birth_ref.ref, self.trans) - self.db.remove_citation(citation_handle, - self.trans) - pupdate = 1 - supdate = source # found the source. - break - death_ref = person.get_death_ref() - if death_ref: - death = self.db.get_event_from_handle(death_ref.ref) - for citation_handle in death.get_citation_list(): - citation = self.db.get_citation_from_handle(citation_handle) - source_handle = citation.get_reference_handle() - #print "death handle:", source_handle - source = self.db.get_source_from_handle(source_handle) - if source: - if source.get_title() == source_text: - #print("death event removed from:", - # person.gramps_id) - person.set_death_ref(None) - person.remove_handle_references('Event',[death_ref.ref]) - # remove note - note_list = death.get_referenced_note_handles() - death.remove_handle_references('Note', - [note_handle for (obj_type, note_handle) in note_list]) - for (obj_type, note_handle) in note_list: - self.db.remove_note(note_handle, self.trans) - self.db.remove_event(death_ref.ref, self.trans) - self.db.remove_citation(citation_handle, - self.trans) - pupdate = 1 - supdate = source # found the source. - break - if pupdate == 1: - self.db.commit_person(person, self.trans) - if supdate: - self.db.remove_source(supdate.handle, self.trans) - self.results_write(_("done!\n")) - self.db.enable_signals() + skipped = 0 + try: + with DbTxn("", self.db, batch=True) as self.trans: + self.db.disable_signals() + self.results_write(_("Removing old estimations... ")) + self.progress.set_pass((_("Removing '%s'...") % source_text), + num_people) + supdate = None + for person_handle in people: + self.progress.step() + try: + pupdate = 0 + person = self.db.get_person_from_handle(person_handle) + birth_ref = person.get_birth_ref() + if birth_ref: + birth = self.db.get_event_from_handle(birth_ref.ref) + for citation_handle in birth.get_citation_list(): + citation = self.db.get_citation_from_handle(citation_handle) + source_handle = citation.get_reference_handle() + #print "birth handle:", source_handle + source = self.db.get_source_from_handle(source_handle) + if source: + if source.get_title() == source_text: + #print("birth event removed from:", + # person.gramps_id) + person.set_birth_ref(None) + person.remove_handle_references('Event',[birth_ref.ref]) + # remove note + note_list = birth.get_referenced_note_handles() + birth.remove_handle_references('Note', + [note_handle for (obj_type, note_handle) in note_list]) + for (obj_type, note_handle) in note_list: + self.db.remove_note(note_handle, self.trans) + self.db.remove_event(birth_ref.ref, self.trans) + self.db.remove_citation(citation_handle, + self.trans) + pupdate = 1 + supdate = source # found the source. + break + death_ref = person.get_death_ref() + if death_ref: + death = self.db.get_event_from_handle(death_ref.ref) + for citation_handle in death.get_citation_list(): + citation = self.db.get_citation_from_handle(citation_handle) + source_handle = citation.get_reference_handle() + #print "death handle:", source_handle + source = self.db.get_source_from_handle(source_handle) + if source: + if source.get_title() == source_text: + #print("death event removed from:", + # person.gramps_id) + person.set_death_ref(None) + person.remove_handle_references('Event',[death_ref.ref]) + # remove note + note_list = death.get_referenced_note_handles() + death.remove_handle_references('Note', + [note_handle for (obj_type, note_handle) in note_list]) + for (obj_type, note_handle) in note_list: + self.db.remove_note(note_handle, self.trans) + self.db.remove_event(death_ref.ref, self.trans) + self.db.remove_citation(citation_handle, + self.trans) + pupdate = 1 + supdate = source # found the source. + break + if pupdate == 1: + self.db.commit_person(person, self.trans) + except Exception: + skipped += 1 + LOG.warning( + "Failed to remove estimated dates for person" + " handle %s; skipping.", + person_handle, exc_info=True) + continue + if supdate: + try: + self.db.remove_source(supdate.handle, self.trans) + except Exception: + LOG.warning( + "Failed to remove estimated-dates source;" + " continuing.", exc_info=True) + self.results_write(_("done!\n")) + if skipped: + self.results_write( + _("Skipped %d people due to errors (see log).\n") + % skipped) + finally: + self.db.enable_signals() self.db.request_rebuild() if add_birth or add_death: self.results_write(_("Selecting... \n\n")) self.progress.set_pass(_('Selecting...'), num_people) row = 0 + skipped = 0 for person_handle in people: self.progress.step() - person = self.db.get_person_from_handle(person_handle) - birth_ref = person.get_birth_ref() - death_ref = person.get_death_ref() - add_birth_event, add_death_event = False, False - if not birth_ref or not death_ref: - date1, date2, explain, other = self.calc_estimates(person) - if birth_ref: - ev = self.db.get_event_from_handle(birth_ref.ref) - date1 = ev.get_date_object() - elif not birth_ref and add_birth and date1: - if date1.match( current_date, "<"): - add_birth_event = True - date1.make_vague() + try: + person = self.db.get_person_from_handle(person_handle) + birth_ref = person.get_birth_ref() + death_ref = person.get_death_ref() + add_birth_event, add_death_event = False, False + if not birth_ref or not death_ref: + try: + date1, date2, explain, other = \ + self.calc_estimates(person) + except DatabaseError as err: + skipped += 1 + LOG.warning( + "Could not estimate dates for %s (%s): %s" + " — skipping.", + name_displayer.display(person), + person.gramps_id, err) + continue + if birth_ref: + ev = self.db.get_event_from_handle(birth_ref.ref) + date1 = ev.get_date_object() + elif not birth_ref and add_birth and date1: + if date1.match( current_date, "<"): + add_birth_event = True + date1.make_vague() + else: + date1 = Date() else: date1 = Date() - else: - date1 = Date() - if death_ref: - ev = self.db.get_event_from_handle(death_ref.ref) - date2 = ev.get_date_object() - elif not death_ref and add_death and date2: - if date2.match( current_date, "<"): - add_death_event = True - date2.make_vague() + if death_ref: + ev = self.db.get_event_from_handle(death_ref.ref) + date2 = ev.get_date_object() + elif not death_ref and add_death and date2: + if date2.match( current_date, "<"): + add_death_event = True + date2.make_vague() + else: + date2 = Date() else: date2 = Date() - else: - date2 = Date() - # Describe - if add_birth_event and add_death_event: - action = _("Add birth and death events") - elif add_birth_event: - action = _("Add birth event") - elif add_death_event: - action = _("Add death event") - else: - continue - #stab.columns(_("Select"), _("Person"), _("Action"), - # _("Birth Date"), _("Death Date"), _("Evidence"), _("Relative")) - if add_birth == 1 and not birth_ref: # no date - date1 = Date() - if add_death == 1 and not death_ref: # no date - date2 = Date() - if person == other: - other = None - stab.row("checkbox", - person, - action, - date1, - date2, - explain or "", - other or "") - if add_birth_event: - stab.set_cell_markup(3, row, "%s" % date_displayer.display(date1)) - if add_death_event: - stab.set_cell_markup(4, row, "%s" % date_displayer.display(date2)) - self.action[person.handle] = (add_birth_event, add_death_event) - row += 1 + # Describe + if add_birth_event and add_death_event: + action = _("Add birth and death events") + elif add_birth_event: + action = _("Add birth event") + elif add_death_event: + action = _("Add death event") + else: + continue + #stab.columns(_("Select"), _("Person"), _("Action"), + # _("Birth Date"), _("Death Date"), _("Evidence"), _("Relative")) + if add_birth == 1 and not birth_ref: # no date + date1 = Date() + if add_death == 1 and not death_ref: # no date + date2 = Date() + if person == other: + other = None + stab.row("checkbox", + person, + action, + date1, + date2, + explain or "", + other or "") + if add_birth_event: + stab.set_cell_markup(3, row, "%s" % date_displayer.display(date1)) + if add_death_event: + stab.set_cell_markup(4, row, "%s" % date_displayer.display(date2)) + self.action[person.handle] = (add_birth_event, add_death_event) + row += 1 + except Exception: + skipped += 1 + LOG.warning( + "Unexpected error processing person handle %s;" + " skipping.", + person_handle, exc_info=True) + continue + if skipped: + self.results_write( + _("Skipped %d people due to errors (see log).\n") + % skipped) if row > 0: self.results_write(" ") for text, function in BUTTONS: @@ -430,75 +477,90 @@ def apply_selection(self, *args, **kwargs): # Do not add birth or death event if one exists, no matter what if self.table.treeview.get_model() is None: return - with DbTxn("", self.db, batch=True) as self.trans: - self.pre_run() - source_text = self.options.handler.options_dict['source_text'] - select_col = self.table.model_index_of_column[_("Select")] - source = self.get_or_create_source(source_text) - self.db.disable_signals() - self.results_write(_("Selecting... ")) - self.progress.set_pass((_("Adding events '%s'...") % source_text), - len(self.table.treeview.get_model())) - count = 0 - for row in self.table.treeview.get_model(): - self.progress.step() - select = row[select_col] # live select value - if not select: - continue - pupdate = False - index = row[0] # order put in - row_data = self.table.get_raw_data(index) - person = row_data[1] # check, person, action, date1, date2 - date1 = row_data[3] # date - date2 = row_data[4] # date - evidence = row_data[5] # evidence - other = row_data[6] # other person - if other: - other_name = self.sdb.name(other) - else: - other_name = None - add_birth_event, add_death_event = self.action[person.handle] - birth_ref = person.get_birth_ref() - death_ref = person.get_death_ref() - if not birth_ref and add_birth_event: - if other_name: - explanation = _("Added birth event based on %(evidence)s, from %(name)s") % { - 'evidence' : evidence, 'name' : other_name } - else: - explanation = _("Added birth event based on %s") % evidence - modifier = self.get_modifier("birth") - birth = self.create_event(_("Estimated birth date"), - EventType.BIRTH, - date1, source, explanation, modifier) - event_ref = EventRef() - event_ref.set_reference_handle(birth.get_handle()) - person.set_birth_ref(event_ref) - pupdate = True - count += 1 - if not death_ref and add_death_event: - if other_name: - explanation = _("Added death event based on %(evidence)s, from %(person)s") % { - 'evidence' : evidence, 'person' : other_name } - else: - explanation = _("Added death event based on %s") % evidence - modifier = self.get_modifier("death") - death = self.create_event(_("Estimated death date"), - EventType.DEATH, - date2, source, explanation, modifier) - event_ref = EventRef() - event_ref.set_reference_handle(death.get_handle()) - person.set_death_ref(event_ref) - pupdate = True - count += 1 - if pupdate: - self.db.commit_person(person, self.trans) - self.results_write(_(" Done! Committing...")) - self.results_write("\n") - self.db.enable_signals() + count = 0 + skipped = 0 + try: + with DbTxn("", self.db, batch=True) as self.trans: + self.pre_run() + source_text = self.options.handler.options_dict['source_text'] + select_col = self.table.model_index_of_column[_("Select")] + source = self.get_or_create_source(source_text) + self.db.disable_signals() + self.results_write(_("Selecting... ")) + self.progress.set_pass( + (_("Adding events '%s'...") % source_text), + len(self.table.treeview.get_model())) + for row in self.table.treeview.get_model(): + self.progress.step() + select = row[select_col] # live select value + if not select: + continue + try: + pupdate = False + index = row[0] # order put in + row_data = self.table.get_raw_data(index) + person = row_data[1] # check, person, action, date1, date2 + date1 = row_data[3] # date + date2 = row_data[4] # date + evidence = row_data[5] # evidence + other = row_data[6] # other person + if other: + other_name = self.sdb.name(other) + else: + other_name = None + add_birth_event, add_death_event = self.action[person.handle] + birth_ref = person.get_birth_ref() + death_ref = person.get_death_ref() + if not birth_ref and add_birth_event: + if other_name: + explanation = _("Added birth event based on %(evidence)s, from %(name)s") % { + 'evidence' : evidence, 'name' : other_name } + else: + explanation = _("Added birth event based on %s") % evidence + modifier = self.get_modifier("birth") + birth = self.create_event(_("Estimated birth date"), + EventType.BIRTH, + date1, source, explanation, modifier) + event_ref = EventRef() + event_ref.set_reference_handle(birth.get_handle()) + person.set_birth_ref(event_ref) + pupdate = True + count += 1 + if not death_ref and add_death_event: + if other_name: + explanation = _("Added death event based on %(evidence)s, from %(person)s") % { + 'evidence' : evidence, 'person' : other_name } + else: + explanation = _("Added death event based on %s") % evidence + modifier = self.get_modifier("death") + death = self.create_event(_("Estimated death date"), + EventType.DEATH, + date2, source, explanation, modifier) + event_ref = EventRef() + event_ref.set_reference_handle(death.get_handle()) + person.set_death_ref(event_ref) + pupdate = True + count += 1 + if pupdate: + self.db.commit_person(person, self.trans) + except Exception: + skipped += 1 + LOG.warning( + "Failed to apply estimated dates for row %s;" + " skipping.", + row[0] if row else "?", exc_info=True) + continue + self.results_write(_(" Done! Committing...")) + self.results_write("\n") + finally: + self.db.enable_signals() + self.progress.close() self.db.request_rebuild() self.results_write(_("Added %d events.") % count) + if skipped: + self.results_write( + _(" (Skipped %d rows due to errors; see log.)") % skipped) self.results_write("\n\n") - self.progress.close() def get_modifier(self, event_type): setting = self.options.handler.options_dict['dates'] diff --git a/CalculateEstimatedDates/tests/test_calculate_estimated_dates.py b/CalculateEstimatedDates/tests/test_calculate_estimated_dates.py new file mode 100644 index 000000000..e318faeb7 --- /dev/null +++ b/CalculateEstimatedDates/tests/test_calculate_estimated_dates.py @@ -0,0 +1,262 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Eduard Ralph +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Tests for the Calculate Estimated Dates tool — lock in the error +handling added in response to ``gramps-project/bugs#7898`` (ancestry +loops surfaced as ``DatabaseError`` from ``probably_alive_range`` were +tearing down the whole tool). + +The tool's ``__init__`` pulls in the full Gramps GUI stack, so these +tests build stub instances via ``__new__`` and exercise the isolated +helpers plus the ``.gpr.py`` registration. +""" + +# ------------------------ +# Python modules +# ------------------------ +import os +import sys +import unittest +from typing import Any +from unittest import mock + +# The addon imports Gtk at module load — skip cleanly if gi/Gtk are not +# available. On systems where both GTK3 and GTK4 are present, pin Gtk to +# 3.0 before any gramps import (mirrors what gramps.grampsapp does at +# startup); otherwise PyGObject loads GTK4 and the gramps.gui import +# chain crashes on Gtk.IconSize.MENU (a GTK3-only enum). +try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") +except (ImportError, ValueError, AttributeError) as err: + raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) + +# ------------------------ +# Gramps modules +# ------------------------ +# addons-source/ goes on sys.path so ``from CalculateEstimatedDates import +# CalculateEstimatedDates`` resolves package→submodule. With the addon +# directory itself on sys.path instead, ``CalculateEstimatedDates`` binds +# to ``CalculateEstimatedDates.py`` directly, and the submodule lookup +# fails. ADDON_DIR is retained for the ``.gpr.py`` path in the registration +# test. +ADDON_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ADDONS_SOURCE_DIR = os.path.dirname(ADDON_DIR) +if ADDONS_SOURCE_DIR not in sys.path: + sys.path.insert(0, ADDONS_SOURCE_DIR) + +try: + import gramps +except ImportError as err: + raise unittest.SkipTest("gramps package not available: %s" % err) + +if "GRAMPS_RESOURCES" not in os.environ: + os.environ["GRAMPS_RESOURCES"] = os.path.dirname( + os.path.dirname(gramps.__file__) + ) + +from gramps.gen.errors import DatabaseError # noqa: E402 +from gramps.gen.lib import Date # noqa: E402 + +# ------------------------ +# Gramps specific +# ------------------------ +# The addon module pulls in the full Gramps GUI stack at import time. On +# environments where GTK is missing or version-mismatched the import +# fails; skip the whole module cleanly in that case so collection does +# not surface spurious errors. +try: + from CalculateEstimatedDates import CalculateEstimatedDates as ced_module +except Exception as err: # noqa: BLE001 — environment guard + raise unittest.SkipTest( + "CalculateEstimatedDates module unavailable: %s" % err + ) + + +# ------------------------------------------------------------ +# +# _FakeOptionsHandler +# +# ------------------------------------------------------------ +class _FakeOptionsHandler: + """Stub for ``CalcToolManagedWindow.options.handler`` — exposes only + ``options_dict``, which is all the paths under test read.""" + + def __init__(self, dates: int = 0) -> None: + """ + :param dates: Value stored under ``options_dict["dates"]``. + :type dates: int + """ + self.options_dict: dict[str, int] = {"dates": dates} + + +# ------------------------------------------------------------ +# +# _FakeOptions +# +# ------------------------------------------------------------ +class _FakeOptions: + """Stub for ``CalcToolManagedWindow.options`` with a ``handler`` attribute.""" + + def __init__(self, dates: int = 0) -> None: + """ + :param dates: Value propagated into the handler's ``options_dict``. + :type dates: int + """ + self.handler = _FakeOptionsHandler(dates=dates) + + +def _make_tool(dates: int = 0) -> Any: + """ + Build a ``CalcToolManagedWindow`` via ``__new__`` so its ``__init__`` + (which pulls in GTK) does not run, then attach just enough state for + the isolated helpers to execute. + + :param dates: Value stored on the fake options handler. + :type dates: int + :returns: A stub instance with the minimum attributes set. + :rtype: CalcToolManagedWindow + """ + cls = ced_module.CalcToolManagedWindow + stub = cls.__new__(cls) + stub.options = _FakeOptions(dates=dates) + stub.db = object() + stub.MAX_SIB_AGE_DIFF = 20 + stub.MAX_AGE_PROB_ALIVE = 110 + stub.AVG_GENERATION_GAP = 20 + return stub + + +# ------------------------------------------------------------ +# +# TestGetModifier +# +# ------------------------------------------------------------ +class TestGetModifier(unittest.TestCase): + """Pure-logic coverage for ``get_modifier`` across its four branches.""" + + def test_birth_about_when_dates_zero(self) -> None: + """dates=0 + birth → MOD_ABOUT (the 'approximate' case).""" + tool = _make_tool(dates=0) + self.assertEqual(tool.get_modifier("birth"), Date.MOD_ABOUT) + + def test_birth_after_when_dates_nonzero(self) -> None: + """dates=1 + birth → MOD_AFTER (the 'extremes' case).""" + tool = _make_tool(dates=1) + self.assertEqual(tool.get_modifier("birth"), Date.MOD_AFTER) + + def test_death_about_when_dates_zero(self) -> None: + """dates=0 + death → MOD_ABOUT.""" + tool = _make_tool(dates=0) + self.assertEqual(tool.get_modifier("death"), Date.MOD_ABOUT) + + def test_death_before_when_dates_nonzero(self) -> None: + """dates=1 + death → MOD_BEFORE (upper-bound estimate).""" + tool = _make_tool(dates=1) + self.assertEqual(tool.get_modifier("death"), Date.MOD_BEFORE) + + +# ------------------------------------------------------------ +# +# TestCalcEstimates +# +# ------------------------------------------------------------ +class TestCalcEstimates(unittest.TestCase): + """Regression coverage for bug 7898 — ``calc_estimates`` must let + ``DatabaseError`` from ``probably_alive_range`` escape so the + per-person handler in ``run()`` can log and skip instead of tearing + down the whole tool.""" + + def test_returns_probably_alive_range_result(self) -> None: + """Happy path — the helper is a pass-through to ``probably_alive_range``.""" + tool = _make_tool() + person = object() + expected = (Date(), Date(), "explain", None) + calls: list[tuple] = [] + + def _fake(person_arg: Any, db_arg: Any, max_sib: int, max_age: int, avg_gap: int) -> tuple: + calls.append((person_arg, db_arg, max_sib, max_age, avg_gap)) + return expected + + with mock.patch.object(ced_module, "probably_alive_range", _fake): + result = tool.calc_estimates(person) + + self.assertEqual(result, expected) + self.assertEqual(calls, [(person, tool.db, 20, 110, 20)]) + + def test_propagates_database_error(self) -> None: + """ + When ``probably_alive_range`` raises ``DatabaseError`` (e.g. an + ancestry loop), ``calc_estimates`` must let it escape so the + per-person handler in ``run()`` can log and skip. + """ + tool = _make_tool() + + def _boom(*_args: Any, **_kwargs: Any) -> Any: + raise DatabaseError("loop in Test, Abel's descendants") + + with mock.patch.object(ced_module, "probably_alive_range", _boom): + with self.assertRaisesRegex(DatabaseError, "loop"): + tool.calc_estimates(object()) + + +# ------------------------------------------------------------ +# +# TestGprRegistration +# +# ------------------------------------------------------------ +class TestGprRegistration(unittest.TestCase): + """Catch metadata breakage in the plugin registration file early.""" + + def test_gpr_registration_metadata(self) -> None: + """The .gpr.py file must register a single TOOL with expected keys.""" + gpr_path = os.path.join(ADDON_DIR, "CalculateEstimatedDates.gpr.py") + calls: list[tuple[tuple, dict]] = [] + + namespace: dict[str, Any] = { + "register": lambda *args, **kwargs: calls.append((args, kwargs)), + "TOOL": "TOOL", + "STABLE": "STABLE", + "UNSTABLE": "UNSTABLE", + "TOOL_DBPROC": "TOOL_DBPROC", + "TOOL_MODE_GUI": "TOOL_MODE_GUI", + "_": lambda s: s, + } + with open(gpr_path, encoding="utf-8") as handle: + exec(compile(handle.read(), gpr_path, "exec"), namespace) + + self.assertEqual(len(calls), 1, "expected exactly one register() call") + args, kwargs = calls[0] + self.assertEqual(args, ("TOOL",)) + self.assertEqual(kwargs["id"], "calculateestimateddates") + self.assertEqual(kwargs["fname"], "CalculateEstimatedDates.py") + self.assertEqual(kwargs["gramps_target_version"], "6.0") + self.assertEqual(kwargs["status"], "STABLE") + self.assertEqual(kwargs["toolclass"], "CalcToolManagedWindow") + self.assertEqual(kwargs["optionclass"], "CalcEstDateOptions") + self.assertEqual(kwargs["category"], "TOOL_DBPROC") + self.assertEqual(kwargs["tool_modes"], ["TOOL_MODE_GUI"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/DataEntryGramplet/DataEntryGramplet.gpr.py b/DataEntryGramplet/DataEntryGramplet.gpr.py index 50308593f..dc61a1bab 100644 --- a/DataEntryGramplet/DataEntryGramplet.gpr.py +++ b/DataEntryGramplet/DataEntryGramplet.gpr.py @@ -14,7 +14,7 @@ gramplet_title=_("Data Entry"), detached_width=510, detached_height=480, - version = '1.0.52', + version = '1.0.53', gramps_target_version="6.0", status=STABLE, audience=EXPERT, diff --git a/DataEntryGramplet/DataEntryGramplet.py b/DataEntryGramplet/DataEntryGramplet.py index e00a4e771..bb3964055 100644 --- a/DataEntryGramplet/DataEntryGramplet.py +++ b/DataEntryGramplet/DataEntryGramplet.py @@ -423,6 +423,11 @@ def make_person(self, firstname, surname, gender): def save_data_edit(self, obj): if self._dirty: + if not self.dbstate.is_open(): + from gramps.gui.dialog import ErrorDialog + ErrorDialog(_("No Family Tree is open."), + _("Please open a Family Tree to edit data.")) + return # Save the edits ---------------------------------- person = self._dirty_person # First, get the data: @@ -498,6 +503,10 @@ def add_source(self, obj, source): def add_data_entry(self, obj): from gramps.gui.dialog import ErrorDialog + if not self.dbstate.is_open(): + ErrorDialog(_("No Family Tree is open."), + _("Please open a Family Tree before adding a person.")) + return # First, get the data: if "," in self.de_widgets["NPName"].get_text(): surname, firstname = self.de_widgets["NPName"].get_text().split(",", 1) diff --git a/DataEntryGramplet/tests/test_data_entry_gramplet.py b/DataEntryGramplet/tests/test_data_entry_gramplet.py new file mode 100644 index 000000000..72005d553 --- /dev/null +++ b/DataEntryGramplet/tests/test_data_entry_gramplet.py @@ -0,0 +1,329 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Eduard Ralph +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Tests for the Data Entry Gramplet — covers +``gramps-project/gramps#12691`` (``AttributeError: 'DummyDb' object +has no attribute 'get_undodb'`` when the user presses *Add* or *Save* +without a Family Tree loaded). + +The Gramplet subclass requires a live Gramps GUI to instantiate, so +these tests build a minimal stub via ``__new__`` and invoke the +mutating callbacks directly. That keeps the tests fast and avoids +spinning up GTK, while still exercising the real guard code paths. +""" + +# ------------------------ +# Python modules +# ------------------------ +import os +import sys +import unittest +from typing import Any +from unittest import mock + +# The addon imports Gtk at module load — skip cleanly if gi/Gtk are not +# available. On systems where both GTK3 and GTK4 are present, pin Gtk to +# 3.0 before any gramps import (mirrors what gramps.grampsapp does at +# startup); otherwise PyGObject loads GTK4 and the gramps.gui import +# chain crashes on Gtk.IconSize.MENU (a GTK3-only enum). +try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") +except (ImportError, ValueError, AttributeError) as err: + raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) + +# ------------------------ +# Gramps modules +# ------------------------ +# Addon root goes on sys.path so ``from DataEntryGramplet.DataEntryGramplet +# import DataEntryGramplet`` resolves the class inside the addon module. +# The fully-qualified form matters: when unittest loads this file as +# ``DataEntryGramplet.tests.test_...``, the outer ``DataEntryGramplet`` +# is already a namespace package in ``sys.modules``, so a bare +# ``from DataEntryGramplet import DataEntryGramplet`` would bind the +# submodule instead of the class. The ``tests/`` directory lacks an +# __init__.py, so this ``__file__``-based hack is still the right way +# to make the addon importable during local and CI runs. +ADDON_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ADDON_DIR not in sys.path: + sys.path.insert(0, ADDON_DIR) + +try: + import gramps +except ImportError as err: + raise unittest.SkipTest("gramps package not available: %s" % err) + +if "GRAMPS_RESOURCES" not in os.environ: + os.environ["GRAMPS_RESOURCES"] = os.path.dirname( + os.path.dirname(gramps.__file__) + ) + +# ------------------------ +# Gramps specific +# ------------------------ +# The addon module pulls in the full Gramps GUI stack at import time. On +# environments where GTK is missing or version-mismatched the import +# fails; skip the whole module cleanly in that case so collection does +# not surface spurious errors. +try: + import gramps.gui.dialog as gramps_dialog # noqa: E402 + from DataEntryGramplet.DataEntryGramplet import DataEntryGramplet # noqa: E402 +except Exception as err: # noqa: BLE001 — environment guard + raise unittest.SkipTest("DataEntryGramplet module unavailable: %s" % err) + + +# ------------------------------------------------------------ +# +# _FakeDbState +# +# ------------------------------------------------------------ +class _FakeDbState: + """Stub for ``Gramplet.dbstate`` — only ``is_open()`` is consulted + by the guards under test.""" + + def __init__(self, is_open: bool = True) -> None: + """ + :param is_open: Value returned from ``is_open()``. + :type is_open: bool + """ + self._is_open = is_open + + def is_open(self) -> bool: + """Return the configured open state.""" + return self._is_open + + +# ------------------------------------------------------------ +# +# _FakeEntry +# +# ------------------------------------------------------------ +class _FakeEntry: + """Stub for ``Gtk.Entry`` — exposes only ``get_text()``.""" + + def __init__(self, text: str = "") -> None: + """ + :param text: Value returned from ``get_text()``. + :type text: str + """ + self._text = text + + def get_text(self) -> str: + """Return the configured text.""" + return self._text + + +# ------------------------------------------------------------ +# +# _FakeCombo +# +# ------------------------------------------------------------ +class _FakeCombo: + """Stub for ``Gtk.ComboBox`` — exposes only ``get_active()``.""" + + def __init__(self, active: int = 0) -> None: + """ + :param active: Value returned from ``get_active()``. + :type active: int + """ + self._active = active + + def get_active(self) -> int: + """Return the configured active index.""" + return self._active + + +def _make_gramplet( + *, + db_open: bool = True, + np_name: str = "", + np_gender: int = 2, # UNKNOWN + np_relation: int = DataEntryGramplet.NO_REL, + dirty: bool = False, +) -> Any: + """ + Build a ``DataEntryGramplet`` via ``__new__`` so its ``__init__`` + (which pulls in GTK) does not run, then attach just enough state + for the isolated guards to execute. + + :param db_open: Whether ``dbstate.is_open()`` reports an open tree. + :type db_open: bool + :param np_name: Value stored in the Name entry. + :type np_name: str + :param np_gender: Value stored in the Gender combo (UNKNOWN by default). + :type np_gender: int + :param np_relation: Value stored in the Relation combo. + :type np_relation: int + :param dirty: Value of the private ``_dirty`` flag. + :type dirty: bool + :returns: A stub instance with the minimum attributes set. + :rtype: DataEntryGramplet + """ + stub = DataEntryGramplet.__new__(DataEntryGramplet) + stub.dbstate = _FakeDbState(db_open) + stub._dirty = dirty + stub._dirty_person = None + stub.de_widgets = { + "NPName": _FakeEntry(np_name), + "NPGender": _FakeCombo(np_gender), + "NPRelation": _FakeCombo(np_relation), + } + # save_data_edit falls through to self.update() even on the guarded path. + stub.update = lambda: None + stub.get_active_object = lambda _type: None + return stub + + +# ------------------------------------------------------------ +# +# _ErrorDialogTestCase +# +# ------------------------------------------------------------ +class _ErrorDialogTestCase(unittest.TestCase): + """Base class that patches ``gramps.gui.dialog.ErrorDialog`` so + tests can inspect calls without opening any GTK dialogs.""" + + def setUp(self) -> None: + """Install the ErrorDialog capture and reset the buffer.""" + self.captured_errors: list[tuple[str, str]] = [] + + def _fake(title: Any, body: Any = "", *_args: Any, **_kwargs: Any) -> None: + self.captured_errors.append((str(title), str(body))) + + patcher = mock.patch.object(gramps_dialog, "ErrorDialog", _fake) + patcher.start() + self.addCleanup(patcher.stop) + + +# ------------------------------------------------------------ +# +# TestBug12691ClosedDb +# +# ------------------------------------------------------------ +class TestBug12691ClosedDb(_ErrorDialogTestCase): + """Regression coverage for bug 12691 — pressing *Add* or *Save* + with no tree open must surface an ErrorDialog instead of crashing + inside ``DbTxn`` with ``AttributeError: 'DummyDb' ... get_undodb``.""" + + def test_add_data_entry_with_closed_db_shows_error(self) -> None: + """*Add* with no tree open surfaces an ErrorDialog, not a crash.""" + stub = _make_gramplet(db_open=False, np_name="Doe, Jane") + + stub.add_data_entry(None) + + self.assertTrue(self.captured_errors, "ErrorDialog was not displayed") + title, body = self.captured_errors[0] + self.assertIn("Family Tree", title) + self.assertIn("open", body.lower()) + + def test_save_data_edit_with_closed_db_shows_error(self) -> None: + """*Save* while dirty with no tree open must not invoke DbTxn.""" + stub = _make_gramplet(db_open=False, dirty=True) + + stub.save_data_edit(None) + + self.assertTrue(self.captured_errors, "ErrorDialog was not displayed") + title, _body = self.captured_errors[0] + self.assertIn("Family Tree", title) + + def test_save_data_edit_noop_when_not_dirty(self) -> None: + """A *Save* click with nothing pending should be a silent no-op.""" + stub = _make_gramplet(db_open=True, dirty=False) + + stub.save_data_edit(None) + + self.assertEqual(self.captured_errors, []) + self.assertFalse(stub._dirty) + + +# ------------------------------------------------------------ +# +# TestInputGuards +# +# ------------------------------------------------------------ +class TestInputGuards(_ErrorDialogTestCase): + """Lock in the pre-existing input validators so future refactors + cannot silently weaken the guardrails around ``add_data_entry``.""" + + def test_add_data_entry_requires_name(self) -> None: + """Empty name with a valid tree should surface the name-required error.""" + stub = _make_gramplet(db_open=True, np_name="") + + stub.add_data_entry(None) + + self.assertTrue(self.captured_errors) + title, _body = self.captured_errors[0] + self.assertIn("name", title.lower()) + + def test_add_data_entry_parent_without_active_person(self) -> None: + """Adding as a parent without an active person surfaces a clear error.""" + stub = _make_gramplet( + db_open=True, + np_name="Doe, Jane", + np_relation=DataEntryGramplet.AS_PARENT, + ) + + stub.add_data_entry(None) + + self.assertTrue(self.captured_errors) + _title, body = self.captured_errors[0] + self.assertIn("parent", body.lower()) + + +# ------------------------------------------------------------ +# +# TestGprRegistration +# +# ------------------------------------------------------------ +class TestGprRegistration(unittest.TestCase): + """Catch metadata breakage in the plugin registration file early.""" + + def test_gpr_registration_metadata(self) -> None: + """The .gpr.py file must register a single gramplet with expected keys.""" + gpr_path = os.path.join(ADDON_DIR, "DataEntryGramplet.gpr.py") + calls: list[tuple[tuple, dict]] = [] + + namespace: dict[str, Any] = { + "register": lambda *args, **kwargs: calls.append((args, kwargs)), + "GRAMPLET": "GRAMPLET", + "STABLE": "STABLE", + "EXPERT": "EXPERT", + "_": lambda s: s, + } + with open(gpr_path, encoding="utf-8") as handle: + exec(compile(handle.read(), gpr_path, "exec"), namespace) + + self.assertEqual(len(calls), 1, "expected exactly one register() call") + args, kwargs = calls[0] + self.assertEqual(args, ("GRAMPLET",)) + self.assertEqual(kwargs["id"], "Data Entry Gramplet") + self.assertEqual(kwargs["gramplet"], "DataEntryGramplet") + self.assertEqual(kwargs["fname"], "DataEntryGramplet.py") + self.assertEqual(kwargs["gramps_target_version"], "6.0") + self.assertEqual(kwargs["status"], "STABLE") + # Navigation type must stay Person — the active object is fetched that way. + self.assertEqual(kwargs["navtypes"], ["Person"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Form/CensusCheckQuickview.gpr.py b/Form/CensusCheckQuickview.gpr.py index 81ac06e22..9f3cc2b52 100644 --- a/Form/CensusCheckQuickview.gpr.py +++ b/Form/CensusCheckQuickview.gpr.py @@ -8,7 +8,7 @@ id = 'censuscheckquickview', name = _("CensusCheck"), description= _("Check whether any Census events are missing for a person and some of their descendents"), - version = '1.0.1', + version = '1.0.2', gramps_target_version = '6.0', status = STABLE, fname = 'CensusCheckQuickview.py', @@ -22,7 +22,7 @@ id = 'censuscheckupquickview', name = _("CensusCheckUp"), description= _("Check whether any Census events are missing for a person and some of their ancestors"), - version = '1.0.1', + version = '1.0.2', gramps_target_version = '6.0', status = STABLE, fname = 'CensusCheckUpQuickview.py', diff --git a/Form/editform.py b/Form/editform.py index 910cd827c..7e43fdd16 100644 --- a/Form/editform.py +++ b/Form/editform.py @@ -28,8 +28,11 @@ # Python modules # ------------------------------------------------------------------------- from gi.repository import Gdk +import logging import pickle +LOG = logging.getLogger(".FormGramplet") + # ------------------------------------------------------------------------ # # GTK modules @@ -77,6 +80,7 @@ get_section_columns, get_form_citation, ) +from form_validator import split_family_title from entrygrid import EntryGrid # ------------------------------------------------------------------------ @@ -114,6 +118,12 @@ def __init__(self, dbstate, uistate, track, event, citation, callback): self.citation = citation self.callback = callback + LOG.debug( + "Opening EditForm for event %s, citation %s", + event.get_handle() or "", + citation.get_handle() or "", + ) + ManagedWindow.__init__(self, uistate, track, citation) self.widgets = {} @@ -391,10 +401,10 @@ def close(self, *args): """ Close the editor window. """ - (width, height) = self.window.get_size() + width, height = self.window.get_size() self._config.set("interface.form-width", width) self._config.set("interface.form-height", height) - (width, height) = self.window.get_position() + width, height = self.window.get_position() self._config.set("interface.form-horiz-position", width) self._config.set("interface.form-vert-position", height) self._config.save() @@ -700,7 +710,7 @@ def on_drag_data_received( self, widget, context, pos_x, pos_y, sel_data, info, time ): if sel_data and sel_data.get_data(): - (drag_type, idval, handle, val) = pickle.loads(sel_data.get_data()) + drag_type, idval, handle, val = pickle.loads(sel_data.get_data()) person = self.db.get_person_from_handle(handle) if person: self.__person_added(person) @@ -972,7 +982,7 @@ def on_drag_data_received( self, widget, context, pos_x, pos_y, sel_data, info, time ): if sel_data and sel_data.get_data(): - (drag_type, idval, handle, val) = pickle.loads(sel_data.get_data()) + drag_type, idval, handle, val = pickle.loads(sel_data.get_data()) person = self.db.get_person_from_handle(handle) if person: self.__added(person) @@ -1102,7 +1112,15 @@ def __init__(self, dbstate, uistate, track, event, citation, form_id, section): hbox = Gtk.Box() title = get_section_title(form_id, section) - title1, title2 = title.split("/") + title1, title2 = split_family_title(title) + if not title2: + LOG.warning( + "FamilySection for form '%s' section '%s' has title '%s' " + "without the expected 'X/Y' separator; second label will be empty", + form_id, + section, + title, + ) label = Gtk.Label(label="%s" % title1) label.set_use_markup(True) @@ -1158,7 +1176,7 @@ def on_drag_data_received( self, widget, context, pos_x, pos_y, sel_data, info, time ): if sel_data and sel_data.get_data(): - (drag_type, idval, handle, val) = pickle.loads(sel_data.get_data()) + drag_type, idval, handle, val = pickle.loads(sel_data.get_data()) family = self.db.get_family_from_handle(handle) if family: self.__added(family) diff --git a/Form/form.py b/Form/form.py index a2a4c0cf3..81b99352f 100644 --- a/Form/form.py +++ b/Form/form.py @@ -22,6 +22,7 @@ """ Form definitions. """ + # --------------------------------------------------------------- # # Python imports @@ -29,6 +30,8 @@ # --------------------------------------------------------------- import os import xml.dom.minidom +import xml.parsers.expat +import logging # --------------------------------------------------------------- # @@ -37,8 +40,18 @@ # --------------------------------------------------------------- from gramps.gen.datehandler import parser from gramps.gen.config import config -from gramps.gui.dialog import ErrorDialog, WarningDialog -import logging +from gramps.gui.dialog import ErrorDialog + +# --------------------------------------------------------------- +# +# Gramps specific +# +# --------------------------------------------------------------- +from form_validator import ( + get_form_warnings, + validate_form_dom, + validate_form_element, +) LOG = logging.getLogger(".FormGramplet") @@ -111,7 +124,14 @@ class Form: A class to read form definitions from an XML file. """ - def __init__(self): + def __init__(self, definition_dir=None): + """ + :param definition_dir: optional override for the directory the + loader scans for ``form_*.xml`` / ``custom.xml`` files. + Defaults to the directory containing this module. Exposed + primarily so tests can point the loader at an isolated + temporary directory. + """ self.__references = {} self.__dates = {} self.__headings = {} @@ -122,27 +142,91 @@ def __init__(self): self.__names = {} self.__section_types = {} + base_dir = definition_dir or os.path.dirname(__file__) + LOG.debug("Loading form definitions from %s", base_dir) for file_name in definition_files: - full_path = os.path.join(os.path.dirname(__file__), file_name) + full_path = os.path.join(base_dir, file_name) if os.path.exists(full_path): - try: - self.__load_definitions(full_path) - except Exception as e: - WarningDialog( - _("Failed to load Form definition file:\n%s\n") % full_path, - ) - LOG.warning( - "\nERROR: failed to load Form definition file.\n%s\nException:\n%s", - full_path, - str(e), - ) - - def __load_definitions(self, definition_file): - dom = xml.dom.minidom.parse(definition_file) + self.__load_file(full_path) + else: + LOG.debug("Form definition file not present: %s", full_path) + LOG.info( + "Loaded %d form definition(s) from %s", + len(self.__names), + base_dir, + ) + + def __load_file(self, full_path): + """ + Parse and validate a single form definition file, then load any + well-formed ```` elements it contains. + + Parse errors and structural validation errors are reported to the + user through an :class:`ErrorDialog` and logged; malformed forms + are skipped while valid forms from the same file are still loaded. + """ + LOG.debug("Parsing form definition file %s", full_path) + try: + dom = xml.dom.minidom.parse(full_path) + except xml.parsers.expat.ExpatError as exc: + ErrorDialog( + _("XML syntax error in Form definition file"), + "%s\n\n%s" % (full_path, exc), + ) + LOG.warning( + "XML syntax error in Form definition file %s: %s", + full_path, + exc, + ) + return + except Exception as exc: + ErrorDialog( + _("Failed to read Form definition file"), + "%s\n\n%s" % (full_path, exc), + ) + LOG.warning("Failed to read Form definition file %s: %s", full_path, exc) + return + + errors = validate_form_dom(dom) + if errors: + ErrorDialog( + _("Invalid Form definition file"), + "%s\n\n%s" % (full_path, "\n".join(errors)), + ) + LOG.warning( + "Invalid Form definition file %s:\n%s", + full_path, + "\n".join(errors), + ) + + for warning in get_form_warnings(dom): + LOG.warning("In %s: %s", full_path, warning) + + try: + self.__load_definitions(dom) + finally: + dom.unlink() + + def __load_definitions(self, dom): top = dom.getElementsByTagName("forms") + if not top: + return for form in top[0].getElementsByTagName("form"): + if validate_form_element(form): + # Errors for this form were already surfaced by the + # file-level validator — skip it so __load_definitions + # never touches a malformed element. + skipped_id = ( + form.attributes["id"].value + if "id" in form.attributes + else "" + ) + LOG.debug("Skipping invalid '%s'", skipped_id) + continue + id = form.attributes["id"].value + LOG.debug("Loading form '%s'", id) self.__names[id] = form.attributes["title"].value self.__types[id] = form.attributes["type"].value if "reference" in form.attributes: @@ -194,7 +278,6 @@ def __load_definitions(self, definition_file): self.__columns[id][role].append( (attr_text, long_text, int(size_text)) ) - dom.unlink() def get_form_ids(self): """Return a list of ids for all form definitions.""" @@ -205,7 +288,7 @@ def get_title(self, form_id): return self.__names[form_id] def get_reference(self, form_id): - """ Return the reference for a given form. """ + """Return the reference for a given form.""" return self.__references[form_id] def get_date(self, form_id): @@ -258,12 +341,14 @@ def get_form_title(form_id): """ return FORM.get_title(form_id) + def get_form_reference(form_id): """ Return the reference for a given form. """ return FORM.get_reference(form_id) + def get_form_date(form_id): """ Return the date for a given form. diff --git a/Form/form_validator.py b/Form/form_validator.py new file mode 100644 index 000000000..5b82cd98c --- /dev/null +++ b/Form/form_validator.py @@ -0,0 +1,238 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Eduard Ralph +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Pure-Python validation for Form addon XML definition files. + +Kept free of GTK/Gramps imports so it can be unit-tested without a GUI +environment. +""" + +# ------------------------ +# Python modules +# ------------------------ +import xml.dom.minidom +import xml.parsers.expat + +VALID_SECTION_TYPES = frozenset({"person", "family", "multi"}) +REQUIRED_FORM_ATTRS = ("id", "title", "type") + + +def split_family_title(title: str) -> tuple[str, str]: + """ + Split a family-section title of the form ``'X/Y'`` into ``(X, Y)``. + + Falls back gracefully when the separator is absent or the input is + empty, so callers never raise ``ValueError`` on malformed XML. + + :param title: the raw title string from the XML (may be empty) + :returns: a two-tuple of title parts; the second element is empty + when the title contains no ``'/'`` + """ + if not title: + return "", "" + parts = title.split("/", 1) + if len(parts) == 2: + return parts[0], parts[1] + return parts[0], "" + + +def validate_form_element(form) -> list[str]: + """ + Validate a single ```` DOM element against the form schema. + + :param form: a DOM element for a single form definition + :returns: a list of human-readable error messages scoped to this + form; empty when the form is valid + """ + errors: list[str] = [] + + form_id = form.attributes["id"].value if "id" in form.attributes else "" + for required in REQUIRED_FORM_ATTRS: + if required not in form.attributes: + errors.append( + "Form '%s': missing required attribute '%s'" % (form_id, required) + ) + + for section in form.getElementsByTagName("section"): + role = section.attributes["role"].value if "role" in section.attributes else "" + if not role: + errors.append( + "Form '%s':
is missing required attribute 'role'" % form_id + ) + continue + + if "type" not in section.attributes: + errors.append( + "Form '%s': section '%s' is missing required attribute 'type'" + % (form_id, role) + ) + continue + + section_type = section.attributes["type"].value + if not section_type: + errors.append( + "Form '%s': section '%s' has an empty 'type' attribute" + % (form_id, role) + ) + continue + + if section_type not in VALID_SECTION_TYPES: + errors.append( + "Form '%s': section '%s' has invalid type '%s' " + "(expected one of: %s)" + % ( + form_id, + role, + section_type, + ", ".join(sorted(VALID_SECTION_TYPES)), + ) + ) + continue + + title = ( + section.attributes["title"].value if "title" in section.attributes else "" + ) + if section_type == "family": + parts = title.split("/") + if len(parts) != 2 or not parts[0].strip() or not parts[1].strip(): + errors.append( + "Form '%s': family section '%s' requires a title " + "of the form 'Name1/Name2' (got '%s')" % (form_id, role, title) + ) + + return errors + + +def validate_form_dom(dom: xml.dom.minidom.Document) -> list[str]: + """ + Validate the structure of a parsed form definitions DOM. + + Checks that: + + * a ```` root element exists and contains at least one + ```` definition; + * each ```` element has ``id``, ``title`` and ``type`` attributes; + * each ``
`` element has non-empty ``role`` and ``type`` + attributes; + * each section's ``type`` is one of ``person``, ``family`` or ``multi``; + * ``family``-type sections declare a title of the form ``'X/Y'`` with + two non-empty parts. + + :param dom: a parsed ``xml.dom.minidom.Document`` + :returns: a list of human-readable error messages; empty when the + document is valid + """ + top = dom.getElementsByTagName("forms") + if not top: + return ["Missing root element"] + + errors: list[str] = [] + forms = top[0].getElementsByTagName("form") + if not forms: + errors.append(" root element contains no definitions") + for form in forms: + errors.extend(validate_form_element(form)) + return errors + + +def get_form_warnings(dom: xml.dom.minidom.Document) -> list[str]: + """ + Collect non-fatal warnings about a parsed form definitions DOM. + + Warnings describe likely authoring mistakes that do not prevent the + form from loading. Currently covers Gramps bug 11010's observation + that a section's ```` ```` values are expected to sum + to 100 — sections that declare explicit sizes on every column but do + not sum to 100 are reported as warnings so callers can log them + without blocking the form from loading. + + Sections without any sized columns, or with only some columns + sized, are skipped because the intent is ambiguous. + + :param dom: a parsed ``xml.dom.minidom.Document`` + :returns: a list of human-readable warning messages; empty when + nothing questionable is detected + """ + warnings: list[str] = [] + top = dom.getElementsByTagName("forms") + if not top: + return warnings + + for form in top[0].getElementsByTagName("form"): + form_id = ( + form.attributes["id"].value + if "id" in form.attributes + else "" + ) + for section in form.getElementsByTagName("section"): + role = ( + section.attributes["role"].value + if "role" in section.attributes + else "" + ) + columns = section.getElementsByTagName("column") + if not columns: + continue + + sizes: list[int] = [] + all_sized = True + for column in columns: + size_nodes = column.getElementsByTagName("size") + if not size_nodes or not size_nodes[0].childNodes: + all_sized = False + break + try: + sizes.append(int(size_nodes[0].childNodes[0].data)) + except ValueError: + all_sized = False + break + if not all_sized: + continue + + total = sum(sizes) + if total != 100: + warnings.append( + "Form '%s': section '%s' column sizes sum to %d " + "(expected 100); form will still load but column " + "widths may not render as intended" + % (form_id, role, total) + ) + return warnings + + +def parse_and_validate(path: str) -> tuple[xml.dom.minidom.Document | None, list[str]]: + """ + Parse ``path`` as XML and validate it against the form schema. + + :param path: filesystem path to a form definitions XML file + :returns: a ``(dom, errors)`` tuple. When parsing fails, ``dom`` is + ``None`` and ``errors`` contains a single description of + the syntax error. When parsing succeeds, ``dom`` is the + parsed document and ``errors`` lists any structural + problems (empty when the file is valid). + """ + try: + dom = xml.dom.minidom.parse(path) + except xml.parsers.expat.ExpatError as exc: + return None, ["XML syntax error: %s" % exc] + except (OSError, ValueError) as exc: + return None, ["Failed to read file: %s" % exc] + return dom, validate_form_dom(dom) diff --git a/Form/formgramplet.gpr.py b/Form/formgramplet.gpr.py index bcdfed128..55d6312d0 100644 --- a/Form/formgramplet.gpr.py +++ b/Form/formgramplet.gpr.py @@ -31,7 +31,7 @@ name=_("Form Gramplet"), description=_("Gramplet interface for Forms"), status=STABLE, - version = '2.0.51', + version = '2.0.52', gramps_target_version="6.0", navtypes=["Person"], fname="formgramplet.py", diff --git a/Form/po/template.pot b/Form/po/template.pot index 91af5203a..9662fd535 100644 --- a/Form/po/template.pot +++ b/Form/po/template.pot @@ -9045,3 +9045,15 @@ msgstr "" #: Form/form_fr.xml.h:144 msgid "Arrived since" msgstr "" + +#: Form/form.py:154 +msgid "XML syntax error in Form definition file" +msgstr "" + +#: Form/form.py:165 +msgid "Failed to read Form definition file" +msgstr "" + +#: Form/form.py:176 +msgid "Invalid Form definition file" +msgstr "" diff --git a/Form/tests/test_form_validator.py b/Form/tests/test_form_validator.py new file mode 100644 index 000000000..0efc350eb --- /dev/null +++ b/Form/tests/test_form_validator.py @@ -0,0 +1,518 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Eduard Ralph +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Unit tests for ``form_validator`` — the pure-Python validation layer +for Form addon XML definition files. + +These tests do not touch Gramps or GTK, so they run in every CI job. + +Run with:: + + python3 -m unittest Form.tests.test_form_validator -v +""" + +# ------------------------ +# Python modules +# ------------------------ +import os +import sys +import tempfile +import textwrap +import unittest +import xml.dom.minidom + +# ------------------------ +# Gramps specific +# ------------------------ +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from form_validator import ( + get_form_warnings, + parse_and_validate, + split_family_title, + validate_form_dom, + VALID_SECTION_TYPES, +) + + +def _dom_from_string(xml_text: str) -> xml.dom.minidom.Document: + """Parse an XML string into a DOM for validator testing.""" + return xml.dom.minidom.parseString(xml_text) + + +# --------------------------------------------------------------------------- +# split_family_title — belt-and-braces helper used by FamilySection +# --------------------------------------------------------------------------- +class TestSplitFamilyTitle(unittest.TestCase): + """ + Regression coverage for Gramps bug 11707 — ``FamilySection`` used to + crash with ``ValueError: not enough values to unpack (expected 2, + got 1)`` when a form's ``
`` title did not + contain a ``/`` separator. + """ + + def test_two_parts(self): + self.assertEqual(split_family_title("Groom/Bride"), ("Groom", "Bride")) + + def test_no_separator_returns_empty_second(self): + self.assertEqual(split_family_title("Couple"), ("Couple", "")) + + def test_empty_string_returns_two_empties(self): + self.assertEqual(split_family_title(""), ("", "")) + + def test_only_separator(self): + self.assertEqual(split_family_title("/"), ("", "")) + + def test_leading_separator(self): + self.assertEqual(split_family_title("/Bride"), ("", "Bride")) + + def test_trailing_separator(self): + self.assertEqual(split_family_title("Groom/"), ("Groom", "")) + + def test_multiple_separators_only_split_once(self): + self.assertEqual(split_family_title("A/B/C"), ("A", "B/C")) + + def test_whitespace_preserved(self): + self.assertEqual( + split_family_title(" Groom / Bride "), + (" Groom ", " Bride "), + ) + + +# --------------------------------------------------------------------------- +# validate_form_dom — happy path +# --------------------------------------------------------------------------- +class TestValidateFormDomValid(unittest.TestCase): + + def test_minimal_valid_form(self): + dom = _dom_from_string(textwrap.dedent("""\ + + +
+ + + """)) + self.assertEqual(validate_form_dom(dom), []) + + def test_valid_family_section_with_slashed_title(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + self.assertEqual(validate_form_dom(dom), []) + + def test_valid_person_section_without_title(self): + """Person sections may legitimately omit the title attribute.""" + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + self.assertEqual(validate_form_dom(dom), []) + +# --------------------------------------------------------------------------- +# validate_form_dom — structural errors +# --------------------------------------------------------------------------- +class TestValidateFormDomErrors(unittest.TestCase): + + def test_missing_forms_root(self): + dom = _dom_from_string("") + errors = validate_form_dom(dom) + self.assertEqual(len(errors), 1) + self.assertIn("", errors[0]) + + def test_empty_forms_element_is_rejected(self): + """ + Regression for Gramps bug 11010 — a file whose ```` root + contains no ``
`` definitions used to load silently, leaving + users with no feedback. The validator now reports it as an error. + """ + dom = _dom_from_string("") + errors = validate_form_dom(dom) + self.assertEqual(len(errors), 1) + self.assertIn("no ", errors[0]) + + def test_forms_root_with_only_comments_is_rejected(self): + dom = _dom_from_string("") + errors = validate_form_dom(dom) + self.assertEqual(len(errors), 1) + self.assertIn("no ", errors[0]) + + def test_form_missing_id_attribute(self): + dom = _dom_from_string("") + errors = validate_form_dom(dom) + self.assertTrue(any("missing required attribute 'id'" in e for e in errors)) + + def test_form_missing_title_attribute(self): + dom = _dom_from_string("") + errors = validate_form_dom(dom) + self.assertTrue(any("missing required attribute 'title'" in e for e in errors)) + + def test_form_missing_type_attribute(self): + dom = _dom_from_string("") + errors = validate_form_dom(dom) + self.assertTrue(any("missing required attribute 'type'" in e for e in errors)) + + def test_section_missing_role_attribute(self): + dom = _dom_from_string(textwrap.dedent("""\ + + +
+ + + """)) + errors = validate_form_dom(dom) + self.assertTrue(any("'role'" in e for e in errors)) + + def test_section_empty_role_attribute(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + errors = validate_form_dom(dom) + self.assertTrue(any("'role'" in e for e in errors)) + + def test_section_missing_type_attribute(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + errors = validate_form_dom(dom) + self.assertTrue(any("'type'" in e for e in errors)) + + def test_section_empty_type_attribute(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + errors = validate_form_dom(dom) + self.assertTrue(any("'type'" in e for e in errors)) + + def test_section_invalid_type(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + errors = validate_form_dom(dom) + self.assertEqual(len(errors), 1) + self.assertIn("invalid type 'bogus'", errors[0]) + for valid in VALID_SECTION_TYPES: + self.assertIn(valid, errors[0]) + + def test_section_type_is_case_sensitive(self): + """ + A real custom.xml in the wild used ``type='Person'`` instead of + ``type='person'``. The validator rejects it so the user sees a + clear error rather than silently loading a broken form. + """ + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + errors = validate_form_dom(dom) + self.assertEqual(len(errors), 1) + self.assertIn("invalid type 'Person'", errors[0]) + + def test_family_section_without_title_is_rejected(self): + """ + Reproduction of bug 11707 at the validation layer: a family + section without a slashed title produces a clear error instead + of a ValueError crash in the GUI. + """ + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + errors = validate_form_dom(dom) + self.assertEqual(len(errors), 1) + self.assertIn("Name1/Name2", errors[0]) + + def test_family_section_with_single_part_title_is_rejected(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + errors = validate_form_dom(dom) + self.assertEqual(len(errors), 1) + self.assertIn("Couple", errors[0]) + + def test_family_section_with_blank_part_is_rejected(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + errors = validate_form_dom(dom) + self.assertEqual(len(errors), 1) + + def test_family_section_with_three_parts_is_rejected(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + errors = validate_form_dom(dom) + self.assertEqual(len(errors), 1) + + def test_multiple_errors_aggregated(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+
+ +
+ + """)) + errors = validate_form_dom(dom) + # 1 for invalid type + 1 for missing role + 1 for missing id + self.assertGreaterEqual(len(errors), 3) + + +# --------------------------------------------------------------------------- +# parse_and_validate — file-level entry point +# --------------------------------------------------------------------------- +class TestParseAndValidate(unittest.TestCase): + + def _write(self, content: str) -> str: + """Write a temporary XML file and return its path.""" + fd, path = tempfile.mkstemp(suffix=".xml") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + self.addCleanup(os.remove, path) + return path + + def test_valid_file(self): + path = self._write(textwrap.dedent("""\ + + +
+ + + """)) + dom, errors = parse_and_validate(path) + self.assertIsNotNone(dom) + self.assertEqual(errors, []) + + def test_xml_syntax_error_reports_cleanly(self): + path = self._write("
") + dom, errors = parse_and_validate(path) + self.assertIsNone(dom) + self.assertEqual(len(errors), 1) + self.assertIn("XML syntax error", errors[0]) + + def test_missing_file_reports_cleanly(self): + dom, errors = parse_and_validate("/nonexistent/path.xml") + self.assertIsNone(dom) + self.assertEqual(len(errors), 1) + + def test_invalid_structure_returns_errors_and_dom(self): + path = self._write(textwrap.dedent("""\ + + +
+ + + """)) + dom, errors = parse_and_validate(path) + self.assertIsNotNone(dom) + self.assertEqual(len(errors), 1) + + +# --------------------------------------------------------------------------- +# Sanity: the shipped built-in definition files must validate +# --------------------------------------------------------------------------- +class TestShippedDefinitionFilesValidate(unittest.TestCase): + """ + Every built-in ``form_*.xml`` file shipped with the addon must pass + validation. If this test fails, the addon would refuse to load one + of its own definition files for end users. + """ + + FORM_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + def test_all_builtin_form_files_validate(self): + import glob + + files = sorted(glob.glob(os.path.join(self.FORM_DIR, "form_*.xml"))) + self.assertGreater(len(files), 0, "no form_*.xml files discovered") + for path in files: + with self.subTest(form_file=os.path.basename(path)): + dom, errors = parse_and_validate(path) + self.assertIsNotNone(dom, f"failed to parse {path}") + self.assertEqual( + errors, + [], + f"validation errors in {path}:\n" + "\n".join(errors), + ) + + +# --------------------------------------------------------------------------- +# get_form_warnings — non-fatal authoring warnings (Gramps bug 11010) +# --------------------------------------------------------------------------- +class TestGetFormWarnings(unittest.TestCase): + """ + Column sizes are expected to sum to 100. Rather than reject the + form, ``get_form_warnings`` flags suspect sections so the caller can + log them — 78 shipped definition files currently violate this rule + without breaking rendering, so escalating to an error would be + user-hostile. + """ + + def test_section_without_columns_has_no_warning(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ + + """)) + self.assertEqual(get_form_warnings(dom), []) + + def test_columns_without_any_size_have_no_warning(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ <_attribute>Name + <_attribute>Age +
+
+
+ """)) + self.assertEqual(get_form_warnings(dom), []) + + def test_columns_summing_to_100_have_no_warning(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ <_attribute>A60 + <_attribute>B40 +
+
+
+ """)) + self.assertEqual(get_form_warnings(dom), []) + + def test_columns_not_summing_to_100_emit_warning(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ <_attribute>A25 +
+
+
+ """)) + warnings = get_form_warnings(dom) + self.assertEqual(len(warnings), 1) + self.assertIn("sum to 25", warnings[0]) + self.assertIn("F1", warnings[0]) + self.assertIn("Primary", warnings[0]) + + def test_partially_sized_columns_have_no_warning(self): + """ + When only some columns declare a ````, the author's intent + is ambiguous (mixed relative/absolute sizing) so the check is + skipped to avoid false positives. + """ + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ <_attribute>A25 + <_attribute>B +
+
+
+ """)) + self.assertEqual(get_form_warnings(dom), []) + + def test_warnings_are_independent_of_errors(self): + """ + Warnings are structurally orthogonal to errors: a malformed form + still produces warnings for its well-formed sibling. + """ + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ +
+
+ <_attribute>A30 +
+
+ + """)) + warnings = get_form_warnings(dom) + self.assertEqual(len(warnings), 1) + self.assertIn("GOOD", warnings[0]) + + def test_multiple_misaligned_sections_all_reported(self): + dom = _dom_from_string(textwrap.dedent("""\ + +
+
+ <_attribute>X40 +
+
+ <_attribute>Y70 +
+
+
+ """)) + warnings = get_form_warnings(dom) + self.assertEqual(len(warnings), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/Form/tests/test_integration_form.py b/Form/tests/test_integration_form.py new file mode 100644 index 000000000..82839c0cf --- /dev/null +++ b/Form/tests/test_integration_form.py @@ -0,0 +1,332 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Eduard Ralph +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Integration tests for the Form addon loader — covers +``gramps-project/gramps#11707`` (*ValueError: not enough values to +unpack* when a form's ``
`` title lacks the +``X/Y`` separator) and ``gramps-project/gramps#11010`` (empty +```` roots used to load silently, and column-size mismatches +had no diagnostic). + +Scenarios covered: + +* Malformed XML produces an ``ErrorDialog`` rather than a bare traceback. +* A partially-broken file still loads its well-formed ``
`` entries. +* Empty ```` roots surface as an ``ErrorDialog`` (bug 11010 item a). +* Column-size mismatches are logged as WARNINGs only (bug 11010 item b). +* The shipped built-in definition files load cleanly without any error + dialogs being raised. + +Run with:: + + python3 -m unittest Form.tests.test_integration_form -v +""" + +# ------------------------ +# Python modules +# ------------------------ +import logging +import os +import shutil +import sys +import tempfile +import textwrap +import unittest +from unittest.mock import patch + +# ------------------------ +# Gramps modules +# ------------------------ +try: + import gi # noqa: F401 + import gramps +except ImportError as exc: + raise unittest.SkipTest( + "Form integration tests require 'gi' and 'gramps': %s" % exc + ) + +if "GRAMPS_RESOURCES" not in os.environ: + os.environ["GRAMPS_RESOURCES"] = os.path.dirname(os.path.dirname(gramps.__file__)) + +# ------------------------ +# Gramps specific +# ------------------------ +ADDON_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ADDON_DIR not in sys.path: + sys.path.insert(0, ADDON_DIR) + + +# --------------------------------------------------------------------------- +# Shared harness +# --------------------------------------------------------------------------- +class FormLoaderTestCase(unittest.TestCase): + """ + Base class that imports the Form addon's ``form`` module and + redirects its ``ErrorDialog`` to an in-memory list instead of + opening a GTK dialog. + + Subclasses access :attr:`shown` to inspect captured dialog calls + and use :meth:`_write` to drop XML files into an isolated + temporary directory. + """ + + def setUp(self) -> None: + import form + + self.form = form + self.shown: list[tuple[str, str]] = [] + + def _fake_error_dialog(*args, **kwargs): + title = str(args[0]) if args else "" + body = str(args[1]) if len(args) > 1 else "" + self.shown.append((title, body)) + + error_patch = patch.object(form, "ErrorDialog", _fake_error_dialog) + error_patch.start() + self.addCleanup(error_patch.stop) + + self.tmp_dir = tempfile.mkdtemp(prefix="form-integration-") + self.addCleanup(shutil.rmtree, self.tmp_dir, True) + + def _write(self, filename: str, content: str) -> None: + """Write an XML fixture into the test's temporary directory.""" + path = os.path.join(self.tmp_dir, filename) + with open(path, "w", encoding="utf-8") as handle: + handle.write(content) + + def _patch_definition_files(self, files: list[str]) -> None: + """Redirect the loader to the given filenames (inside tmp_dir).""" + files_patch = patch.object(self.form, "definition_files", files) + files_patch.start() + self.addCleanup(files_patch.stop) + + +# --------------------------------------------------------------------------- +# Error-dialog wiring +# --------------------------------------------------------------------------- +class TestErrorDialogWiring(FormLoaderTestCase): + """ + Exercises the four failure modes the loader now surfaces via + :class:`ErrorDialog` instead of an unhandled traceback. + """ + + def test_malformed_xml_shows_error_dialog(self) -> None: + """XML syntax errors should raise an ErrorDialog, not a traceback.""" + self._write("custom.xml", "") + self._patch_definition_files(["custom.xml"]) + + instance = self.form.Form(definition_dir=self.tmp_dir) + + self.assertTrue(self.shown, "no ErrorDialog was displayed") + title, body = self.shown[0] + self.assertIn("XML syntax error", title) + self.assertIn("custom.xml", body) + self.assertEqual(list(instance.get_form_ids()), []) + + def test_invalid_family_title_shows_error_dialog(self) -> None: + """ + The exact condition from bug 11707 — a family section with a + non-``X/Y`` title — must be surfaced as an ErrorDialog at load + time rather than an unhandled exception when the user later + opens the form. + """ + self._write( + "custom.xml", + textwrap.dedent("""\ + + +
+ + + """), + ) + self._patch_definition_files(["custom.xml"]) + + self.form.Form(definition_dir=self.tmp_dir) + + self.assertTrue( + self.shown, "no ErrorDialog was displayed for invalid family title" + ) + title, body = self.shown[0] + self.assertIn("Invalid Form definition file", title) + self.assertIn("Name1/Name2", body) + + def test_partially_broken_file_still_loads_valid_forms(self) -> None: + """A broken
must not stop sibling elements loading.""" + self._write( + "custom.xml", + textwrap.dedent("""\ + + +
+ +
+
+ + + """), + ) + self._patch_definition_files(["custom.xml"]) + + instance = self.form.Form(definition_dir=self.tmp_dir) + loaded_ids = list(instance.get_form_ids()) + + self.assertIn("GOOD", loaded_ids, "valid form should still load") + self.assertNotIn("BAD", loaded_ids, "invalid form should be skipped") + self.assertTrue(self.shown, "the broken form should have been reported") + + def test_missing_role_attribute_shows_error_dialog(self) -> None: + """A section missing its ``role`` attribute is reported clearly.""" + self._write( + "custom.xml", + textwrap.dedent("""\ + +
+
+ + + """), + ) + self._patch_definition_files(["custom.xml"]) + + self.form.Form(definition_dir=self.tmp_dir) + + self.assertTrue(self.shown) + _, body = self.shown[0] + self.assertIn("role", body) + + def test_invalid_section_type_shows_error_dialog(self) -> None: + """Unknown section types produce a clear error, not a later crash.""" + self._write( + "custom.xml", + textwrap.dedent("""\ + +
+
+ + + """), + ) + self._patch_definition_files(["custom.xml"]) + + instance = self.form.Form(definition_dir=self.tmp_dir) + + self.assertTrue(self.shown) + _, body = self.shown[0] + self.assertIn("bogus", body) + self.assertNotIn("F1", list(instance.get_form_ids())) + + +# --------------------------------------------------------------------------- +# Empty / content-less definition files (Gramps bug 11010 item a) +# --------------------------------------------------------------------------- +class TestEmptyFormsValidation(FormLoaderTestCase): + """ + A definition file whose ```` root contains no ``
`` + children used to load silently. The validator now surfaces it as an + ErrorDialog so the user notices the empty file. + """ + + def test_empty_forms_element_shows_error_dialog(self) -> None: + """Empty ```` must raise an ErrorDialog, not load silently.""" + self._write("custom.xml", "") + self._patch_definition_files(["custom.xml"]) + + instance = self.form.Form(definition_dir=self.tmp_dir) + + self.assertTrue(self.shown, "no ErrorDialog was displayed for empty ") + title, body = self.shown[0] + self.assertIn("Invalid Form definition file", title) + self.assertIn("no ", body) + self.assertEqual(list(instance.get_form_ids()), []) + + +# --------------------------------------------------------------------------- +# Column-size warnings (Gramps bug 11010 item b) — WARNING only, not errors +# --------------------------------------------------------------------------- +class TestColumnSizeWarnings(FormLoaderTestCase): + """ + Sections whose ```` sizes do not sum to 100 must be logged + as warnings only — 78 shipped forms trip this check, so escalating + to an ErrorDialog would harass users on every launch. + """ + + def test_column_size_sum_warning_is_logged_not_dialog(self) -> None: + """Column-size mismatch → WARNING log entry, no ErrorDialog.""" + self._write( + "custom.xml", + textwrap.dedent("""\ + + +
+ <_attribute>A25 +
+ +
+ """), + ) + self._patch_definition_files(["custom.xml"]) + + with self.assertLogs(".FormGramplet", level=logging.WARNING) as log_ctx: + instance = self.form.Form(definition_dir=self.tmp_dir) + + self.assertFalse( + self.shown, + "column-size mismatch must not produce an ErrorDialog:\n" + + "\n".join("%s: %s" % (t, b) for t, b in self.shown), + ) + self.assertIn( + "F1", + list(instance.get_form_ids()), + "the form must still load despite the size mismatch", + ) + self.assertTrue( + any("sum to 25" in message for message in log_ctx.output), + "expected a column-size warning in the log", + ) + + +# --------------------------------------------------------------------------- +# Shipped files load cleanly +# --------------------------------------------------------------------------- +class TestShippedFilesLoadCleanly(FormLoaderTestCase): + """ + The built-in definition files that ship with the addon must load + without triggering a single ErrorDialog, otherwise end users would + see a popup every time they opened Gramps. + """ + + def test_shipped_files_load_without_errors(self) -> None: + instance = self.form.Form() + + self.assertFalse( + self.shown, + "Built-in definition files triggered ErrorDialog calls:\n" + + "\n".join("%s: %s" % (t, b) for t, b in self.shown), + ) + self.assertTrue( + list(instance.get_form_ids()), + "no forms loaded from built-in definition files", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ImportMerge/importmerge.gpr.py b/ImportMerge/importmerge.gpr.py index f4be5dad9..f707a0581 100644 --- a/ImportMerge/importmerge.gpr.py +++ b/ImportMerge/importmerge.gpr.py @@ -32,7 +32,7 @@ description=_( "Compares a Gramps XML database with the current one, and allows merging of the differences." ), - version = '0.0.25', + version = '0.0.26', gramps_target_version="6.0", status=STABLE, fname="importmerge.py", diff --git a/ImportMerge/importmerge.py b/ImportMerge/importmerge.py index 2b49e8276..e789e2def 100644 --- a/ImportMerge/importmerge.py +++ b/ImportMerge/importmerge.py @@ -803,8 +803,8 @@ def do_commits(self, status, obj_type, hndl, action, trans): continue # now check the missing list for a match changed += self.check_miss(r_objtype, r_hndl, item) - # check for GID conflict - if item.gramps_id != item1.gramps_id: + # check for GID conflict (Tag is a table object without gramps_id) + if obj_type != 'Tag' and item.gramps_id != item1.gramps_id: if getattr(self.db1, 'has_' + obj_type.lower() + '_gramps_id')(item.gramps_id): item.gramps_id = getattr(self.db1, 'find_next_' + @@ -823,9 +823,10 @@ def do_commits(self, status, obj_type, hndl, action, trans): continue # now check the differences list for a match self.check_diffs(r_objtype, r_hndl, obj_type, item) - # check for GID conflict - if getattr(self.db1, 'has_' + obj_type.lower() + - '_gramps_id')(item.gramps_id): + # check for GID conflict (Tag is a table object without gramps_id) + if obj_type != 'Tag' and getattr( + self.db1, 'has_' + obj_type.lower() + + '_gramps_id')(item.gramps_id): item.gramps_id = getattr(self.db1, 'find_next_' + obj_type.lower() + '_gramps_id')() diff --git a/ImportMerge/tests/__init__.py b/ImportMerge/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ImportMerge/tests/test_integration_importmerge.py b/ImportMerge/tests/test_integration_importmerge.py new file mode 100644 index 000000000..26b006dff --- /dev/null +++ b/ImportMerge/tests/test_integration_importmerge.py @@ -0,0 +1,187 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Eduard Ralph +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +"""Integration tests for the Import and Merge tool. + +These tests exercise ``ImportMerge.do_commits`` against a real Gramps SQLite +database. We call the method unbound with a stub ``self`` so the GUI +(ManagedWindow + Gtk dialog) doesn't need to be instantiated — the data-path +logic is what we're verifying. + +Regression coverage: + +* Gramps bug 0014056 — Adding a Tag via the Import Merge tool raised + ``AttributeError: 'SQLite' object has no attribute 'has_tag_gramps_id'`` + because Tag is a table object and has no gramps_id. +""" + +# ------------------------ +# Python modules +# ------------------------ +import os +import shutil +import tempfile +import unittest +from types import SimpleNamespace + +# The ImportMerge module imports Gtk at module load — skip the whole file if +# gi/Gtk aren't available (headless-without-GTK environments). On systems +# where both GTK3 and GTK4 are present, pin Gtk to 3.0 before any gramps +# import (mirrors what gramps.grampsapp does at startup); otherwise +# PyGObject loads GTK4 and the gramps.gui import chain crashes on +# Gtk.IconSize.MENU (a GTK3-only enum). +try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") +except (ImportError, ValueError, AttributeError) as err: + raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) + +# ------------------------ +# Gramps modules +# ------------------------ +from gramps.gen.db import DbTxn +from gramps.gen.db.base import DbReadBase +from gramps.gen.db.utils import make_database +from gramps.gen.lib import Tag + +# ------------------------ +# Gramps specific +# ------------------------ +from ImportMerge.importmerge import ImportMerge, S_ADD, S_DIFFERS, A_ADD + + +def _make_db(suffix: str) -> tuple[DbReadBase, str]: + """Create a fresh on-disk Gramps SQLite DB inside a temp directory. + + :param suffix: Short label used in the temp-directory prefix. + :returns: A ``(db, tmpdir)`` tuple — the caller owns ``tmpdir`` and must + remove it. + """ + tmpdir = tempfile.mkdtemp(prefix="gramps_test_%s_" % suffix) + db_path = os.path.join(tmpdir, "db_%s" % suffix) + os.makedirs(db_path) + db = make_database("sqlite") + db.load(db_path, None) + return db, tmpdir + + +def _add_tag(db: DbReadBase, name: str) -> str: + """Add a Tag to ``db`` and return its handle. + + :param db: The database to add the tag to. + :param name: The tag name. + :returns: The handle of the newly-created tag. + """ + tag = Tag() + tag.set_name(name) + with DbTxn("add tag", db, batch=True) as trans: + handle = db.add_tag(tag, trans) + return handle + + +# ------------------------------------------------------------ +# +# ImportMergeTagTestCase +# +# ------------------------------------------------------------ +class ImportMergeTagTestCase(unittest.TestCase): + """Regression tests for bug 0014056 (Tag handling in do_commits).""" + + def setUp(self) -> None: + self.db1, self.tmp1 = _make_db("db1") + self.db2, self.tmp2 = _make_db("db2") + + def tearDown(self) -> None: + for db in (self.db1, self.db2): + try: + db.close() + except Exception: + pass + shutil.rmtree(self.tmp1, ignore_errors=True) + shutil.rmtree(self.tmp2, ignore_errors=True) + + def _make_stub(self) -> SimpleNamespace: + """Stub ``self`` for ``ImportMerge.do_commits`` — no GUI attributes.""" + return SimpleNamespace( + db1=self.db1, + db2=self.db2, + added={}, + missing={}, + diffs={}, + ) + + def test_add_tag_does_not_crash(self) -> None: + """Regression for bug 0014056 — adding a Tag via do_commits must succeed. + + Before the fix, this raised ``AttributeError: 'SQLite' object has no + attribute 'has_tag_gramps_id'`` because the generic GID-conflict check + didn't special-case Tag (a table object without a gramps_id field). + """ + tag_handle = _add_tag(self.db2, "Imported") + stub = self._make_stub() + + with DbTxn("import merge", self.db1, batch=True) as trans: + ImportMerge.do_commits(stub, S_ADD, "Tag", tag_handle, A_ADD, trans) + + self.assertEqual(self.db1.get_number_of_tags(), 1) + committed = self.db1.get_tag_from_handle(tag_handle) + self.assertIsNotNone(committed) + self.assertEqual(committed.get_name(), "Imported") + + def test_differing_tag_does_not_crash(self) -> None: + """S_DIFFERS branch must also skip the gramps_id check for Tag. + + Same root cause as the S_ADD path: the GID-conflict block at the end + of the S_DIFFERS branch dereferences ``item.gramps_id`` and calls + ``has_tag_gramps_id`` — both fail for Tag objects. + """ + tag_handle = _add_tag(self.db1, "Original") + tag2 = Tag() + tag2.set_handle(tag_handle) + tag2.set_name("Modified") + with DbTxn("seed db2", self.db2, batch=True) as trans: + self.db2.add_tag(tag2, trans) + + stub = self._make_stub() + + def fake_diff_result( + action: int, obj_type: str, hndl: str + ) -> tuple[Tag | None, Tag | None, Tag | None]: + item1 = self.db1.get_tag_from_handle(hndl) + item2 = self.db2.get_tag_from_handle(hndl) + return item1, item2, item2 + + stub.diff_result = fake_diff_result + stub.check_diffs = lambda *a, **kw: False + stub.check_added = lambda *a, **kw: False + stub.check_miss = lambda *a, **kw: False + + with DbTxn("import merge", self.db1, batch=True) as trans: + ImportMerge.do_commits(stub, S_DIFFERS, "Tag", tag_handle, A_ADD, trans) + + committed = self.db1.get_tag_from_handle(tag_handle) + self.assertIsNotNone(committed) + self.assertEqual(committed.get_name(), "Modified") + + +if __name__ == "__main__": + unittest.main() diff --git a/TimelinePedigreeView/TimelinePedigreeView.gpr.py b/TimelinePedigreeView/TimelinePedigreeView.gpr.py index 2d6237670..11bdc514b 100644 --- a/TimelinePedigreeView/TimelinePedigreeView.gpr.py +++ b/TimelinePedigreeView/TimelinePedigreeView.gpr.py @@ -36,7 +36,7 @@ "The view shows a timeline pedigree with ancestors and " "descendants of the selected person" ), - version = '0.1.66', + version = '0.1.67', gramps_target_version="6.0", status=STABLE, fname="TimelinePedigreeView.py", diff --git a/TimelinePedigreeView/TimelinePedigreeView.py b/TimelinePedigreeView/TimelinePedigreeView.py index d490f0242..1926987cf 100644 --- a/TimelinePedigreeView/TimelinePedigreeView.py +++ b/TimelinePedigreeView/TimelinePedigreeView.py @@ -705,7 +705,7 @@ def Tree_Rebuild(self): if Tick[1] > 0 and Tick[1] < RequiredWidth: self.gtklayout_lines.append([Tick[1], int(5*TimeLineHeight/8), Tick[1], int(7*TimeLineHeight/8), 1]) if Tick[0]: - label = Gtk.Label(label=Tick[0]) + label = Gtk.Label(label=str(Tick[0])) label.set_justify(Gtk.Justification.CENTER) label.show() layout_widget.put(label, int(Tick[1]-label.get_preferred_size()[0].width/2), 1*TimeLineHeight/4) @@ -1501,7 +1501,6 @@ def add_settings_to_menu(self, menu): menu.append(item) # Help menu entry - menu.append(item) item = Gtk.MenuItem(label=_("About Timeline Pedigree View")) item.connect("activate", self.on_help_clicked) item.show() diff --git a/WebSearch/WebSearch.gpr.py b/WebSearch/WebSearch.gpr.py index 3c9e12de9..968e254b0 100644 --- a/WebSearch/WebSearch.gpr.py +++ b/WebSearch/WebSearch.gpr.py @@ -37,7 +37,7 @@ "Person, Place, Family, or Source record" ), status=STABLE, - version = '1.10.8', + version = '1.10.9', fname="WebSearch.py", height=20, detached_width=400, diff --git a/WebSearch/tests/test_filetable.py b/WebSearch/tests/test_filetable.py index d88d05666..909a1f4fe 100644 --- a/WebSearch/tests/test_filetable.py +++ b/WebSearch/tests/test_filetable.py @@ -37,8 +37,18 @@ Each test ensures that the file table behaves correctly according to its configuration. """ -import unittest import os +import sys +import unittest + +# Make sure addon modules are importable from the parent directory +# (matches the convention used by TMGimporter/Form tests). Required when +# the test is loaded via its dotted path — e.g. +# `python3 -m unittest WebSearch.tests.test_filetable` from +# addons-source/, the form used by addons-source's own ci.yml — where +# `models`/`constants`/`db_file_table` are otherwise resolved against +# the addons-source root rather than against `WebSearch/`. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from models import DBFileTableConfig from constants import DuplicateHandlingMode, DB_FILE_TABLE_DIR From 02cf1efbef25aa4a59852fb0e829d0e7255bbc5a Mon Sep 17 00:00:00 2001 From: "Eduard R." Date: Fri, 8 May 2026 21:16:20 +0200 Subject: [PATCH 4/6] CI: auto-derive addon pip deps from requires_mod in .gpr.py (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dockerfile bakes in only `dbf`, but addons declare a wider set of Python deps in their .gpr.py `requires_mod` lists (networkx, psycopg2, pygraphviz, lxml, svgwrite, boto3, litellm, life_line_chart, psycopg). Without these installed, per-addon unit tests and the plugin- registration subprocess load fail with ImportError/NameError. Add a pre-test step to unit-test-linux, unit-test-windows, and integration-test that globs every *.gpr.py, extracts the requires_mod union via ast.literal_eval, and pip-installs each package one at a time. Per-package install (not batched) keeps a single build failure (pygraphviz without graphviz-dev, psycopg2 without libpq-dev) from aborting the rest — the affected addon's tests will skip or fail in isolation without blocking others. Mirrors Gramps' Addon Manager install path (gramps/gui/plug/_windows.py __on_install_clicked → req.install → gen/utils/requirements.py), keeping .gpr.py files as the single source of truth for addon deps. New addon deps do not need a parallel update to the Dockerfile or this workflow. --- .github/workflows/ci.yml | 100 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23ccbe4c5..1a79cda8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,44 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install addon runtime deps (derived from requires_mod) + # Auto-derive the union of requires_mod across every .gpr.py in + # the repo. Mirrors Gramps' Addon Manager install path + # (gramps/gui/plug/_windows.py __on_install_clicked → req.install → + # gen/utils/requirements.py). Keeps .gpr.py files as the single + # source of truth for addon deps — no parallel list to maintain + # in the image or workflow. Best-effort: a package needing exotic + # system deps (pygraphviz → graphviz-dev, psycopg2 → libpq-dev) + # may fail here; the affected addon's tests will skip or fail in + # isolation without blocking the rest. + shell: bash + run: | + addon_mods=$(python3 - <<'PY' + import ast, glob, re + pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") + mods = set() + for f in glob.glob("*/*.gpr.py"): + try: + text = open(f, encoding="utf-8").read() + except OSError: + continue + for m in pat.finditer(text): + try: + mods.update(ast.literal_eval(m.group(1))) + except (ValueError, SyntaxError): + pass + print(" ".join(sorted(mods))) + PY + ) + if [ -n "$addon_mods" ]; then + echo "→ addon deps: $addon_mods" + for mod in $addon_mods; do + pip install "$mod" || echo "× $mod failed to install (continuing)" + done + else + echo "no requires_mod declarations found" + fi + - name: Run per-addon unit tests # Filename convention (all OSes): # test_*.py — general (any OS) @@ -166,6 +204,36 @@ jobs: mamba list | head -30 python -c "import gramps, gi; print('deps OK')" + - name: Install addon runtime deps (derived from requires_mod) + # See unit-test-linux for rationale. Uses `python` (conda-forge + # env) to match the surrounding Windows job style. + run: | + addon_mods=$(python - <<'PY' + import ast, glob, re + pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") + mods = set() + for f in glob.glob("*/*.gpr.py"): + try: + text = open(f, encoding="utf-8").read() + except OSError: + continue + for m in pat.finditer(text): + try: + mods.update(ast.literal_eval(m.group(1))) + except (ValueError, SyntaxError): + pass + print(" ".join(sorted(mods))) + PY + ) + if [ -n "$addon_mods" ]; then + echo "→ addon deps: $addon_mods" + for mod in $addon_mods; do + pip install "$mod" || echo "× $mod failed to install (continuing)" + done + else + echo "no requires_mod declarations found" + fi + - name: Run per-addon unit tests # See filename-convention note in unit-test-linux. The Windows # job runs test_*.py except test_linux_* and test_integration_*. @@ -206,6 +274,38 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install addon runtime deps (derived from requires_mod) + # See unit-test-linux for rationale. The plugin registration test + # subprocess-loads each addon's module, which imports its + # requires_mod packages. + shell: bash + run: | + addon_mods=$(python3 - <<'PY' + import ast, glob, re + pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") + mods = set() + for f in glob.glob("*/*.gpr.py"): + try: + text = open(f, encoding="utf-8").read() + except OSError: + continue + for m in pat.finditer(text): + try: + mods.update(ast.literal_eval(m.group(1))) + except (ValueError, SyntaxError): + pass + print(" ".join(sorted(mods))) + PY + ) + if [ -n "$addon_mods" ]; then + echo "→ addon deps: $addon_mods" + for mod in $addon_mods; do + pip install "$mod" || echo "× $mod failed to install (continuing)" + done + else + echo "no requires_mod declarations found" + fi + - name: Run plugin registration tests # shell: bash for consistency with the surrounding steps; the # current command uses no bashisms, but keeps this block safe From dcc99a13e8fe943008ef5bc3a79bf7f3c2d76487 Mon Sep 17 00:00:00 2001 From: "Eduard R." Date: Sun, 17 May 2026 12:42:38 +0200 Subject: [PATCH 5/6] QuiltView: import Surname for child-add path (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `QuiltView/QuiltView.py:1266` constructs a fresh `Surname()` to attach to a new `Name` when adding a child via the editor, but `Surname` is never imported. Ruff (F821) flags it: F821 Undefined name `Surname` --> QuiltView/QuiltView.py:1266:26 The file already imports `Person, Family, ChildRef, Name` from `gramps.gen.lib` — extend that import to include `Surname`. Without it, the add-child path raises `NameError` instead of opening the editor. This PR addresses only the `Surname` F821. Two other F821 sites in the same file (`displayer` on lines 1239/1241, where the call should likely be `name_displayer.display(...)` rather than a top-level `displayer` reference) are real-bug fixes and are deliberately left out of this surgical lint PR — they need a small behavioural review of their own. Verified via `ruff check --select=E9,F63,F7,F82 --no-fix --exclude='*.gpr.py' QuiltView/` (Surname F821 removed; the two unrelated `displayer` F821s remain) and `python3 -m py_compile`. Co-authored-by: Claude Opus 4.7 (1M context) --- QuiltView/QuiltView.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QuiltView/QuiltView.py b/QuiltView/QuiltView.py index 0dc33f3b9..fc6c61bea 100755 --- a/QuiltView/QuiltView.py +++ b/QuiltView/QuiltView.py @@ -56,7 +56,7 @@ # Gramps Modules # #------------------------------------------------------------------------- -from gramps.gen.lib import Person, Family, ChildRef, Name +from gramps.gen.lib import Person, Family, ChildRef, Name, Surname from gramps.gui.views.navigationview import NavigationView from gramps.gen.display.name import displayer as name_displayer from gramps.gen.errors import WindowActiveError From 3685448d6f6eac166be39a0a00a944a77e4b9901 Mon Sep 17 00:00:00 2001 From: "Eduard R." Date: Sat, 30 May 2026 13:06:14 +0200 Subject: [PATCH 6/6] ci: forward-port current pipeline (harness + timeout) to gramps60 (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring maintenance/gramps60's .github/ up to feature/ci-cd-pipeline-upstream (the 820 PR head, which is gramps60-based), same forward-port as gramps61. gramps60 was further behind — also updates the gramps-ci Dockerfile and docker-build.yml in addition to the run_addon_tests.py harness, addon_system_deps.py, gi_bootstrap, and ci.yml wiring. Branch-neutral ci.yml unchanged in intent. Forward-port only; no addon changes. --- .github/CI-MAINTAINER.md | 145 ++++++ .github/docker/gramps-ci/Dockerfile | 81 +++- .github/environment.yml | 12 +- .github/scripts/addon_system_deps.py | 209 +++++++++ .github/scripts/gi_bootstrap/sitecustomize.py | 29 ++ .github/scripts/run_addon_tests.py | 210 +++++++++ .github/workflows/ci.yml | 442 ++++++++++++++++-- .github/workflows/docker-build.yml | 45 +- 8 files changed, 1127 insertions(+), 46 deletions(-) create mode 100644 .github/CI-MAINTAINER.md create mode 100644 .github/scripts/addon_system_deps.py create mode 100644 .github/scripts/gi_bootstrap/sitecustomize.py create mode 100644 .github/scripts/run_addon_tests.py diff --git a/.github/CI-MAINTAINER.md b/.github/CI-MAINTAINER.md new file mode 100644 index 000000000..69324a203 --- /dev/null +++ b/.github/CI-MAINTAINER.md @@ -0,0 +1,145 @@ +# CI Maintainer Notes + +Operational steps that a `gramps-project/addons-source` maintainer +needs to handle once PR 820 (and the branch-neutral follow-up) lands. +The pipeline is otherwise self-driving — this document is only about +the rough edges. + +For day-to-day addon-release maintenance see [MAINTAINERS.md](../MAINTAINERS.md); +for the contributor-facing summary of what a green CI check means on +an unreleased branch see [CONTRIBUTING.md](../CONTRIBUTING.md#work-towards-a-merge). + +## Contents + +1. [One-time setup when the PR first merges](#one-time-setup-when-the-pr-first-merges) +2. [Creating a new maintenance branch](#creating-a-new-maintenance-branch) +3. [When a Gramps minor release lands on PyPI](#when-a-gramps-minor-release-lands-on-pypi) +4. [Diagnostic log markers](#diagnostic-log-markers) +5. [Optional future-proofing knobs](#optional-future-proofing-knobs) + +## One-time setup when the PR first merges + +### 1. Make the `gramps-ci` GHCR package public + +`docker-build.yml` pushes images to +`ghcr.io/gramps-project/addons-source/gramps-ci:` using the +workflow's `GITHUB_TOKEN`. GHCR creates the package as **private** the +first time. Same-repo CI keeps working (token covers own packages), +but **fork PRs cannot pull the image** because their `GITHUB_TOKEN` +has no read access to private packages in `gramps-project`. Fork-PR +container jobs would fail at "Initialize containers" with an +authentication error. + +Fix once, immediately after the first `Build Docker Images` run +finishes: + +1. Go to +2. Under "Danger Zone" → "Change visibility" → set to **Public** + +Every existing and future `gramps-ci:` tag inherits public +visibility from this single setting. + +### 2. Expect the first-push race on `maintenance/gramps60` + +The first push event after merge fires both workflows in parallel: + +- `Build Docker Images` builds and pushes `gramps-ci:gramps60` + (~5 min cold). +- `CI` runs `setup` (~2 s), then its container jobs try to pull the + image. + +Because both start on the same push, the CI container jobs race the +image push and may fail at "Initialize containers" the first time. +This race only happens once per branch: + +1. Wait for `Build Docker Images` to complete. +2. Open the failed CI run → click "Re-run failed jobs". +3. Subsequent pushes find the image already in GHCR — no race. + +This is also explained in the header comment of `ci.yml`. + +## Creating a new maintenance branch + +When a new Gramps minor series goes into development and addons need +a corresponding branch (e.g. `maintenance/gramps62` once 6.2 opens): + +``` +git branch maintenance/gramps62 maintenance/gramps61 +git push origin maintenance/gramps62 +``` + +No workflow edits required. The workflows derive everything from +`github.ref_name`. The first push fires the same race described +above — re-run the failed CI jobs once `Build Docker Images` +finishes. + +The setup job's regex (`gramps[0-9][0-9]`) requires a two-digit +suffix. When Gramps 10.0 opens this regex needs updating in two +places (`ci.yml` setup job and `docker-build.yml` params step). + +## When a Gramps minor release lands on PyPI + +The hybrid Dockerfile auto-detects PyPI availability: + +- `pip install "gramps==X.Y.*"` succeeds → image installs the tagged + PyPI release (`::notice::` log line). +- pip reports "No matching distribution found" → image falls back to a + SHA-pinned `git clone` of `gramps-project/gramps@maintenance/grampsNN` + at the SHA captured by `docker-build.yml`'s params step + (`::warning::` log line). + +When `gramps==6.1.0` is finally published to PyPI, the next image +rebuild on `maintenance/gramps61` silently switches from "git tip" to +"PyPI release" — no maintainer action needed. + +To **immediately** rebuild against the new PyPI release without +waiting for the next push: + +1. Open the `Build Docker Images` workflow in the Actions tab. +2. "Run workflow" → select `maintenance/grampsNN` → Run. + +The `::notice::` line in the build log confirms the switch. + +## Diagnostic log markers + +When investigating a CI failure, the install-step output in +`Build Docker Images` carries these annotations: + +| Annotation | Meaning | +| --- | --- | +| `::notice::installed gramps==X.Y.* from PyPI` | Released-path install. CI is testing against a tagged release. | +| `::warning::no gramps==X.Y.* on PyPI; installing from gramps-project/gramps@maintenance/grampsNN at ` | Unreleased-branch fallback. CI is testing against the upstream branch tip at ``. Visible to contributors via the CONTRIBUTING.md note. | +| `::error::no gramps==X.Y.* on PyPI and GRAMPS_FALLBACK_SHA is unset` | `git ls-remote` in `docker-build.yml`'s params step returned no SHA for `maintenance/grampsNN` on `gramps-project/gramps`. The matching upstream branch is missing, or the addons-source branch is misnamed. | +| `::error::pip install gramps failed (non-version reason)` | pip failed for a network/registry reason, not because the version is missing. Captured stderr is dumped after the line. The build does **not** fall back to git in this case — by design, so a transient PyPI hiccup cannot silently flip a released branch into "git tip" mode. | + +Other useful entry points: + +- `ci.yml` setup job log shows the derived `branch_suffix` and + `ci_image`. A failure here means the branch name doesn't match + `maintenance/gramps[0-9][0-9]`. +- `docker-build.yml` params step log shows the captured + `fallback_sha`. An empty value means upstream `gramps-project/gramps` + has no matching maintenance branch (warning issued; image build will + fail iff the fallback is needed). + +## Optional future-proofing knobs + +Not needed today; record here in case they ever come up. + +### Upstream gramps repo URL + +The Dockerfile hardcodes `https://github.com/gramps-project/gramps.git` +as the fallback source. If the project ever reorgs or renames, this +URL must change in one place (`.github/docker/gramps-ci/Dockerfile`). +It could be parameterised as a build arg (`ARG GRAMPS_UPSTREAM_REPO`) +with the current URL as default, but a hardcoded value keeps the +Dockerfile simpler and a rename is a sufficiently large event that +editing one string is not the bottleneck. + +### GHCR tag retention + +`docker-build.yml` pushes both a moving `gramps-ci:` tag (e.g. +`gramps60`) and a per-commit `gramps-ci:-` tag. +The moving tag is always overwritten; the SHA tags accumulate over +time. Set a retention policy on the GHCR package settings page if +the count becomes inconvenient — the moving tags are what CI consumes. diff --git a/.github/docker/gramps-ci/Dockerfile b/.github/docker/gramps-ci/Dockerfile index 3ef3b8945..38a4e9a28 100644 --- a/.github/docker/gramps-ci/Dockerfile +++ b/.github/docker/gramps-ci/Dockerfile @@ -1,6 +1,9 @@ # .github/docker/gramps-ci/Dockerfile # -# Unified Gramps 6.0 CI image. Includes everything jobs need: +# Gramps CI image. The Gramps minor series (6.0, 6.1, …) is picked at +# build time via the GRAMPS_SERIES build arg, so the same Dockerfile +# produces gramps-ci:gramps60, gramps-ci:gramps61, … without per-branch +# edits. Includes everything jobs need: # - Python + pip-installed Gramps, PyGObject, pycairo # - GTK typelibs (so addon modules that `from gi.repository import Gtk` # at module load time are importable — widgets still need xvfb to render) @@ -13,11 +16,44 @@ # `--init` (or use a container runtime that injects tini) because xvfb-run # hangs if it inherits PID 1. # +# Gramps install path. PyPI is tried first; when no gramps==${SERIES}.* +# release exists (e.g. while 6.1 is still in development) the build +# falls back to a SHA-pinned git clone of +# gramps-project/gramps@maintenance/gramps${SERIES_NODOT}. Other pip +# failures (network, etc.) are NOT silently retried so a transient +# PyPI outage cannot flip a normally-released branch to "test against +# moving tip" mode. docker-build.yml captures the upstream SHA via +# git ls-remote and passes it in as GRAMPS_FALLBACK_SHA; the SHA +# becomes part of the buildx cache key so a moved upstream tip +# actually re-runs the install layer. +# +# Local build (released series): +# docker build --build-arg GRAMPS_SERIES=6.0 .github/docker/gramps-ci +# +# Local build (unreleased series, e.g. 6.1): +# sha=$(git ls-remote https://github.com/gramps-project/gramps.git \ +# refs/heads/maintenance/gramps61 | awk '{print $1}') +# docker build --build-arg GRAMPS_SERIES=6.1 \ +# --build-arg GRAMPS_FALLBACK_SHA=$sha \ +# .github/docker/gramps-ci +# ARG PYTHON_VERSION=3.12 FROM python:${PYTHON_VERSION}-slim +# Gramps minor series to install (e.g. 6.0, 6.1). No default — must be +# passed explicitly so a wrong default cannot silently produce an image +# for the wrong Gramps series. docker-build.yml derives this from the +# branch ref. +ARG GRAMPS_SERIES +# Commit SHA on gramps-project/gramps@maintenance/grampsNN. Only used +# when no gramps==${GRAMPS_SERIES}.* release exists on PyPI. Ignored +# otherwise. Empty default is intentional — on released branches the +# fallback never fires. +ARG GRAMPS_FALLBACK_SHA="" +RUN [ -n "$GRAMPS_SERIES" ] || { echo "GRAMPS_SERIES is required (e.g. 6.0)"; exit 1; } + LABEL org.opencontainers.image.source="https://github.com/gramps-project/addons-source" -LABEL org.opencontainers.image.description="Unified Gramps 6.0 CI image (Python, Gramps, GTK typelibs, xvfb)" +LABEL org.opencontainers.image.description="Gramps ${GRAMPS_SERIES} CI image (Python, Gramps, GTK typelibs, xvfb)" RUN apt-get update && apt-get install -y --no-install-recommends \ libgirepository-2.0-dev \ @@ -26,6 +62,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ gir1.2-pango-1.0 \ gir1.2-gdkpixbuf-2.0 \ gir1.2-atk-1.0 \ + gir1.2-gexiv2-0.10 \ gcc \ pkg-config \ python3-dev \ @@ -37,13 +74,39 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ xauth \ && rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir \ - PyGObject \ - pycairo \ - "gramps>=6.0,<6.1" \ - orjson \ - ruff \ - dbf +# Addon runtime deps (dbf, networkx, lxml, svgwrite, boto3, etc.) are +# NOT baked in here — ci.yml's "Install addon runtime deps (derived from +# requires_mod)" step pip-installs them at CI runtime from every +# .gpr.py's requires_mod list, matching what Gramps' Addon Manager does +# for an end user. Keeps .gpr.py the single source of truth. +RUN pip install --no-cache-dir PyGObject pycairo orjson ruff + +# Install gramps: PyPI first, SHA-pinned git clone as fallback. +RUN /bin/bash -eo pipefail <<'BASH' +suffix_nodot="${GRAMPS_SERIES//./}" +if pip install --no-cache-dir "gramps==${GRAMPS_SERIES}.*" 2>/tmp/pip.err; then + echo "::notice::installed gramps==${GRAMPS_SERIES}.* from PyPI" +elif grep -q "No matching distribution found for gramps==" /tmp/pip.err; then + if [ -z "${GRAMPS_FALLBACK_SHA}" ]; then + echo "::error::no gramps==${GRAMPS_SERIES}.* on PyPI and GRAMPS_FALLBACK_SHA is unset" + exit 1 + fi + echo "::warning::no gramps==${GRAMPS_SERIES}.* on PyPI; installing from gramps-project/gramps@maintenance/gramps${suffix_nodot} at ${GRAMPS_FALLBACK_SHA}" + mkdir -p /tmp/gramps + cd /tmp/gramps + git init -q + git remote add origin https://github.com/gramps-project/gramps.git + git fetch --depth 1 origin "${GRAMPS_FALLBACK_SHA}" + git checkout FETCH_HEAD + pip install --no-cache-dir . + cd / + rm -rf /tmp/gramps +else + echo "::error::pip install gramps failed (non-version reason); aborting rather than silently switching to git tip" + cat /tmp/pip.err + exit 1 +fi +BASH RUN apt-get purge -y gcc python3-dev pkg-config && apt-get autoremove -y diff --git a/.github/environment.yml b/.github/environment.yml index 9ca19f57f..f1c7c9ae7 100644 --- a/.github/environment.yml +++ b/.github/environment.yml @@ -6,7 +6,17 @@ dependencies: - pygobject - gtk3 - pip + # Addon runtime deps (dbf, networkx, lxml, svgwrite, boto3, etc.) are + # 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 - - dbf diff --git a/.github/scripts/addon_system_deps.py b/.github/scripts/addon_system_deps.py new file mode 100644 index 000000000..6e3cef514 --- /dev/null +++ b/.github/scripts/addon_system_deps.py @@ -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()) diff --git a/.github/scripts/gi_bootstrap/sitecustomize.py b/.github/scripts/gi_bootstrap/sitecustomize.py new file mode 100644 index 000000000..9f5cb090d --- /dev/null +++ b/.github/scripts/gi_bootstrap/sitecustomize.py @@ -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 diff --git a/.github/scripts/run_addon_tests.py b/.github/scripts/run_addon_tests.py new file mode 100644 index 000000000..6491aa0e5 --- /dev/null +++ b/.github/scripts/run_addon_tests.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Run per-addon unit tests with a GI bootstrap, a timeout, and honest skips. + +Replaces a bare ``python -m unittest `` in CI. It does three things +plain unittest does not: + +1. **GI version bootstrap.** Before any test imports a ``gramps.gui`` module, it + calls ``gi.require_version`` for Pango/PangoCairo/Gtk — the set the Gramps + GUI launcher (``gramps/gui/grampsgui.py``) pins at startup. A direct test + import never runs that launcher, so without this the first + ``from gi.repository import Gtk`` (in gramps core) warns and risks the wrong + GTK on a host where GTK 4 is the default. + +2. **A per-module timeout.** Each module runs in its own subprocess with a wall + clock. A test that hangs (e.g. a DB import that blocks on a platform) would + otherwise hang the whole CI job indefinitely — neither plain unittest nor + xmlrunner has a timeout. A module that exceeds the limit is killed and + reported as a FAILURE, so the job stays bounded and names the culprit. + +3. **Honest skip accounting.** unittest exits 0 when every test SKIPS, so a + wholly-skipped module reads as a pass. This runner FAILS such a module — + UNLESS the addon's declared system deps are unavailable on this platform + (e.g. goocanvas/osm-gps-map are not on conda-forge), in which case the skip + is expected and tolerated (the map lives in ``addon_system_deps.py``). + +Usage:: + + run_addon_tests.py --platform apt Addon.tests.test_x Other.tests.test_y + run_addon_tests.py --platform conda Addon.tests.test_x + +Exit code is non-zero if any module is a hard failure (test failure/error, +timeout, or an unexpected all-skip on a platform where the addon's deps are +available). +""" + +# ------------------------ +# Python modules +# ------------------------ +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import addon_system_deps as deps # noqa: E402 + +# Per-module wall clock. Generous enough for a legitimate DB-backed suite, small +# enough that a hung test is caught promptly instead of running to the job cap. +# Overridable via env for tuning/testing. +MODULE_TIMEOUT_S = int(os.environ.get("RUN_ADDON_TESTS_TIMEOUT", "300")) + +# Markers the worker prints so the parent can read the outcome without needing +# xmlrunner (820's env has only stdlib unittest). +_OK = "__RESULT__ ok" +_LOADERROR = "__RESULT__ loaderror" + + +def _bootstrap_gi() -> None: + """Pin the GI versions the Gramps GUI launcher pins, before tests import.""" + try: + import gi + except ImportError: + return + for namespace, version in (("Pango", "1.0"), ("PangoCairo", "1.0"), ("Gtk", "3.0")): + try: + gi.require_version(namespace, version) + except (ValueError, AttributeError): + pass + + +# ------------------------------------------------------------ +# +# worker: runs ONE module in this (sub)process +# +# ------------------------------------------------------------ +def _run_worker(modname: str) -> int: + """Run a single module; print a machine-readable result line; exit 0. + + The parent classifies pass/fail from the printed counts and its own platform + knowledge, so the worker always exits 0 (a non-zero exit would be + indistinguishable from an interpreter crash). + """ + _bootstrap_gi() + try: + suite = unittest.defaultTestLoader.loadTestsFromName(modname) + except Exception as exc: # import-time failure + print(f"{_LOADERROR} {exc!r}", flush=True) + return 0 + result = unittest.TextTestRunner(verbosity=2).run(suite) + broke = len(result.failures) + len(result.errors) + print( + f"{_OK} tests={result.testsRun} skipped={len(result.skipped)} broke={broke}", + flush=True, + ) + return 0 + + +# ------------------------------------------------------------ +# +# parent: spawns a timed worker per module and classifies the outcome +# +# ------------------------------------------------------------ +def _classify(modname: str, platform: str, root: str) -> tuple[bool, str]: + """Run one module in a timed subprocess. Return (is_hard_failure, summary).""" + addon = modname.split(".", 1)[0] + satisfiable = deps.addon_satisfiable_on(os.path.join(root, addon), platform) + + proc = subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "--worker", modname], + stdout=subprocess.PIPE, + stderr=None, # stream the test output straight to the CI log + text=True, + ) + try: + # communicate() enforces the wall clock and reaps the process. + stdout, _ = proc.communicate(timeout=MODULE_TIMEOUT_S) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + return True, f" FAIL {modname} — timed out after {MODULE_TIMEOUT_S}s (hung)" + + out_lines = stdout.splitlines() + for line in out_lines: # echo the worker's result marker into the log + print(line) + + result_line = next( + (ln for ln in reversed(out_lines) if ln.startswith("__RESULT__")), "" + ) + + if result_line.startswith(_LOADERROR): + if satisfiable: + return True, f" FAIL {modname} — load error" + return False, ( + f" skip {modname} — not loadable on {platform} " + f"(addon system deps unavailable here)" + ) + + if not result_line.startswith(_OK): + return True, f" FAIL {modname} — no result (worker crashed)" + + fields = dict(tok.split("=", 1) for tok in result_line.split()[2:] if "=" in tok) + ran = int(fields.get("tests", 0)) + skipped = int(fields.get("skipped", 0)) + broke = int(fields.get("broke", 0)) + + if broke: + return True, f" FAIL {modname} — {broke} failed/errored" + if ran > 0 and skipped == ran: + if satisfiable: + return True, ( + f" FAIL {modname} — all {ran} tests skipped " + f"(degraded coverage; deps ARE available on {platform})" + ) + return False, ( + f" skip {modname} — all {ran} skipped, expected " + f"(addon system deps unavailable on {platform})" + ) + if skipped: + return False, f" ok {modname} — {ran} tests, {skipped} skipped" + return False, f" ok {modname} — {ran} tests" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", choices=deps.PLATFORMS) + parser.add_argument( + "--root", + default=".", + help="addons-source root holding the / dirs (default: cwd)", + ) + parser.add_argument( + "--worker", + metavar="MODULE", + help="internal: run this single module and print its result line", + ) + parser.add_argument("modules", nargs="*", help="dotted test modules to run") + args = parser.parse_args(argv) + + if args.worker: + return _run_worker(args.worker) + + if not args.platform: + parser.error("--platform is required in parent mode") + if not args.modules: + print("No per-addon unit test modules found") + return 0 + + hard_failures: list[str] = [] + summary: list[str] = [] + for modname in args.modules: + failed, line = _classify(modname, args.platform, args.root) + summary.append(line) + if failed: + hard_failures.append(modname) + + print("\n=== addon test summary ===") + for line in summary: + print(line) + + if hard_failures: + print(f"\n{len(hard_failures)} module(s) failed: {hard_failures}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a79cda8b..d606be7b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,35 +1,101 @@ name: CI +# Branch-neutral workflow: the image tag, make.py argument, and Gramps +# series pin are all derived from the branch ref at runtime, so the same +# file runs unchanged on maintenance/gramps60, maintenance/gramps61, and +# any future maintenance/grampsNN branch. +# +# NOTE for maintainers — first push to a new maintenance branch: the +# corresponding gramps-ci: image does not exist in GHCR yet, so +# the container jobs in this workflow will fail at "Initialize containers" +# on the very first run. The companion docker-build.yml workflow fires +# on the same push and builds/pushes the image (~5 min cold). After it +# finishes, re-run the failed CI jobs (Actions tab → this run → "Re-run +# failed jobs") and they will pull the now-existing image. This race +# happens only on initial branch creation — every subsequent push to +# that branch finds the image already in GHCR. +# +# Full operational runbook (GHCR visibility, PyPI-release transitions, +# diagnostic log markers, etc.): .github/CI-MAINTAINER.md + on: push: - branches: [maintenance/gramps60] + branches: [maintenance/gramps**] pull_request: - branches: [maintenance/gramps60] - -env: - CI_IMAGE: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + branches: [maintenance/gramps**] jobs: + # ----------------------------------------------------------------- + # Setup — derive the branch suffix (gramps60 / gramps61 / …) from + # the ref. On push events github.ref_name is the branch being + # pushed; on pull_request events github.base_ref is the target + # branch. Either way the suffix is what follows "maintenance/". + # ----------------------------------------------------------------- + setup: + name: Setup + runs-on: ubuntu-latest + outputs: + branch_suffix: ${{ steps.compute.outputs.branch_suffix }} + ci_image: ${{ steps.compute.outputs.ci_image }} + steps: + - id: compute + shell: bash + run: | + ref="${{ github.base_ref || github.ref_name }}" + suffix="${ref#maintenance/}" + case "$suffix" in + gramps[0-9][0-9]) ;; + *) echo "::error::unexpected ref '$ref' (suffix '$suffix')"; exit 1 ;; + esac + echo "branch_suffix=$suffix" >> "$GITHUB_OUTPUT" + echo "ci_image=ghcr.io/${{ github.repository }}/gramps-ci:$suffix" >> "$GITHUB_OUTPUT" + # ----------------------------------------------------------------- # Lint (ci container) # ----------------------------------------------------------------- lint: name: Lint + needs: setup runs-on: ubuntu-latest - # Non-blocking until the existing ruff E9/F63/F7/F82 errors across the - # addon set are cleaned up in a follow-up PR. Flip this off in that PR. - continue-on-error: true container: - image: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + image: ${{ needs.setup.outputs.ci_image }} steps: - uses: actions/checkout@v4 - name: Run ruff (syntax and import errors only) - run: ruff check --select=E9,F63,F7,F82 --no-fix --exclude='*.gpr.py' . + # Skip addon directories whose every register() in .gpr.py sets + # include_in_listing=False — those addons are not built or released + # by make.py, so CI does not gate on their lint state (per Gary + # Griffin's request on PR #820). To re-enable lint gating for an + # addon, set include_in_listing=True on at least one register() + # call in its descriptor (or remove the field — Gramps' default + # is True). Repeated inline rather than centralised so each job + # step stays self-contained. + shell: bash + run: | + is_active() { + local addon="$1" g + for g in "$addon"/*.gpr.py; do + [ -f "$g" ] || continue + grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 + grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 + done + return 1 + } + excludes="" + for d in */; do + d="${d%/}" + ls "$d"/*.gpr.py >/dev/null 2>&1 || continue + is_active "$d" || excludes="$excludes --exclude=$d" + done + ruff check --select=E9,F63,F7,F82 --no-fix --exclude='*.gpr.py' $excludes . - name: Check trailing whitespace in Python files run: | - if git --no-pager grep --color -n --full-name '[ \t]$' -- '*.py'; then + # Use PCRE (-P): in BRE/ERE the bracket expression [ \t] is the + # set { space, backslash, 't' } — git grep matches anything ending + # in 't', not just whitespace. -P makes \t a tab. + if git --no-pager grep --color -n --full-name -P '[ \t]+$' -- '*.py'; then echo "::error::Trailing whitespace found in Python files" exit 1 fi @@ -46,11 +112,23 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Check all addons have po/template.pot + - name: Check all listed addons have po/template.pot + # Skip include_in_listing=False addons (see lint job for rationale). + shell: bash run: | + is_active() { + local addon="$1" g + for g in "$addon"/*.gpr.py; do + [ -f "$g" ] || continue + grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 + grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 + done + return 1 + } failed=0 for gpr in */*.gpr.py; do addon_dir="$(dirname "$gpr")" + is_active "$addon_dir" || continue if [ ! -d "$addon_dir/po" ]; then echo "::error::$addon_dir is missing po/ directory" failed=1 @@ -60,7 +138,7 @@ jobs: fi done if [ "$failed" -eq 0 ]; then - echo "All addons have po/template.pot" + echo "All listed addons have po/template.pot" fi exit $failed @@ -69,17 +147,39 @@ jobs: # ----------------------------------------------------------------- compile-check: name: Compile Check + needs: setup runs-on: ubuntu-latest container: - image: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + image: ${{ needs.setup.outputs.ci_image }} steps: - uses: actions/checkout@v4 - - name: Compile all Python files (excluding .gpr.py) + - name: Compile all Python files in listed addons (excluding .gpr.py) + # Skip include_in_listing=False addons (see lint job for rationale). shell: bash run: | + is_active() { + local addon="$1" g + for g in "$addon"/*.gpr.py; do + [ -f "$g" ] || continue + grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 + grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 + done + return 1 + } + skipped="" + for d in */; do + d="${d%/}" + ls "$d"/*.gpr.py >/dev/null 2>&1 || continue + is_active "$d" || skipped="$skipped $d" + done failed=0 while IFS= read -r f; do + skip=0 + for s in $skipped; do + case "$f" in ./$s/*) skip=1; break;; esac + done + [ "$skip" = 1 ] && continue if ! python3 -m py_compile "$f" 2>&1; then failed=1 fi @@ -91,15 +191,42 @@ jobs: # ----------------------------------------------------------------- unit-test-linux: name: Unit Tests (Linux) + needs: setup runs-on: ubuntu-latest - # Non-blocking until the currently-broken addon unit modules (import - # failures, stale API usage) are sorted out in follow-up PRs. - continue-on-error: true container: - image: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + image: ${{ needs.setup.outputs.ci_image }} steps: - uses: actions/checkout@v4 + - name: Install addon system deps (derived from requires_gi / requires_exe) + # System deps (GI typelibs, executables) are not pip-installable and + # gramps only *checks* them, so the image cannot bake them generically + # (its build context excludes addons-source). Derive the apt set from + # every .gpr.py via the single-source map and install it — the container + # runs as root. Mirrors the requires_mod derivation below. + shell: bash + run: | + pkgs=$(python3 .github/scripts/addon_system_deps.py --platform apt) + if [ -n "$pkgs" ]; then + echo "→ addon system deps (apt): $pkgs" + apt-get update + apt-get install -y --no-install-recommends $pkgs + else + echo "no requires_gi / requires_exe declarations found" + fi + + - name: Validate addon system deps are mapped + # Every requires_gi / requires_exe an addon declares must have an entry + # in addon_system_deps.py, so the install list never silently drifts + # from what addons declare (the system-dep analogue of the requires_mod + # find_spec gate below). + shell: bash + run: | + python3 .github/scripts/addon_system_deps.py --unmapped . || { + echo "::error::Addon(s) declare requires_gi/requires_exe with no entry in .github/scripts/addon_system_deps.py — add a mapping row." + exit 1 + } + - name: Install addon runtime deps (derived from requires_mod) # Auto-derive the union of requires_mod across every .gpr.py in # the repo. Mirrors Gramps' Addon Manager install path @@ -138,6 +265,58 @@ jobs: echo "no requires_mod declarations found" fi + - name: Validate requires_mod names against Gramps' dep gate + # Cross-check: every requires_mod entry that pip successfully + # installed in the previous step must also resolve via + # find_spec(), since that is what Gramps' Addon Manager calls + # (gramps/gen/utils/requirements.py:check_mod). A name that + # pip-installs but does not import is a declaration bug — e.g. + # requires_mod=["Pillow"] when the importable name is "PIL". + # Pip-install failures upstream are skipped: those are + # system-dep / image gaps, not PR-caused. + shell: bash + run: | + python3 - <<'PY' + import ast, glob, re, subprocess, sys + from importlib.util import find_spec + + pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") + names = set() + for f in glob.glob("*/*.gpr.py"): + try: + text = open(f, encoding="utf-8").read() + except OSError: + continue + for m in pat.finditer(text): + try: + names.update(ast.literal_eval(m.group(1))) + except (ValueError, SyntaxError): + pass + + bad = [] + for name in sorted(names): + installed = subprocess.run( + [sys.executable, "-m", "pip", "show", name], + capture_output=True, + ).returncode == 0 + if not installed: + print(f"~ {name} (pip-install failed earlier, skipping)") + continue + if find_spec(name) is None: + bad.append(name) + print(f"x {name} (pip-installed but find_spec returned None)") + else: + print(f"ok {name}") + + if bad: + print() + print(f"::error::Wrong requires_mod names: {bad}") + print("These pip-install but are not importable. requires_mod is") + print("consumed by gramps' check_mod() via find_spec(), so the") + print("importable module name is required (e.g. 'PIL', not 'Pillow').") + sys.exit(1) + PY + - name: Run per-addon unit tests # Filename convention (all OSes): # test_*.py — general (any OS) @@ -150,14 +329,25 @@ jobs: # shell: bash — the container's default shell is /bin/sh # (dash on python:3.12-slim), which does not support the # ${var//pattern/repl} and ${var%.py} parameter expansions - # used below. Falls silently under continue-on-error. + # used below. shell: bash env: PYTHONPATH: . run: | + is_active() { + local addon="$1" g + for g in "$addon"/*.gpr.py; do + [ -f "$g" ] || continue + grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 + grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 + done + return 1 + } modules="" for f in */tests/test_*.py; do [ -f "$f" ] || continue + addon="${f%%/*}" + is_active "$addon" || continue case "$(basename "$f")" in test_integration*) continue ;; test_windows_*) continue ;; @@ -171,7 +361,14 @@ jobs: done if [ -n "$modules" ]; then echo "Running unit tests:$modules" - python3 -m unittest -v $modules + # xvfb-run: some addons create a Gtk style context at import and + # need a display (else a hard Gtk-ERROR abort, not a clean skip). + # run_addon_tests.py: pins the GI versions like gramps' launcher + # (so gramps.gui imports load GTK 3, no PyGIWarning) and fails a + # wholly-skipped module unless the addon's deps are unavailable on + # this platform. + xvfb-run -a --server-args="-screen 0 1920x1080x24" \ + python3 .github/scripts/run_addon_tests.py --platform apt --root . $modules else echo "No per-addon unit test modules found" fi @@ -181,9 +378,8 @@ jobs: # ----------------------------------------------------------------- unit-test-windows: name: Unit Tests (Windows) + needs: setup runs-on: windows-latest - # Non-blocking for the same reason as unit-test-linux. - continue-on-error: true defaults: run: shell: bash -el {0} @@ -204,6 +400,47 @@ jobs: mamba list | head -30 python -c "import gramps, gi; print('deps OK')" + - name: Report gramps-vs-branch series (Windows lane caveat) + # The Linux lane git-builds the branch's exact gramps in its CI image + # (.github/docker/gramps-ci/Dockerfile, PyPI-first/git-fallback). The + # conda-forge Windows lane CANNOT match that: conda-forge has no gramps + # 6.1 yet, and gramps' own Windows build targets MSYS2 UCRT64, not conda + # — building 6.1 from git here fails in gramps' build hook (build_intl's + # `msgfmt --xml` cannot locate the shared-mime-info/appstream ITS rules, + # which are absent in the conda env). So on a maintenance/gramps61 (or + # later) branch this lane validates addons against conda-forge's newest + # in-range gramps (6.0.x today) rather than the branch's series. This + # step surfaces that honestly; it does NOT fail. Addon tests that depend + # on series-exact gramps behaviour skip themselves on Windows (e.g. + # TMGimporter's real-DB import tests) and run on the Linux lane instead. + # When gramps 6.1 reaches conda-forge, environment.yml's pin picks it up + # and this caveat disappears on its own. + run: | + suffix="${{ needs.setup.outputs.branch_suffix }}" # e.g. gramps61 + digits="${suffix#gramps}" # e.g. 61 + want="${digits:0:1}.${digits:1}" # e.g. 6.1 + have="$(python -c 'from gramps.version import major_version; print(major_version)')" + if [ "$want" = "$have" ]; then + echo "conda-forge gramps $have matches branch series $want — addons tested against the branch's gramps" + else + echo "::warning::Windows lane: branch targets gramps $want but conda-forge ships $have; addons here are validated against $have. Full $want coverage is on the Linux lane (its CI image git-builds $want). See step comment for why conda-Windows cannot build $want." + fi + + - name: Install addon system deps (derived, conda-available subset) + # Only the conda-forge-available subset (e.g. graphviz). The GTK 3 addon + # GI libs (goocanvas/osm-gps-map/gexiv2) are NOT on conda-forge, so the + # map returns None for them on conda and they are not installed; addons + # needing them skip on Windows by necessity, which run_addon_tests + # tolerates (--platform conda). + run: | + pkgs=$(python .github/scripts/addon_system_deps.py --platform conda) + if [ -n "$pkgs" ]; then + echo "→ addon system deps (conda): $pkgs" + mamba install -y -c conda-forge $pkgs + else + echo "no conda-available addon system deps to install" + fi + - name: Install addon runtime deps (derived from requires_mod) # See unit-test-linux for rationale. Uses `python` (conda-forge # env) to match the surrounding Windows job style. @@ -234,15 +471,71 @@ jobs: echo "no requires_mod declarations found" fi + - name: Validate requires_mod names against Gramps' dep gate + # See unit-test-linux for rationale. Uses `python` (conda-forge + # env) to match the surrounding Windows job style. + run: | + python - <<'PY' + import ast, glob, re, subprocess, sys + from importlib.util import find_spec + + pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") + names = set() + for f in glob.glob("*/*.gpr.py"): + try: + text = open(f, encoding="utf-8").read() + except OSError: + continue + for m in pat.finditer(text): + try: + names.update(ast.literal_eval(m.group(1))) + except (ValueError, SyntaxError): + pass + + bad = [] + for name in sorted(names): + installed = subprocess.run( + [sys.executable, "-m", "pip", "show", name], + capture_output=True, + ).returncode == 0 + if not installed: + print(f"~ {name} (pip-install failed earlier, skipping)") + continue + if find_spec(name) is None: + bad.append(name) + print(f"x {name} (pip-installed but find_spec returned None)") + else: + print(f"ok {name}") + + if bad: + print() + print(f"::error::Wrong requires_mod names: {bad}") + print("These pip-install but are not importable. requires_mod is") + print("consumed by gramps' check_mod() via find_spec(), so the") + print("importable module name is required (e.g. 'PIL', not 'Pillow').") + sys.exit(1) + PY + - name: Run per-addon unit tests # See filename-convention note in unit-test-linux. The Windows # job runs test_*.py except test_linux_* and test_integration_*. env: PYTHONPATH: . run: | + is_active() { + local addon="$1" g + for g in "$addon"/*.gpr.py; do + [ -f "$g" ] || continue + grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 + grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 + done + return 1 + } modules="" for f in */tests/test_*.py; do [ -f "$f" ] || continue + addon="${f%%/*}" + is_active "$addon" || continue case "$(basename "$f")" in test_integration*) continue ;; test_linux_*) continue ;; @@ -256,7 +549,10 @@ jobs: done if [ -n "$modules" ]; then echo "Running unit tests:$modules" - python -m unittest -v $modules + # No xvfb on Windows (GTK renders natively). run_addon_tests pins + # the GI versions and tolerates addons whose GI deps are not on + # conda-forge (they skip here by platform necessity). + python .github/scripts/run_addon_tests.py --platform conda --root . $modules else echo "No per-addon unit test modules found" fi @@ -267,13 +563,28 @@ jobs: integration-test: name: Integration Tests (Gramps) runs-on: ubuntu-latest - needs: [unit-test-linux] + needs: [setup, unit-test-linux] container: - image: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + image: ${{ needs.setup.outputs.ci_image }} options: --init steps: - uses: actions/checkout@v4 + - name: Install addon system deps (derived from requires_gi / requires_exe) + # Same as unit-test-linux: derive the apt set from the single-source + # map and install it (container runs as root). The plugin registration + # test subprocess-loads each addon module, so the GI typelibs those + # modules import must be present here too. (Mapping is drift-guarded in + # unit-test-linux, which this job needs:, so no duplicate gate here.) + shell: bash + run: | + pkgs=$(python3 .github/scripts/addon_system_deps.py --platform apt) + if [ -n "$pkgs" ]; then + echo "→ addon system deps (apt): $pkgs" + apt-get update + apt-get install -y --no-install-recommends $pkgs + fi + - name: Install addon runtime deps (derived from requires_mod) # See unit-test-linux for rationale. The plugin registration test # subprocess-loads each addon's module, which imports its @@ -306,14 +617,70 @@ jobs: echo "no requires_mod declarations found" fi + - name: Validate requires_mod names against Gramps' dep gate + # See unit-test-linux for rationale. + shell: bash + run: | + python3 - <<'PY' + import ast, glob, re, subprocess, sys + from importlib.util import find_spec + + pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") + names = set() + for f in glob.glob("*/*.gpr.py"): + try: + text = open(f, encoding="utf-8").read() + except OSError: + continue + for m in pat.finditer(text): + try: + names.update(ast.literal_eval(m.group(1))) + except (ValueError, SyntaxError): + pass + + bad = [] + for name in sorted(names): + installed = subprocess.run( + [sys.executable, "-m", "pip", "show", name], + capture_output=True, + ).returncode == 0 + if not installed: + print(f"~ {name} (pip-install failed earlier, skipping)") + continue + if find_spec(name) is None: + bad.append(name) + print(f"x {name} (pip-installed but find_spec returned None)") + else: + print(f"ok {name}") + + if bad: + print() + print(f"::error::Wrong requires_mod names: {bad}") + print("These pip-install but are not importable. requires_mod is") + print("consumed by gramps' check_mod() via find_spec(), so the") + print("importable module name is required (e.g. 'PIL', not 'Pillow').") + sys.exit(1) + PY + - name: Run plugin registration tests # shell: bash for consistency with the surrounding steps; the # current command uses no bashisms, but keeps this block safe # against future edits. Container default is /bin/sh → dash. + # + # gi_bootstrap on PYTHONPATH pins the GI versions (like the gramps GUI + # launcher) for this process AND the addon-module subprocesses this test + # spawns, so gramps.gui imports load GTK 3 without a PyGIWarning. + # + # NOT run under xvfb: this test only *loads* (imports) addon modules in + # subprocesses and tolerates load failures; it does not render. Giving it + # a display made an addon load hang on the (absent) AT-SPI accessibility + # bus until the per-load timeout. Imports that build a Gtk style context + # are exercised under xvfb in the unit/integration test runs instead. shell: bash env: - PYTHONPATH: . - run: python3 -m unittest discover -s tests -p "test_*.py" -t . -v + PYTHONPATH: .github/scripts/gi_bootstrap:. + run: | + python3 -m unittest discover -s tests -p "test_*.py" -t . -v - name: Run per-addon integration tests # shell: bash — see unit-test-linux for rationale; the @@ -323,16 +690,28 @@ jobs: env: PYTHONPATH: . run: | + is_active() { + local addon="$1" g + for g in "$addon"/*.gpr.py; do + [ -f "$g" ] || continue + grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 + grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 + done + return 1 + } modules="" for f in */tests/test_integration*.py; do [ -f "$f" ] || continue + addon="${f%%/*}" + is_active "$addon" || continue mod="${f%.py}" mod="${mod//\//.}" modules="$modules $mod" done if [ -n "$modules" ]; then echo "Running per-addon integration tests:$modules" - python3 -m unittest -v $modules + xvfb-run -a --server-args="-screen 0 1920x1080x24" \ + python3 .github/scripts/run_addon_tests.py --platform apt --root . $modules else echo "No per-addon integration test modules found" fi @@ -342,9 +721,10 @@ jobs: # ----------------------------------------------------------------- build: name: Build + needs: setup runs-on: ubuntu-latest container: - image: ghcr.io/${{ github.repository }}/gramps-ci:gramps60 + image: ${{ needs.setup.outputs.ci_image }} steps: - uses: actions/checkout@v4 @@ -359,4 +739,4 @@ jobs: GRAMPSPATH: ${{ steps.gramps-path.outputs.path }} run: | mkdir -p ../download - python3 make.py gramps60 build all + python3 make.py "${{ needs.setup.outputs.branch_suffix }}" build all diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 9289949b2..6ebd19ed5 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -2,9 +2,11 @@ name: Build Docker Images on: push: - branches: [maintenance/gramps60] - paths: - - '.github/docker/**' + # No paths filter on purpose. Buildx layer cache makes the rebuild + # a ~20-30 s no-op when nothing under .github/docker/ has changed, + # in exchange for guaranteeing gramps-ci: exists on the + # first push to any newly-created maintenance branch. + branches: [maintenance/gramps**] workflow_dispatch: env: @@ -32,14 +34,44 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Compute branch parameters + # Derive the image-tag suffix, Gramps minor series, and upstream + # fallback SHA from the branch ref. Same validation as ci.yml's + # setup job: anything outside maintenance/grampsNN fails fast. + # The fallback SHA is the current tip of gramps-project/gramps + # at the matching maintenance branch; the Dockerfile only uses + # it when no gramps==${series}.* release exists on PyPI. The + # SHA is part of the buildx cache key so a moved upstream tip + # actually re-runs the install layer (otherwise gramps61 CI + # would stay on the same stale gramps revision build after + # build). + id: params + shell: bash + run: | + ref="${{ github.ref_name }}" + suffix="${ref#maintenance/}" + case "$suffix" in + gramps[0-9][0-9]) ;; + *) echo "::error::unexpected ref '$ref' (suffix '$suffix')"; exit 1 ;; + esac + # gramps60 → 6.0, gramps61 → 6.1, gramps62 → 6.2, … + series="${suffix:6:1}.${suffix:7}" + fallback_sha=$(git ls-remote https://github.com/gramps-project/gramps.git "refs/heads/maintenance/${suffix}" | awk '{print $1}') + if [ -z "$fallback_sha" ]; then + echo "::warning::upstream gramps-project/gramps has no maintenance/${suffix} branch; fallback path will fail if PyPI lacks gramps==${series}.*" + fi + echo "suffix=$suffix" >> "$GITHUB_OUTPUT" + echo "series=$series" >> "$GITHUB_OUTPUT" + echo "fallback_sha=$fallback_sha" >> "$GITHUB_OUTPUT" + - name: Docker metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.REPO }}/gramps-ci tags: | - type=raw,value=gramps60 - type=sha,prefix=gramps60- + type=raw,value=${{ steps.params.outputs.suffix }} + type=sha,prefix=${{ steps.params.outputs.suffix }}- - name: Build and push gramps-ci uses: docker/build-push-action@v6 @@ -48,5 +80,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + GRAMPS_SERIES=${{ steps.params.outputs.series }} + GRAMPS_FALLBACK_SHA=${{ steps.params.outputs.fallback_sha }} cache-from: type=gha cache-to: type=gha,mode=max