Skip to content

Commit b639a8c

Browse files
eduralphclaude
andcommitted
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>
1 parent 8c3880b commit b639a8c

3 files changed

Lines changed: 179 additions & 5 deletions

File tree

ImportMerge/importmerge.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -803,8 +803,8 @@ def do_commits(self, status, obj_type, hndl, action, trans):
803803
continue
804804
# now check the missing list for a match
805805
changed += self.check_miss(r_objtype, r_hndl, item)
806-
# check for GID conflict
807-
if item.gramps_id != item1.gramps_id:
806+
# check for GID conflict (Tag is a table object without gramps_id)
807+
if obj_type != 'Tag' and item.gramps_id != item1.gramps_id:
808808
if getattr(self.db1, 'has_' + obj_type.lower() +
809809
'_gramps_id')(item.gramps_id):
810810
item.gramps_id = getattr(self.db1, 'find_next_' +
@@ -823,9 +823,10 @@ def do_commits(self, status, obj_type, hndl, action, trans):
823823
continue
824824
# now check the differences list for a match
825825
self.check_diffs(r_objtype, r_hndl, obj_type, item)
826-
# check for GID conflict
827-
if getattr(self.db1, 'has_' + obj_type.lower() +
828-
'_gramps_id')(item.gramps_id):
826+
# check for GID conflict (Tag is a table object without gramps_id)
827+
if obj_type != 'Tag' and getattr(
828+
self.db1, 'has_' + obj_type.lower() +
829+
'_gramps_id')(item.gramps_id):
829830
item.gramps_id = getattr(self.db1, 'find_next_' +
830831
obj_type.lower() +
831832
'_gramps_id')()

ImportMerge/tests/__init__.py

Whitespace-only changes.
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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 for the Import and Merge tool.
22+
23+
These tests exercise ``ImportMerge.do_commits`` against a real Gramps SQLite
24+
database. We call the method unbound with a stub ``self`` so the GUI
25+
(ManagedWindow + Gtk dialog) doesn't need to be instantiated — the data-path
26+
logic is what we're verifying.
27+
28+
Regression coverage:
29+
30+
* Gramps bug 0014056 — Adding a Tag via the Import Merge tool raised
31+
``AttributeError: 'SQLite' object has no attribute 'has_tag_gramps_id'``
32+
because Tag is a table object and has no gramps_id.
33+
"""
34+
35+
# ------------------------
36+
# Python modules
37+
# ------------------------
38+
import os
39+
import shutil
40+
import tempfile
41+
import types
42+
from types import SimpleNamespace
43+
44+
import pytest
45+
46+
# The ImportMerge module imports Gtk at module load — skip the whole file if
47+
# gi/Gtk aren't available (headless-without-GTK environments).
48+
pytest.importorskip("gi")
49+
50+
# ------------------------
51+
# Gramps modules
52+
# ------------------------
53+
from gramps.gen.db import DbTxn
54+
from gramps.gen.db.utils import make_database
55+
from gramps.gen.lib import Tag
56+
57+
# ------------------------
58+
# Gramps specific
59+
# ------------------------
60+
from ImportMerge.importmerge import ImportMerge, S_ADD, S_DIFFERS, A_ADD
61+
62+
63+
def _make_db(suffix):
64+
"""Create a fresh on-disk Gramps SQLite DB inside a temp directory."""
65+
tmpdir = tempfile.mkdtemp(prefix=f"gramps_test_{suffix}_")
66+
db_path = os.path.join(tmpdir, f"db_{suffix}")
67+
os.makedirs(db_path)
68+
db = make_database("sqlite")
69+
db.load(db_path, None)
70+
return db, tmpdir
71+
72+
73+
@pytest.fixture
74+
def db1():
75+
"""Destination Gramps DB (the one being merged into)."""
76+
db, tmpdir = _make_db("db1")
77+
yield db
78+
try:
79+
db.close()
80+
except Exception:
81+
pass
82+
shutil.rmtree(tmpdir, ignore_errors=True)
83+
84+
85+
@pytest.fixture
86+
def db2():
87+
"""Source Gramps DB (simulates the XML being imported)."""
88+
db, tmpdir = _make_db("db2")
89+
yield db
90+
try:
91+
db.close()
92+
except Exception:
93+
pass
94+
shutil.rmtree(tmpdir, ignore_errors=True)
95+
96+
97+
def _make_stub(db1, db2):
98+
"""Stub ``self`` for ``ImportMerge.do_commits`` — no GUI attributes."""
99+
return SimpleNamespace(
100+
db1=db1,
101+
db2=db2,
102+
added={},
103+
missing={},
104+
diffs={},
105+
)
106+
107+
108+
def _add_tag(db, name):
109+
"""Add a Tag to ``db`` and return its handle."""
110+
tag = Tag()
111+
tag.set_name(name)
112+
with DbTxn("add tag", db, batch=True) as trans:
113+
handle = db.add_tag(tag, trans)
114+
return handle
115+
116+
117+
def test_add_tag_does_not_crash(db1, db2):
118+
"""Regression for bug 0014056 — adding a Tag via do_commits must succeed.
119+
120+
Before the fix, this raised ``AttributeError: 'SQLite' object has no
121+
attribute 'has_tag_gramps_id'`` because the generic GID-conflict check
122+
didn't special-case Tag (a table object without a gramps_id field).
123+
"""
124+
tag_handle = _add_tag(db2, "Imported")
125+
stub = _make_stub(db1, db2)
126+
127+
with DbTxn("import merge", db1, batch=True) as trans:
128+
ImportMerge.do_commits(stub, S_ADD, "Tag", tag_handle, A_ADD, trans)
129+
130+
# Tag should now exist in the destination DB.
131+
assert db1.get_number_of_tags() == 1
132+
committed = db1.get_tag_from_handle(tag_handle)
133+
assert committed is not None
134+
assert committed.get_name() == "Imported"
135+
136+
137+
def test_differing_tag_does_not_crash(db1, db2):
138+
"""S_DIFFERS branch must also skip the gramps_id check for Tag.
139+
140+
Same root cause as the S_ADD path: the GID-conflict block at the end of
141+
the S_DIFFERS branch dereferences ``item.gramps_id`` and calls
142+
``has_tag_gramps_id`` — both fail for Tag objects.
143+
"""
144+
tag_handle = _add_tag(db1, "Original")
145+
# Same handle in db2 with a different name — simulates S_DIFFERS.
146+
tag2 = Tag()
147+
tag2.set_handle(tag_handle)
148+
tag2.set_name("Modified")
149+
with DbTxn("seed db2", db2, batch=True) as trans:
150+
db2.add_tag(tag2, trans)
151+
152+
stub = _make_stub(db1, db2)
153+
154+
# diff_result is invoked by do_commits on the S_DIFFERS path; stub it to
155+
# return the db2 version as the resolved item (action = A_ADD ⇒ "replace").
156+
def fake_diff_result(action, obj_type, hndl):
157+
item1 = db1.get_tag_from_handle(hndl)
158+
item2 = db2.get_tag_from_handle(hndl)
159+
return item1, item2, item2
160+
161+
stub.diff_result = fake_diff_result
162+
stub.check_diffs = lambda *a, **kw: False
163+
stub.check_added = lambda *a, **kw: False
164+
stub.check_miss = lambda *a, **kw: False
165+
166+
with DbTxn("import merge", db1, batch=True) as trans:
167+
ImportMerge.do_commits(
168+
stub, S_DIFFERS, "Tag", tag_handle, A_ADD, trans
169+
)
170+
171+
committed = db1.get_tag_from_handle(tag_handle)
172+
assert committed is not None
173+
assert committed.get_name() == "Modified"

0 commit comments

Comments
 (0)