|
| 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 | +Pure-Python validation for Form addon XML definition files. |
| 23 | +
|
| 24 | +Kept free of GTK/Gramps imports so it can be unit-tested without a GUI |
| 25 | +environment. |
| 26 | +""" |
| 27 | + |
| 28 | +# ------------------------ |
| 29 | +# Python modules |
| 30 | +# ------------------------ |
| 31 | +import xml.dom.minidom |
| 32 | +import xml.parsers.expat |
| 33 | + |
| 34 | + |
| 35 | +VALID_SECTION_TYPES = frozenset({"person", "family", "multi"}) |
| 36 | +REQUIRED_FORM_ATTRS = ("id", "title", "type") |
| 37 | + |
| 38 | + |
| 39 | +def split_family_title(title: str) -> tuple[str, str]: |
| 40 | + """ |
| 41 | + Split a family-section title of the form ``'X/Y'`` into ``(X, Y)``. |
| 42 | +
|
| 43 | + Falls back gracefully when the separator is absent or the input is |
| 44 | + empty, so callers never raise ``ValueError`` on malformed XML. |
| 45 | +
|
| 46 | + :param title: the raw title string from the XML (may be empty) |
| 47 | + :returns: a two-tuple of title parts; the second element is empty |
| 48 | + when the title contains no ``'/'`` |
| 49 | + """ |
| 50 | + if not title: |
| 51 | + return "", "" |
| 52 | + parts = title.split("/", 1) |
| 53 | + if len(parts) == 2: |
| 54 | + return parts[0], parts[1] |
| 55 | + return parts[0], "" |
| 56 | + |
| 57 | + |
| 58 | +def validate_form_element(form) -> list[str]: |
| 59 | + """ |
| 60 | + Validate a single ``<form>`` DOM element against the form schema. |
| 61 | +
|
| 62 | + :param form: a DOM element for a single form definition |
| 63 | + :returns: a list of human-readable error messages scoped to this |
| 64 | + form; empty when the form is valid |
| 65 | + """ |
| 66 | + errors: list[str] = [] |
| 67 | + |
| 68 | + form_id = ( |
| 69 | + form.attributes["id"].value |
| 70 | + if "id" in form.attributes |
| 71 | + else "<missing id>" |
| 72 | + ) |
| 73 | + for required in REQUIRED_FORM_ATTRS: |
| 74 | + if required not in form.attributes: |
| 75 | + errors.append( |
| 76 | + "Form '%s': missing required attribute '%s'" |
| 77 | + % (form_id, required) |
| 78 | + ) |
| 79 | + |
| 80 | + for section in form.getElementsByTagName("section"): |
| 81 | + role = ( |
| 82 | + section.attributes["role"].value |
| 83 | + if "role" in section.attributes |
| 84 | + else "" |
| 85 | + ) |
| 86 | + if not role: |
| 87 | + errors.append( |
| 88 | + "Form '%s': <section> is missing required attribute 'role'" |
| 89 | + % form_id |
| 90 | + ) |
| 91 | + continue |
| 92 | + |
| 93 | + if "type" not in section.attributes: |
| 94 | + errors.append( |
| 95 | + "Form '%s': section '%s' is missing required attribute 'type'" |
| 96 | + % (form_id, role) |
| 97 | + ) |
| 98 | + continue |
| 99 | + |
| 100 | + section_type = section.attributes["type"].value |
| 101 | + if not section_type: |
| 102 | + errors.append( |
| 103 | + "Form '%s': section '%s' has an empty 'type' attribute" |
| 104 | + % (form_id, role) |
| 105 | + ) |
| 106 | + continue |
| 107 | + |
| 108 | + if section_type not in VALID_SECTION_TYPES: |
| 109 | + errors.append( |
| 110 | + "Form '%s': section '%s' has invalid type '%s' " |
| 111 | + "(expected one of: %s)" |
| 112 | + % ( |
| 113 | + form_id, |
| 114 | + role, |
| 115 | + section_type, |
| 116 | + ", ".join(sorted(VALID_SECTION_TYPES)), |
| 117 | + ) |
| 118 | + ) |
| 119 | + continue |
| 120 | + |
| 121 | + title = ( |
| 122 | + section.attributes["title"].value |
| 123 | + if "title" in section.attributes |
| 124 | + else "" |
| 125 | + ) |
| 126 | + if section_type == "family": |
| 127 | + parts = title.split("/") |
| 128 | + if len(parts) != 2 or not parts[0].strip() or not parts[1].strip(): |
| 129 | + errors.append( |
| 130 | + "Form '%s': family section '%s' requires a title " |
| 131 | + "of the form 'Name1/Name2' (got '%s')" |
| 132 | + % (form_id, role, title) |
| 133 | + ) |
| 134 | + |
| 135 | + return errors |
| 136 | + |
| 137 | + |
| 138 | +def validate_form_dom(dom: xml.dom.minidom.Document) -> list[str]: |
| 139 | + """ |
| 140 | + Validate the structure of a parsed form definitions DOM. |
| 141 | +
|
| 142 | + Checks that: |
| 143 | +
|
| 144 | + * a ``<forms>`` root element exists; |
| 145 | + * each ``<form>`` element has ``id``, ``title`` and ``type`` attributes; |
| 146 | + * each ``<section>`` element has non-empty ``role`` and ``type`` |
| 147 | + attributes; |
| 148 | + * each section's ``type`` is one of ``person``, ``family`` or ``multi``; |
| 149 | + * ``family``-type sections declare a title of the form ``'X/Y'`` with |
| 150 | + two non-empty parts. |
| 151 | +
|
| 152 | + :param dom: a parsed ``xml.dom.minidom.Document`` |
| 153 | + :returns: a list of human-readable error messages; empty when the |
| 154 | + document is valid |
| 155 | + """ |
| 156 | + top = dom.getElementsByTagName("forms") |
| 157 | + if not top: |
| 158 | + return ["Missing <forms> root element"] |
| 159 | + |
| 160 | + errors: list[str] = [] |
| 161 | + for form in top[0].getElementsByTagName("form"): |
| 162 | + errors.extend(validate_form_element(form)) |
| 163 | + return errors |
| 164 | + |
| 165 | + |
| 166 | +def parse_and_validate(path: str) -> tuple[xml.dom.minidom.Document | None, list[str]]: |
| 167 | + """ |
| 168 | + Parse ``path`` as XML and validate it against the form schema. |
| 169 | +
|
| 170 | + :param path: filesystem path to a form definitions XML file |
| 171 | + :returns: a ``(dom, errors)`` tuple. When parsing fails, ``dom`` is |
| 172 | + ``None`` and ``errors`` contains a single description of |
| 173 | + the syntax error. When parsing succeeds, ``dom`` is the |
| 174 | + parsed document and ``errors`` lists any structural |
| 175 | + problems (empty when the file is valid). |
| 176 | + """ |
| 177 | + try: |
| 178 | + dom = xml.dom.minidom.parse(path) |
| 179 | + except xml.parsers.expat.ExpatError as exc: |
| 180 | + return None, ["XML syntax error: %s" % exc] |
| 181 | + except (OSError, ValueError) as exc: |
| 182 | + return None, ["Failed to read file: %s" % exc] |
| 183 | + return dom, validate_form_dom(dom) |
0 commit comments