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 ``