Skip to content

Commit 41ec2a9

Browse files
eduralphGaryGriffin
authored andcommitted
RepositoriesReport: stop feeding wrong-typed handles to type getters
Running the report on example.gramps crashed with HandleError in two traversal paths that passed handles to a type-specific getter without checking the object class: * Citations: __write_referenced_sources queried find_backlink_handles(source) with no class filter, then fed every backlink to get_citation_from_handle. A source's backlinks include Note objects, so the first Note handle raised HandleError. Restrict the query to ['Citation'], mirroring the ['Source'] filter used a few lines above for the repository->source lookup. * Notes: the repository note loop fed every handle from repository.get_referenced_handles() to get_note_from_handle without the classname == 'Note' guard. A tagged repository returns a ('Tag', handle) tuple, so its tag handle raised HandleError. Add the same guard the sibling source-note loop already has. Both reproduce on the canonical example.gramps fixture (citations out of the box; notes once a repository is tagged). Found during review of PR gramps-project#920 by GaryGriffin. Test: RepositoriesReport/tests/test_integration_repositoriesreport.py (new; DB-backed integration test against example.gramps, plus tests/__init__.py). Drives the public write_report end-to-end; pre-fix both cases fail with HandleError, post-fix the report completes.
1 parent 771db58 commit 41ec2a9

3 files changed

Lines changed: 230 additions & 3 deletions

File tree

RepositoriesReport/RepositoriesReportAlt.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,9 @@ def __write_repository(self, handle):
182182

183183
# on tuple : [0] = classname ; [1] = handle
184184

185-
note_handle = note_handle[1]
186-
self.__write_referenced_notes(note_handle)
185+
if note_handle[0] == 'Note': # We can have 'Tag' here.
186+
note_handle = note_handle[1]
187+
self.__write_referenced_notes(note_handle)
187188

188189
# additional repository informations
189190

@@ -299,7 +300,8 @@ def __write_referenced_sources(self, handle):
299300
self.__write_referenced_media(photos, media_handle)
300301
self.black_list.append(media_handle)
301302

302-
for (object_type, citationref) in self.database.find_backlink_handles(source_handle):
303+
for (object_type, citationref) in \
304+
self.database.find_backlink_handles(source_handle, ['Citation']):
303305
if self.incl_citat:
304306
self.__write_referenced_citations(citationref)
305307

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
"""Test package for the RepositoriesReport addon.
22+
23+
Pins the GTK 3 stack (Gtk + Gdk) before any test module imports a
24+
``gramps`` GUI/plugin module. The addon's import chain pulls
25+
``gramps.gen.plug.docgen``, which loads Gtk; on a host where GTK 4 is the
26+
default GI resolution a bare import would bind the wrong version (or warn
27+
and skip). Pinning here — mirroring ``gramps/gen/constfunc.py`` — applies on
28+
every launch path, including a direct ``python3 -m unittest`` run with no
29+
test runner.
30+
"""
31+
32+
try:
33+
import gi
34+
35+
gi.require_version("Gtk", "3.0")
36+
gi.require_version("Gdk", "3.0")
37+
except (ImportError, ValueError):
38+
# No PyGObject / GTK 3 here; the test modules guard their imports and
39+
# skip cleanly.
40+
pass
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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+
"""Integration tests: run RepositoriesReport against ``example.gramps``.
22+
23+
These reproduce two crashes that only surface when the report walks a real
24+
database — they are invisible to a mocked-DB unit test, because a mock
25+
returns only what it is told to:
26+
27+
* Citations: ``__write_referenced_sources`` queried
28+
``find_backlink_handles(source)`` with no class filter, then fed every
29+
backlink to ``get_citation_from_handle``. A source's backlinks include
30+
``Note`` objects, so the first Note handle raised ``HandleError``.
31+
* Notes: the repository note loop fed every handle from
32+
``repository.get_referenced_handles()`` to ``get_note_from_handle``
33+
without the ``classname == 'Note'`` guard its sibling source loop already
34+
has, so a tagged repository raised ``HandleError`` on the Tag handle.
35+
36+
The report is driven through its public ``write_report`` against
37+
``example.gramps`` (the canonical fixture); the only stub is the output doc
38+
backend, which is not the code under test. Pre-fix these fail with
39+
``HandleError``; post-fix the report completes.
40+
"""
41+
42+
# ------------------------
43+
# Python modules
44+
# ------------------------
45+
import os
46+
import sys
47+
import unittest
48+
from unittest.mock import MagicMock
49+
50+
# ------------------------
51+
# Gramps modules
52+
# ------------------------
53+
try:
54+
import gi
55+
56+
gi.require_version("Gtk", "3.0")
57+
gi.require_version("Gdk", "3.0")
58+
import gramps # noqa: F401
59+
from gramps.cli.user import User
60+
from gramps.gen.db import DbTxn
61+
from gramps.gen.db.utils import import_as_dict
62+
from gramps.gen.lib import Tag
63+
except (ImportError, ValueError) as exc:
64+
raise unittest.SkipTest("RepositoriesReport tests require 'gi' + 'gramps': %s" % exc)
65+
66+
# ------------------------
67+
# Gramps specific
68+
# ------------------------
69+
ADDON_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
70+
if ADDON_DIR not in sys.path:
71+
sys.path.insert(0, ADDON_DIR)
72+
73+
try:
74+
from RepositoriesReportAlt import RepositoryReportAlt
75+
except ImportError as exc:
76+
raise unittest.SkipTest("RepositoriesReportAlt import failed: %s" % exc)
77+
78+
79+
def _find_example_gramps():
80+
"""Locate the canonical example.gramps shipped with Gramps."""
81+
candidates = []
82+
res = os.environ.get("GRAMPS_RESOURCES")
83+
if res:
84+
candidates.append(os.path.join(res, "example", "gramps", "example.gramps"))
85+
root = os.path.dirname(os.path.dirname(os.path.abspath(gramps.__file__)))
86+
candidates.append(os.path.join(root, "example", "gramps", "example.gramps"))
87+
for path in candidates:
88+
if os.path.exists(path):
89+
return path
90+
return None
91+
92+
93+
# ------------------------------------------------------------------------
94+
#
95+
# _StubFilter
96+
#
97+
# ------------------------------------------------------------------------
98+
class _StubFilter:
99+
"""Empty-named filter so ``__write_all_repositories`` walks every
100+
repository via ``get_repository_handles`` (no GUI filter plumbing)."""
101+
102+
def get_name(self):
103+
return ""
104+
105+
106+
# ------------------------------------------------------------------------
107+
#
108+
# RepositoriesReportExampleGrampsTest
109+
#
110+
# ------------------------------------------------------------------------
111+
class RepositoriesReportExampleGrampsTest(unittest.TestCase):
112+
"""Drive the report end-to-end against example.gramps."""
113+
114+
@classmethod
115+
def setUpClass(cls):
116+
cls.example = _find_example_gramps()
117+
if cls.example is None:
118+
raise unittest.SkipTest("example.gramps not found")
119+
120+
def _load_db(self):
121+
return import_as_dict(self.example, User())
122+
123+
def _make_report(self, db):
124+
"""Build a report via ``__new__``-bypass against a real db, with
125+
only the output doc stubbed (it is not the code under test)."""
126+
report = RepositoryReportAlt.__new__(RepositoryReportAlt)
127+
report.database = db
128+
report.user = User()
129+
report.doc = MagicMock()
130+
report._ = lambda text, *args: text
131+
report.black_list = []
132+
report.filter = _StubFilter()
133+
for attr in (
134+
"inc_intern",
135+
"inc_addres",
136+
"inc_author",
137+
"inc_abbrev",
138+
"inc_public",
139+
"inc_datamp",
140+
"inclu_note",
141+
"incl_citat",
142+
"incl_empty",
143+
):
144+
setattr(report, attr, True)
145+
# Skip the PrivateProxyDb wrap (we already hold a real db) and the
146+
# media path (it would load image files off disk, unrelated here).
147+
report.inc_privat = True
148+
report.incl_media = False
149+
return report
150+
151+
def test_report_runs_over_example_gramps(self):
152+
"""Full report walk must complete — exercises the citations
153+
backlink path that raised HandleError on a Note backlink."""
154+
db = self._load_db()
155+
report = self._make_report(db)
156+
report.write_report() # pre-fix: HandleError from get_citation_from_handle
157+
# Prove it actually traversed, not just no-op'd.
158+
self.assertTrue(report.doc.write_text.called)
159+
self.assertTrue(report.doc.start_paragraph.called)
160+
161+
def test_tagged_repository_does_not_crash_note_path(self):
162+
"""A tagged repository must not crash the note loop — its
163+
get_referenced_handles() returns a ('Tag', handle) tuple that was
164+
fed unguarded to get_note_from_handle."""
165+
db = self._load_db()
166+
repo_handle = next(iter(db.get_repository_handles()), None)
167+
self.assertIsNotNone(repo_handle, "example.gramps must contain a repository")
168+
with DbTxn("attach tag", db) as trans:
169+
tag = Tag()
170+
tag.set_name("IntegrationTestTag")
171+
tag_handle = db.add_tag(tag, trans)
172+
repo = db.get_repository_from_handle(repo_handle)
173+
repo.add_tag(tag_handle)
174+
db.commit_repository(repo, trans)
175+
# Sanity: the repo now references a non-Note object.
176+
repo = db.get_repository_from_handle(repo_handle)
177+
classes = {cls for cls, _ in repo.get_referenced_handles()}
178+
self.assertIn("Tag", classes)
179+
180+
report = self._make_report(db)
181+
report.write_report() # pre-fix: HandleError from get_note_from_handle
182+
183+
184+
if __name__ == "__main__":
185+
unittest.main()

0 commit comments

Comments
 (0)