Skip to content

Documentation for the CtsUrn class

urn_citation.CtsUrn

Bases: Urn

A CTS URN identifying a passage of a canonically citable text.

Canonical Text Service (CTS) URNs model passages of texts with two overlapping hierarchies: a work hierarchy, and a passage hierarchy. Values in the work hierarchy belong to a specified namespace. The work hierarchy is required to identify at least a text group; optionally, it may specify a work, a version (edition or translation) of the work, and exemplar (specific copy of the version). The passage hierarchy may be empty, in which case the URN refers to the entire contents of the work identified in the work hierarchy. Otherwise, the passage hierarchy identifies a specific passage of the work, at any depth of the citation hierarchy appropriate for the work (e.g., book, chapter, verse, line, token.) The passage hierarchy may identify either a single passage or a range of passages.

Attributes:

Name Type Description
namespace str

Required identifier for the namespace of the text (e.g., "greekLit" or "latinLit") where values for the work hierarchy are defined.

text_group str

Required identifier for text group.

work str

Optional identifier for work.

version str

Optional identifier for version (edition or translation) of the work.

exemplar str

Optional identifier for exemplar (specific copy of the version) of the work.

passage str

Optional identifier for passage of the work, at any depth of the citation hierarchy appropriate for the work (e.g., book, chapter, verse, line, token). May identify either a single passage or a range of passages.

Source code in src/urn_citation/ctsurn.py
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
class CtsUrn(Urn):
    """A CTS URN identifying a passage of a canonically citable text.

    Canonical Text Service (CTS) URNs model passages of texts with two overlapping hierarchies: a work hierarchy, and a passage hierarchy. Values in the work hierarchy belong to a specified namespace. The work hierarchy is required to identify at least a text group; optionally, it may specify a work, a version (edition or translation) of the work, and exemplar (specific copy of the version). The passage hierarchy may be empty, in which case the URN refers to the entire contents of the work identified in the work hierarchy. Otherwise, the passage hierarchy identifies a specific passage of the work, at any depth of the citation hierarchy appropriate for the work (e.g., book, chapter, verse, line, token.) The passage hierarchy may identify either a single passage or a range of passages.

    Attributes:
        namespace (str): Required identifier for the namespace of the text (e.g., "greekLit" or "latinLit") where values for the work hierarchy are defined.
        text_group (str): Required identifier for text group.
        work (str): Optional identifier for work.
        version (str): Optional identifier for version (edition or translation) of the work.
        exemplar (str): Optional identifier for exemplar (specific copy of the version) of the work.
        passage (str): Optional identifier for passage of the work, at any depth of the citation hierarchy appropriate for the work (e.g., book, chapter, verse, line, token). May identify either a single passage or a range of passages.
    """    
    namespace: str
    text_group: str
    work: str | None = None
    version: str | None = None
    exemplar: str | None = None
    passage: str | None = None

    @model_validator(mode='after')
    def validate_work_hierarchy(self):
        """Validate the work hierarchy structure.

        Ensures that:
        - version cannot be set if work is None
        - exemplar cannot be set if version or work is None
        - passage component has at most one @ per range part

        Raises:
            ValueError: If the hierarchy constraints are violated.
        """
        if self.version is not None and self.work is None:
            raise ValueError("version cannot be set when work is None")

        # Check work before version for exemplar (check hierarchy from root to leaf)
        if self.exemplar is not None and self.work is None:
            raise ValueError("exemplar cannot be set when work is None")

        if self.exemplar is not None and self.version is None:
            raise ValueError("exemplar cannot be set when version is None")

        # Validate subreferences in passage component
        if self.passage is not None:
            range_parts = self.passage.split("-")
            for part in range_parts:
                if part.count("@") > 1:
                    raise ValueError(f"Each passage component can have at most one @ delimiter for subreference, found {part.count('@')} in '{part}'")
                # Check for empty subreferences
                if "@" in part:
                    subref_parts = part.split("@")
                    if len(subref_parts) != 2 or not subref_parts[1]:
                        raise ValueError(f"Subreference cannot be empty, found empty subreference in '{part}'")

        return self

    @classmethod
    def from_string(cls, raw_string):
        # 1. Split the string into a list of values
        parts = raw_string.split(":")
        if len(parts) != 5:
            raise ValueError("Bad.")
        header, urn_type, namespace, work_component, passage_component = parts

        rangeparts = passage_component.split("-")
        if len(rangeparts) > 2:
            raise ValueError(f"Passage component of CTS URN cannot have more than one hyphen to indicate a range, found {len(rangeparts)-1} hyphenated parts in {passage_component}.")

        # Validate subreferences (at most one @ per range part)
        for part in rangeparts:
            if part.count("@") > 1:
                raise ValueError(f"Each passage component can have at most one @ delimiter for subreference, found {part.count('@')} in '{part}'")
            # Check for empty subreferences
            if "@" in part:
                subref_parts = part.split("@")
                if len(subref_parts) != 2 or not subref_parts[1]:
                    raise ValueError(f"Subreference cannot be empty, found empty subreference in '{part}'")

        if ".." in work_component:
            raise ValueError(f"Work component of CTS URN cannot contain successive periods, found in {work_component}.")

        if ".." in passage_component:
            raise ValueError(f"Passage component of CTS URN cannot contain successive periods, found in {passage_component}.")

        workparts = work_component.split(".")
        if len(workparts) > 4:
            raise ValueError(f"Work component of CTS URN cannot have more than 4 dot-delimited components, got {len(workparts)} from {work_component}.")

        groupid, workid, versionid, exemplarid =         (workparts + [None] * 4)[:4]

        if not passage_component:
            passage_component = None

        return cls(
            urn_type=urn_type,
            namespace=namespace,
            text_group=groupid,
            work=workid,
            version=versionid,
            exemplar=exemplarid,
            passage=passage_component
        )

    def __str__(self) -> str:
        """Serialize the CtsUrn to its string representation.

        Returns a CTS URN string in the format:
        urn:cts:namespace:work.hierarchy:passage

        Where work.hierarchy is constructed from the text_group, work, version, and exemplar,
        and passage is the passage component (or empty string if None).

        Returns:
            str: The serialized CTS URN string.
        """
        # Build the work component from the work hierarchy
        work_parts = [self.text_group]
        if self.work is not None:
            work_parts.append(self.work)
        if self.version is not None:
            work_parts.append(self.version)
        if self.exemplar is not None:
            work_parts.append(self.exemplar)

        work_component = ".".join(work_parts)

        # Build the passage component (empty string if None)
        passage_component = self.passage if self.passage is not None else ""

        # Construct the full URN string
        return f"urn:{self.urn_type}:{self.namespace}:{work_component}:{passage_component}"

    def is_range(self) -> bool:
        """Check if the passage component represents a range.

        A passage is a range if it contains exactly one hyphen, indicating both
        a range beginning and range end separated by that hyphen.

        Returns:
            bool: True if the passage is a range, False otherwise.
        """
        if self.passage is None:
            return False

        range_parts = self.passage.split("-")
        return len(range_parts) == 2

    def has_subreference(self) -> bool:
        """Check if the passage component has a subreference.

        A passage has a subreference if it contains at least one @ character,
        which may appear on either or both parts of a range reference, or on
        a single reference.

        Returns:
            bool: True if the passage contains a subreference (@ character), False otherwise.
        """
        if self.passage is None:
            return False

        return "@" in self.passage

    def has_subreference1(self) -> bool:
        """Check if the range begin part has a subreference.

        Returns True if the URN is a range and the range begin part contains
        a @ character indicating a subreference.

        Returns:
            bool: True if the range begin part has a subreference, False otherwise.

        Raises:
            ValueError: If the URN is not a range.
        """
        if not self.is_range():
            raise ValueError("has_subreference1 can only be called on range URNs")

        range_begin = self.range_begin()
        return "@" in range_begin if range_begin else False

    def has_subreference2(self) -> bool:
        """Check if the range end part has a subreference.

        Returns True if the URN is a range and the range end part contains
        a @ character indicating a subreference.

        Returns:
            bool: True if the range end part has a subreference, False otherwise.

        Raises:
            ValueError: If the URN is not a range.
        """
        if not self.is_range():
            raise ValueError("has_subreference2 can only be called on range URNs")

        range_end = self.range_end()
        return "@" in range_end if range_end else False

    def subreference(self) -> str | None:
        """Get the subreference part of a passage reference.

        Returns the subreference part (the text after @) if the passage has a subreference.
        Returns None if the passage has no subreference.

        Returns:
            str | None: The subreference part, or None if no subreference exists.

        Raises:
            ValueError: If the URN is a range reference.
        """
        if self.is_range():
            raise ValueError("subreference can only be called on non-range URNs")

        if self.passage is None or "@" not in self.passage:
            return None

        parts = self.passage.split("@")
        return parts[1]

    def subreference1(self) -> str | None:
        """Get the subreference part of the range begin reference.

        Returns the subreference part (the text after @) of the range begin part
        if it has a subreference. Returns None if the range begin part has no subreference.

        Returns:
            str | None: The subreference part of the range begin, or None if no subreference exists.

        Raises:
            ValueError: If the URN is not a range reference.
        """
        if not self.is_range():
            raise ValueError("subreference1 can only be called on range URNs")

        range_begin = self.range_begin()
        if range_begin is None or "@" not in range_begin:
            return None

        parts = range_begin.split("@")
        return parts[1]

    def subreference2(self) -> str | None:
        """Get the subreference part of the range end reference.

        Returns the subreference part (the text after @) of the range end part
        if it has a subreference. Returns None if the range end part has no subreference.

        Returns:
            str | None: The subreference part of the range end, or None if no subreference exists.

        Raises:
            ValueError: If the URN is not a range reference.
        """
        if not self.is_range():
            raise ValueError("subreference2 can only be called on range URNs")

        range_end = self.range_end()
        if range_end is None or "@" not in range_end:
            return None

        parts = range_end.split("@")
        return parts[1]

    def range_begin(self) -> str | None:
        """Get the beginning of a passage range.

        Returns the first range piece if the passage component represents a range
        (i.e., contains exactly one hyphen). Returns None if the passage is not
        a range or if passage is None.

        Returns:
            str | None: The beginning of the range, or None if not a range.
        """
        if not self.is_range():
            return None

        range_parts = self.passage.split("-")
        return range_parts[0]

    def range_end(self) -> str | None:
        """Get the end of a passage range.

        Returns the second range piece if the passage component represents a range
        (i.e., contains exactly one hyphen). Returns None if the passage is not
        a range or if passage is None.

        Returns:
            str | None: The end of the range, or None if not a range.
        """
        if not self.is_range():
            return None

        range_parts = self.passage.split("-")
        return range_parts[1]

    @classmethod
    def valid_string(cls, raw_string: str) -> bool:
        """Check if a string is valid for constructing a CtsUrn.

        A valid CTS URN string must:
        - Split into exactly 5 colon-delimited components
        - Have a passage component with at most 1 hyphen (for ranges)
        - Have a work component with at most 4 dot-delimited parts

        Args:
            raw_string (str): The string to validate.

        Returns:
            bool: True if the string is valid, False otherwise.
        """
        try:
            parts = raw_string.split(":")
            if len(parts) != 5:
                return False

            header, urn_type, namespace, work_component, passage_component = parts

            # Check passage component (at most 1 hyphen)
            rangeparts = passage_component.split("-")
            if len(rangeparts) > 2:
                return False

            # Check for successive periods in work and passage components
            if ".." in work_component or ".." in passage_component:
                return False

            # Check work component (at most 4 dot-delimited parts)
            workparts = work_component.split(".")
            if len(workparts) > 4:
                return False

            return True
        except Exception:
            return False

    def work_equals(self, other: CtsUrn) -> bool:
        """Check if the work hierarchy is equal to another CtsUrn.

        Compares the text_group, work, version, and exemplar fields.

        Args:
            other (CtsUrn): The CtsUrn to compare with.

        Returns:
            bool: True if all work hierarchy fields are equal, False otherwise.
        """
        return (
            self.text_group == other.text_group
            and self.work == other.work
            and self.version == other.version
            and self.exemplar == other.exemplar
        )


    # rewrite this using a more elegant `getattr` approach, and also add a docstring
    def work_contains(self, other: CtsUrn) -> bool:
        """Check if the work hierarchy contains another CtsUrn.

        Returns True if all non-None values of text_group, work, version, and exemplar
        in this CtsUrn equal the corresponding values in the other CtsUrn.

        Args:
            other (CtsUrn): The CtsUrn to compare with.

        Returns:
            bool: True if all non-None work hierarchy fields match, False otherwise.
        """
        if self.text_group is not None and self.text_group != other.text_group:
            return False
        if self.work is not None and self.work != other.work:
            return False
        if self.version is not None and self.version != other.version:
            return False
        if self.exemplar is not None and self.exemplar != other.exemplar:
            return False
        return True

    def passage_equals(self, other: CtsUrn) -> bool:
        """Check if the passage component is equal to another CtsUrn.

        Compares the passage field of this CtsUrn with the passage field of another.

        Args:
            other (CtsUrn): The CtsUrn to compare with.

        Returns:
            bool: True if the passage fields are equal, False otherwise.
        """
        return self.passage == other.passage

    def passage_contains(self, other: CtsUrn) -> bool:
        """Check if the passage component contains another CtsUrn.

        Returns True if:
        - The passages are exactly equal, OR
        - The other passage is at least 2 characters longer and starts with 
          this passage followed by a period character.

        Raises ValueError if either passage is a range.

        Examples:
        - passage="1", other.passage="1.11" -> True
        - passage="1", other.passage="12" -> False

        Args:
            other (CtsUrn): The CtsUrn to compare with.

        Returns:
            bool: True if the passages match the similarity criteria, False otherwise.

        Raises:
            ValueError: If either passage is a range (contains a hyphen).
        """
        if self.is_range():
            raise ValueError("passage_contains cannot be called on a CtsUrn with a range passage")
        if other.is_range():
            raise ValueError("passage_contains cannot be called with a CtsUrn argument that has a range passage")

        # Check exact equality
        if self.passage == other.passage:
            return True

        # Check if other passage is a refinement of this passage
        if self.passage is not None and other.passage is not None:
            expected_prefix = self.passage + "."
            return (
                len(other.passage) >= len(self.passage) + 2
                and other.passage.startswith(expected_prefix)
            )

        return False

    def contains(self, other: CtsUrn) -> bool:
        """Check if this CtsUrn contains another CtsUrn.

        Returns True if both the work hierarchy and passage contain the other.

        Args:
            other (CtsUrn): The CtsUrn to compare with.

        Returns:
            bool: True if both work_contains and passage_contains are True, False otherwise.
        """
        return self.work_contains(other) and self.passage_contains(other)

    def drop_passage(self) -> CtsUrn:
        """Create a new CtsUrn without the passage component.

        Returns a new CtsUrn instance with the same work hierarchy but
        with the passage set to None.

        Returns:
            CtsUrn: A new CtsUrn instance without the passage component.
        """
        return CtsUrn(
            urn_type=self.urn_type,
            namespace=self.namespace,
            text_group=self.text_group,
            work=self.work,
            version=self.version,
            exemplar=self.exemplar,
            passage=None
        )

    def set_passage(self, new_passage: str) -> CtsUrn:
        """Create a new CtsUrn with a specified passage component.

        Returns a new CtsUrn instance with the same work hierarchy but
        with the passage set to the provided new_passage value.

        Args:
            new_passage (str | None): The new passage component to set.

        Returns:
            CtsUrn: A new CtsUrn instance with the updated passage component.
        """
        return CtsUrn(
            urn_type=self.urn_type,
            namespace=self.namespace,
            text_group=self.text_group,
            work=self.work,
            version=self.version,
            exemplar=self.exemplar,
            passage=new_passage
        )


    def drop_subreference(self) -> CtsUrn:
        """Create a new CtsUrn with all subreferences removed.

        Returns a new CtsUrn instance with subreferences (text after @) removed
        from the passage component. Works on both single passages and ranges.
        If there are no subreferences, returns a new instance with the same passage.

        Returns:
            CtsUrn: A new CtsUrn instance without subreferences in the passage.
        """
        if self.passage is None or "@" not in self.passage:
            # No subreference to drop, return copy with same passage
            return CtsUrn(
                urn_type=self.urn_type,
                namespace=self.namespace,
                text_group=self.text_group,
                work=self.work,
                version=self.version,
                exemplar=self.exemplar,
                passage=self.passage
            )

        # Remove subreferences from passage
        range_parts = self.passage.split("-")
        cleaned_parts = []
        for part in range_parts:
            if "@" in part:
                # Keep only the part before @
                cleaned_parts.append(part.split("@")[0])
            else:
                cleaned_parts.append(part)

        new_passage = "-".join(cleaned_parts)

        return CtsUrn(
            urn_type=self.urn_type,
            namespace=self.namespace,
            text_group=self.text_group,
            work=self.work,
            version=self.version,
            exemplar=self.exemplar,
            passage=new_passage
        )

    def drop_version(self) -> CtsUrn:
        """Create a new CtsUrn without the version component.

        Returns a new CtsUrn instance with the same work hierarchy but
        with the version set to None. Note: exemplar will also be set to None
        since exemplar cannot exist without a version.

        Returns:
            CtsUrn: A new CtsUrn instance without the version component.
        """
        return CtsUrn(
            urn_type=self.urn_type,
            namespace=self.namespace,
            text_group=self.text_group,
            work=self.work,
            version=None,
            exemplar=None,  # Must also drop exemplar since it requires version
            passage=self.passage
        )


    def set_version(self, new_version: str) -> CtsUrn:
        """Create a new CtsUrn with a specified version component.

        Returns a new CtsUrn instance with the same work hierarchy but
        with the version set to the provided new_version value.

        Args:
            new_version (str | None): The new version component to set.

        Returns:
            CtsUrn: A new CtsUrn instance with the updated version component.
        """
        return CtsUrn(
            urn_type=self.urn_type,
            namespace=self.namespace,
            text_group=self.text_group,
            work=self.work,
            version=new_version,
            exemplar=self.exemplar,
            passage=self.passage
        )

    def drop_exemplar(self) -> CtsUrn:
        """Create a new CtsUrn without the exemplar component.

        Returns a new CtsUrn instance with the same work hierarchy but
        with the exemplar set to None.

        Returns:
            CtsUrn: A new CtsUrn instance without the exemplar component.
        """
        return CtsUrn(
            urn_type=self.urn_type,
            namespace=self.namespace,
            text_group=self.text_group,
            work=self.work,
            version=self.version,
            exemplar=None,
            passage=self.passage
        )

    def set_exemplar(self, new_exemplar: str) -> CtsUrn:
        """Create a new CtsUrn with a specified exemplar component.

        Returns a new CtsUrn instance with the same work hierarchy but
        with the exemplar set to the provided new_exemplar value.

        Args:
            new_exemplar (str | None): The new exemplar component to set.

        Returns:
            CtsUrn: A new CtsUrn instance with the updated exemplar component.
        """
        return CtsUrn(
            urn_type=self.urn_type,
            namespace=self.namespace,
            text_group=self.text_group,
            work=self.work,
            version=self.version,
            exemplar=new_exemplar,
            passage=self.passage
        )

__str__()

Serialize the CtsUrn to its string representation.

Returns a CTS URN string in the format: urn:cts:namespace:work.hierarchy:passage

Where work.hierarchy is constructed from the text_group, work, version, and exemplar, and passage is the passage component (or empty string if None).

Returns:

Name Type Description
str str

The serialized CTS URN string.

Source code in src/urn_citation/ctsurn.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def __str__(self) -> str:
    """Serialize the CtsUrn to its string representation.

    Returns a CTS URN string in the format:
    urn:cts:namespace:work.hierarchy:passage

    Where work.hierarchy is constructed from the text_group, work, version, and exemplar,
    and passage is the passage component (or empty string if None).

    Returns:
        str: The serialized CTS URN string.
    """
    # Build the work component from the work hierarchy
    work_parts = [self.text_group]
    if self.work is not None:
        work_parts.append(self.work)
    if self.version is not None:
        work_parts.append(self.version)
    if self.exemplar is not None:
        work_parts.append(self.exemplar)

    work_component = ".".join(work_parts)

    # Build the passage component (empty string if None)
    passage_component = self.passage if self.passage is not None else ""

    # Construct the full URN string
    return f"urn:{self.urn_type}:{self.namespace}:{work_component}:{passage_component}"

contains(other)

Check if this CtsUrn contains another CtsUrn.

Returns True if both the work hierarchy and passage contain the other.

Parameters:

Name Type Description Default
other CtsUrn

The CtsUrn to compare with.

required

Returns:

Name Type Description
bool bool

True if both work_contains and passage_contains are True, False otherwise.

Source code in src/urn_citation/ctsurn.py
438
439
440
441
442
443
444
445
446
447
448
449
def contains(self, other: CtsUrn) -> bool:
    """Check if this CtsUrn contains another CtsUrn.

    Returns True if both the work hierarchy and passage contain the other.

    Args:
        other (CtsUrn): The CtsUrn to compare with.

    Returns:
        bool: True if both work_contains and passage_contains are True, False otherwise.
    """
    return self.work_contains(other) and self.passage_contains(other)

drop_exemplar()

Create a new CtsUrn without the exemplar component.

Returns a new CtsUrn instance with the same work hierarchy but with the exemplar set to None.

Returns:

Name Type Description
CtsUrn CtsUrn

A new CtsUrn instance without the exemplar component.

Source code in src/urn_citation/ctsurn.py
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
def drop_exemplar(self) -> CtsUrn:
    """Create a new CtsUrn without the exemplar component.

    Returns a new CtsUrn instance with the same work hierarchy but
    with the exemplar set to None.

    Returns:
        CtsUrn: A new CtsUrn instance without the exemplar component.
    """
    return CtsUrn(
        urn_type=self.urn_type,
        namespace=self.namespace,
        text_group=self.text_group,
        work=self.work,
        version=self.version,
        exemplar=None,
        passage=self.passage
    )

drop_passage()

Create a new CtsUrn without the passage component.

Returns a new CtsUrn instance with the same work hierarchy but with the passage set to None.

Returns:

Name Type Description
CtsUrn CtsUrn

A new CtsUrn instance without the passage component.

Source code in src/urn_citation/ctsurn.py
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
def drop_passage(self) -> CtsUrn:
    """Create a new CtsUrn without the passage component.

    Returns a new CtsUrn instance with the same work hierarchy but
    with the passage set to None.

    Returns:
        CtsUrn: A new CtsUrn instance without the passage component.
    """
    return CtsUrn(
        urn_type=self.urn_type,
        namespace=self.namespace,
        text_group=self.text_group,
        work=self.work,
        version=self.version,
        exemplar=self.exemplar,
        passage=None
    )

drop_subreference()

Create a new CtsUrn with all subreferences removed.

Returns a new CtsUrn instance with subreferences (text after @) removed from the passage component. Works on both single passages and ranges. If there are no subreferences, returns a new instance with the same passage.

Returns:

Name Type Description
CtsUrn CtsUrn

A new CtsUrn instance without subreferences in the passage.

Source code in src/urn_citation/ctsurn.py
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
def drop_subreference(self) -> CtsUrn:
    """Create a new CtsUrn with all subreferences removed.

    Returns a new CtsUrn instance with subreferences (text after @) removed
    from the passage component. Works on both single passages and ranges.
    If there are no subreferences, returns a new instance with the same passage.

    Returns:
        CtsUrn: A new CtsUrn instance without subreferences in the passage.
    """
    if self.passage is None or "@" not in self.passage:
        # No subreference to drop, return copy with same passage
        return CtsUrn(
            urn_type=self.urn_type,
            namespace=self.namespace,
            text_group=self.text_group,
            work=self.work,
            version=self.version,
            exemplar=self.exemplar,
            passage=self.passage
        )

    # Remove subreferences from passage
    range_parts = self.passage.split("-")
    cleaned_parts = []
    for part in range_parts:
        if "@" in part:
            # Keep only the part before @
            cleaned_parts.append(part.split("@")[0])
        else:
            cleaned_parts.append(part)

    new_passage = "-".join(cleaned_parts)

    return CtsUrn(
        urn_type=self.urn_type,
        namespace=self.namespace,
        text_group=self.text_group,
        work=self.work,
        version=self.version,
        exemplar=self.exemplar,
        passage=new_passage
    )

drop_version()

Create a new CtsUrn without the version component.

Returns a new CtsUrn instance with the same work hierarchy but with the version set to None. Note: exemplar will also be set to None since exemplar cannot exist without a version.

Returns:

Name Type Description
CtsUrn CtsUrn

A new CtsUrn instance without the version component.

Source code in src/urn_citation/ctsurn.py
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
def drop_version(self) -> CtsUrn:
    """Create a new CtsUrn without the version component.

    Returns a new CtsUrn instance with the same work hierarchy but
    with the version set to None. Note: exemplar will also be set to None
    since exemplar cannot exist without a version.

    Returns:
        CtsUrn: A new CtsUrn instance without the version component.
    """
    return CtsUrn(
        urn_type=self.urn_type,
        namespace=self.namespace,
        text_group=self.text_group,
        work=self.work,
        version=None,
        exemplar=None,  # Must also drop exemplar since it requires version
        passage=self.passage
    )

has_subreference()

Check if the passage component has a subreference.

A passage has a subreference if it contains at least one @ character, which may appear on either or both parts of a range reference, or on a single reference.

Returns:

Name Type Description
bool bool

True if the passage contains a subreference (@ character), False otherwise.

Source code in src/urn_citation/ctsurn.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def has_subreference(self) -> bool:
    """Check if the passage component has a subreference.

    A passage has a subreference if it contains at least one @ character,
    which may appear on either or both parts of a range reference, or on
    a single reference.

    Returns:
        bool: True if the passage contains a subreference (@ character), False otherwise.
    """
    if self.passage is None:
        return False

    return "@" in self.passage

has_subreference1()

Check if the range begin part has a subreference.

Returns True if the URN is a range and the range begin part contains a @ character indicating a subreference.

Returns:

Name Type Description
bool bool

True if the range begin part has a subreference, False otherwise.

Raises:

Type Description
ValueError

If the URN is not a range.

Source code in src/urn_citation/ctsurn.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def has_subreference1(self) -> bool:
    """Check if the range begin part has a subreference.

    Returns True if the URN is a range and the range begin part contains
    a @ character indicating a subreference.

    Returns:
        bool: True if the range begin part has a subreference, False otherwise.

    Raises:
        ValueError: If the URN is not a range.
    """
    if not self.is_range():
        raise ValueError("has_subreference1 can only be called on range URNs")

    range_begin = self.range_begin()
    return "@" in range_begin if range_begin else False

has_subreference2()

Check if the range end part has a subreference.

Returns True if the URN is a range and the range end part contains a @ character indicating a subreference.

Returns:

Name Type Description
bool bool

True if the range end part has a subreference, False otherwise.

Raises:

Type Description
ValueError

If the URN is not a range.

Source code in src/urn_citation/ctsurn.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def has_subreference2(self) -> bool:
    """Check if the range end part has a subreference.

    Returns True if the URN is a range and the range end part contains
    a @ character indicating a subreference.

    Returns:
        bool: True if the range end part has a subreference, False otherwise.

    Raises:
        ValueError: If the URN is not a range.
    """
    if not self.is_range():
        raise ValueError("has_subreference2 can only be called on range URNs")

    range_end = self.range_end()
    return "@" in range_end if range_end else False

is_range()

Check if the passage component represents a range.

A passage is a range if it contains exactly one hyphen, indicating both a range beginning and range end separated by that hyphen.

Returns:

Name Type Description
bool bool

True if the passage is a range, False otherwise.

Source code in src/urn_citation/ctsurn.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def is_range(self) -> bool:
    """Check if the passage component represents a range.

    A passage is a range if it contains exactly one hyphen, indicating both
    a range beginning and range end separated by that hyphen.

    Returns:
        bool: True if the passage is a range, False otherwise.
    """
    if self.passage is None:
        return False

    range_parts = self.passage.split("-")
    return len(range_parts) == 2

passage_contains(other)

Check if the passage component contains another CtsUrn.

Returns True if: - The passages are exactly equal, OR - The other passage is at least 2 characters longer and starts with this passage followed by a period character.

Raises ValueError if either passage is a range.

Examples: - passage="1", other.passage="1.11" -> True - passage="1", other.passage="12" -> False

Parameters:

Name Type Description Default
other CtsUrn

The CtsUrn to compare with.

required

Returns:

Name Type Description
bool bool

True if the passages match the similarity criteria, False otherwise.

Raises:

Type Description
ValueError

If either passage is a range (contains a hyphen).

Source code in src/urn_citation/ctsurn.py
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
def passage_contains(self, other: CtsUrn) -> bool:
    """Check if the passage component contains another CtsUrn.

    Returns True if:
    - The passages are exactly equal, OR
    - The other passage is at least 2 characters longer and starts with 
      this passage followed by a period character.

    Raises ValueError if either passage is a range.

    Examples:
    - passage="1", other.passage="1.11" -> True
    - passage="1", other.passage="12" -> False

    Args:
        other (CtsUrn): The CtsUrn to compare with.

    Returns:
        bool: True if the passages match the similarity criteria, False otherwise.

    Raises:
        ValueError: If either passage is a range (contains a hyphen).
    """
    if self.is_range():
        raise ValueError("passage_contains cannot be called on a CtsUrn with a range passage")
    if other.is_range():
        raise ValueError("passage_contains cannot be called with a CtsUrn argument that has a range passage")

    # Check exact equality
    if self.passage == other.passage:
        return True

    # Check if other passage is a refinement of this passage
    if self.passage is not None and other.passage is not None:
        expected_prefix = self.passage + "."
        return (
            len(other.passage) >= len(self.passage) + 2
            and other.passage.startswith(expected_prefix)
        )

    return False

passage_equals(other)

Check if the passage component is equal to another CtsUrn.

Compares the passage field of this CtsUrn with the passage field of another.

Parameters:

Name Type Description Default
other CtsUrn

The CtsUrn to compare with.

required

Returns:

Name Type Description
bool bool

True if the passage fields are equal, False otherwise.

Source code in src/urn_citation/ctsurn.py
383
384
385
386
387
388
389
390
391
392
393
394
def passage_equals(self, other: CtsUrn) -> bool:
    """Check if the passage component is equal to another CtsUrn.

    Compares the passage field of this CtsUrn with the passage field of another.

    Args:
        other (CtsUrn): The CtsUrn to compare with.

    Returns:
        bool: True if the passage fields are equal, False otherwise.
    """
    return self.passage == other.passage

range_begin()

Get the beginning of a passage range.

Returns the first range piece if the passage component represents a range (i.e., contains exactly one hyphen). Returns None if the passage is not a range or if passage is None.

Returns:

Type Description
str | None

str | None: The beginning of the range, or None if not a range.

Source code in src/urn_citation/ctsurn.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
def range_begin(self) -> str | None:
    """Get the beginning of a passage range.

    Returns the first range piece if the passage component represents a range
    (i.e., contains exactly one hyphen). Returns None if the passage is not
    a range or if passage is None.

    Returns:
        str | None: The beginning of the range, or None if not a range.
    """
    if not self.is_range():
        return None

    range_parts = self.passage.split("-")
    return range_parts[0]

range_end()

Get the end of a passage range.

Returns the second range piece if the passage component represents a range (i.e., contains exactly one hyphen). Returns None if the passage is not a range or if passage is None.

Returns:

Type Description
str | None

str | None: The end of the range, or None if not a range.

Source code in src/urn_citation/ctsurn.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
def range_end(self) -> str | None:
    """Get the end of a passage range.

    Returns the second range piece if the passage component represents a range
    (i.e., contains exactly one hyphen). Returns None if the passage is not
    a range or if passage is None.

    Returns:
        str | None: The end of the range, or None if not a range.
    """
    if not self.is_range():
        return None

    range_parts = self.passage.split("-")
    return range_parts[1]

set_exemplar(new_exemplar)

Create a new CtsUrn with a specified exemplar component.

Returns a new CtsUrn instance with the same work hierarchy but with the exemplar set to the provided new_exemplar value.

Parameters:

Name Type Description Default
new_exemplar str | None

The new exemplar component to set.

required

Returns:

Name Type Description
CtsUrn CtsUrn

A new CtsUrn instance with the updated exemplar component.

Source code in src/urn_citation/ctsurn.py
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
def set_exemplar(self, new_exemplar: str) -> CtsUrn:
    """Create a new CtsUrn with a specified exemplar component.

    Returns a new CtsUrn instance with the same work hierarchy but
    with the exemplar set to the provided new_exemplar value.

    Args:
        new_exemplar (str | None): The new exemplar component to set.

    Returns:
        CtsUrn: A new CtsUrn instance with the updated exemplar component.
    """
    return CtsUrn(
        urn_type=self.urn_type,
        namespace=self.namespace,
        text_group=self.text_group,
        work=self.work,
        version=self.version,
        exemplar=new_exemplar,
        passage=self.passage
    )

set_passage(new_passage)

Create a new CtsUrn with a specified passage component.

Returns a new CtsUrn instance with the same work hierarchy but with the passage set to the provided new_passage value.

Parameters:

Name Type Description Default
new_passage str | None

The new passage component to set.

required

Returns:

Name Type Description
CtsUrn CtsUrn

A new CtsUrn instance with the updated passage component.

Source code in src/urn_citation/ctsurn.py
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
def set_passage(self, new_passage: str) -> CtsUrn:
    """Create a new CtsUrn with a specified passage component.

    Returns a new CtsUrn instance with the same work hierarchy but
    with the passage set to the provided new_passage value.

    Args:
        new_passage (str | None): The new passage component to set.

    Returns:
        CtsUrn: A new CtsUrn instance with the updated passage component.
    """
    return CtsUrn(
        urn_type=self.urn_type,
        namespace=self.namespace,
        text_group=self.text_group,
        work=self.work,
        version=self.version,
        exemplar=self.exemplar,
        passage=new_passage
    )

set_version(new_version)

Create a new CtsUrn with a specified version component.

Returns a new CtsUrn instance with the same work hierarchy but with the version set to the provided new_version value.

Parameters:

Name Type Description Default
new_version str | None

The new version component to set.

required

Returns:

Name Type Description
CtsUrn CtsUrn

A new CtsUrn instance with the updated version component.

Source code in src/urn_citation/ctsurn.py
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
def set_version(self, new_version: str) -> CtsUrn:
    """Create a new CtsUrn with a specified version component.

    Returns a new CtsUrn instance with the same work hierarchy but
    with the version set to the provided new_version value.

    Args:
        new_version (str | None): The new version component to set.

    Returns:
        CtsUrn: A new CtsUrn instance with the updated version component.
    """
    return CtsUrn(
        urn_type=self.urn_type,
        namespace=self.namespace,
        text_group=self.text_group,
        work=self.work,
        version=new_version,
        exemplar=self.exemplar,
        passage=self.passage
    )

subreference()

Get the subreference part of a passage reference.

Returns the subreference part (the text after @) if the passage has a subreference. Returns None if the passage has no subreference.

Returns:

Type Description
str | None

str | None: The subreference part, or None if no subreference exists.

Raises:

Type Description
ValueError

If the URN is a range reference.

Source code in src/urn_citation/ctsurn.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def subreference(self) -> str | None:
    """Get the subreference part of a passage reference.

    Returns the subreference part (the text after @) if the passage has a subreference.
    Returns None if the passage has no subreference.

    Returns:
        str | None: The subreference part, or None if no subreference exists.

    Raises:
        ValueError: If the URN is a range reference.
    """
    if self.is_range():
        raise ValueError("subreference can only be called on non-range URNs")

    if self.passage is None or "@" not in self.passage:
        return None

    parts = self.passage.split("@")
    return parts[1]

subreference1()

Get the subreference part of the range begin reference.

Returns the subreference part (the text after @) of the range begin part if it has a subreference. Returns None if the range begin part has no subreference.

Returns:

Type Description
str | None

str | None: The subreference part of the range begin, or None if no subreference exists.

Raises:

Type Description
ValueError

If the URN is not a range reference.

Source code in src/urn_citation/ctsurn.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def subreference1(self) -> str | None:
    """Get the subreference part of the range begin reference.

    Returns the subreference part (the text after @) of the range begin part
    if it has a subreference. Returns None if the range begin part has no subreference.

    Returns:
        str | None: The subreference part of the range begin, or None if no subreference exists.

    Raises:
        ValueError: If the URN is not a range reference.
    """
    if not self.is_range():
        raise ValueError("subreference1 can only be called on range URNs")

    range_begin = self.range_begin()
    if range_begin is None or "@" not in range_begin:
        return None

    parts = range_begin.split("@")
    return parts[1]

subreference2()

Get the subreference part of the range end reference.

Returns the subreference part (the text after @) of the range end part if it has a subreference. Returns None if the range end part has no subreference.

Returns:

Type Description
str | None

str | None: The subreference part of the range end, or None if no subreference exists.

Raises:

Type Description
ValueError

If the URN is not a range reference.

Source code in src/urn_citation/ctsurn.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def subreference2(self) -> str | None:
    """Get the subreference part of the range end reference.

    Returns the subreference part (the text after @) of the range end part
    if it has a subreference. Returns None if the range end part has no subreference.

    Returns:
        str | None: The subreference part of the range end, or None if no subreference exists.

    Raises:
        ValueError: If the URN is not a range reference.
    """
    if not self.is_range():
        raise ValueError("subreference2 can only be called on range URNs")

    range_end = self.range_end()
    if range_end is None or "@" not in range_end:
        return None

    parts = range_end.split("@")
    return parts[1]

valid_string(raw_string) classmethod

Check if a string is valid for constructing a CtsUrn.

A valid CTS URN string must: - Split into exactly 5 colon-delimited components - Have a passage component with at most 1 hyphen (for ranges) - Have a work component with at most 4 dot-delimited parts

Parameters:

Name Type Description Default
raw_string str

The string to validate.

required

Returns:

Name Type Description
bool bool

True if the string is valid, False otherwise.

Source code in src/urn_citation/ctsurn.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
@classmethod
def valid_string(cls, raw_string: str) -> bool:
    """Check if a string is valid for constructing a CtsUrn.

    A valid CTS URN string must:
    - Split into exactly 5 colon-delimited components
    - Have a passage component with at most 1 hyphen (for ranges)
    - Have a work component with at most 4 dot-delimited parts

    Args:
        raw_string (str): The string to validate.

    Returns:
        bool: True if the string is valid, False otherwise.
    """
    try:
        parts = raw_string.split(":")
        if len(parts) != 5:
            return False

        header, urn_type, namespace, work_component, passage_component = parts

        # Check passage component (at most 1 hyphen)
        rangeparts = passage_component.split("-")
        if len(rangeparts) > 2:
            return False

        # Check for successive periods in work and passage components
        if ".." in work_component or ".." in passage_component:
            return False

        # Check work component (at most 4 dot-delimited parts)
        workparts = work_component.split(".")
        if len(workparts) > 4:
            return False

        return True
    except Exception:
        return False

validate_work_hierarchy()

Validate the work hierarchy structure.

Ensures that: - version cannot be set if work is None - exemplar cannot be set if version or work is None - passage component has at most one @ per range part

Raises:

Type Description
ValueError

If the hierarchy constraints are violated.

Source code in src/urn_citation/ctsurn.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@model_validator(mode='after')
def validate_work_hierarchy(self):
    """Validate the work hierarchy structure.

    Ensures that:
    - version cannot be set if work is None
    - exemplar cannot be set if version or work is None
    - passage component has at most one @ per range part

    Raises:
        ValueError: If the hierarchy constraints are violated.
    """
    if self.version is not None and self.work is None:
        raise ValueError("version cannot be set when work is None")

    # Check work before version for exemplar (check hierarchy from root to leaf)
    if self.exemplar is not None and self.work is None:
        raise ValueError("exemplar cannot be set when work is None")

    if self.exemplar is not None and self.version is None:
        raise ValueError("exemplar cannot be set when version is None")

    # Validate subreferences in passage component
    if self.passage is not None:
        range_parts = self.passage.split("-")
        for part in range_parts:
            if part.count("@") > 1:
                raise ValueError(f"Each passage component can have at most one @ delimiter for subreference, found {part.count('@')} in '{part}'")
            # Check for empty subreferences
            if "@" in part:
                subref_parts = part.split("@")
                if len(subref_parts) != 2 or not subref_parts[1]:
                    raise ValueError(f"Subreference cannot be empty, found empty subreference in '{part}'")

    return self

work_contains(other)

Check if the work hierarchy contains another CtsUrn.

Returns True if all non-None values of text_group, work, version, and exemplar in this CtsUrn equal the corresponding values in the other CtsUrn.

Parameters:

Name Type Description Default
other CtsUrn

The CtsUrn to compare with.

required

Returns:

Name Type Description
bool bool

True if all non-None work hierarchy fields match, False otherwise.

Source code in src/urn_citation/ctsurn.py
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def work_contains(self, other: CtsUrn) -> bool:
    """Check if the work hierarchy contains another CtsUrn.

    Returns True if all non-None values of text_group, work, version, and exemplar
    in this CtsUrn equal the corresponding values in the other CtsUrn.

    Args:
        other (CtsUrn): The CtsUrn to compare with.

    Returns:
        bool: True if all non-None work hierarchy fields match, False otherwise.
    """
    if self.text_group is not None and self.text_group != other.text_group:
        return False
    if self.work is not None and self.work != other.work:
        return False
    if self.version is not None and self.version != other.version:
        return False
    if self.exemplar is not None and self.exemplar != other.exemplar:
        return False
    return True

work_equals(other)

Check if the work hierarchy is equal to another CtsUrn.

Compares the text_group, work, version, and exemplar fields.

Parameters:

Name Type Description Default
other CtsUrn

The CtsUrn to compare with.

required

Returns:

Name Type Description
bool bool

True if all work hierarchy fields are equal, False otherwise.

Source code in src/urn_citation/ctsurn.py
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
def work_equals(self, other: CtsUrn) -> bool:
    """Check if the work hierarchy is equal to another CtsUrn.

    Compares the text_group, work, version, and exemplar fields.

    Args:
        other (CtsUrn): The CtsUrn to compare with.

    Returns:
        bool: True if all work hierarchy fields are equal, False otherwise.
    """
    return (
        self.text_group == other.text_group
        and self.work == other.work
        and self.version == other.version
        and self.exemplar == other.exemplar
    )