From e9dc5d212c07b1f3338631493a2155ccb402d0c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Cl=C3=A9rice?= Date: Thu, 24 Sep 2026 16:26:41 +0200 Subject: [PATCH 1/2] Raise UnresolvableReference instead of crashing on unresolvable references A reference whose unit only exists under a later occurrence of a repeated milestone parent (e.g. a column the text comes back to) resolved to None, and generate_xpath() handed None to Saxon as an XPath context, which segfaulted. A missing parent ("9.A" without column 9) silently resolved to the first matching child of the whole document. - _resolve_groups stops at the first unit it cannot find - generate_xpath raises UnresolvableReference (a ValueError) - get_xpath_proc refuses a None context instead of reaching Saxon --- dapytains/errors.py | 12 ++++++- dapytains/processor.py | 3 ++ dapytains/tei/citeStructure.py | 14 ++++++-- tests/tei/cb_repeated_column.xml | 24 +++++++++++++ tests/test_unresolvable_reference.py | 51 ++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 tests/tei/cb_repeated_column.xml create mode 100644 tests/test_unresolvable_reference.py diff --git a/dapytains/errors.py b/dapytains/errors.py index df53b8f..c13ca1d 100644 --- a/dapytains/errors.py +++ b/dapytains/errors.py @@ -2,4 +2,14 @@ class UnknownTreeName(Exception): """This exception is raised when a requested tree is unknown """ class InvalidRangeOrder(Exception): - """Error raised when a range is in the wrong order (start > end) """ \ No newline at end of file + """Error raised when a range is in the wrong order (start > end) """ + + +class UnresolvableReference(ValueError): + """Error raised when a reference matches no node of the document: it does not exist, or one of + its units only exists under a later occurrence of a repeated milestone parent (e.g. a column the + text comes back to), which resolution, following the first occurrence, cannot reach. """ + + def __init__(self, reference: str): + self.reference = reference + super().__init__(f"Reference '{reference}' cannot be resolved to a node of the document.") diff --git a/dapytains/processor.py b/dapytains/processor.py index 0eaf509..668cf30 100644 --- a/dapytains/processor.py +++ b/dapytains/processor.py @@ -30,6 +30,9 @@ def get_xpath_proc(elem: saxonlib.PyXdmNode, processor: saxonlib.PySaxonProcesso :param elem: An XML node, root or not :return: XPathProccesor """ + if elem is None: + # Saxon segfaults on a None context instead of raising: fail in Python instead. + raise TypeError("get_xpath_proc() needs a node as context, got None") xpath = processor.new_xpath_processor() xpath.declare_namespace("", "http://www.tei-c.org/ns/1.0") xpath.set_context(xdm_item=elem) diff --git a/dapytains/tei/citeStructure.py b/dapytains/tei/citeStructure.py index abe4514..2d65551 100644 --- a/dapytains/tei/citeStructure.py +++ b/dapytains/tei/citeStructure.py @@ -2,6 +2,7 @@ from typing import Dict, List, Optional from dataclasses import dataclass, field from collections import namedtuple, defaultdict +from dapytains.errors import UnresolvableReference from dapytains.processor import get_xpath_proc, saxonlib _pos_re = re.compile(r'\[(\d+)\]') @@ -273,10 +274,10 @@ def _resolve_groups(self, groups: List[tuple]) -> Optional[saxonlib.PyXdmNode]: xpath_proc = get_xpath_proc(self.doc_root, processor=self.processor) concrete_node = None prev_structure: Optional[CitableStructure] = None - for key, value in groups: + for position, (key, value) in enumerate(groups): formatted = self.xpath_matcher[key].format(**{key: value}) structure = self.structure_by_key[key] - if concrete_node is None: + if position == 0: concrete_node = xpath_proc.evaluate_single(f"({formatted})[1]") elif prev_structure.milestone: boundary = self._milestone_boundary(concrete_node, prev_structure.match) @@ -284,6 +285,10 @@ def _resolve_groups(self, groups: List[tuple]) -> Optional[saxonlib.PyXdmNode]: else: local_proc = get_xpath_proc(concrete_node, processor=self.processor) concrete_node = local_proc.evaluate_single(_relative("./", formatted)) + if concrete_node is None: + # A missing unit: stop here, instead of looking for its children in the whole + # document (which would resolve "9.A" to the first letter A when there is no column 9). + return None prev_structure = structure return concrete_node @@ -321,7 +326,10 @@ def generate_xpath(self, reference): # (self-closing elements like ) cannot be joined with their children via "/", then # turn it into its real, DOM-accurate absolute positional XPath so it flows through the # rest of the pipeline (document.py's reconstruct_doc) like any other absolute XPath. - return self._absolute_path(self.resolve_node(reference)) + node = self.resolve_node(reference) + if node is None: + raise UnresolvableReference(reference) + return self._absolute_path(node) def get_next_milestone_boundary(self, reference: str) -> Optional[saxonlib.PyXdmNode]: """ If `reference`'s deepest unit is nested directly under a milestone-mode parent diff --git a/tests/tei/cb_repeated_column.xml b/tests/tei/cb_repeated_column.xml new file mode 100644 index 0000000..b09d4ef --- /dev/null +++ b/tests/tei/cb_repeated_column.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + +
+ +

Column one, A.

+

Column two, A.

+

Column one again, B.

+

Column three, A.

+

Column one a third time, B.

+
+ +
+
diff --git a/tests/test_unresolvable_reference.py b/tests/test_unresolvable_reference.py new file mode 100644 index 0000000..127a908 --- /dev/null +++ b/tests/test_unresolvable_reference.py @@ -0,0 +1,51 @@ +import os.path + +import pytest + +from dapytains.errors import UnresolvableReference +from dapytains.processor import get_processor, get_xpath_proc +from dapytains.tei.document import Document + +p = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture +def doc(): + # Column 1 comes back twice: letter B only exists under its later occurrences. + return Document(os.path.join(p, "tei/cb_repeated_column.xml")) + + +def test_resolvable_reference_still_works(doc): + assert doc.citeStructure[doc.default_tree].generate_xpath("1.A") == "/TEI[1]/text[1]/body[1]/div[1]/p[1]/milestone[1]" + assert doc.citeStructure[doc.default_tree].generate_xpath("2.A") == "/TEI[1]/text[1]/body[1]/div[1]/p[2]/milestone[1]" + + +def test_child_of_a_later_occurrence_raises(doc): + """ Used to hand None to Saxon as an XPath context, which segfaulted. """ + with pytest.raises(UnresolvableReference) as error: + doc.citeStructure[doc.default_tree].generate_xpath("1.B") + assert error.value.reference == "1.B" + + +def test_missing_parent_raises_instead_of_matching_elsewhere(doc): + """ There is no column 9: "9.A" used to resolve to the first letter A of the document. """ + assert doc.citeStructure[doc.default_tree].resolve_node("9.A") is None + with pytest.raises(UnresolvableReference): + doc.citeStructure[doc.default_tree].generate_xpath("9.A") + + +def test_get_passage_raises(doc): + with pytest.raises(UnresolvableReference): + doc.get_passage("1.B") + with pytest.raises(UnresolvableReference): + doc.get_passage("9.A") + + +def test_unresolvable_reference_is_a_value_error(): + """ Callers catching ValueError (e.g. malformed references) keep working. """ + assert issubclass(UnresolvableReference, ValueError) + + +def test_get_xpath_proc_refuses_none(): + with pytest.raises(TypeError): + get_xpath_proc(None, processor=get_processor()) From cbcfa69d366a99fb24929285aa18e9985f8120ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Cl=C3=A9rice?= Date: Thu, 24 Sep 2026 16:27:04 +0200 Subject: [PATCH 2/2] 1.0.0rc4 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3abe8d2..8195490 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ def read_requirements(): setup( name="dapytains", - version="1.0.0rc3", + version="1.0.0rc4", author="Thibault Clérice", author_email="thibault.clerice@inria.fr", description="A brief description of dapytains",