udsyntax

udsyntax: extract simple syntax graphs from spaCy Universal Dependencies parses.

Typical usage::

from udsyntax import load_latin, SyntaxGraph

nlp = load_latin()
doc = nlp("Gallia est omnis divisa in partes tres.")
graph = SyntaxGraph.from_doc(doc)
df = graph.to_polars()
 1"""udsyntax: extract simple syntax graphs from spaCy Universal Dependencies parses.
 2
 3Typical usage::
 4
 5    from udsyntax import load_latin, SyntaxGraph
 6
 7    nlp = load_latin()
 8    doc = nlp("Gallia est omnis divisa in partes tres.")
 9    graph = SyntaxGraph.from_doc(doc)
10    df = graph.to_polars()
11"""
12from .corpora import read_cex, read_cex_many, select_urn
13from .graph import SyntaxGraph, corpus_to_polars
14from .models import SyntaxEdge, SyntaxNode, VerbalUnit
15from .nlp import (
16    DEFAULT_GREEK_MODEL,
17    DEFAULT_LATIN_MODEL,
18    load_greek,
19    load_latin,
20    load_pipeline,
21)
22from .urn import CtsUrn, parse_cts_urn
23
24__all__ = [
25    "SyntaxGraph",
26    "SyntaxNode",
27    "SyntaxEdge",
28    "VerbalUnit",
29    "corpus_to_polars",
30    "read_cex",
31    "read_cex_many",
32    "select_urn",
33    "load_latin",
34    "load_greek",
35    "load_pipeline",
36    "DEFAULT_LATIN_MODEL",
37    "DEFAULT_GREEK_MODEL",
38    "CtsUrn",
39    "parse_cts_urn",
40]
41
42__version__ = "0.1.0"
@dataclass
class SyntaxGraph:
121@dataclass
122class SyntaxGraph:
123    """A dependency parse represented as syntax-graph nodes and edges."""
124
125    nodes: list[SyntaxNode] = field(default_factory=list)
126    edges: list[SyntaxEdge] = field(default_factory=list)
127    urn: str | None = None
128
129    @classmethod
130    def from_doc(cls, doc, *, urn: str | None = None) -> "SyntaxGraph":
131        """Build a ``SyntaxGraph`` from a parsed spaCy ``Doc``.
132
133        ``doc`` must come from a pipeline that has run a dependency
134        parser (its tokens need ``.dep_``, ``.head``, and ``.morph``).
135        """
136        nodes: list[SyntaxNode] = []
137        edges: list[SyntaxEdge] = []
138        has_sents = doc.has_annotation("SENT_START")
139        for tok in doc:
140            sent_id = tok.sent.start if has_sents else None
141            nodes.append(
142                SyntaxNode(
143                    id=tok.i,
144                    text=tok.text,
145                    lemma=tok.lemma_,
146                    pos=tok.pos_,
147                    relation=tok.dep_,
148                    head_id=tok.head.i,
149                    morph=tok.morph.to_dict(),
150                    sent_id=sent_id,
151                )
152            )
153            if tok.dep_ != "ROOT":
154                edges.append(SyntaxEdge(src=tok.head.i, target=tok.i, relation=tok.dep_))
155        return cls(nodes=nodes, edges=edges, urn=urn)
156
157    def node(self, node_id: int) -> SyntaxNode:
158        for n in self.nodes:
159            if n.id == node_id:
160                return n
161        raise KeyError(node_id)
162
163    def root_nodes(self) -> list[SyntaxNode]:
164        return [n for n in self.nodes if n.is_root]
165
166    def children_of(self, node_id: int) -> list[SyntaxNode]:
167        child_ids = {e.target for e in self.edges if e.src == node_id}
168        return [n for n in self.nodes if n.id in child_ids]
169
170    def verbal_units(self) -> list[VerbalUnit]:
171        """Finite verbs in this graph, as ``VerbalUnit`` records.
172
173        See :class:`udsyntax.models.VerbalUnit` for the current
174        limitation on ``depth``.
175        """
176        units = []
177        for i, n in enumerate(node for node in self.nodes if node.morph.get("VerbForm") == "Fin"):
178            depth = 1 if n.is_root else None
179            units.append(VerbalUnit(id=i, verb_token_id=n.id, verb_text=n.text, depth=depth))
180        return units
181
182    def to_polars(self):
183        """Render this graph's nodes as a polars ``DataFrame``, one row per token."""
184        import polars as pl
185
186        rows = []
187        for n in self.nodes:
188            row = {
189                "id": n.id,
190                "text": n.text,
191                "lemma": n.lemma,
192                "pos": n.pos,
193                "syntax": n.relation,
194                "parentid": n.head_id,
195                "sent_id": n.sent_id,
196                **n.morph,
197            }
198            rows.append(row)
199        return pl.DataFrame(rows)
200
201    def to_networkx(self):
202        """Render this graph as a ``networkx.DiGraph``, with token data as node attrs.
203
204        Requires the optional ``networkx`` dependency (``pip install
205        "udsyntax[graph]"``).
206        """
207        import networkx as nx
208
209        g = nx.DiGraph()
210        for n in self.nodes:
211            g.add_node(n.id, text=n.text, lemma=n.lemma, pos=n.pos, relation=n.relation, **n.morph)
212        for e in self.edges:
213            g.add_edge(e.src, e.target, relation=e.relation)
214        return g
215
216    def to_mermaid(self, orientation: str = "TB") -> str:
217        """Render this graph as a Mermaid flowchart definition.
218
219        Each node is rendered as ``n<id>["text (id:pos)"]`` and each
220        dependency edge as ``n<head> -->|relation| n<dependent>``, so the
221        arrow direction always runs from a token to its dependent,
222        regardless of ``orientation``.
223
224        Parameters
225        ----------
226        orientation:
227            The direction keyword written after ``graph`` in the header
228            line -- one of ``"TB"``, ``"TD"``, ``"BT"``, ``"RL"``, or
229            ``"LR"`` (see the Mermaid flowchart docs). Defaults to
230            ``"TB"`` (top-to-bottom): since an edge always runs from a
231            token to its dependent, this puts the governing token (the
232            one with no incoming edge -- a sentence root) at the top and
233            its dependents below, the traditional syntax-tree reading.
234
235        No external dependency is required -- this returns plain text
236        that can be pasted into any Mermaid-aware renderer (GitHub,
237        Claude/Cowork artifacts, the Mermaid Live Editor, etc.).
238        """
239        if orientation not in MERMAID_ORIENTATIONS:
240            raise ValueError(
241                f"orientation must be one of {sorted(MERMAID_ORIENTATIONS)}, got {orientation!r}"
242            )
243
244        lines = [f"graph {orientation}"]
245        for n in self.nodes:
246            label = _mermaid_escape(f"{n.text} ({n.id}:{n.pos})")
247            lines.append(f'    n{n.id}["{label}"]')
248        for e in self.edges:
249            label = _mermaid_escape(e.relation)
250            lines.append(f"    n{e.src} -->|{label}| n{e.target}")
251        return "\n".join(lines)
252
253    def to_dot(self, orientation: str = "TB", *, color_by_clause: bool = True) -> str:
254        """Render this graph as a Graphviz DOT ``digraph`` definition,
255        styled after neelsmith/arsgrammatica's own dot renderer
256        (``arsgrammatica.dot.tokengraph_to_dot()``): filled, boxy nodes
257        colored by clause, laid out with an explicit ``rankdir``, rather
258        than plain unstyled nodes left to Graphviz's own default ellipses.
259
260        Each node is rendered as ``n<id> [label="text (pos)", fillcolor=
261        "...", color="...", fontcolor="...", style="filled"];`` -- or, when
262        ``color_by_clause`` is False or a token belongs to no clause, just
263        ``n<id> [label="text (pos)"];`` (an unfilled box). Each dependency
264        edge is ``n<head> -> n<dependent> [label="relation"];``. The
265        digraph is named after ``self.urn`` when present, otherwise
266        ``"SyntaxGraph"``.
267
268        Parameters
269        ----------
270        orientation:
271            DOT's own ``rankdir`` value: ``"TB"`` (the default -- since
272            an edge always runs from a token to its dependent, this puts
273            the governing token (a sentence root has no incoming edge)
274            at the top and its dependents below, the traditional
275            syntax-tree reading, matching `to_mermaid()`'s own default),
276            ``"BT"``, ``"LR"``, or ``"RL"``.
277        color_by_clause:
278            Color every token by the clause it belongs to -- the nearest
279            finite verb above it in the dependency tree, per
280            `_clause_anchor_for()` (a token that IS a finite verb anchors
281            its own clause) -- cycling through the same 8-color pastel
282            palette arsgrammatica uses. A token with no finite verb
283            anywhere above it (a verbless fragment) is left uncolored.
284            Emits a ``UserWarning`` (never raises) if a passage has more
285            than 8 clauses, since colors repeat past the 8th.
286
287        No external dependency is required -- this returns plain DOT
288        source text; feed it to the ``dot`` command line tool or the
289        ``graphviz`` Python package to render an image.
290        """
291        if orientation not in DOT_ORIENTATIONS:
292            raise ValueError(
293                f"orientation must be one of {sorted(DOT_ORIENTATIONS)}, got {orientation!r}"
294            )
295
296        colors_by_node: dict = {}
297        if color_by_clause:
298            nodes_by_id = {n.id: n for n in self.nodes}
299            assignment = {n.id: _clause_anchor_for(n.id, nodes_by_id) for n in self.nodes}
300            palette, repeats_warning = _assign_clause_colors(self.nodes, assignment)
301            if repeats_warning:
302                warnings.warn(repeats_warning, stacklevel=2)
303            for n in self.nodes:
304                unit = assignment.get(n.id)
305                colors_by_node[n.id] = palette.get(unit) if unit is not None else None
306
307        graph_name = _dot_escape(self.urn if self.urn else "SyntaxGraph")
308        lines = [
309            f'digraph "{graph_name}" {{',
310            f"    rankdir={orientation};",
311            "    node [shape=box];",
312            "",
313        ]
314        for n in self.nodes:
315            label = _dot_escape(f"{n.text} ({n.pos})")
316            attrs = [f'label="{label}"']
317            color = colors_by_node.get(n.id)
318            if color is not None:
319                fill, stroke, text_color = color
320                attrs.append(f'fillcolor="{fill}"')
321                attrs.append(f'color="{stroke}"')
322                attrs.append(f'fontcolor="{text_color}"')
323                attrs.append('style="filled"')
324            lines.append(f"    n{n.id} [{', '.join(attrs)}];")
325
326        lines.append("")
327        for e in self.edges:
328            label = _dot_escape(e.relation)
329            lines.append(f'    n{e.src} -> n{e.target} [label="{label}"];')
330        lines.append("}")
331        return "\n".join(lines)

A dependency parse represented as syntax-graph nodes and edges.

SyntaxGraph( nodes: list[SyntaxNode] = <factory>, edges: list[SyntaxEdge] = <factory>, urn: str | None = None)
nodes: list[SyntaxNode]
edges: list[SyntaxEdge]
urn: str | None = None
@classmethod
def from_doc(cls, doc, *, urn: str | None = None) -> SyntaxGraph:
129    @classmethod
130    def from_doc(cls, doc, *, urn: str | None = None) -> "SyntaxGraph":
131        """Build a ``SyntaxGraph`` from a parsed spaCy ``Doc``.
132
133        ``doc`` must come from a pipeline that has run a dependency
134        parser (its tokens need ``.dep_``, ``.head``, and ``.morph``).
135        """
136        nodes: list[SyntaxNode] = []
137        edges: list[SyntaxEdge] = []
138        has_sents = doc.has_annotation("SENT_START")
139        for tok in doc:
140            sent_id = tok.sent.start if has_sents else None
141            nodes.append(
142                SyntaxNode(
143                    id=tok.i,
144                    text=tok.text,
145                    lemma=tok.lemma_,
146                    pos=tok.pos_,
147                    relation=tok.dep_,
148                    head_id=tok.head.i,
149                    morph=tok.morph.to_dict(),
150                    sent_id=sent_id,
151                )
152            )
153            if tok.dep_ != "ROOT":
154                edges.append(SyntaxEdge(src=tok.head.i, target=tok.i, relation=tok.dep_))
155        return cls(nodes=nodes, edges=edges, urn=urn)

Build a SyntaxGraph from a parsed spaCy Doc.

doc must come from a pipeline that has run a dependency parser (its tokens need .dep_, .head, and .morph).

def node(self, node_id: int) -> SyntaxNode:
157    def node(self, node_id: int) -> SyntaxNode:
158        for n in self.nodes:
159            if n.id == node_id:
160                return n
161        raise KeyError(node_id)
def root_nodes(self) -> list[SyntaxNode]:
163    def root_nodes(self) -> list[SyntaxNode]:
164        return [n for n in self.nodes if n.is_root]
def children_of(self, node_id: int) -> list[SyntaxNode]:
166    def children_of(self, node_id: int) -> list[SyntaxNode]:
167        child_ids = {e.target for e in self.edges if e.src == node_id}
168        return [n for n in self.nodes if n.id in child_ids]
def verbal_units(self) -> list[VerbalUnit]:
170    def verbal_units(self) -> list[VerbalUnit]:
171        """Finite verbs in this graph, as ``VerbalUnit`` records.
172
173        See :class:`udsyntax.models.VerbalUnit` for the current
174        limitation on ``depth``.
175        """
176        units = []
177        for i, n in enumerate(node for node in self.nodes if node.morph.get("VerbForm") == "Fin"):
178            depth = 1 if n.is_root else None
179            units.append(VerbalUnit(id=i, verb_token_id=n.id, verb_text=n.text, depth=depth))
180        return units

Finite verbs in this graph, as VerbalUnit records.

See udsyntax.models.VerbalUnit for the current limitation on depth.

def to_polars(self):
182    def to_polars(self):
183        """Render this graph's nodes as a polars ``DataFrame``, one row per token."""
184        import polars as pl
185
186        rows = []
187        for n in self.nodes:
188            row = {
189                "id": n.id,
190                "text": n.text,
191                "lemma": n.lemma,
192                "pos": n.pos,
193                "syntax": n.relation,
194                "parentid": n.head_id,
195                "sent_id": n.sent_id,
196                **n.morph,
197            }
198            rows.append(row)
199        return pl.DataFrame(rows)

Render this graph's nodes as a polars DataFrame, one row per token.

def to_networkx(self):
201    def to_networkx(self):
202        """Render this graph as a ``networkx.DiGraph``, with token data as node attrs.
203
204        Requires the optional ``networkx`` dependency (``pip install
205        "udsyntax[graph]"``).
206        """
207        import networkx as nx
208
209        g = nx.DiGraph()
210        for n in self.nodes:
211            g.add_node(n.id, text=n.text, lemma=n.lemma, pos=n.pos, relation=n.relation, **n.morph)
212        for e in self.edges:
213            g.add_edge(e.src, e.target, relation=e.relation)
214        return g

Render this graph as a networkx.DiGraph, with token data as node attrs.

Requires the optional networkx dependency (pip install "udsyntax[graph]").

def to_mermaid(self, orientation: str = 'TB') -> str:
216    def to_mermaid(self, orientation: str = "TB") -> str:
217        """Render this graph as a Mermaid flowchart definition.
218
219        Each node is rendered as ``n<id>["text (id:pos)"]`` and each
220        dependency edge as ``n<head> -->|relation| n<dependent>``, so the
221        arrow direction always runs from a token to its dependent,
222        regardless of ``orientation``.
223
224        Parameters
225        ----------
226        orientation:
227            The direction keyword written after ``graph`` in the header
228            line -- one of ``"TB"``, ``"TD"``, ``"BT"``, ``"RL"``, or
229            ``"LR"`` (see the Mermaid flowchart docs). Defaults to
230            ``"TB"`` (top-to-bottom): since an edge always runs from a
231            token to its dependent, this puts the governing token (the
232            one with no incoming edge -- a sentence root) at the top and
233            its dependents below, the traditional syntax-tree reading.
234
235        No external dependency is required -- this returns plain text
236        that can be pasted into any Mermaid-aware renderer (GitHub,
237        Claude/Cowork artifacts, the Mermaid Live Editor, etc.).
238        """
239        if orientation not in MERMAID_ORIENTATIONS:
240            raise ValueError(
241                f"orientation must be one of {sorted(MERMAID_ORIENTATIONS)}, got {orientation!r}"
242            )
243
244        lines = [f"graph {orientation}"]
245        for n in self.nodes:
246            label = _mermaid_escape(f"{n.text} ({n.id}:{n.pos})")
247            lines.append(f'    n{n.id}["{label}"]')
248        for e in self.edges:
249            label = _mermaid_escape(e.relation)
250            lines.append(f"    n{e.src} -->|{label}| n{e.target}")
251        return "\n".join(lines)

Render this graph as a Mermaid flowchart definition.

Each node is rendered as n<id>["text (id:pos)"] and each dependency edge as n<head> -->|relation| n<dependent>, so the arrow direction always runs from a token to its dependent, regardless of orientation.

Parameters

orientation: The direction keyword written after graph in the header line -- one of "TB", "TD", "BT", "RL", or "LR" (see the Mermaid flowchart docs). Defaults to "TB" (top-to-bottom): since an edge always runs from a token to its dependent, this puts the governing token (the one with no incoming edge -- a sentence root) at the top and its dependents below, the traditional syntax-tree reading.

No external dependency is required -- this returns plain text that can be pasted into any Mermaid-aware renderer (GitHub, Claude/Cowork artifacts, the Mermaid Live Editor, etc.).

def to_dot(self, orientation: str = 'TB', *, color_by_clause: bool = True) -> str:
253    def to_dot(self, orientation: str = "TB", *, color_by_clause: bool = True) -> str:
254        """Render this graph as a Graphviz DOT ``digraph`` definition,
255        styled after neelsmith/arsgrammatica's own dot renderer
256        (``arsgrammatica.dot.tokengraph_to_dot()``): filled, boxy nodes
257        colored by clause, laid out with an explicit ``rankdir``, rather
258        than plain unstyled nodes left to Graphviz's own default ellipses.
259
260        Each node is rendered as ``n<id> [label="text (pos)", fillcolor=
261        "...", color="...", fontcolor="...", style="filled"];`` -- or, when
262        ``color_by_clause`` is False or a token belongs to no clause, just
263        ``n<id> [label="text (pos)"];`` (an unfilled box). Each dependency
264        edge is ``n<head> -> n<dependent> [label="relation"];``. The
265        digraph is named after ``self.urn`` when present, otherwise
266        ``"SyntaxGraph"``.
267
268        Parameters
269        ----------
270        orientation:
271            DOT's own ``rankdir`` value: ``"TB"`` (the default -- since
272            an edge always runs from a token to its dependent, this puts
273            the governing token (a sentence root has no incoming edge)
274            at the top and its dependents below, the traditional
275            syntax-tree reading, matching `to_mermaid()`'s own default),
276            ``"BT"``, ``"LR"``, or ``"RL"``.
277        color_by_clause:
278            Color every token by the clause it belongs to -- the nearest
279            finite verb above it in the dependency tree, per
280            `_clause_anchor_for()` (a token that IS a finite verb anchors
281            its own clause) -- cycling through the same 8-color pastel
282            palette arsgrammatica uses. A token with no finite verb
283            anywhere above it (a verbless fragment) is left uncolored.
284            Emits a ``UserWarning`` (never raises) if a passage has more
285            than 8 clauses, since colors repeat past the 8th.
286
287        No external dependency is required -- this returns plain DOT
288        source text; feed it to the ``dot`` command line tool or the
289        ``graphviz`` Python package to render an image.
290        """
291        if orientation not in DOT_ORIENTATIONS:
292            raise ValueError(
293                f"orientation must be one of {sorted(DOT_ORIENTATIONS)}, got {orientation!r}"
294            )
295
296        colors_by_node: dict = {}
297        if color_by_clause:
298            nodes_by_id = {n.id: n for n in self.nodes}
299            assignment = {n.id: _clause_anchor_for(n.id, nodes_by_id) for n in self.nodes}
300            palette, repeats_warning = _assign_clause_colors(self.nodes, assignment)
301            if repeats_warning:
302                warnings.warn(repeats_warning, stacklevel=2)
303            for n in self.nodes:
304                unit = assignment.get(n.id)
305                colors_by_node[n.id] = palette.get(unit) if unit is not None else None
306
307        graph_name = _dot_escape(self.urn if self.urn else "SyntaxGraph")
308        lines = [
309            f'digraph "{graph_name}" {{',
310            f"    rankdir={orientation};",
311            "    node [shape=box];",
312            "",
313        ]
314        for n in self.nodes:
315            label = _dot_escape(f"{n.text} ({n.pos})")
316            attrs = [f'label="{label}"']
317            color = colors_by_node.get(n.id)
318            if color is not None:
319                fill, stroke, text_color = color
320                attrs.append(f'fillcolor="{fill}"')
321                attrs.append(f'color="{stroke}"')
322                attrs.append(f'fontcolor="{text_color}"')
323                attrs.append('style="filled"')
324            lines.append(f"    n{n.id} [{', '.join(attrs)}];")
325
326        lines.append("")
327        for e in self.edges:
328            label = _dot_escape(e.relation)
329            lines.append(f'    n{e.src} -> n{e.target} [label="{label}"];')
330        lines.append("}")
331        return "\n".join(lines)

Render this graph as a Graphviz DOT digraph definition, styled after neelsmith/arsgrammatica's own dot renderer (arsgrammatica.dot.tokengraph_to_dot()): filled, boxy nodes colored by clause, laid out with an explicit rankdir, rather than plain unstyled nodes left to Graphviz's own default ellipses.

Each node is rendered as n<id> [label="text (pos)", fillcolor= "...", color="...", fontcolor="...", style="filled"]; -- or, when color_by_clause is False or a token belongs to no clause, just n<id> [label="text (pos)"]; (an unfilled box). Each dependency edge is n<head> -> n<dependent> [label="relation"];. The digraph is named after self.urn when present, otherwise "SyntaxGraph".

Parameters

orientation: DOT's own rankdir value: "TB" (the default -- since an edge always runs from a token to its dependent, this puts the governing token (a sentence root has no incoming edge) at the top and its dependents below, the traditional syntax-tree reading, matching to_mermaid()'s own default), "BT", "LR", or "RL". color_by_clause: Color every token by the clause it belongs to -- the nearest finite verb above it in the dependency tree, per _clause_anchor_for() (a token that IS a finite verb anchors its own clause) -- cycling through the same 8-color pastel palette arsgrammatica uses. A token with no finite verb anywhere above it (a verbless fragment) is left uncolored. Emits a UserWarning (never raises) if a passage has more than 8 clauses, since colors repeat past the 8th.

No external dependency is required -- this returns plain DOT source text; feed it to the dot command line tool or the graphviz Python package to render an image.

@dataclass
class SyntaxNode:
13@dataclass
14class SyntaxNode:
15    """A single token in a dependency parse, as a syntax-graph node."""
16
17    id: int
18    text: str
19    lemma: str
20    pos: str
21    relation: str
22    head_id: int
23    morph: dict = field(default_factory=dict)
24    sent_id: int | None = None
25
26    def __str__(self) -> str:
27        return f"{self.text} ({self.id})"
28
29    @property
30    def is_root(self) -> bool:
31        return self.relation == "ROOT"

A single token in a dependency parse, as a syntax-graph node.

SyntaxNode( id: int, text: str, lemma: str, pos: str, relation: str, head_id: int, morph: dict = <factory>, sent_id: int | None = None)
id: int
text: str
lemma: str
pos: str
relation: str
head_id: int
morph: dict
sent_id: int | None = None
is_root: bool
29    @property
30    def is_root(self) -> bool:
31        return self.relation == "ROOT"
@dataclass
class SyntaxEdge:
34@dataclass
35class SyntaxEdge:
36    """A directed dependency relation between two token ids."""
37
38    src: int
39    target: int
40    relation: str
41
42    def __str__(self) -> str:
43        return f"{self.src} -> {self.target}: {self.relation}"

A directed dependency relation between two token ids.

SyntaxEdge(src: int, target: int, relation: str)
src: int
target: int
relation: str
@dataclass
class VerbalUnit:
46@dataclass
47class VerbalUnit:
48    """A finite verb and (eventually) the syntactic material organized around it.
49
50    ``depth`` mirrors the original prototype's heuristic: only a verbal
51    unit that is itself the sentence ``ROOT`` currently gets a depth
52    (``1``); dependent verbal units are left as ``None`` pending a real
53    subordination-depth calculation.
54    """
55
56    id: int
57    verb_token_id: int
58    verb_text: str
59    depth: int | None = None

A finite verb and (eventually) the syntactic material organized around it.

depth mirrors the original prototype's heuristic: only a verbal unit that is itself the sentence ROOT currently gets a depth (1); dependent verbal units are left as None pending a real subordination-depth calculation.

VerbalUnit( id: int, verb_token_id: int, verb_text: str, depth: int | None = None)
id: int
verb_token_id: int
verb_text: str
depth: int | None = None
def corpus_to_polars(graphs: Iterable[SyntaxGraph]):
334def corpus_to_polars(graphs: Iterable[SyntaxGraph]):
335    """Concatenate several ``SyntaxGraph``\\ s into one polars ``DataFrame``.
336
337    Adds a ``doc_index`` column (position of each graph in ``graphs``).
338    When a graph's ``urn`` is a parseable CTS URN, ``group`` / ``work`` /
339    ``version`` / ``passage`` columns are added alongside it.
340    """
341    import polars as pl
342
343    frames = []
344    for i, g in enumerate(graphs):
345        df = g.to_polars().with_columns(pl.lit(i).alias("doc_index"))
346        if g.urn:
347            try:
348                parsed = parse_cts_urn(g.urn)
349            except ValueError:
350                df = df.with_columns(pl.lit(g.urn).alias("urn"))
351            else:
352                df = df.with_columns(
353                    pl.lit(g.urn).alias("urn"),
354                    pl.lit(parsed.group).alias("group"),
355                    pl.lit(parsed.work).alias("work"),
356                    pl.lit(parsed.version).alias("version"),
357                    pl.lit(parsed.passage).alias("passage"),
358                )
359        frames.append(df)
360    return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame()

Concatenate several SyntaxGraph\ s into one polars DataFrame.

Adds a doc_index column (position of each graph in graphs). When a graph's urn is a parseable CTS URN, group / work / version / passage columns are added alongside it.

def read_cex( path, *, urn_filter: str | None = None, encoding: str = 'utf-8') -> list[tuple[str, str]]:
14def read_cex(
15    path,
16    *,
17    urn_filter: str | None = None,
18    encoding: str = "utf-8",
19) -> list[tuple[str, str]]:
20    """Read a CEX file of ``urn|text`` lines.
21
22    Parameters
23    ----------
24    path:
25        Path to a ``.cex`` file.
26    urn_filter:
27        If given, keep only lines whose URN contains this substring
28        (e.g. ``"vulgate"``, ``"septuagint"``, ``"targum_latin"``).
29    encoding:
30        Text encoding to use when reading the file.
31
32    Returns
33    -------
34    A list of ``(urn, text)`` tuples, in file order. Blank lines are
35    skipped.
36    """
37    path = Path(path)
38    pairs: list[tuple[str, str]] = []
39    with path.open("r", encoding=encoding) as f:
40        for line in f:
41            line = line.rstrip("\n")
42            if not line.strip():
43                continue
44            urn, _, text = line.partition("|")
45            if urn_filter is not None and urn_filter not in urn:
46                continue
47            pairs.append((urn, text))
48    return pairs

Read a CEX file of urn|text lines.

Parameters

path: Path to a .cex file. urn_filter: If given, keep only lines whose URN contains this substring (e.g. "vulgate", "septuagint", "targum_latin"). encoding: Text encoding to use when reading the file.

Returns

A list of (urn, text) tuples, in file order. Blank lines are skipped.

def read_cex_many( paths, *, urn_filter: str | None = None, encoding: str = 'utf-8') -> list[tuple[str, str]]:
51def read_cex_many(
52    paths,
53    *,
54    urn_filter: str | None = None,
55    encoding: str = "utf-8",
56) -> list[tuple[str, str]]:
57    """Read and concatenate several CEX files, in the order given."""
58    pairs: list[tuple[str, str]] = []
59    for path in paths:
60        pairs.extend(read_cex(path, urn_filter=urn_filter, encoding=encoding))
61    return pairs

Read and concatenate several CEX files, in the order given.

def select_urn(pairs, urn: str) -> str:
64def select_urn(pairs, urn: str) -> str:
65    """Return the text paired with an exact URN match.
66
67    ``pairs`` is a list of ``(urn, text)`` tuples such as those returned
68    by :func:`read_cex` / :func:`read_cex_many`. Unlike the ``urn_filter``
69    those readers accept, the match here is exact, not a substring --
70    this is how you pick out one citable passage once you already know
71    its full URN.
72
73    Raises ``KeyError`` if no pair has this exact URN. When more than one
74    pair shares the URN, the first one wins.
75    """
76    for u, text in pairs:
77        if u == urn:
78            return text
79    raise KeyError(f"no line with URN {urn!r}")

Return the text paired with an exact URN match.

pairs is a list of (urn, text) tuples such as those returned by read_cex() / read_cex_many(). Unlike the urn_filter those readers accept, the match here is exact, not a substring -- this is how you pick out one citable passage once you already know its full URN.

Raises KeyError if no pair has this exact URN. When more than one pair shares the URN, the first one wins.

def load_latin(model_name: str = 'la_core_web_lg'):
34def load_latin(model_name: str = DEFAULT_LATIN_MODEL):
35    """Load (and cache) a Latin dependency-parsing pipeline."""
36    return load_pipeline(model_name)

Load (and cache) a Latin dependency-parsing pipeline.

def load_greek(model_name: str = 'grc_dep_web_lg'):
39def load_greek(model_name: str = DEFAULT_GREEK_MODEL):
40    """Load (and cache) an Ancient Greek dependency-parsing pipeline."""
41    return load_pipeline(model_name)

Load (and cache) an Ancient Greek dependency-parsing pipeline.

@functools.lru_cache(maxsize=None)
def load_pipeline(model_name: str):
28@functools.lru_cache(maxsize=None)
29def load_pipeline(model_name: str):
30    """Load (and cache) a spaCy pipeline by name."""
31    return spacy.load(model_name)

Load (and cache) a spaCy pipeline by name.

DEFAULT_LATIN_MODEL = 'la_core_web_lg'
DEFAULT_GREEK_MODEL = 'grc_dep_web_lg'
@dataclass
class CtsUrn:
13@dataclass
14class CtsUrn:
15    """The parsed components of a CTS URN."""
16
17    namespace: str
18    group: str | None
19    work: str | None
20    version: str | None
21    passage: str | None
22    raw: str
23
24    def __str__(self) -> str:
25        return self.raw

The parsed components of a CTS URN.

CtsUrn( namespace: str, group: str | None, work: str | None, version: str | None, passage: str | None, raw: str)
namespace: str
group: str | None
work: str | None
version: str | None
passage: str | None
raw: str
def parse_cts_urn(urn: str) -> CtsUrn:
28def parse_cts_urn(urn: str) -> CtsUrn:
29    """Parse a CTS URN string into its components.
30
31    Tolerant of URNs missing a version or passage component -- fields
32    that aren't present come back as ``None``. Raises ``ValueError`` if
33    ``urn`` isn't a CTS URN at all (i.e. doesn't start with ``urn:cts:``).
34    """
35    parts = urn.strip().split(":")
36    if len(parts) < 3 or parts[0] != "urn" or parts[1] != "cts":
37        raise ValueError(f"not a CTS URN: {urn!r}")
38
39    namespace = parts[2]
40    work_component = parts[3] if len(parts) > 3 else ""
41    passage = parts[4] if len(parts) > 4 else None
42
43    work_parts = work_component.split(".") if work_component else []
44    group = work_parts[0] if len(work_parts) > 0 else None
45    work = work_parts[1] if len(work_parts) > 1 else None
46    version = work_parts[2] if len(work_parts) > 2 else None
47
48    return CtsUrn(
49        namespace=namespace,
50        group=group,
51        work=work,
52        version=version,
53        passage=passage,
54        raw=urn,
55    )

Parse a CTS URN string into its components.

Tolerant of URNs missing a version or passage component -- fields that aren't present come back as None. Raises ValueError if urn isn't a CTS URN at all (i.e. doesn't start with urn:cts:).