mmif-python 1.0.16__tar.gz → 1.0.18__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mmif_python-1.0.16 → mmif_python-1.0.18}/PKG-INFO +1 -1
- mmif_python-1.0.18/VERSION +1 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/serialize/annotation.py +50 -7
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/serialize/mmif.py +144 -94
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/serialize/model.py +6 -6
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/serialize/view.py +38 -17
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/utils/video_document_helper.py +6 -6
- mmif_python-1.0.18/mmif/ver/__init__.py +2 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/vocabulary/annotation_types.py +2 -2
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/vocabulary/base_types.py +1 -1
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/vocabulary/document_types.py +2 -2
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif_docloc_http/__init__.py +4 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif_python.egg-info/PKG-INFO +1 -1
- {mmif_python-1.0.16 → mmif_python-1.0.18}/tests/test_serialize.py +68 -32
- {mmif_python-1.0.16 → mmif_python-1.0.18}/tests/test_utils.py +2 -3
- mmif_python-1.0.16/VERSION +0 -1
- mmif_python-1.0.16/mmif/ver/__init__.py +0 -2
- {mmif_python-1.0.16 → mmif_python-1.0.18}/LICENSE +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/MANIFEST.in +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/README.md +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/__init__.py +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/res/__init__.py +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/res/clams.vocabulary.yaml +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/res/do-not-edit.txt +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/res/mmif.json +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/serialize/__init__.py +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/utils/__init__.py +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/utils/sequence_helper.py +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/utils/text_document_helper.py +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/utils/timeunit_helper.py +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/ver/do-not-edit.txt +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/vocabulary/__init__.py +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif/vocabulary/do-not-edit.txt +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif_python.egg-info/SOURCES.txt +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif_python.egg-info/dependency_links.txt +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif_python.egg-info/requires.txt +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/mmif_python.egg-info/top_level.txt +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/requirements.cv +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/requirements.seq +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/requirements.txt +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/setup.cfg +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/setup.py +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/tests/test_versioncompat.py +0 -0
- {mmif_python-1.0.16 → mmif_python-1.0.18}/tests/test_vocab.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: mmif-python
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.18
|
|
4
4
|
Summary: Python implementation of MultiMedia Interchange Format specification. (https://mmif.clams.ai)
|
|
5
5
|
Home-page: https://mmif.clams.ai
|
|
6
6
|
Author: Brandeis Lab for Linguistics and Computation
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.18
|
|
@@ -14,7 +14,7 @@ import pkgutil
|
|
|
14
14
|
import re
|
|
15
15
|
import typing
|
|
16
16
|
import warnings
|
|
17
|
-
from typing import Union, Dict,
|
|
17
|
+
from typing import Union, Dict, Optional, Iterator, MutableMapping, TypeVar
|
|
18
18
|
from urllib.parse import urlparse
|
|
19
19
|
|
|
20
20
|
from mmif.vocabulary import ThingTypesBase, DocumentTypesBase
|
|
@@ -46,12 +46,13 @@ class Annotation(MmifObject):
|
|
|
46
46
|
MmifObject that represents an annotation in a MMIF view.
|
|
47
47
|
"""
|
|
48
48
|
|
|
49
|
-
def __init__(self, anno_obj: Optional[Union[bytes, str, dict]] = None) -> None:
|
|
49
|
+
def __init__(self, anno_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
|
|
50
50
|
self._type: ThingTypesBase = ThingTypesBase('')
|
|
51
51
|
# to store the parent view ID
|
|
52
52
|
self._parent_view_id = ''
|
|
53
53
|
self._props_ephemeral: AnnotationProperties = AnnotationProperties()
|
|
54
|
-
self.
|
|
54
|
+
self._alignments = {} # to hold alignment information (Alignment anno long_id -> aligned anno long_id)
|
|
55
|
+
self.reserved_names.update(('_parent_view_id', '_props_ephemeral', '_alignments'))
|
|
55
56
|
if not hasattr(self, 'properties'): # don't overwrite DocumentProperties on super() call
|
|
56
57
|
self.properties: AnnotationProperties = AnnotationProperties()
|
|
57
58
|
self._attribute_classes = {'properties': AnnotationProperties}
|
|
@@ -59,6 +60,9 @@ class Annotation(MmifObject):
|
|
|
59
60
|
self._required_attributes = ["_type", "properties"]
|
|
60
61
|
super().__init__(anno_obj)
|
|
61
62
|
|
|
63
|
+
def __hash__(self):
|
|
64
|
+
return hash(self.serialize())
|
|
65
|
+
|
|
62
66
|
def _deserialize(self, input_dict: dict) -> None:
|
|
63
67
|
self.at_type = input_dict.pop('_type', '')
|
|
64
68
|
# TODO (krim @ 6/1/21): If annotation IDs must follow a certain string format,
|
|
@@ -69,6 +73,40 @@ class Annotation(MmifObject):
|
|
|
69
73
|
for k, v in self.properties.items():
|
|
70
74
|
self._add_prop_aliases(k, v)
|
|
71
75
|
|
|
76
|
+
def _cache_alignment(self, alignment_ann: 'Annotation', alignedto_ann: 'Annotation') -> None:
|
|
77
|
+
"""
|
|
78
|
+
Cache alignment information. This cache will not be serialized.
|
|
79
|
+
|
|
80
|
+
:param alignment_ann: the Alignment annotation that has this annotation on one side
|
|
81
|
+
:param alignedto_ann: the annotation that this annotation is aligned to (other side of Alignment)
|
|
82
|
+
"""
|
|
83
|
+
self._alignments[alignment_ann] = alignedto_ann
|
|
84
|
+
|
|
85
|
+
def aligned_to_by(self, alignment: 'Annotation') -> Optional['Annotation']:
|
|
86
|
+
"""
|
|
87
|
+
Retrieves the other side of ``Alignment`` annotation that has this annotation on one side.
|
|
88
|
+
|
|
89
|
+
:param alignment: ``Alignment`` annotation that has this annotation on one side
|
|
90
|
+
:return: the annotation that this annotation is aligned to (other side of ``Alignment``),
|
|
91
|
+
or None if this annotation is not used in the ``Alignment``.
|
|
92
|
+
"""
|
|
93
|
+
return self._alignments.get(alignment)
|
|
94
|
+
|
|
95
|
+
def get_all_aligned(self) -> Iterator['Annotation']:
|
|
96
|
+
"""
|
|
97
|
+
Generator to iterate through all alignments and aligned annotations. Note that this generator will yield
|
|
98
|
+
the `Alignment` annotations as well. Every odd-numbered yield will be an `Alignment` annotation, and every
|
|
99
|
+
even-numbered yield will be the aligned annotation. If there's a specific annotation type that you're looking
|
|
100
|
+
for, you need to filter the generated results outside.
|
|
101
|
+
|
|
102
|
+
:return: yields the alignment annotation and the aligned annotation.
|
|
103
|
+
The order is decided by the order of appearance of Alignment annotations in the MMIF
|
|
104
|
+
"""
|
|
105
|
+
for alignment, aligned in self._alignments.items():
|
|
106
|
+
yield alignment
|
|
107
|
+
yield aligned
|
|
108
|
+
|
|
109
|
+
|
|
72
110
|
def _add_prop_aliases(self, key_to_add, val_to_add):
|
|
73
111
|
"""
|
|
74
112
|
Method to handle aliases of the same property.
|
|
@@ -163,6 +201,7 @@ class Annotation(MmifObject):
|
|
|
163
201
|
value: Union[PRMTV_TYPES, LIST_PRMTV, LIST_LIST_PRMTV, DICT_PRMTV, DICT_LIST_PRMTV]) -> None:
|
|
164
202
|
"""
|
|
165
203
|
Adds a property to the annotation's properties.
|
|
204
|
+
|
|
166
205
|
:param name: the name of the property
|
|
167
206
|
:param value: the property's desired value
|
|
168
207
|
:return: None
|
|
@@ -241,7 +280,7 @@ class Document(Annotation):
|
|
|
241
280
|
|
|
242
281
|
:param document_obj: the JSON data that defines the document
|
|
243
282
|
"""
|
|
244
|
-
def __init__(self, doc_obj: Optional[Union[bytes, str, dict]] = None) -> None:
|
|
283
|
+
def __init__(self, doc_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
|
|
245
284
|
# see https://github.com/clamsproject/mmif-python/issues/226 for discussion
|
|
246
285
|
# around the use of these three dictionaries
|
|
247
286
|
# (names changed since, `existing` >> `ephemeral` and `temporary` >> `pending`)
|
|
@@ -284,6 +323,10 @@ class Document(Annotation):
|
|
|
284
323
|
only keep the latest value (in order of appearances in views list) of
|
|
285
324
|
the property, effectively overwriting the previous values.
|
|
286
325
|
"""
|
|
326
|
+
# we don't checking if this k-v already exists in _original (new props) or _ephemeral (read from existing MMIF)
|
|
327
|
+
# because it is impossible to keep the _original updated when a new annotation is added (via `new_annotation`)
|
|
328
|
+
# without look across other views and top-level documents list. Also see
|
|
329
|
+
# ``mmif.serialize.mmif.Mmif.generate_capital_annotations`` for where the "de-duplication" happens.
|
|
287
330
|
if name == "text":
|
|
288
331
|
self.properties.text = Text(value)
|
|
289
332
|
elif name == "mime":
|
|
@@ -434,7 +477,7 @@ class AnnotationProperties(MmifObject, MutableMapping[str, T]):
|
|
|
434
477
|
else:
|
|
435
478
|
return self._unnamed_attributes[key]
|
|
436
479
|
|
|
437
|
-
def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None) -> None:
|
|
480
|
+
def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
|
|
438
481
|
self.id: str = ''
|
|
439
482
|
# any individual at_type (subclassing this class) can have its own set of required attributes
|
|
440
483
|
self._required_attributes = ["id"]
|
|
@@ -451,7 +494,7 @@ class DocumentProperties(AnnotationProperties):
|
|
|
451
494
|
:param mmif_obj: the JSON data that defines the properties
|
|
452
495
|
"""
|
|
453
496
|
|
|
454
|
-
def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None) -> None:
|
|
497
|
+
def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
|
|
455
498
|
self.mime: str = ''
|
|
456
499
|
# note the trailing underscore here. I wanted to use the name `location`
|
|
457
500
|
# for @property in this class and `Document` class, so had to use a diff
|
|
@@ -572,7 +615,7 @@ class DocumentProperties(AnnotationProperties):
|
|
|
572
615
|
|
|
573
616
|
class Text(MmifObject):
|
|
574
617
|
|
|
575
|
-
def __init__(self, text_obj: Optional[Union[bytes, str, dict]] = None) -> None:
|
|
618
|
+
def __init__(self, text_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
|
|
576
619
|
self._value: str = ''
|
|
577
620
|
self._language: str = ''
|
|
578
621
|
self.disallow_additional_properties()
|
|
@@ -10,16 +10,16 @@ import math
|
|
|
10
10
|
import warnings
|
|
11
11
|
from collections import defaultdict
|
|
12
12
|
from datetime import datetime
|
|
13
|
-
from typing import List, Union, Optional, Dict, cast, Iterator
|
|
13
|
+
from typing import List, Union, Optional, Dict, cast, Iterator
|
|
14
14
|
|
|
15
15
|
import jsonschema.validators
|
|
16
16
|
|
|
17
17
|
import mmif
|
|
18
18
|
from mmif import ThingTypesBase
|
|
19
|
+
from mmif.serialize.annotation import Annotation, Document
|
|
20
|
+
from mmif.serialize.model import MmifObject, DataList
|
|
21
|
+
from mmif.serialize.view import View
|
|
19
22
|
from mmif.vocabulary import AnnotationTypes, DocumentTypes
|
|
20
|
-
from .annotation import Annotation, Document
|
|
21
|
-
from .model import MmifObject, DataList
|
|
22
|
-
from .view import View
|
|
23
23
|
|
|
24
24
|
__all__ = ['Mmif']
|
|
25
25
|
|
|
@@ -31,7 +31,7 @@ class MmifMetadata(MmifObject):
|
|
|
31
31
|
:param metadata_obj: the JSON data
|
|
32
32
|
"""
|
|
33
33
|
|
|
34
|
-
def __init__(self, metadata_obj: Optional[Union[bytes, str, dict]] = None) -> None:
|
|
34
|
+
def __init__(self, metadata_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
|
|
35
35
|
# TODO (krim @ 10/7/20): there could be a better name and a better way to give a value to this
|
|
36
36
|
self.mmif: str = f"http://mmif.clams.ai/{mmif.__specver__}"
|
|
37
37
|
self._required_attributes = ["mmif"]
|
|
@@ -81,7 +81,9 @@ class ViewsList(DataList[View]):
|
|
|
81
81
|
"""
|
|
82
82
|
_items: Dict[str, View]
|
|
83
83
|
|
|
84
|
-
def __init__(self, mmif_obj: Optional[Union[bytes, str, list]] = None):
|
|
84
|
+
def __init__(self, mmif_obj: Optional[Union[bytes, str, list]] = None, parent_mmif=None, *_):
|
|
85
|
+
self._parent_mmif = parent_mmif
|
|
86
|
+
self.reserved_names.update(("_parent_mmif", "_id_counts"))
|
|
85
87
|
super().__init__(mmif_obj)
|
|
86
88
|
|
|
87
89
|
def _deserialize(self, input_list: list) -> None: # pytype: disable=signature-mismatch
|
|
@@ -93,7 +95,7 @@ class ViewsList(DataList[View]):
|
|
|
93
95
|
:return: None
|
|
94
96
|
"""
|
|
95
97
|
if input_list:
|
|
96
|
-
self._items = {item['id']: View(item) for item in input_list}
|
|
98
|
+
self._items = {item['id']: View(item, self._parent_mmif) for item in input_list}
|
|
97
99
|
|
|
98
100
|
def append(self, value: View, overwrite=False) -> None:
|
|
99
101
|
"""
|
|
@@ -113,13 +115,24 @@ class ViewsList(DataList[View]):
|
|
|
113
115
|
"""
|
|
114
116
|
super()._append_with_key(value.id, value, overwrite)
|
|
115
117
|
|
|
116
|
-
def
|
|
118
|
+
def get_last_contentful_view(self) -> Optional[View]:
|
|
117
119
|
"""
|
|
118
|
-
Returns the last view
|
|
120
|
+
Returns the last view that is contentful, i.e., has no error or warning .
|
|
119
121
|
"""
|
|
120
122
|
for view in reversed(self._items.values()):
|
|
121
123
|
if 'error' not in view.metadata and 'warning' not in view.metadata:
|
|
122
124
|
return view
|
|
125
|
+
|
|
126
|
+
def get_last_view(self) -> Optional[View]:
|
|
127
|
+
"""
|
|
128
|
+
Returns the last view appended.
|
|
129
|
+
"""
|
|
130
|
+
if self._items:
|
|
131
|
+
return self._items[list(self._items.keys())[-1]]
|
|
132
|
+
|
|
133
|
+
def get_last(self) -> Optional[View]:
|
|
134
|
+
warnings.warn('get_last() is deprecated, use get_last_contentful_view() instead.', DeprecationWarning)
|
|
135
|
+
return self.get_last_contentful_view()
|
|
123
136
|
|
|
124
137
|
|
|
125
138
|
class Mmif(MmifObject):
|
|
@@ -205,6 +218,7 @@ class Mmif(MmifObject):
|
|
|
205
218
|
"""
|
|
206
219
|
super()._deserialize(input_dict)
|
|
207
220
|
for view in self.views:
|
|
221
|
+
view._parent_mmif = self
|
|
208
222
|
# this dict will be populated with properties
|
|
209
223
|
# that are not encoded in individual annotations objects themselves
|
|
210
224
|
extrinsic_props = defaultdict(dict)
|
|
@@ -212,6 +226,7 @@ class Mmif(MmifObject):
|
|
|
212
226
|
for prop_key, prop_value in type_lv_props.items():
|
|
213
227
|
extrinsic_props[at_type][prop_key] = prop_value
|
|
214
228
|
for ann in view.get_annotations():
|
|
229
|
+
## for "capital" Annotation properties
|
|
215
230
|
# first add all extrinsic properties to the Annotation objects
|
|
216
231
|
# as "ephemeral" properties
|
|
217
232
|
for prop_key, prop_value in extrinsic_props[ann.at_type].items():
|
|
@@ -226,13 +241,44 @@ class Mmif(MmifObject):
|
|
|
226
241
|
except KeyError:
|
|
227
242
|
warnings.warn(f"Annotation {ann.id} (in view {view.id}) has a document ID {doc_id} that "
|
|
228
243
|
f"does not exist in the MMIF object. Skipping.", RuntimeWarning)
|
|
229
|
-
|
|
244
|
+
|
|
245
|
+
## caching start and end points for time-based annotations
|
|
246
|
+
# add quick access to `start` and `end` values if the annotation is using `targets` property
|
|
230
247
|
if 'targets' in ann.properties:
|
|
231
248
|
if 'start' in ann.properties or 'end' in ann.properties:
|
|
232
|
-
raise ValueError(f"Annotation {ann.id} (in view {view.id}) has `
|
|
249
|
+
raise ValueError(f"Annotation {ann.id} (in view {view.id}) has `targets` and `start`/`end/` "
|
|
233
250
|
f"properties at the same time. Annotation anchors are ambiguous.")
|
|
234
251
|
ann._props_ephemeral['start'] = self._get_linear_anchor_point(ann, start=True)
|
|
235
252
|
ann._props_ephemeral['end'] = self._get_linear_anchor_point(ann, start=False)
|
|
253
|
+
|
|
254
|
+
## caching alignments
|
|
255
|
+
if ann.at_type == AnnotationTypes.Alignment:
|
|
256
|
+
self._cache_alignment(ann)
|
|
257
|
+
|
|
258
|
+
def _cache_alignment(self, alignment_ann: Annotation):
|
|
259
|
+
view = self.views.get(alignment_ann.parent)
|
|
260
|
+
if view is None:
|
|
261
|
+
warnings.warn(f"Alignment {alignment_ann.long_id} doesn't have a parent view, but it should.", RuntimeWarning)
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
## caching alignments
|
|
265
|
+
def _desprately_search_annotation_object(ann_short_id):
|
|
266
|
+
ann_long_id = f"{view.id}{self.id_delimiter}{ann_short_id}"
|
|
267
|
+
try:
|
|
268
|
+
return self.__getitem__(ann_long_id)
|
|
269
|
+
except KeyError:
|
|
270
|
+
return self.__getitem__(ann_short_id)
|
|
271
|
+
|
|
272
|
+
if all(map(lambda x: x in alignment_ann.properties, ('source', 'target'))):
|
|
273
|
+
source_ann = _desprately_search_annotation_object(alignment_ann.get('source'))
|
|
274
|
+
target_ann = _desprately_search_annotation_object(alignment_ann.get('target'))
|
|
275
|
+
if isinstance(source_ann, Annotation) and isinstance(target_ann, Annotation):
|
|
276
|
+
source_ann._cache_alignment(alignment_ann, target_ann)
|
|
277
|
+
target_ann._cache_alignment(alignment_ann, source_ann)
|
|
278
|
+
else:
|
|
279
|
+
warnings.warn(
|
|
280
|
+
f"Alignment {alignment_ann.long_id} has `source` and `target` properties that do not point to Annotation objects.",
|
|
281
|
+
RuntimeWarning)
|
|
236
282
|
|
|
237
283
|
def generate_capital_annotations(self):
|
|
238
284
|
"""
|
|
@@ -243,13 +289,23 @@ class Mmif(MmifObject):
|
|
|
243
289
|
See https://github.com/clamsproject/mmif-python/issues/226 for rationale
|
|
244
290
|
behind this behavior and discussion.
|
|
245
291
|
"""
|
|
246
|
-
# this view will be the default kitchen sink for all generated annotations
|
|
247
|
-
last_view = self.views.
|
|
292
|
+
# this view will be the default kitchen sink for all generated annotations.
|
|
293
|
+
last_view = self.views.get_last_contentful_view()
|
|
294
|
+
|
|
248
295
|
# proceed only when there's at least one view
|
|
249
296
|
if last_view:
|
|
297
|
+
|
|
298
|
+
# this app name is used to check a view is generated by the "currently running" app.
|
|
299
|
+
# knowing the currently running app is important so that properties of `Document` objects generated by the
|
|
300
|
+
# current app can be properly recorded inside the `Document` objects (since they are "writable" to the
|
|
301
|
+
# current app), instead of being recorded in a separate `Annotation` object.
|
|
302
|
+
current_app = last_view.metadata.app
|
|
303
|
+
|
|
250
304
|
# to avoid duplicate property recording, this will be populated with
|
|
251
305
|
# existing Annotation objects from all existing views
|
|
252
306
|
existing_anns = defaultdict(lambda: defaultdict(dict))
|
|
307
|
+
# ideally, if we can "de-duplicate" props at `add_property()` time, that'd be more efficient,
|
|
308
|
+
# but that is impossible without looking for the target `document` across other views and top documents list
|
|
253
309
|
|
|
254
310
|
# new properties to record in the current serialization call
|
|
255
311
|
anns_to_write = defaultdict(dict)
|
|
@@ -261,20 +317,30 @@ class Mmif(MmifObject):
|
|
|
261
317
|
for ann in view.get_annotations(AnnotationTypes.Annotation):
|
|
262
318
|
if doc_id is None:
|
|
263
319
|
doc_id = ann.get_property('document')
|
|
320
|
+
# only if we are sure that the document ID is unique across all views... (with v_id prefix)
|
|
321
|
+
# TODO (krim @ 7/15/24): update id checking once https://github.com/clamsproject/mmif/issues/228 is resolved
|
|
322
|
+
if not any([doc_id == doc.id for doc in self.documents]) and Mmif.id_delimiter not in doc_id:
|
|
323
|
+
doc_id = f"{view.id}{Mmif.id_delimiter}{doc_id}"
|
|
264
324
|
existing_anns[doc_id].update(ann.properties)
|
|
265
325
|
for doc in view.get_documents():
|
|
266
|
-
anns_to_write[doc.
|
|
326
|
+
anns_to_write[doc.long_id].update(doc._props_pending)
|
|
267
327
|
for doc in self.documents:
|
|
268
|
-
anns_to_write[doc.
|
|
328
|
+
anns_to_write[doc.long_id].update(doc._props_pending)
|
|
269
329
|
# additional iteration of views, to find a proper view to add the
|
|
270
330
|
# generated annotations. If none found, use the last view as the kitchen sink
|
|
271
331
|
last_view_for_docs = defaultdict(lambda: last_view)
|
|
272
332
|
doc_ids = set(anns_to_write.keys())
|
|
273
333
|
for doc_id in doc_ids:
|
|
334
|
+
if len(last_view.annotations) == 0:
|
|
335
|
+
# meaning, this new app didn't generate any annotation except for these document properties
|
|
336
|
+
# thus, we should add capital annotations to the last (empty) view
|
|
337
|
+
last_view_for_docs[doc_id] = last_view
|
|
338
|
+
break
|
|
274
339
|
for view in reversed(self.views):
|
|
275
340
|
# first try to find out if this view "contains" any annotation to the doc
|
|
276
341
|
# then, check for individual annotations
|
|
277
|
-
|
|
342
|
+
# TODO (krim @ 7/15/24): update id checking once https://github.com/clamsproject/mmif/issues/228 is resolved
|
|
343
|
+
if [cont for cont in view.metadata.contains.values() if doc_id.endswith(cont.get('document', 'TODO:this endswith test is a temporal solution we use until long_id is forced everywhere'))] \
|
|
278
344
|
or list(view.get_annotations(document=doc_id)):
|
|
279
345
|
last_view_for_docs[doc_id] = view
|
|
280
346
|
break
|
|
@@ -288,14 +354,18 @@ class Mmif(MmifObject):
|
|
|
288
354
|
if k != 'id' and existing_anns[doc_id][k] != v:
|
|
289
355
|
props[k] = v
|
|
290
356
|
if props:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
props.pop('document', None)
|
|
357
|
+
view_to_write = last_view_for_docs[doc_id]
|
|
358
|
+
if view_to_write.metadata.app == current_app and view_to_write.annotations.get(doc_id) is not None:
|
|
359
|
+
view_to_write.get_document_by_id(doc_id).properties.update(props)
|
|
295
360
|
else:
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
361
|
+
if len(anns_to_write) == 1:
|
|
362
|
+
# if there's only one document, we can record the doc_id in the contains metadata
|
|
363
|
+
view_to_write.metadata.new_contain(AnnotationTypes.Annotation, document=doc_id)
|
|
364
|
+
props.pop('document', None)
|
|
365
|
+
else:
|
|
366
|
+
# otherwise, doc_id needs to be recorded in the annotation property
|
|
367
|
+
props['document'] = doc_id
|
|
368
|
+
view_to_write.new_annotation(AnnotationTypes.Annotation, **props)
|
|
299
369
|
|
|
300
370
|
def sanitize(self):
|
|
301
371
|
"""
|
|
@@ -339,7 +409,7 @@ class Mmif(MmifObject):
|
|
|
339
409
|
new_view = View()
|
|
340
410
|
new_view.id = self.new_view_id()
|
|
341
411
|
new_view.metadata.timestamp = datetime.now()
|
|
342
|
-
self.
|
|
412
|
+
self.add_view(new_view)
|
|
343
413
|
return new_view
|
|
344
414
|
|
|
345
415
|
def add_view(self, view: View, overwrite=False) -> None:
|
|
@@ -353,6 +423,7 @@ class Mmif(MmifObject):
|
|
|
353
423
|
an existing view with the same ID
|
|
354
424
|
:return: None
|
|
355
425
|
"""
|
|
426
|
+
view._parent_mmif = self
|
|
356
427
|
self.views.append(view, overwrite)
|
|
357
428
|
|
|
358
429
|
def add_document(self, document: Document, overwrite=False) -> None:
|
|
@@ -555,7 +626,7 @@ class Mmif(MmifObject):
|
|
|
555
626
|
|
|
556
627
|
get_views_with_error = get_all_views_with_error
|
|
557
628
|
|
|
558
|
-
def get_all_views_contain(self, at_types: Union[ThingTypesBase, str
|
|
629
|
+
def get_all_views_contain(self, *at_types: Union[ThingTypesBase, str]) -> List[View]:
|
|
559
630
|
"""
|
|
560
631
|
Returns the list of all views in the MMIF if given types
|
|
561
632
|
are present in that view's 'contains' metadata.
|
|
@@ -563,11 +634,8 @@ class Mmif(MmifObject):
|
|
|
563
634
|
:param at_types: a list of types or just a type to check for. When given more than one types, all types must be found.
|
|
564
635
|
:return: the list of views that contain the type
|
|
565
636
|
"""
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if all(map(lambda x: x in view.metadata.contains, at_types))]
|
|
569
|
-
else:
|
|
570
|
-
return [view for view in self.views if at_types in view.metadata.contains]
|
|
637
|
+
return [view for view in self.views
|
|
638
|
+
if all(map(lambda x: x in view.metadata.contains, at_types))]
|
|
571
639
|
|
|
572
640
|
get_views_contain = get_all_views_contain
|
|
573
641
|
|
|
@@ -610,72 +678,54 @@ class Mmif(MmifObject):
|
|
|
610
678
|
return view
|
|
611
679
|
return None
|
|
612
680
|
|
|
613
|
-
def
|
|
614
|
-
|
|
615
|
-
|
|
681
|
+
def _is_in_time_range(self, ann: Annotation, range_s: Union[int, float], range_e: Union[int, float]) -> bool:
|
|
682
|
+
"""
|
|
683
|
+
Checks if the annotation is anchored within the given time range. Any overlap is considered included.
|
|
684
|
+
|
|
685
|
+
:param ann: the Annotation object to check, must be time-based itself or anchored to time-based annotations
|
|
686
|
+
:param range_s: the start time point of the range (in milliseconds)
|
|
687
|
+
:param range_e: the end time point of the range (in milliseconds)
|
|
688
|
+
|
|
689
|
+
:return: True if the annotation is anchored within the time range, False otherwise
|
|
690
|
+
"""
|
|
691
|
+
ann_s, ann_e = self.get_start(ann), self.get_end(ann)
|
|
692
|
+
return (ann_s < range_s < ann_e) or (ann_s < range_e < ann_e) or (ann_s > range_s and ann_e < range_e)
|
|
693
|
+
|
|
694
|
+
def get_annotations_between_time(self, start: Union[int, float], end: Union[int, float], time_unit: str = "ms",
|
|
695
|
+
at_types: List[Union[ThingTypesBase, str]] = []) -> Iterator[Annotation]:
|
|
696
|
+
"""
|
|
697
|
+
Finds annotations that are anchored between the given time points.
|
|
616
698
|
|
|
617
|
-
|
|
618
|
-
|
|
699
|
+
:param start: the start time point in the unit of `input_unit`
|
|
700
|
+
:param end: the end time point in the unit of `input_unit`
|
|
701
|
+
:param time_unit: the unit of the input time points. Default is `ms`.
|
|
702
|
+
:param at_types: a list of annotation types to filter with. Any type in this list will be included in the return.
|
|
703
|
+
:return: an iterator of Annotation objects that are anchored between the given time points
|
|
704
|
+
"""
|
|
705
|
+
assert start < end, f"Start time point must be smaller than the end time point, given {start} and {end}"
|
|
706
|
+
assert start >= 0, f"Start time point must be non-negative, given {start}"
|
|
707
|
+
assert end >= 0, f"End time point must be non-negative, given {end}"
|
|
708
|
+
|
|
619
709
|
from mmif.utils.timeunit_helper import convert
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
valid_tf_anns = []
|
|
640
|
-
tf_to_anns = defaultdict(list)
|
|
641
|
-
|
|
642
|
-
# 1. find all views that contain the type of TF
|
|
643
|
-
views = self.get_all_views_contain([AnnotationTypes.TimeFrame, AnnotationTypes.Alignment])
|
|
644
|
-
|
|
645
|
-
# 2. For each view, extract annotations that satisfy conditions that are TF/TP and fall into time interval
|
|
646
|
-
for view in views:
|
|
647
|
-
# Make sure time unit stay at the same level
|
|
648
|
-
start_time, end_time = self._handle_time_unit(time_unit, view.metadata.contains.get(AnnotationTypes.TimeFrame)["timeUnit"],
|
|
649
|
-
start, end)
|
|
650
|
-
tf_anns = view.get_annotations(at_type=AnnotationTypes.TimeFrame)
|
|
651
|
-
al_anns = view.get_annotations(at_type=AnnotationTypes.Alignment)
|
|
652
|
-
|
|
653
|
-
# Select 'TimeFrame' annotations within given time interval
|
|
654
|
-
for tf in tf_anns:
|
|
655
|
-
if self._is_in_time_between(start_time, end_time, tf):
|
|
656
|
-
valid_tf_anns.append(tf)
|
|
657
|
-
|
|
658
|
-
# Map 'TimeFrame' annotation to its aligned annotation
|
|
659
|
-
for align in al_anns:
|
|
660
|
-
source_id, target_id = align.get_property('source'), align.get_property('target')
|
|
661
|
-
to_long_id = lambda x: x if self.id_delimiter in x else f'{view.id}{self.id_delimiter}{x}'
|
|
662
|
-
try:
|
|
663
|
-
source, target = view.get_annotation_by_id(source_id), view.get_annotation_by_id(target_id)
|
|
664
|
-
if source in valid_tf_anns:
|
|
665
|
-
tf_to_anns[to_long_id(source_id)].append(target)
|
|
666
|
-
elif target in valid_tf_anns:
|
|
667
|
-
tf_to_anns[to_long_id(target_id)].append(source)
|
|
668
|
-
except KeyError:
|
|
669
|
-
pass
|
|
670
|
-
|
|
671
|
-
# 3. For those extracted 'TimeFrame' annotations, sort them by their start time
|
|
672
|
-
sort_tf_anns = sorted(valid_tf_anns, key=lambda x: self.get_start(x))
|
|
673
|
-
|
|
674
|
-
# 4. Yield all annotations aligned with sorted 'TimeFrame' annotations
|
|
675
|
-
for tf_ann in sort_tf_anns:
|
|
676
|
-
anns = tf_to_anns[tf_ann.long_id]
|
|
677
|
-
for ann in anns:
|
|
678
|
-
yield ann
|
|
710
|
+
|
|
711
|
+
time_anchors_in_range = []
|
|
712
|
+
at_types = set(at_types)
|
|
713
|
+
|
|
714
|
+
for view in self.get_all_views_contain(AnnotationTypes.TimeFrame) + self.get_all_views_contain(AnnotationTypes.TimePoint):
|
|
715
|
+
time_unit_in_view = view.metadata.contains.get(AnnotationTypes.TimeFrame)["timeUnit"]
|
|
716
|
+
|
|
717
|
+
start_time = convert(start, time_unit, time_unit_in_view, 1)
|
|
718
|
+
end_time = convert(end, time_unit, time_unit_in_view, 1)
|
|
719
|
+
for ann in view.get_annotations():
|
|
720
|
+
if ann.at_type in (AnnotationTypes.TimeFrame, AnnotationTypes.TimePoint) and self._is_in_time_range(ann, start_time, end_time):
|
|
721
|
+
time_anchors_in_range.append(ann)
|
|
722
|
+
time_anchors_in_range.sort(key=lambda x: self.get_start(x))
|
|
723
|
+
for time_anchor in time_anchors_in_range:
|
|
724
|
+
if not at_types or time_anchor.at_type in at_types:
|
|
725
|
+
yield time_anchor
|
|
726
|
+
for aligned in time_anchor.get_all_aligned():
|
|
727
|
+
if not at_types or aligned.at_type in at_types:
|
|
728
|
+
yield aligned
|
|
679
729
|
|
|
680
730
|
def _get_linear_anchor_point(self, ann: Annotation, targets_sorted=False, start: bool = True) -> Union[int, float]:
|
|
681
731
|
# TODO (krim @ 2/5/24): Update the return type once timeunits are unified to `ms` as integers (https://github.com/clamsproject/mmif/issues/192)
|
|
@@ -99,7 +99,7 @@ class MmifObject(object):
|
|
|
99
99
|
_exclude_from_diff: Set[str]
|
|
100
100
|
_attribute_classes: Dict[str, Type] = {} # Mapping: str -> Type
|
|
101
101
|
|
|
102
|
-
def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None) -> None:
|
|
102
|
+
def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
|
|
103
103
|
if isinstance(mmif_obj, bytes):
|
|
104
104
|
mmif_obj = mmif_obj.decode('utf8')
|
|
105
105
|
if not hasattr(self, '_required_attributes'):
|
|
@@ -258,7 +258,7 @@ class MmifObject(object):
|
|
|
258
258
|
"""
|
|
259
259
|
for k, v in input_dict.items():
|
|
260
260
|
if self._attribute_classes and k in self._attribute_classes:
|
|
261
|
-
self[k] = self._attribute_classes[k](v)
|
|
261
|
+
self[k] = self._attribute_classes[k](v, self)
|
|
262
262
|
else:
|
|
263
263
|
self[k] = v
|
|
264
264
|
|
|
@@ -285,13 +285,13 @@ class MmifObject(object):
|
|
|
285
285
|
if key in self._named_attributes():
|
|
286
286
|
if self._attribute_classes and key in self._attribute_classes \
|
|
287
287
|
and not isinstance(value, (self._attribute_classes[key])):
|
|
288
|
-
self.__dict__[key] = self._attribute_classes[key](value)
|
|
288
|
+
self.__dict__[key] = self._attribute_classes[key](value, self)
|
|
289
289
|
else:
|
|
290
290
|
self.__dict__[key] = value
|
|
291
291
|
else:
|
|
292
292
|
if self._attribute_classes and key in self._attribute_classes \
|
|
293
293
|
and not isinstance(value, (self._attribute_classes[key])):
|
|
294
|
-
self.set_additional_property(key, self._attribute_classes[key](value))
|
|
294
|
+
self.set_additional_property(key, self._attribute_classes[key](value, self))
|
|
295
295
|
else:
|
|
296
296
|
self.set_additional_property(key, value)
|
|
297
297
|
|
|
@@ -342,7 +342,7 @@ class DataList(MmifObject, Generic[T]):
|
|
|
342
342
|
|
|
343
343
|
:param Union[str, list] mmif_obj: the data that the list contains
|
|
344
344
|
"""
|
|
345
|
-
def __init__(self, mmif_obj: Optional[Union[bytes, str, list]] = None):
|
|
345
|
+
def __init__(self, mmif_obj: Optional[Union[bytes, str, list]] = None, *_):
|
|
346
346
|
self.reserved_names.add('_items')
|
|
347
347
|
self._items: Dict[str, T] = dict()
|
|
348
348
|
self.disallow_additional_properties()
|
|
@@ -440,7 +440,7 @@ class DataList(MmifObject, Generic[T]):
|
|
|
440
440
|
|
|
441
441
|
|
|
442
442
|
class DataDict(MmifObject, Generic[T, S]):
|
|
443
|
-
def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None):
|
|
443
|
+
def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None, *_):
|
|
444
444
|
self.reserved_names.add('_items')
|
|
445
445
|
self._items: Dict[T, S] = dict()
|
|
446
446
|
self.disallow_additional_properties()
|
|
@@ -9,15 +9,13 @@ import json
|
|
|
9
9
|
from datetime import datetime
|
|
10
10
|
from typing import Dict, Union, Optional, Generator, List, cast
|
|
11
11
|
|
|
12
|
-
from mmif
|
|
13
|
-
from mmif.
|
|
14
|
-
from .annotation import Annotation, Document
|
|
15
|
-
from .model import MmifObject, DataList, DataDict
|
|
12
|
+
from mmif import DocumentTypes, AnnotationTypes, ThingTypesBase, ClamsTypesBase
|
|
13
|
+
from mmif.serialize import model
|
|
14
|
+
from mmif.serialize.annotation import Annotation, Document
|
|
15
|
+
from mmif.serialize.model import PRMTV_TYPES, MmifObject, DataList, DataDict
|
|
16
16
|
|
|
17
17
|
__all__ = ['View', 'ViewMetadata', 'Contain']
|
|
18
18
|
|
|
19
|
-
from .. import DocumentTypes
|
|
20
|
-
|
|
21
19
|
|
|
22
20
|
class View(MmifObject):
|
|
23
21
|
"""
|
|
@@ -32,10 +30,12 @@ class View(MmifObject):
|
|
|
32
30
|
:param view_obj: the JSON data that defines the view
|
|
33
31
|
"""
|
|
34
32
|
|
|
35
|
-
def __init__(self, view_obj: Optional[Union[bytes, str, dict]] = None) -> None:
|
|
33
|
+
def __init__(self, view_obj: Optional[Union[bytes, str, dict]] = None, parent_mmif=None, *_) -> None:
|
|
36
34
|
# used to autogenerate annotation ids
|
|
37
35
|
self._id_counts = {}
|
|
38
|
-
|
|
36
|
+
# used to access the parent MMIF object
|
|
37
|
+
self._parent_mmif = parent_mmif
|
|
38
|
+
self.reserved_names.update(("_parent_mmif", "_id_counts"))
|
|
39
39
|
self._exclude_from_diff = {"_id_counts"}
|
|
40
40
|
|
|
41
41
|
self.id: str = ''
|
|
@@ -64,7 +64,7 @@ class View(MmifObject):
|
|
|
64
64
|
else:
|
|
65
65
|
return self.metadata.new_contain(at_type, **contains_metadata)
|
|
66
66
|
|
|
67
|
-
def
|
|
67
|
+
def _set_ann_id(self, annotation: Annotation, identifier):
|
|
68
68
|
if identifier is not None:
|
|
69
69
|
annotation.id = identifier
|
|
70
70
|
else:
|
|
@@ -73,7 +73,7 @@ class View(MmifObject):
|
|
|
73
73
|
new_id = f'{prefix}_{new_num}'
|
|
74
74
|
self._id_counts[prefix] = new_num
|
|
75
75
|
annotation.id = new_id
|
|
76
|
-
|
|
76
|
+
|
|
77
77
|
def new_annotation(self, at_type: Union[str, ThingTypesBase], aid: Optional[str] = None,
|
|
78
78
|
overwrite=False, **properties) -> 'Annotation':
|
|
79
79
|
"""
|
|
@@ -96,7 +96,7 @@ class View(MmifObject):
|
|
|
96
96
|
"""
|
|
97
97
|
new_annotation = Annotation()
|
|
98
98
|
new_annotation.at_type = at_type
|
|
99
|
-
self.
|
|
99
|
+
self._set_ann_id(new_annotation, aid)
|
|
100
100
|
for propk, propv in properties.items():
|
|
101
101
|
new_annotation.add_property(propk, propv)
|
|
102
102
|
for propk, propv in self.metadata.contains.get(at_type, {}).items():
|
|
@@ -122,8 +122,10 @@ class View(MmifObject):
|
|
|
122
122
|
annotation.parent = self.id
|
|
123
123
|
self.annotations.append(annotation, overwrite)
|
|
124
124
|
self.new_contain(annotation.at_type)
|
|
125
|
+
if annotation.at_type == AnnotationTypes.Alignment:
|
|
126
|
+
self._parent_mmif._cache_alignment(annotation)
|
|
125
127
|
return annotation
|
|
126
|
-
|
|
128
|
+
|
|
127
129
|
def new_textdocument(self, text: str, lang: str = "en", did: Optional[str] = None,
|
|
128
130
|
overwrite=False, **properties) -> 'Document':
|
|
129
131
|
"""
|
|
@@ -147,7 +149,7 @@ class View(MmifObject):
|
|
|
147
149
|
"""
|
|
148
150
|
new_document = Document()
|
|
149
151
|
new_document.at_type = DocumentTypes.TextDocument
|
|
150
|
-
self.
|
|
152
|
+
self._set_ann_id(new_document, did)
|
|
151
153
|
new_document.text_language = lang
|
|
152
154
|
new_document.text_value = text
|
|
153
155
|
for propk, propv in properties.items():
|
|
@@ -189,9 +191,18 @@ class View(MmifObject):
|
|
|
189
191
|
yield annotation
|
|
190
192
|
|
|
191
193
|
def get_annotation_by_id(self, ann_id) -> Annotation:
|
|
192
|
-
|
|
194
|
+
if self.id_delimiter in ann_id and not ann_id.startswith(self.id):
|
|
195
|
+
try:
|
|
196
|
+
ann_found = self._parent_mmif[ann_id]
|
|
197
|
+
except KeyError:
|
|
198
|
+
ann_found = None
|
|
199
|
+
else:
|
|
200
|
+
ann_found = self.annotations.get(ann_id.split(self.id_delimiter)[-1])
|
|
193
201
|
if ann_found is None or not isinstance(ann_found, Annotation):
|
|
194
|
-
|
|
202
|
+
if self.id_delimiter in ann_id:
|
|
203
|
+
raise KeyError(f"Annotation \"{ann_id}\" is not found in the MMIF.")
|
|
204
|
+
else:
|
|
205
|
+
raise KeyError(f"Annotation \"{ann_id}\" is not found in view {self.id}.")
|
|
195
206
|
else:
|
|
196
207
|
return ann_found
|
|
197
208
|
|
|
@@ -257,7 +268,7 @@ class ViewMetadata(MmifObject):
|
|
|
257
268
|
:param viewmetadata_obj: the JSON data that defines the metadata
|
|
258
269
|
"""
|
|
259
270
|
|
|
260
|
-
def __init__(self, viewmetadata_obj: Optional[Union[bytes, str, dict]] = None) -> None:
|
|
271
|
+
def __init__(self, viewmetadata_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
|
|
261
272
|
self.document: str = ''
|
|
262
273
|
self.timestamp: Optional[datetime] = None
|
|
263
274
|
self.app: str = ''
|
|
@@ -377,7 +388,7 @@ class ErrorDict(MmifObject):
|
|
|
377
388
|
"""
|
|
378
389
|
Error object that stores information about error occurred during processing.
|
|
379
390
|
"""
|
|
380
|
-
def __init__(self, error_obj: Optional[Union[bytes, str, dict]] = None) -> None:
|
|
391
|
+
def __init__(self, error_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
|
|
381
392
|
self.message: str = ''
|
|
382
393
|
self.stackTrace: str = ''
|
|
383
394
|
super().__init__(error_obj)
|
|
@@ -429,6 +440,16 @@ class AnnotationsList(DataList[Union[Annotation, Document]]):
|
|
|
429
440
|
:return: None
|
|
430
441
|
"""
|
|
431
442
|
super()._append_with_key(value.id, value, overwrite)
|
|
443
|
+
|
|
444
|
+
def __getitem__(self, key: str):
|
|
445
|
+
"""
|
|
446
|
+
specialized getter implementation to workaround https://github.com/clamsproject/mmif/issues/228
|
|
447
|
+
# TODO (krim @ 7/12/24): annotation ids must be in the long form in the future, so this check will be unnecessary once https://github.com/clamsproject/mmif/issues/228 is resolved.
|
|
448
|
+
"""
|
|
449
|
+
if ":" in key:
|
|
450
|
+
_, aid = key.split(":")
|
|
451
|
+
return self._items.__getitem__(aid)
|
|
452
|
+
return self._items.get(key, None)
|
|
432
453
|
|
|
433
454
|
|
|
434
455
|
class ContainsDict(DataDict[ThingTypesBase, Contain]):
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import importlib
|
|
2
|
+
import math
|
|
2
3
|
import warnings
|
|
3
4
|
from typing import List, Union, Tuple
|
|
4
|
-
import math
|
|
5
5
|
|
|
6
6
|
import mmif
|
|
7
7
|
from mmif import Annotation, Document, Mmif
|
|
8
8
|
from mmif.utils.timeunit_helper import convert
|
|
9
|
-
from mmif.vocabulary import DocumentTypes
|
|
9
|
+
from mmif.vocabulary import DocumentTypes
|
|
10
10
|
|
|
11
11
|
for cv_dep in ('cv2', 'ffmpeg', 'PIL'):
|
|
12
12
|
try:
|
|
@@ -212,19 +212,19 @@ def convert_timepoint(mmif: Mmif, timepoint: Annotation, out_unit: str) -> Union
|
|
|
212
212
|
return convert(timepoint.get_property('timePoint'), in_unit, out_unit, get_framerate(vd))
|
|
213
213
|
|
|
214
214
|
|
|
215
|
-
def convert_timeframe(mmif: Mmif, time_frame: Annotation, out_unit: str) ->
|
|
215
|
+
def convert_timeframe(mmif: Mmif, time_frame: Annotation, out_unit: str) -> Tuple[Union[int, float, str], Union[int, float, str]]:
|
|
216
216
|
"""
|
|
217
217
|
Converts start and end points in a ``TimeFrame`` annotation a different time unit.
|
|
218
218
|
|
|
219
219
|
:param mmif: :py:class:`~mmif.serialize.mmif.Mmif` instance
|
|
220
220
|
:param time_frame: :py:class:`~mmif.serialize.annotation.Annotation` instance that holds a time interval annotation (``"@type": ".../TimeFrame/..."``)
|
|
221
221
|
:param out_unit: time unit to which the point is converted
|
|
222
|
-
:return: tuple of frame numbers
|
|
222
|
+
:return: tuple of frame numbers, seconds/milliseconds, or ISO notation of TimeFrame's start and end
|
|
223
223
|
"""
|
|
224
224
|
in_unit = time_frame.get_property('timeUnit')
|
|
225
225
|
vd = mmif[time_frame.get_property('document')]
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
fps = get_framerate(vd)
|
|
227
|
+
return convert(time_frame.get_property('start'), in_unit, out_unit, fps), convert(time_frame.get_property('end'), in_unit, out_unit, fps)
|
|
228
228
|
|
|
229
229
|
|
|
230
230
|
def framenum_to_second(video_doc: Document, frame: int):
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Spec version 1.0.
|
|
1
|
+
# Spec version 1.0.5
|
|
2
2
|
# This file is auto-generated by setup.py
|
|
3
3
|
|
|
4
4
|
from .base_types import AnnotationTypesBase
|
|
@@ -7,7 +7,7 @@ from .base_types import AnnotationTypesBase
|
|
|
7
7
|
class AnnotationTypes(AnnotationTypesBase):
|
|
8
8
|
"""
|
|
9
9
|
This class contains the CLAMS annotation types
|
|
10
|
-
defined in the spec version 1.0.
|
|
10
|
+
defined in the spec version 1.0.5 as class variables.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
# prpoerty aliases, first added in MMIF 1.0.2
|
|
@@ -196,7 +196,7 @@ class DocumentTypesBase(ClamsTypesBase):
|
|
|
196
196
|
class ThingType(ThingTypesBase):
|
|
197
197
|
"""
|
|
198
198
|
This class contains the topmost CLAMS thing type
|
|
199
|
-
defined in the spec version 1.0.
|
|
199
|
+
defined in the spec version 1.0.5 as a class variable.
|
|
200
200
|
"""
|
|
201
201
|
Thing = ClamsTypesBase('http://mmif.clams.ai/vocabulary/Thing/v1')
|
|
202
202
|
_typevers = {'Thing': 'v1'}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Spec version 1.0.
|
|
1
|
+
# Spec version 1.0.5
|
|
2
2
|
# This file is auto-generated by setup.py
|
|
3
3
|
|
|
4
4
|
from .base_types import DocumentTypesBase
|
|
@@ -7,7 +7,7 @@ from .base_types import DocumentTypesBase
|
|
|
7
7
|
class DocumentTypes(DocumentTypesBase):
|
|
8
8
|
"""
|
|
9
9
|
This class contains the CLAMS document types
|
|
10
|
-
defined in the spec version 1.0.
|
|
10
|
+
defined in the spec version 1.0.5 as class variables.
|
|
11
11
|
"""
|
|
12
12
|
Document = DocumentTypesBase('http://mmif.clams.ai/vocabulary/Document/v1')
|
|
13
13
|
VideoDocument = DocumentTypesBase('http://mmif.clams.ai/vocabulary/VideoDocument/v1')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: mmif-python
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.18
|
|
4
4
|
Summary: Python implementation of MultiMedia Interchange Format specification. (https://mmif.clams.ai)
|
|
5
5
|
Home-page: https://mmif.clams.ai
|
|
6
6
|
Author: Brandeis Lab for Linguistics and Computation
|
|
@@ -222,7 +222,7 @@ class TestMmif(unittest.TestCase):
|
|
|
222
222
|
def test_document_location_helpers_http(self):
|
|
223
223
|
new_doc = Document()
|
|
224
224
|
new_doc.id = "d1"
|
|
225
|
-
new_doc.location = f"https://
|
|
225
|
+
new_doc.location = f"https://example.com/"
|
|
226
226
|
self.assertEqual(new_doc.location_scheme(), 'https')
|
|
227
227
|
try:
|
|
228
228
|
path = new_doc.location_path()
|
|
@@ -277,11 +277,11 @@ class TestMmif(unittest.TestCase):
|
|
|
277
277
|
self.assertEqual(2, len(views))
|
|
278
278
|
views = mmif_obj.get_all_views_contain('http://vocab.lappsgrid.org/SemanticTag')
|
|
279
279
|
self.assertEqual(1, len(views))
|
|
280
|
-
views = mmif_obj.get_views_contain(
|
|
280
|
+
views = mmif_obj.get_views_contain(
|
|
281
281
|
AnnotationTypes.TimeFrame,
|
|
282
282
|
DocumentTypes.TextDocument,
|
|
283
283
|
AnnotationTypes.Alignment,
|
|
284
|
-
|
|
284
|
+
)
|
|
285
285
|
self.assertEqual(1, len(views))
|
|
286
286
|
views = mmif_obj.get_all_views_contain(not_existing_attype)
|
|
287
287
|
self.assertEqual(0, len(views))
|
|
@@ -323,6 +323,17 @@ class TestMmif(unittest.TestCase):
|
|
|
323
323
|
views_and_alignments = mmif_obj.get_alignments(DocumentTypes.TextDocument, AnnotationTypes.BoundingBox)
|
|
324
324
|
self.assertEqual(1, len(views_and_alignments))
|
|
325
325
|
self.assertTrue('v6' in views_and_alignments)
|
|
326
|
+
|
|
327
|
+
def test_cache_alignment(self):
|
|
328
|
+
mmif_obj = Mmif(MMIF_EXAMPLES['everything'])
|
|
329
|
+
views_and_alignments = mmif_obj.get_alignments(DocumentTypes.TextDocument, AnnotationTypes.TimeFrame)
|
|
330
|
+
for vid, alignments in views_and_alignments.items():
|
|
331
|
+
v = mmif_obj.get_view_by_id(vid)
|
|
332
|
+
for alignment in alignments:
|
|
333
|
+
s = v.get_annotation_by_id(alignment.get('source'))
|
|
334
|
+
t = v.get_annotation_by_id(alignment.get('target'))
|
|
335
|
+
self.assertTrue(s.aligned_to_by(alignment).long_id.endswith(t.long_id))
|
|
336
|
+
self.assertTrue(t.aligned_to_by(alignment).long_id.endswith(s.long_id))
|
|
326
337
|
|
|
327
338
|
def test_new_view_id(self):
|
|
328
339
|
p = Mmif.view_prefix
|
|
@@ -350,7 +361,7 @@ class TestMmif(unittest.TestCase):
|
|
|
350
361
|
# .[] |
|
|
351
362
|
# select(."@type"=="http://vocab.lappsgrid.org/Token")] |
|
|
352
363
|
# sort_by(.properties.id | ltrimstr("t") | tonumber) |
|
|
353
|
-
# map(.properties.
|
|
364
|
+
# map(.properties.word)' <examples>.json
|
|
354
365
|
tokens_in_order = ["Hello",
|
|
355
366
|
",",
|
|
356
367
|
"this",
|
|
@@ -382,23 +393,26 @@ class TestMmif(unittest.TestCase):
|
|
|
382
393
|
mmif_obj = Mmif(MMIF_EXAMPLES['everything'])
|
|
383
394
|
|
|
384
395
|
# Test case 1: All token annotations are selected
|
|
385
|
-
selected_token_anns = mmif_obj.get_annotations_between_time(0, 22000)
|
|
386
|
-
self.assertEqual(28, len(
|
|
396
|
+
selected_token_anns = [ann for ann in mmif_obj.get_annotations_between_time(0, 22000) if ann.is_type(token_type)]
|
|
397
|
+
self.assertEqual(28, len(selected_token_anns))
|
|
387
398
|
for i, ann in enumerate(selected_token_anns):
|
|
388
|
-
self.
|
|
389
|
-
self.assertEqual(tokens_in_order[i], ann.get_property("text"))
|
|
399
|
+
self.assertEqual(tokens_in_order[i], ann.get_property("word"))
|
|
390
400
|
|
|
391
401
|
# Test case 2: No token annotation are selected
|
|
392
|
-
selected_token_anns = mmif_obj.get_annotations_between_time(0, 5, time_unit="seconds")
|
|
393
|
-
self.assertEqual(
|
|
402
|
+
selected_token_anns = list(mmif_obj.get_annotations_between_time(0, 5, time_unit="seconds"))
|
|
403
|
+
self.assertEqual(4, len(list(selected_token_anns)))
|
|
404
|
+
for ann in selected_token_anns:
|
|
405
|
+
self.assertFalse(ann.is_type(token_type))
|
|
394
406
|
|
|
395
407
|
# Test case 3(a): Partial tokens are selected (involve partial overlap)
|
|
396
|
-
selected_token_anns = mmif_obj.get_annotations_between_time(7, 10, time_unit="seconds"
|
|
397
|
-
|
|
408
|
+
selected_token_anns = mmif_obj.get_annotations_between_time(7, 10, time_unit="seconds",
|
|
409
|
+
at_types=['http://vocab.lappsgrid.org/Token'])
|
|
410
|
+
self.assertEqual(tokens_in_order[3:9], [ann.get_property("word") for ann in selected_token_anns])
|
|
398
411
|
|
|
399
412
|
# Test case 3(b): Partial tokens are selected (only full overlap)
|
|
400
|
-
selected_token_anns = mmif_obj.get_annotations_between_time(11500, 14600
|
|
401
|
-
|
|
413
|
+
selected_token_anns = mmif_obj.get_annotations_between_time(11500, 14600,
|
|
414
|
+
at_types=['http://vocab.lappsgrid.org/Token'])
|
|
415
|
+
self.assertEqual(tokens_in_order[12:17], [ann.get_property("word") for ann in selected_token_anns])
|
|
402
416
|
|
|
403
417
|
def test_add_document(self):
|
|
404
418
|
mmif_obj = Mmif(MMIF_EXAMPLES['everything'])
|
|
@@ -662,6 +676,7 @@ class TestView(unittest.TestCase):
|
|
|
662
676
|
self.assertEqual(view_from_str, view_from_bytes)
|
|
663
677
|
self.assertEqual(json.loads(view_from_json.serialize()), json.loads(view_from_str.serialize()))
|
|
664
678
|
self.assertEqual(json.loads(view_from_bytes.serialize()), json.loads(view_from_str.serialize()))
|
|
679
|
+
|
|
665
680
|
|
|
666
681
|
def test_annotation_order_preserved(self):
|
|
667
682
|
view_serial = self.view_obj.serialize()
|
|
@@ -885,13 +900,12 @@ class TestAnnotation(unittest.TestCase):
|
|
|
885
900
|
# TODO (angus-lherrou @ 7/27/2020): testing should include validation for required attrs
|
|
886
901
|
# once that is implemented (issue #23)
|
|
887
902
|
def setUp(self) -> None:
|
|
888
|
-
self.data = {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
for i, example in MMIF_EXAMPLES.items()}
|
|
903
|
+
self.data = {}
|
|
904
|
+
for i, example in MMIF_EXAMPLES.items():
|
|
905
|
+
self.data[i] = {'string': example,
|
|
906
|
+
'json': json.loads(example),
|
|
907
|
+
'mmif': Mmif(example),
|
|
908
|
+
'annotations': [annotation for view in json.loads(example)['views'] for annotation in view['annotations']]}
|
|
895
909
|
|
|
896
910
|
def test_annotation_properties(self):
|
|
897
911
|
ann_json = self.data['everything']['annotations'][0]
|
|
@@ -1104,11 +1118,11 @@ class TestDocument(unittest.TestCase):
|
|
|
1104
1118
|
# then converted to an `Annotation` annotation during serialization at `Mmif`-level
|
|
1105
1119
|
mmif.add_document(doc1)
|
|
1106
1120
|
## no Annotation before serialization
|
|
1107
|
-
self.assertEqual(0, len(list(mmif.views.
|
|
1121
|
+
self.assertEqual(0, len(list(mmif.views.get_last_contentful_view().get_annotations(AnnotationTypes.Annotation, author='me'))))
|
|
1108
1122
|
## after serialization, the `Annotation` annotation should be added to the last view
|
|
1109
1123
|
## note that we didn't add any views, so the last view before and after the serialization are the same
|
|
1110
1124
|
mmif_roundtrip = Mmif(mmif.serialize())
|
|
1111
|
-
self.assertTrue(next(mmif_roundtrip.views.
|
|
1125
|
+
self.assertTrue(next(mmif_roundtrip.views.get_last_contentful_view().get_annotations(AnnotationTypes.Annotation, author='me')))
|
|
1112
1126
|
# finally, when deserialized back to a Mmif instance, the `Annotation` props should be added
|
|
1113
1127
|
# as a property of the document
|
|
1114
1128
|
doc1_mmif_roundtrip = mmif_roundtrip.get_document_by_id('doc1')
|
|
@@ -1121,7 +1135,7 @@ class TestDocument(unittest.TestCase):
|
|
|
1121
1135
|
mmif.add_document(doc1)
|
|
1122
1136
|
did = 'doc1'
|
|
1123
1137
|
# last view before serialization rounds
|
|
1124
|
-
r0_vid = mmif.views.
|
|
1138
|
+
r0_vid = mmif.views.get_last_contentful_view().id
|
|
1125
1139
|
doc1.at_type = DocumentTypes.TextDocument
|
|
1126
1140
|
doc1.id = did
|
|
1127
1141
|
doc1.location = 'aScheme:///data/doc1.txt'
|
|
@@ -1151,7 +1165,7 @@ class TestDocument(unittest.TestCase):
|
|
|
1151
1165
|
self.assertEqual(2, len(doc1._props_pending))
|
|
1152
1166
|
mmif_roundtrip2 = Mmif(mmif_roundtrip1.serialize())
|
|
1153
1167
|
## but not serialized
|
|
1154
|
-
self.assertEqual(0, len(list(mmif_roundtrip2.views.
|
|
1168
|
+
self.assertEqual(0, len(list(mmif_roundtrip2.views.get_last_contentful_view().get_annotations(AnnotationTypes.Annotation))))
|
|
1155
1169
|
|
|
1156
1170
|
# adding non-duplicate value should be serialized into a new Annotation object
|
|
1157
1171
|
# even when there is a duplicate key in a previous view
|
|
@@ -1197,7 +1211,7 @@ class TestDocument(unittest.TestCase):
|
|
|
1197
1211
|
doc1.add_property('author', 'me')
|
|
1198
1212
|
mmif_roundtrip = Mmif(mmif.serialize())
|
|
1199
1213
|
## should be only one Annotation object, from manual call of `new_annotation`
|
|
1200
|
-
self.assertEqual(1, len(list(mmif_roundtrip.views.
|
|
1214
|
+
self.assertEqual(1, len(list(mmif_roundtrip.views.get_last_contentful_view().get_annotations(AnnotationTypes.Annotation))))
|
|
1201
1215
|
|
|
1202
1216
|
# test with duplicate property added by a downstream app
|
|
1203
1217
|
mmif_roundtrip = Mmif(mmif.serialize())
|
|
@@ -1209,9 +1223,9 @@ class TestDocument(unittest.TestCase):
|
|
|
1209
1223
|
# author=me is already in the input MMIF
|
|
1210
1224
|
doc1_prime.add_property('author', 'me')
|
|
1211
1225
|
mmif_roundtrip2 = Mmif(mmif_roundtrip.serialize())
|
|
1212
|
-
self.assertEqual(1, len(list(mmif.views.
|
|
1226
|
+
self.assertEqual(1, len(list(mmif.views.get_last_contentful_view().get_annotations(AnnotationTypes.Annotation))))
|
|
1213
1227
|
# none should be added
|
|
1214
|
-
self.assertEqual(0, len(list(mmif_roundtrip2.views.
|
|
1228
|
+
self.assertEqual(0, len(list(mmif_roundtrip2.views.get_last_contentful_view().get_annotations(AnnotationTypes.Annotation))))
|
|
1215
1229
|
|
|
1216
1230
|
# test with same key, different value
|
|
1217
1231
|
mmif_roundtrip = Mmif(mmif.serialize())
|
|
@@ -1224,13 +1238,13 @@ class TestDocument(unittest.TestCase):
|
|
|
1224
1238
|
doc1_prime.add_property('author', 'you')
|
|
1225
1239
|
mmif_roundtrip2 = Mmif(mmif_roundtrip.serialize())
|
|
1226
1240
|
# the Annotation in the previous view should be preserved
|
|
1227
|
-
self.assertEqual(1, len(list(mmif.views.
|
|
1241
|
+
self.assertEqual(1, len(list(mmif.views.get_last_contentful_view().get_annotations(AnnotationTypes.Annotation))))
|
|
1228
1242
|
# but a new one should be added
|
|
1229
|
-
self.assertEqual(1, len(list(mmif_roundtrip2.views.
|
|
1243
|
+
self.assertEqual(1, len(list(mmif_roundtrip2.views.get_last_contentful_view().get_annotations(AnnotationTypes.Annotation))))
|
|
1230
1244
|
|
|
1231
|
-
self.assertEqual('me', list(mmif.views.
|
|
1245
|
+
self.assertEqual('me', list(mmif.views.get_last_contentful_view().get_annotations(AnnotationTypes.Annotation))[0].get_property(
|
|
1232
1246
|
'author'))
|
|
1233
|
-
self.assertEqual('you', list(mmif_roundtrip2.views.
|
|
1247
|
+
self.assertEqual('you', list(mmif_roundtrip2.views.get_last_contentful_view().get_annotations(AnnotationTypes.Annotation))[
|
|
1234
1248
|
0].get_property('author'))
|
|
1235
1249
|
|
|
1236
1250
|
def test_capital_annotation_generation_viewfinder(self):
|
|
@@ -1255,6 +1269,28 @@ class TestDocument(unittest.TestCase):
|
|
|
1255
1269
|
cap_anns = list(mmif_roundtrip.views[f'v{i}'].get_annotations(AnnotationTypes.Annotation))
|
|
1256
1270
|
self.assertEqual(1, len(cap_anns))
|
|
1257
1271
|
self.assertEqual(authors[i-1], cap_anns[0].get_property('author'))
|
|
1272
|
+
|
|
1273
|
+
def test_capital_annotation_nongeneration_for_writable_documents(self):
|
|
1274
|
+
mmif = Mmif(validate=False)
|
|
1275
|
+
doc = Document()
|
|
1276
|
+
doc.at_type = DocumentTypes.TextDocument
|
|
1277
|
+
doc.id = f'doc0'
|
|
1278
|
+
doc.location = f'aScheme:///data/doc0.txt'
|
|
1279
|
+
mmif.add_document(doc)
|
|
1280
|
+
|
|
1281
|
+
v = mmif.new_view()
|
|
1282
|
+
v.metadata.app = tester_appname
|
|
1283
|
+
vid = v.id
|
|
1284
|
+
new_td_id = mmif[vid].new_textdocument(text='new text', document='doc0', origin='transformation').id
|
|
1285
|
+
doc.add_property('author', 'me')
|
|
1286
|
+
|
|
1287
|
+
mmif_roundtrip = Mmif(mmif.serialize())
|
|
1288
|
+
|
|
1289
|
+
self.assertTrue(AnnotationTypes.Annotation in mmif_roundtrip[vid].metadata.contains)
|
|
1290
|
+
self.assertTrue(mmif_roundtrip.get_document_by_id('doc0').get_property('author'), 'me')
|
|
1291
|
+
self.assertTrue(next(mmif_roundtrip[vid].get_annotations(AnnotationTypes.Annotation)).get_property('author'), 'me')
|
|
1292
|
+
self.assertTrue(next(mmif_roundtrip[vid].get_annotations(AnnotationTypes.Annotation)).get_property('document'), doc.id)
|
|
1293
|
+
self.assertTrue(mmif_roundtrip[new_td_id].get_property('origin'), 'transformation')
|
|
1258
1294
|
|
|
1259
1295
|
def test_deserialize_with_whole_mmif(self):
|
|
1260
1296
|
for i, datum in self.data.items():
|
|
@@ -4,10 +4,9 @@ import pytest
|
|
|
4
4
|
|
|
5
5
|
from mmif import Mmif, Document, AnnotationTypes
|
|
6
6
|
from mmif.utils import sequence_helper as sqh
|
|
7
|
+
from mmif.utils import text_document_helper as tdh
|
|
7
8
|
from mmif.utils import timeunit_helper as tuh
|
|
8
9
|
from mmif.utils import video_document_helper as vdh
|
|
9
|
-
from mmif.utils import text_document_helper as tdh
|
|
10
|
-
from mmif.serialize import mmif
|
|
11
10
|
from tests.mmif_examples import *
|
|
12
11
|
|
|
13
12
|
|
|
@@ -178,7 +177,7 @@ class TestSequenceHelper(unittest.TestCase):
|
|
|
178
177
|
class TestTextDocHelper(unittest.TestCase):
|
|
179
178
|
mmif_obj = Mmif(MMIF_EXAMPLES['everything'])
|
|
180
179
|
|
|
181
|
-
@pytest.mark.skip("The only valid test cases come from
|
|
180
|
+
@pytest.mark.skip("The only valid test cases come from kaldi app which annotates wrong property")
|
|
182
181
|
def test_slice_text(self):
|
|
183
182
|
sliced_text_full_overlap = tdh.slice_text(self.mmif_obj, 11500, 14600)
|
|
184
183
|
sliced_text_partial_overlap = tdh.slice_text(self.mmif_obj, 7, 10, unit="seconds")
|
mmif_python-1.0.16/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
1.0.16
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|