Skip to content

Commit 7abb603

Browse files
eduralphhgohelGaryGriffinclaude
authored
Sync fork's maintenance/gramps60 with upstream (2026-05-08) (#13)
* 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 <noreply@anthropic.com> * 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) gramps-project#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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Merge CalculateEstimatedDates: handle ancestry-loop DatabaseError (bug 0007898)gramps-project#825 * ImportMerge: fix AttributeError when adding/merging Tag objects Tag is a table object without a gramps_id field, so the generic has_<obj_type>_gramps_id / find_next_<obj_type>_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 <noreply@anthropic.com> * 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) gramps-project#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 <form> 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 <noreply@anthropic.com> * Form: detect empty definition files and warn on column-size mismatches (bug 0011010) A form definition file whose <forms> root contained no <form> elements used to load silently — the loader iterated zero <form> 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 <column> <size> 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>, 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: gramps-project#821 and (bug 11010)gramps-project#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) <noreply@anthropic.com> * Merge WebSearch: fix bare imports in test_filetable for dotted-path loading gramps-project#833 --------- Co-authored-by: Himanshu Gohel <1551217+hgohel@users.noreply.github.com> Co-authored-by: GaryGriffin <genealogy@garygriffin.net> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 146b9c3 commit 7abb603

22 files changed

Lines changed: 2292 additions & 230 deletions

CalculateEstimatedDates/CalculateEstimatedDates.gpr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
id="calculateestimateddates",
1010
name=_("Calculate Estimated Dates"),
1111
description=_("Calculates estimated dates for birth and death."),
12-
version = '0.90.41',
12+
version = '0.90.42',
1313
gramps_target_version="6.0",
1414
status=STABLE, # not yet tested with python 3
1515
fname="CalculateEstimatedDates.py",

CalculateEstimatedDates/CalculateEstimatedDates.py

Lines changed: 250 additions & 188 deletions
Large diffs are not rendered by default.
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2026 Eduard Ralph
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation; either version 2 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with this program; if not, write to the Free Software
18+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19+
#
20+
21+
"""
22+
Tests for the Calculate Estimated Dates tool — lock in the error
23+
handling added in response to ``gramps-project/bugs#7898`` (ancestry
24+
loops surfaced as ``DatabaseError`` from ``probably_alive_range`` were
25+
tearing down the whole tool).
26+
27+
The tool's ``__init__`` pulls in the full Gramps GUI stack, so these
28+
tests build stub instances via ``__new__`` and exercise the isolated
29+
helpers plus the ``.gpr.py`` registration.
30+
"""
31+
32+
# ------------------------
33+
# Python modules
34+
# ------------------------
35+
import os
36+
import sys
37+
import unittest
38+
from typing import Any
39+
from unittest import mock
40+
41+
# The addon imports Gtk at module load — skip cleanly if gi/Gtk are not
42+
# available. On systems where both GTK3 and GTK4 are present, pin Gtk to
43+
# 3.0 before any gramps import (mirrors what gramps.grampsapp does at
44+
# startup); otherwise PyGObject loads GTK4 and the gramps.gui import
45+
# chain crashes on Gtk.IconSize.MENU (a GTK3-only enum).
46+
try:
47+
import gi
48+
49+
gi.require_version("Gtk", "3.0")
50+
gi.require_version("Gdk", "3.0")
51+
except (ImportError, ValueError, AttributeError) as err:
52+
raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err)
53+
54+
# ------------------------
55+
# Gramps modules
56+
# ------------------------
57+
# addons-source/ goes on sys.path so ``from CalculateEstimatedDates import
58+
# CalculateEstimatedDates`` resolves package→submodule. With the addon
59+
# directory itself on sys.path instead, ``CalculateEstimatedDates`` binds
60+
# to ``CalculateEstimatedDates.py`` directly, and the submodule lookup
61+
# fails. ADDON_DIR is retained for the ``.gpr.py`` path in the registration
62+
# test.
63+
ADDON_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
64+
ADDONS_SOURCE_DIR = os.path.dirname(ADDON_DIR)
65+
if ADDONS_SOURCE_DIR not in sys.path:
66+
sys.path.insert(0, ADDONS_SOURCE_DIR)
67+
68+
try:
69+
import gramps
70+
except ImportError as err:
71+
raise unittest.SkipTest("gramps package not available: %s" % err)
72+
73+
if "GRAMPS_RESOURCES" not in os.environ:
74+
os.environ["GRAMPS_RESOURCES"] = os.path.dirname(
75+
os.path.dirname(gramps.__file__)
76+
)
77+
78+
from gramps.gen.errors import DatabaseError # noqa: E402
79+
from gramps.gen.lib import Date # noqa: E402
80+
81+
# ------------------------
82+
# Gramps specific
83+
# ------------------------
84+
# The addon module pulls in the full Gramps GUI stack at import time. On
85+
# environments where GTK is missing or version-mismatched the import
86+
# fails; skip the whole module cleanly in that case so collection does
87+
# not surface spurious errors.
88+
try:
89+
from CalculateEstimatedDates import CalculateEstimatedDates as ced_module
90+
except Exception as err: # noqa: BLE001 — environment guard
91+
raise unittest.SkipTest(
92+
"CalculateEstimatedDates module unavailable: %s" % err
93+
)
94+
95+
96+
# ------------------------------------------------------------
97+
#
98+
# _FakeOptionsHandler
99+
#
100+
# ------------------------------------------------------------
101+
class _FakeOptionsHandler:
102+
"""Stub for ``CalcToolManagedWindow.options.handler`` — exposes only
103+
``options_dict``, which is all the paths under test read."""
104+
105+
def __init__(self, dates: int = 0) -> None:
106+
"""
107+
:param dates: Value stored under ``options_dict["dates"]``.
108+
:type dates: int
109+
"""
110+
self.options_dict: dict[str, int] = {"dates": dates}
111+
112+
113+
# ------------------------------------------------------------
114+
#
115+
# _FakeOptions
116+
#
117+
# ------------------------------------------------------------
118+
class _FakeOptions:
119+
"""Stub for ``CalcToolManagedWindow.options`` with a ``handler`` attribute."""
120+
121+
def __init__(self, dates: int = 0) -> None:
122+
"""
123+
:param dates: Value propagated into the handler's ``options_dict``.
124+
:type dates: int
125+
"""
126+
self.handler = _FakeOptionsHandler(dates=dates)
127+
128+
129+
def _make_tool(dates: int = 0) -> Any:
130+
"""
131+
Build a ``CalcToolManagedWindow`` via ``__new__`` so its ``__init__``
132+
(which pulls in GTK) does not run, then attach just enough state for
133+
the isolated helpers to execute.
134+
135+
:param dates: Value stored on the fake options handler.
136+
:type dates: int
137+
:returns: A stub instance with the minimum attributes set.
138+
:rtype: CalcToolManagedWindow
139+
"""
140+
cls = ced_module.CalcToolManagedWindow
141+
stub = cls.__new__(cls)
142+
stub.options = _FakeOptions(dates=dates)
143+
stub.db = object()
144+
stub.MAX_SIB_AGE_DIFF = 20
145+
stub.MAX_AGE_PROB_ALIVE = 110
146+
stub.AVG_GENERATION_GAP = 20
147+
return stub
148+
149+
150+
# ------------------------------------------------------------
151+
#
152+
# TestGetModifier
153+
#
154+
# ------------------------------------------------------------
155+
class TestGetModifier(unittest.TestCase):
156+
"""Pure-logic coverage for ``get_modifier`` across its four branches."""
157+
158+
def test_birth_about_when_dates_zero(self) -> None:
159+
"""dates=0 + birth → MOD_ABOUT (the 'approximate' case)."""
160+
tool = _make_tool(dates=0)
161+
self.assertEqual(tool.get_modifier("birth"), Date.MOD_ABOUT)
162+
163+
def test_birth_after_when_dates_nonzero(self) -> None:
164+
"""dates=1 + birth → MOD_AFTER (the 'extremes' case)."""
165+
tool = _make_tool(dates=1)
166+
self.assertEqual(tool.get_modifier("birth"), Date.MOD_AFTER)
167+
168+
def test_death_about_when_dates_zero(self) -> None:
169+
"""dates=0 + death → MOD_ABOUT."""
170+
tool = _make_tool(dates=0)
171+
self.assertEqual(tool.get_modifier("death"), Date.MOD_ABOUT)
172+
173+
def test_death_before_when_dates_nonzero(self) -> None:
174+
"""dates=1 + death → MOD_BEFORE (upper-bound estimate)."""
175+
tool = _make_tool(dates=1)
176+
self.assertEqual(tool.get_modifier("death"), Date.MOD_BEFORE)
177+
178+
179+
# ------------------------------------------------------------
180+
#
181+
# TestCalcEstimates
182+
#
183+
# ------------------------------------------------------------
184+
class TestCalcEstimates(unittest.TestCase):
185+
"""Regression coverage for bug 7898 — ``calc_estimates`` must let
186+
``DatabaseError`` from ``probably_alive_range`` escape so the
187+
per-person handler in ``run()`` can log and skip instead of tearing
188+
down the whole tool."""
189+
190+
def test_returns_probably_alive_range_result(self) -> None:
191+
"""Happy path — the helper is a pass-through to ``probably_alive_range``."""
192+
tool = _make_tool()
193+
person = object()
194+
expected = (Date(), Date(), "explain", None)
195+
calls: list[tuple] = []
196+
197+
def _fake(person_arg: Any, db_arg: Any, max_sib: int, max_age: int, avg_gap: int) -> tuple:
198+
calls.append((person_arg, db_arg, max_sib, max_age, avg_gap))
199+
return expected
200+
201+
with mock.patch.object(ced_module, "probably_alive_range", _fake):
202+
result = tool.calc_estimates(person)
203+
204+
self.assertEqual(result, expected)
205+
self.assertEqual(calls, [(person, tool.db, 20, 110, 20)])
206+
207+
def test_propagates_database_error(self) -> None:
208+
"""
209+
When ``probably_alive_range`` raises ``DatabaseError`` (e.g. an
210+
ancestry loop), ``calc_estimates`` must let it escape so the
211+
per-person handler in ``run()`` can log and skip.
212+
"""
213+
tool = _make_tool()
214+
215+
def _boom(*_args: Any, **_kwargs: Any) -> Any:
216+
raise DatabaseError("loop in Test, Abel's descendants")
217+
218+
with mock.patch.object(ced_module, "probably_alive_range", _boom):
219+
with self.assertRaisesRegex(DatabaseError, "loop"):
220+
tool.calc_estimates(object())
221+
222+
223+
# ------------------------------------------------------------
224+
#
225+
# TestGprRegistration
226+
#
227+
# ------------------------------------------------------------
228+
class TestGprRegistration(unittest.TestCase):
229+
"""Catch metadata breakage in the plugin registration file early."""
230+
231+
def test_gpr_registration_metadata(self) -> None:
232+
"""The .gpr.py file must register a single TOOL with expected keys."""
233+
gpr_path = os.path.join(ADDON_DIR, "CalculateEstimatedDates.gpr.py")
234+
calls: list[tuple[tuple, dict]] = []
235+
236+
namespace: dict[str, Any] = {
237+
"register": lambda *args, **kwargs: calls.append((args, kwargs)),
238+
"TOOL": "TOOL",
239+
"STABLE": "STABLE",
240+
"UNSTABLE": "UNSTABLE",
241+
"TOOL_DBPROC": "TOOL_DBPROC",
242+
"TOOL_MODE_GUI": "TOOL_MODE_GUI",
243+
"_": lambda s: s,
244+
}
245+
with open(gpr_path, encoding="utf-8") as handle:
246+
exec(compile(handle.read(), gpr_path, "exec"), namespace)
247+
248+
self.assertEqual(len(calls), 1, "expected exactly one register() call")
249+
args, kwargs = calls[0]
250+
self.assertEqual(args, ("TOOL",))
251+
self.assertEqual(kwargs["id"], "calculateestimateddates")
252+
self.assertEqual(kwargs["fname"], "CalculateEstimatedDates.py")
253+
self.assertEqual(kwargs["gramps_target_version"], "6.0")
254+
self.assertEqual(kwargs["status"], "STABLE")
255+
self.assertEqual(kwargs["toolclass"], "CalcToolManagedWindow")
256+
self.assertEqual(kwargs["optionclass"], "CalcEstDateOptions")
257+
self.assertEqual(kwargs["category"], "TOOL_DBPROC")
258+
self.assertEqual(kwargs["tool_modes"], ["TOOL_MODE_GUI"])
259+
260+
261+
if __name__ == "__main__":
262+
unittest.main()

DataEntryGramplet/DataEntryGramplet.gpr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
gramplet_title=_("Data Entry"),
1515
detached_width=510,
1616
detached_height=480,
17-
version = '1.0.52',
17+
version = '1.0.53',
1818
gramps_target_version="6.0",
1919
status=STABLE,
2020
audience=EXPERT,

DataEntryGramplet/DataEntryGramplet.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,11 @@ def make_person(self, firstname, surname, gender):
423423

424424
def save_data_edit(self, obj):
425425
if self._dirty:
426+
if not self.dbstate.is_open():
427+
from gramps.gui.dialog import ErrorDialog
428+
ErrorDialog(_("No Family Tree is open."),
429+
_("Please open a Family Tree to edit data."))
430+
return
426431
# Save the edits ----------------------------------
427432
person = self._dirty_person
428433
# First, get the data:
@@ -498,6 +503,10 @@ def add_source(self, obj, source):
498503

499504
def add_data_entry(self, obj):
500505
from gramps.gui.dialog import ErrorDialog
506+
if not self.dbstate.is_open():
507+
ErrorDialog(_("No Family Tree is open."),
508+
_("Please open a Family Tree before adding a person."))
509+
return
501510
# First, get the data:
502511
if "," in self.de_widgets["NPName"].get_text():
503512
surname, firstname = self.de_widgets["NPName"].get_text().split(",", 1)

0 commit comments

Comments
 (0)