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"
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.
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).
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.
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.
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]").
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.).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:).