Skip to content

Commit ac09fb5

Browse files
committed
Merge branch 'main' into feature/330-enable-deep-copy
* main: #328 - Remove annotations in a specified range #328 - Remove annotations in a specified range #328 - Remove annotations in a specified range #328 - Remove annotations in a specified range #328 - Remove annotations in a specified range #328 - Remove annotations in a specified range #328 - Remove annotations in a specified range #328 - Remove annotations in a specified range Issue #342: Improve cas_to_comparable_text #328 - Remove annotations in a specified range #328 - Remove annotations in a specified range % Conflicts: % tests/test_cas.py
2 parents 128b265 + 1e5b9af commit ac09fb5

5 files changed

Lines changed: 1046 additions & 140 deletions

File tree

cassis/cas.py

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
FEATURE_BASE_NAME_HEAD,
1616
FEATURE_BASE_NAME_LANGUAGE,
1717
TYPE_NAME_DOCUMENT_ANNOTATION,
18+
TYPE_NAME_ANNOTATION,
1819
TYPE_NAME_FS_ARRAY,
1920
TYPE_NAME_FS_LIST,
2021
TYPE_NAME_SOFA,
@@ -368,6 +369,78 @@ def add_annotations(self, annotations: Iterable[FeatureStructure]):
368369
"""
369370
self.add_all(annotations)
370371

372+
def crop_sofa_string(self, sofa_begin: int, sofa_end: int, overlap: bool = True):
373+
"""Replaces current sofa string with a cutout of the given range. Removes all annotations outside of range,
374+
but keeps annotations that overlap with cutout points by default.
375+
376+
Args:
377+
sofa_begin: The beginning of the cutout sofa.
378+
sofa_end: The end of the cutout sofa.
379+
overlap: If true, keeps overlapping annotations and modifies begin and end of annotation accordingly.
380+
381+
Raises:
382+
ValueError: If cutout indices are invalid.
383+
Note:
384+
Removal performed by this method only removes annotations from the current view's
385+
index. Feature structures that are removed from the view remain in memory and any
386+
references from kept annotations to those feature structures are left intact. Such
387+
transitively referenced feature structures will still be discovered by traversal
388+
(e.g. ``_find_all_fs()``) and included during serialization.
389+
390+
Important: only the annotations that are kept (inside the cut or overlapping
391+
the cut boundaries) have their ``begin``/``end`` offsets adjusted to the new
392+
sofa coordinate space. Feature structures that are removed from the view are
393+
not re-anchored or relocated — they keep their original ``begin``/``end``
394+
values. As a result, serializers may attempt to transcode offsets that fall
395+
outside the new sofa range; the offset converter will emit ``UserWarning``
396+
messages for unmappable offsets but will not raise an exception. If you
397+
require a cascading delete or re-anchoring of transitively referenced feature
398+
structures, perform an explicit graph traversal and removal or implement an
399+
opt-in ``cascade=True`` behavior.
400+
"""
401+
if self.sofa_string is None:
402+
raise ValueError("Cannot crop sofa string: CAS has no sofa string for the current view")
403+
404+
if 0 <= sofa_begin < sofa_end <= len(self.sofa_string):
405+
self.sofa_string = self.sofa_string[sofa_begin:sofa_end]
406+
# Make an explicit snapshot of the current annotations to avoid
407+
# issues when removing/modifying elements during iteration.
408+
for annotation in list(self.select_all()):
409+
# Determine whether the annotation will be kept and how its
410+
# offsets need to be adjusted. If offsets are adjusted we must
411+
# reindex the annotation (remove then add) so that the
412+
# underlying SortedKeyList remains correctly ordered by the
413+
# updated begin/end values.
414+
if sofa_begin <= annotation.begin and annotation.end <= sofa_end:
415+
# fully contained
416+
self._current_view.remove_annotation_from_index(annotation)
417+
annotation.begin = annotation.begin - sofa_begin
418+
annotation.end = annotation.end - sofa_begin
419+
self._current_view.add_annotation_to_index(annotation)
420+
elif overlap and sofa_begin < annotation.end <= sofa_end:
421+
# left overlap (annotation starts before cut)
422+
self._current_view.remove_annotation_from_index(annotation)
423+
annotation.begin = 0
424+
annotation.end = annotation.end - sofa_begin
425+
self._current_view.add_annotation_to_index(annotation)
426+
elif overlap and sofa_begin <= annotation.begin < sofa_end:
427+
# right overlap (annotation ends after cut)
428+
self._current_view.remove_annotation_from_index(annotation)
429+
annotation.begin = annotation.begin - sofa_begin
430+
annotation.end = len(self.sofa_string)
431+
self._current_view.add_annotation_to_index(annotation)
432+
elif overlap and annotation.begin <= sofa_begin and sofa_end <= annotation.end:
433+
# annotation fully covers the cut
434+
self._current_view.remove_annotation_from_index(annotation)
435+
annotation.begin = 0
436+
annotation.end = len(self.sofa_string)
437+
self._current_view.add_annotation_to_index(annotation)
438+
else:
439+
# annotation falls completely outside the cut; remove it
440+
self.remove(annotation)
441+
else:
442+
raise ValueError(f"Invalid indices for begin {sofa_begin} and end {sofa_end}")
443+
371444
def remove(self, annotation: FeatureStructure):
372445
"""Removes an annotation from an index. This throws if the
373446
annotation was not present.
@@ -387,6 +460,38 @@ def remove_annotation(self, annotation: FeatureStructure):
387460
"""
388461
self.remove(annotation)
389462

463+
def remove_annotations_in_range(self, begin: int, end: int, type_: Optional[Union[Type, str]] = None):
464+
"""Removes annotations between two indices of the sofa string.
465+
466+
Args:
467+
begin: The beginning of the cutting interval.
468+
end: The end of the cutting interval.
469+
type_: The type or name of the type name whose annotation instances are to be found
470+
Raises:
471+
ValueError: If range indices are invalid.
472+
"""
473+
474+
# If no type is provided, operate on annotation-like feature
475+
# structures only (those that have `begin` and `end`) to avoid
476+
# AttributeError for arbitrary FS (e.g., instances of uima.cas.TOP).
477+
if type_ is None:
478+
# Only operate on annotation-like feature structures to avoid
479+
# AttributeError for non-annotation FS present in the view.
480+
annotations = [a for a in self.select_all() if self.typesystem.is_instance_of(a.type, TYPE_NAME_ANNOTATION)]
481+
else:
482+
annotations = self.select(type_)
483+
if self.sofa_string is None:
484+
raise ValueError("Cannot remove annotations by range: CAS has no sofa string for the current view")
485+
486+
if 0 <= begin < end <= len(self.sofa_string):
487+
# Make an explicit snapshot of the annotations to avoid issues when
488+
# removing elements during iteration (defensive copy).
489+
for annotation in list(annotations):
490+
if begin <= annotation.begin < annotation.end <= end:
491+
self.remove(annotation)
492+
else:
493+
raise ValueError(f"Invalid indices for begin {begin} and end {end}")
494+
390495
@deprecation.deprecated(details="Use annotation.get_covered_text()")
391496
def get_covered_text(self, annotation: FeatureStructure) -> str:
392497
"""Gets the text that is covered by `annotation`.
@@ -848,17 +953,17 @@ def deep_copy(self, copy_typesystem: bool = False) -> "Cas":
848953
ts = self.typesystem.to_xml()
849954
ts = load_typesystem(ts)
850955

851-
cas_copy = Cas(ts,
852-
document_language=self.document_language,
853-
lenient=self._lenient,
854-
sofa_mime=self.sofa_mime,
855-
)
956+
cas_copy = Cas(
957+
ts,
958+
document_language=self.document_language,
959+
lenient=self._lenient,
960+
sofa_mime=self.sofa_mime,
961+
)
856962

857963
cas_copy._views = {}
858964
cas_copy._sofas = {}
859965

860966
for sofa in self.sofas:
861-
862967
sofa_copy = Sofa(
863968
sofaID=sofa.sofaID,
864969
sofaNum=sofa.sofaNum,
@@ -883,9 +988,8 @@ def deep_copy(self, copy_typesystem: bool = False) -> "Cas":
883988
referenced_view = {}
884989

885990
for fs in self._find_all_fs():
886-
887991
# the referenced view is required when adding the fs to the copied cas later
888-
if hasattr(fs, 'sofa') and fs.sofa and hasattr(fs, 'xmiID') and fs.xmiID:
992+
if hasattr(fs, "sofa") and fs.sofa and hasattr(fs, "xmiID") and fs.xmiID:
889993
referenced_view[fs.xmiID] = fs.sofa.sofaID
890994

891995
t = ts.get_type(fs.type.name)
@@ -902,19 +1006,21 @@ def deep_copy(self, copy_typesystem: bool = False) -> "Cas":
9021006
# collect referenced xmiIDs for mapping later
9031007
referenced_list = []
9041008
for item in fs[feature.name].elements:
905-
if hasattr(item, 'xmiID') and item.xmiID is not None:
1009+
if hasattr(item, "xmiID") and item.xmiID is not None:
9061010
referenced_list.append(item.xmiID)
9071011
referenced_arrays.setdefault(fs.xmiID, {})
9081012
referenced_arrays[fs.xmiID][feature.name] = referenced_list
9091013
elif feature.rangeType.name == TYPE_NAME_SOFA:
9101014
# ignore sofa references
9111015
pass
9121016
else:
913-
if hasattr(fs[feature.name], 'xmiID') and fs[feature.name].xmiID is not None:
1017+
if hasattr(fs[feature.name], "xmiID") and fs[feature.name].xmiID is not None:
9141018
references.setdefault(feature.name, [])
9151019
references[feature.name].append((fs.xmiID, fs[feature.name].xmiID))
9161020
else:
917-
warnings.warn(f"Original non-primitive feature \"{feature.name}\" was and not copied from feature structure {fs.xmiID}.")
1021+
warnings.warn(
1022+
f'Original non-primitive feature "{feature.name}" was and not copied from feature structure {fs.xmiID}.'
1023+
)
9181024

9191025
fs_copy.xmiID = fs.xmiID
9201026
all_copied_fs[fs_copy.xmiID] = fs_copy
@@ -924,19 +1030,21 @@ def deep_copy(self, copy_typesystem: bool = False) -> "Cas":
9241030
for current_ID, reference_ID in pairs:
9251031
try:
9261032
all_copied_fs[current_ID][feature] = all_copied_fs[reference_ID]
927-
except KeyError as e:
928-
warnings.warn(f"Reference {reference_ID} not found for feature '{feature}' of feature structure {current_ID}")
1033+
except KeyError:
1034+
warnings.warn(
1035+
f"Reference {reference_ID} not found for feature '{feature}' of feature structure {current_ID}"
1036+
)
9291037

9301038
# set references for objects in arrays
9311039
for current_ID, arrays in referenced_arrays.items():
9321040
for feature, referenced_list in arrays.items():
9331041
elements = [all_copied_fs[reference_ID] for reference_ID in referenced_list]
9341042
all_copied_fs[current_ID][feature].elements = elements
9351043

936-
# add feature structures to the appropriate views
1044+
# add feature structures to the appropriate views (add in xmiID order)
9371045
feature_structures = sorted(all_copied_fs.values(), key=lambda f: f.xmiID, reverse=False)
938-
for item in all_copied_fs.values():
939-
if hasattr(item, 'xmiID') and item.xmiID is not None:
1046+
for item in feature_structures:
1047+
if hasattr(item, "xmiID") and item.xmiID is not None:
9401048
view_name = referenced_view.get(item.xmiID)
9411049
if view_name is not None:
9421050
cas_copy._current_view = cas_copy._views[view_name]

0 commit comments

Comments
 (0)