Skip to content

Commit e5eb8e0

Browse files
committed
#336 - Introduce Annotation class
- Introduced Annotation class with default begin/end 0 - Update reference data accordingly - Fixed several issues with sorting/ordering annotations
1 parent 9f138a7 commit e5eb8e0

File tree

8 files changed

+166
-89
lines changed

8 files changed

+166
-89
lines changed

cassis/cas.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919
TYPE_NAME_FS_LIST,
2020
TYPE_NAME_SOFA,
2121
FeatureStructure,
22+
Annotation,
2223
Type,
2324
TypeCheckError,
2425
TypeSystem,
2526
TypeSystemMode,
27+
is_annotation,
2628
)
2729

2830
_validator_optional_string = validators.optional(validators.instance_of(str))
@@ -171,13 +173,14 @@ def type_index(self) -> Dict[str, SortedKeyList]:
171173
return self._indices
172174

173175
def add_annotation_to_index(self, annotation: FeatureStructure):
176+
"""Adds a feature structure to the type index for this view."""
174177
self._indices[annotation.type.name].add(annotation)
175178

176179
def get_all_annotations(self) -> List[FeatureStructure]:
177-
"""Gets all the annotations in this view.
180+
"""Gets all the FeatureStructure in this view.
178181
179182
Returns:
180-
A list of all annotations in this view.
183+
A list of all FeatureStructure in this view.
181184
182185
"""
183186
result = []
@@ -334,6 +337,8 @@ def add(self, annotation: FeatureStructure, keep_id: Optional[bool] = True):
334337
if hasattr(annotation, "sofa"):
335338
annotation.sofa = self.get_sofa()
336339

340+
# Add to the index. The view index accepts any FeatureStructure;
341+
# `_sort_func` will duck-type annotation-like objects when sorting.
337342
self._current_view.add_annotation_to_index(annotation)
338343

339344
@deprecation.deprecated(details="Use add()")
@@ -387,7 +392,7 @@ def remove_annotation(self, annotation: FeatureStructure):
387392
self.remove(annotation)
388393

389394
@deprecation.deprecated(details="Use annotation.get_covered_text()")
390-
def get_covered_text(self, annotation: FeatureStructure) -> str:
395+
def get_covered_text(self, annotation: Annotation) -> str:
391396
"""Gets the text that is covered by `annotation`.
392397
393398
Args:
@@ -413,7 +418,7 @@ def select(self, type_: Union[Type, str]) -> List[FeatureStructure]:
413418
t = type_ if isinstance(type_, Type) else self.typesystem.get_type(type_)
414419
return self._get_feature_structures(t)
415420

416-
def select_covered(self, type_: Union[Type, str], covering_annotation: FeatureStructure) -> List[FeatureStructure]:
421+
def select_covered(self, type_: Union[Type, str], covering_annotation: Annotation) -> List[Annotation]:
417422
"""Returns a list of covered annotations.
418423
419424
Return all annotations that are covered
@@ -439,7 +444,7 @@ def select_covered(self, type_: Union[Type, str], covering_annotation: FeatureSt
439444
result.append(annotation)
440445
return result
441446

442-
def select_covering(self, type_: Union[Type, str], covered_annotation: FeatureStructure) -> List[FeatureStructure]:
447+
def select_covering(self, type_: Union[Type, str], covered_annotation: Annotation) -> List[FeatureStructure]:
443448
"""Returns a list of annotations that cover the given annotation.
444449
445450
Return all annotations that are covering. This can be potentially be slow.
@@ -465,7 +470,7 @@ def select_covering(self, type_: Union[Type, str], covered_annotation: FeatureSt
465470
if c_begin >= annotation.begin and c_end <= annotation.end:
466471
yield annotation
467472

468-
def select_all(self) -> List[FeatureStructure]:
473+
def select_all(self) -> List[Annotation]:
469474
"""Finds all feature structures in this Cas
470475
471476
Returns:
@@ -834,8 +839,8 @@ def _copy(self) -> "Cas":
834839

835840

836841
def _sort_func(a: FeatureStructure) -> Tuple[int, int, int]:
837-
d = a.__slots__
838-
if "begin" in d and "end" in d:
839-
return a.begin, a.end, id(a)
840-
else:
841-
return sys.maxsize, sys.maxsize, id(a)
842+
if is_annotation(a):
843+
return a.begin, a.end, a.xmiID if getattr(a, "xmiID", None) is not None else id(a)
844+
845+
# Non-annotation feature structures are sorted after annotations using large sentinels
846+
return sys.maxsize, sys.maxsize, a.xmiID if getattr(a, "xmiID", None) is not None else id(a)

cassis/typesystem.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,23 @@ def __repr__(self):
500500
return str(self)
501501

502502

503+
@attr.s(slots=True, hash=False, eq=True, order=True, repr=False)
504+
class Annotation(FeatureStructure):
505+
"""Concrete base class for annotation instances.
506+
507+
Generated types that represent (subtypes of) `uima.tcas.Annotation` will
508+
inherit from this class so that static typing can rely on a nominal base
509+
providing `begin` and `end`.
510+
"""
511+
512+
begin: int = attr.ib(default=0)
513+
end: int = attr.ib(default=0)
514+
515+
516+
def is_annotation(fs: FeatureStructure) -> bool:
517+
return hasattr(fs, "begin") and isinstance(fs.begin, int) and hasattr(fs, "end") and isinstance(fs.end, int)
518+
519+
503520
@attr.s(slots=True, eq=False, order=False, repr=False)
504521
class Feature:
505522
"""A feature defines one attribute of a feature structure"""
@@ -572,15 +589,44 @@ class Type:
572589
def __attrs_post_init__(self):
573590
"""Build the constructor that can create feature structures of this type"""
574591
name = _string_to_valid_classname(self.name)
575-
fields = {feature.name: attr.ib(default=None, repr=(feature.name != "sofa")) for feature in self.all_features}
592+
593+
# Determine whether this type is (transitively) a subtype of uima.tcas.Annotation
594+
def _is_annotation_type(t: "Type") -> bool:
595+
cur = t
596+
while cur is not None:
597+
if cur.name == TYPE_NAME_ANNOTATION:
598+
return True
599+
cur = cur.supertype
600+
return False
601+
602+
# When inheriting from our concrete Annotation base, do not redeclare
603+
# the 'begin' and 'end' features as fields; they are already present.
604+
fields = {}
605+
for feature in self.all_features:
606+
if feature.name in {"begin", "end"} and _is_annotation_type(self):
607+
# skip - Annotation base provides these
608+
continue
609+
fields[feature.name] = attr.ib(default=None, repr=(feature.name != "sofa"))
576610
fields["type"] = attr.ib(default=self)
577611

578612
# We assign this to a lambda to make it lazy
579613
# When creating large type systems, almost no types are used so
580614
# creating them on the fly is on average better
581-
self._constructor_fn = lambda: attr.make_class(
582-
name, fields, bases=(FeatureStructure,), slots=True, eq=False, order=False
583-
)
615+
bases = (Annotation,) if _is_annotation_type(self) else (FeatureStructure,)
616+
617+
def _make_fs_class():
618+
cls = attr.make_class(name, fields, bases=bases, slots=True, eq=False, order=False)
619+
# Ensure generated FS classes are hashable. When a class defines an
620+
# __eq__ (inherited or generated) but no __hash__, Python makes
621+
# instances unhashable. We want FeatureStructure-based instances to
622+
# be usable as dict/set keys (they are keyed by xmiID), so assign the
623+
# base FeatureStructure.__hash__ implementation to the generated
624+
# class if it doesn't already provide one.
625+
if getattr(cls, "__hash__", None) is None:
626+
cls.__hash__ = FeatureStructure.__hash__
627+
return cls
628+
629+
self._constructor_fn = _make_fs_class
584630

585631
def __call__(self, **kwargs) -> FeatureStructure:
586632
"""Creates an feature structure of this type

cassis/util.py

Lines changed: 50 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import csv
22
from collections import defaultdict
3-
from functools import cmp_to_key
43
from io import IOBase, StringIO
5-
from typing import Dict, Iterable, Set
4+
from typing import Any, Dict, Iterable, Set
65

76
from cassis import Cas
8-
from cassis.typesystem import FEATURE_BASE_NAME_SOFA, TYPE_NAME_ANNOTATION, FeatureStructure, Type, is_array
7+
from cassis.typesystem import (
8+
FEATURE_BASE_NAME_SOFA,
9+
TYPE_NAME_ANNOTATION,
10+
FeatureStructure,
11+
Type,
12+
is_annotation,
13+
is_array,
14+
)
915

1016
_EXCLUDED_FEATURES = {FEATURE_BASE_NAME_SOFA}
1117
_NULL_VALUE = "<NULL>"
@@ -74,7 +80,7 @@ def _render_feature_structure(
7480
) -> []:
7581
row_data = [fs_id_to_anchor.get(fs.xmiID)]
7682

77-
if max_covered_text > 0 and _is_annotation_fs(fs):
83+
if max_covered_text > 0 and is_annotation(fs):
7884
covered_text = fs.get_covered_text()
7985
if covered_text and len(covered_text) >= max_covered_text:
8086
prefix = covered_text[0 : (max_covered_text // 2)]
@@ -143,7 +149,19 @@ def _generate_anchors(
143149
for t in types_sorted:
144150
type_ = cas.typesystem.get_type(t)
145151
feature_structures = all_feature_structures_by_type[type_.name]
146-
feature_structures.sort(key=cmp_to_key(lambda a, b: _compare_fs(type_, a, b)))
152+
# Sort deterministically using a stable key function. We avoid using
153+
# the comparator-based approach to prevent unpredictable comparisons
154+
# between mixed types during lexicographic tuple comparisons.
155+
feature_structures.sort(
156+
key=lambda fs: (
157+
0,
158+
fs.begin,
159+
fs.end,
160+
str(_feature_structure_hash(type_, fs)),
161+
)
162+
if is_annotation(fs)
163+
else (1, None, None, str(_feature_structure_hash(type_, fs)))
164+
)
147165

148166
for fs in feature_structures:
149167
add_index_mark = mark_indexed and fs in indexed_feature_structures
@@ -159,7 +177,7 @@ def _generate_anchors(
159177
def _generate_anchor(fs: FeatureStructure, add_index_mark: bool) -> str:
160178
anchor = fs.type.name.rsplit(".", 2)[-1] # Get the short type name (no package)
161179

162-
if _is_annotation_fs(fs):
180+
if is_annotation(fs):
163181
anchor += f"[{fs.begin}-{fs.end}]"
164182

165183
if add_index_mark:
@@ -171,7 +189,7 @@ def _generate_anchor(fs: FeatureStructure, add_index_mark: bool) -> str:
171189
return anchor
172190

173191

174-
def _is_primitive_value(value: any) -> bool:
192+
def _is_primitive_value(value: Any) -> bool:
175193
return type(value) in (int, float, bool, str)
176194

177195

@@ -182,65 +200,34 @@ def _is_array_fs(fs: FeatureStructure) -> bool:
182200
return is_array(fs.type)
183201

184202

185-
def _is_annotation_fs(fs: FeatureStructure) -> bool:
186-
return hasattr(fs, "begin") and isinstance(fs.begin, int) and hasattr(fs, "end") and isinstance(fs.end, int)
187-
188-
189-
def _compare_fs(type_: Type, a: FeatureStructure, b: FeatureStructure) -> int:
190-
if a is b:
191-
return 0
192-
193-
# duck-typing check if something is a annotation - if yes, try sorting by offets
194-
fs_a_is_annotation = _is_annotation_fs(a)
195-
fs_b_is_annotation = _is_annotation_fs(b)
196-
if fs_a_is_annotation != fs_b_is_annotation:
197-
return -1
198-
if fs_a_is_annotation and fs_b_is_annotation:
199-
begin_cmp = a.begin - b.begin
200-
if begin_cmp != 0:
201-
return begin_cmp
202-
203-
begin_cmp = b.end - a.end
204-
if begin_cmp != 0:
205-
return begin_cmp
206-
207-
# Alternative implementation
208-
# Doing arithmetics on the hash value as we have done with the offsets does not work because the hashes do not
209-
# provide a global order. Hence, we map all results to 0, -1 and 1 here.
210-
fs_hash_a = _feature_structure_hash(type_, a)
211-
fs_hash_b = _feature_structure_hash(type_, b)
212-
if fs_hash_a == fs_hash_b:
213-
return 0
214-
return -1 if fs_hash_a < fs_hash_b else 1
215-
216-
217203
def _feature_structure_hash(type_: Type, fs: FeatureStructure):
218-
hash_ = 0
204+
# For backward compatibility keep a function that returns a stable string
205+
# representation of the FS contents. This is used as a deterministic
206+
# tie-breaker when sorting. We avoid returning complex nested tuples to
207+
# keep comparisons simple and stable across original and deserialized CASes.
208+
def _render_val(v):
209+
if v is None:
210+
return "<NULL>"
211+
if type(v) in (int, float, bool, str):
212+
return str(v)
213+
if _is_array_fs(v):
214+
# Join element representations with '|'
215+
return "[" + ",".join(_render_val(e) for e in (v.elements or [])) + "]"
216+
# Feature structure reference
217+
try:
218+
if is_annotation(v):
219+
return f"{v.type.name}@{v.begin}-{v.end}"
220+
else:
221+
return f"{v.type.name}"
222+
except Exception:
223+
return str(v)
224+
219225
if _is_array_fs(fs):
220-
return len(fs.elements) if fs.elements else 0
226+
return _render_val(fs.elements or [])
221227

222-
# Should be possible to get away with not sorting here assuming that all_features returns the features always in
223-
# the same order
228+
parts: list[str] = []
224229
for feature in type_.all_features:
225230
if feature.name == FEATURE_BASE_NAME_SOFA:
226231
continue
227-
228-
feature_value = getattr(fs, feature.name)
229-
230-
if _is_array_fs(feature_value):
231-
if feature_value.elements is not None:
232-
for element in feature_value.elements:
233-
hash_ = _feature_value_hash(feature_value, hash_)
234-
else:
235-
hash_ = _feature_value_hash(feature_value, hash_)
236-
return hash_
237-
238-
239-
def _feature_value_hash(feature_value: any, hash_: int):
240-
# Note we do not recurse further into arrays here because that could lead to endless loops!
241-
if type(feature_value) in (int, float, bool, str):
242-
return hash_ + hash(feature_value)
243-
else:
244-
# If we get here, it is a feature structure reference... we cannot really recursively
245-
# go into it to calculate a recursive hash... so we just check if the value is non-null
246-
return hash_ * (-1 if feature_value is None else 1)
232+
parts.append(_render_val(getattr(fs, feature.name)))
233+
return "|".join(parts)

cassis/xmi.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -619,13 +619,19 @@ def _serialize_feature_structure(self, cas: Cas, root: etree.Element, fs: Featur
619619
continue
620620

621621
# Map back from offsets in Unicode codepoints to UIMA UTF-16 based offsets
622-
if (
623-
ts.is_instance_of(fs.type.name, TYPE_NAME_ANNOTATION)
624-
and feature_name == FEATURE_BASE_NAME_BEGIN
625-
or feature_name == FEATURE_BASE_NAME_END
622+
# Ensure we only convert begin/end for annotation instances. Parentheses are
623+
# required because `and` has higher precedence than `or` and we must not
624+
# attempt conversion for the END feature on non-annotations.
625+
if ts.is_instance_of(fs.type.name, TYPE_NAME_ANNOTATION) and (
626+
feature_name == FEATURE_BASE_NAME_BEGIN or feature_name == FEATURE_BASE_NAME_END
626627
):
627-
sofa: Sofa = fs.sofa
628-
value = sofa._offset_converter.python_to_external(value)
628+
# Be defensive: only perform offset conversion if the sofa and its
629+
# offset converter have been initialized. In some workflows (e.g. a
630+
# freshly constructed CAS without sofa strings) the converter may
631+
# not exist yet and conversion is not possible.
632+
sofa = getattr(fs, "sofa", None)
633+
if sofa is not None and getattr(sofa, "_offset_converter", None) is not None:
634+
value = sofa._offset_converter.python_to_external(value)
629635

630636
if ts.is_instance_of(feature.rangeType, TYPE_NAME_STRING_ARRAY) and not feature.multipleReferencesAllowed:
631637
if value.elements is not None: # Compare to none as not to skip if elements is empty!

tests/test_cas.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,10 +597,43 @@ def test_covered_text_on_non_annotation():
597597
top.get_covered_text()
598598

599599

600+
def test_add_non_annotation_and_select():
601+
"""Create a non-annotation type, add an instance and verify select returns it."""
602+
cas = Cas()
603+
604+
# Create a type that does not define annotation offsets (begin/end)
605+
NonAnnotation = cas.typesystem.create_type("test.NonAnnotation")
606+
607+
# Instantiate and add to CAS
608+
fs = NonAnnotation()
609+
cas.add(fs)
610+
611+
# Should be retrievable by select using the type name
612+
selected = list(cas.select("test.NonAnnotation"))
613+
assert selected == [fs]
614+
615+
# And visible via select_all
616+
assert fs in cas.select_all()
617+
618+
600619
def test_covered_text_on_annotation_without_sofa():
601620
cas = Cas()
602621
Annotation = cas.typesystem.get_type(TYPE_NAME_ANNOTATION)
603622
ann = Annotation()
604623

605624
with pytest.raises(AnnotationHasNoSofa):
606625
ann.get_covered_text()
626+
627+
628+
def test_runtime_generated_annotation_is_detected_and_shown_in_anchor():
629+
ts = TypeSystem()
630+
# Create a new annotation subtype (should inherit from Annotation base)
631+
MyAnno = ts.create_type("my.pkg.MyAnnotation", supertypeName="uima.tcas.Annotation")
632+
633+
cas = Cas(ts)
634+
# Create an instance of the runtime-generated type; ensure we can set begin/end
635+
a = MyAnno(begin=5, end=10)
636+
cas.add(a)
637+
638+
text = cas_to_comparable_text(cas)
639+
assert "MyAnnotation[5-10]" in text

0 commit comments

Comments
 (0)