mmif-python 1.0.15__tar.gz → 1.0.17__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.
Files changed (44) hide show
  1. {mmif_python-1.0.15 → mmif_python-1.0.17}/PKG-INFO +1 -1
  2. mmif_python-1.0.17/VERSION +1 -0
  3. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/serialize/annotation.py +46 -7
  4. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/serialize/mmif.py +99 -17
  5. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/serialize/model.py +6 -6
  6. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/serialize/view.py +27 -17
  7. mmif_python-1.0.17/mmif/utils/text_document_helper.py +12 -0
  8. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/utils/video_document_helper.py +6 -6
  9. mmif_python-1.0.17/mmif/ver/__init__.py +2 -0
  10. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/vocabulary/annotation_types.py +2 -2
  11. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/vocabulary/base_types.py +1 -1
  12. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/vocabulary/document_types.py +2 -2
  13. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif_python.egg-info/PKG-INFO +1 -1
  14. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif_python.egg-info/SOURCES.txt +1 -0
  15. {mmif_python-1.0.15 → mmif_python-1.0.17}/tests/test_serialize.py +82 -10
  16. {mmif_python-1.0.15 → mmif_python-1.0.17}/tests/test_utils.py +27 -7
  17. mmif_python-1.0.15/VERSION +0 -1
  18. mmif_python-1.0.15/mmif/ver/__init__.py +0 -2
  19. {mmif_python-1.0.15 → mmif_python-1.0.17}/LICENSE +0 -0
  20. {mmif_python-1.0.15 → mmif_python-1.0.17}/MANIFEST.in +0 -0
  21. {mmif_python-1.0.15 → mmif_python-1.0.17}/README.md +0 -0
  22. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/__init__.py +0 -0
  23. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/res/__init__.py +0 -0
  24. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/res/clams.vocabulary.yaml +0 -0
  25. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/res/do-not-edit.txt +0 -0
  26. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/res/mmif.json +0 -0
  27. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/serialize/__init__.py +0 -0
  28. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/utils/__init__.py +0 -0
  29. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/utils/sequence_helper.py +0 -0
  30. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/utils/timeunit_helper.py +0 -0
  31. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/ver/do-not-edit.txt +0 -0
  32. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/vocabulary/__init__.py +0 -0
  33. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif/vocabulary/do-not-edit.txt +0 -0
  34. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif_docloc_http/__init__.py +0 -0
  35. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif_python.egg-info/dependency_links.txt +0 -0
  36. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif_python.egg-info/requires.txt +0 -0
  37. {mmif_python-1.0.15 → mmif_python-1.0.17}/mmif_python.egg-info/top_level.txt +0 -0
  38. {mmif_python-1.0.15 → mmif_python-1.0.17}/requirements.cv +0 -0
  39. {mmif_python-1.0.15 → mmif_python-1.0.17}/requirements.seq +0 -0
  40. {mmif_python-1.0.15 → mmif_python-1.0.17}/requirements.txt +0 -0
  41. {mmif_python-1.0.15 → mmif_python-1.0.17}/setup.cfg +0 -0
  42. {mmif_python-1.0.15 → mmif_python-1.0.17}/setup.py +0 -0
  43. {mmif_python-1.0.15 → mmif_python-1.0.17}/tests/test_versioncompat.py +0 -0
  44. {mmif_python-1.0.15 → mmif_python-1.0.17}/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.15
3
+ Version: 1.0.17
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.17
@@ -14,7 +14,7 @@ import pkgutil
14
14
  import re
15
15
  import typing
16
16
  import warnings
17
- from typing import Union, Dict, List, Optional, Iterator, MutableMapping, TypeVar
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.reserved_names.update(('_parent_view_id', '_props_ephemeral'))
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`)
@@ -434,7 +473,7 @@ class AnnotationProperties(MmifObject, MutableMapping[str, T]):
434
473
  else:
435
474
  return self._unnamed_attributes[key]
436
475
 
437
- def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None) -> None:
476
+ def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
438
477
  self.id: str = ''
439
478
  # any individual at_type (subclassing this class) can have its own set of required attributes
440
479
  self._required_attributes = ["id"]
@@ -451,7 +490,7 @@ class DocumentProperties(AnnotationProperties):
451
490
  :param mmif_obj: the JSON data that defines the properties
452
491
  """
453
492
 
454
- def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None) -> None:
493
+ def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
455
494
  self.mime: str = ''
456
495
  # note the trailing underscore here. I wanted to use the name `location`
457
496
  # for @property in this class and `Document` class, so had to use a diff
@@ -572,7 +611,7 @@ class DocumentProperties(AnnotationProperties):
572
611
 
573
612
  class Text(MmifObject):
574
613
 
575
- def __init__(self, text_obj: Optional[Union[bytes, str, dict]] = None) -> None:
614
+ def __init__(self, text_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
576
615
  self._value: str = ''
577
616
  self._language: str = ''
578
617
  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
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
  """
@@ -205,6 +207,7 @@ class Mmif(MmifObject):
205
207
  """
206
208
  super()._deserialize(input_dict)
207
209
  for view in self.views:
210
+ view._parent_mmif = self
208
211
  # this dict will be populated with properties
209
212
  # that are not encoded in individual annotations objects themselves
210
213
  extrinsic_props = defaultdict(dict)
@@ -212,6 +215,7 @@ class Mmif(MmifObject):
212
215
  for prop_key, prop_value in type_lv_props.items():
213
216
  extrinsic_props[at_type][prop_key] = prop_value
214
217
  for ann in view.get_annotations():
218
+ ## for "capital" Annotation properties
215
219
  # first add all extrinsic properties to the Annotation objects
216
220
  # as "ephemeral" properties
217
221
  for prop_key, prop_value in extrinsic_props[ann.at_type].items():
@@ -226,13 +230,44 @@ class Mmif(MmifObject):
226
230
  except KeyError:
227
231
  warnings.warn(f"Annotation {ann.id} (in view {view.id}) has a document ID {doc_id} that "
228
232
  f"does not exist in the MMIF object. Skipping.", RuntimeWarning)
229
- # lastly, add quick access to `start` and `end` values if the annotation is using `targets` property
233
+
234
+ ## caching start and end points for time-based annotations
235
+ # add quick access to `start` and `end` values if the annotation is using `targets` property
230
236
  if 'targets' in ann.properties:
231
237
  if 'start' in ann.properties or 'end' in ann.properties:
232
- raise ValueError(f"Annotation {ann.id} (in view {view.id}) has `targes` and `start`/`end/` "
238
+ raise ValueError(f"Annotation {ann.id} (in view {view.id}) has `targets` and `start`/`end/` "
233
239
  f"properties at the same time. Annotation anchors are ambiguous.")
234
240
  ann._props_ephemeral['start'] = self._get_linear_anchor_point(ann, start=True)
235
241
  ann._props_ephemeral['end'] = self._get_linear_anchor_point(ann, start=False)
242
+
243
+ ## caching alignments
244
+ if ann.at_type == AnnotationTypes.Alignment:
245
+ self._cache_alignment(ann)
246
+
247
+ def _cache_alignment(self, alignment_ann: Annotation):
248
+ view = self.views.get(alignment_ann.parent)
249
+ if view is None:
250
+ warnings.warn(f"Alignment {alignment_ann.long_id} doesn't have a parent view, but it should.", RuntimeWarning)
251
+ return
252
+
253
+ ## caching alignments
254
+ def _desprately_search_annotation_object(ann_short_id):
255
+ ann_long_id = f"{view.id}{self.id_delimiter}{ann_short_id}"
256
+ try:
257
+ return self.__getitem__(ann_long_id)
258
+ except KeyError:
259
+ return self.__getitem__(ann_short_id)
260
+
261
+ if all(map(lambda x: x in alignment_ann.properties, ('source', 'target'))):
262
+ source_ann = _desprately_search_annotation_object(alignment_ann.get('source'))
263
+ target_ann = _desprately_search_annotation_object(alignment_ann.get('target'))
264
+ if isinstance(source_ann, Annotation) and isinstance(target_ann, Annotation):
265
+ source_ann._cache_alignment(alignment_ann, target_ann)
266
+ target_ann._cache_alignment(alignment_ann, source_ann)
267
+ else:
268
+ warnings.warn(
269
+ f"Alignment {alignment_ann.long_id} has `source` and `target` properties that do not point to Annotation objects.",
270
+ RuntimeWarning)
236
271
 
237
272
  def generate_capital_annotations(self):
238
273
  """
@@ -339,7 +374,7 @@ class Mmif(MmifObject):
339
374
  new_view = View()
340
375
  new_view.id = self.new_view_id()
341
376
  new_view.metadata.timestamp = datetime.now()
342
- self.views.append(new_view)
377
+ self.add_view(new_view)
343
378
  return new_view
344
379
 
345
380
  def add_view(self, view: View, overwrite=False) -> None:
@@ -353,6 +388,7 @@ class Mmif(MmifObject):
353
388
  an existing view with the same ID
354
389
  :return: None
355
390
  """
391
+ view._parent_mmif = self
356
392
  self.views.append(view, overwrite)
357
393
 
358
394
  def add_document(self, document: Document, overwrite=False) -> None:
@@ -555,7 +591,7 @@ class Mmif(MmifObject):
555
591
 
556
592
  get_views_with_error = get_all_views_with_error
557
593
 
558
- def get_all_views_contain(self, at_types: Union[ThingTypesBase, str, List[Union[str, ThingTypesBase]]]) -> List[View]:
594
+ def get_all_views_contain(self, *at_types: Union[ThingTypesBase, str]) -> List[View]:
559
595
  """
560
596
  Returns the list of all views in the MMIF if given types
561
597
  are present in that view's 'contains' metadata.
@@ -563,11 +599,8 @@ class Mmif(MmifObject):
563
599
  :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
600
  :return: the list of views that contain the type
565
601
  """
566
- if isinstance(at_types, list):
567
- return [view for view in self.views
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]
602
+ return [view for view in self.views
603
+ if all(map(lambda x: x in view.metadata.contains, at_types))]
571
604
 
572
605
  get_views_contain = get_all_views_contain
573
606
 
@@ -609,7 +642,56 @@ class Mmif(MmifObject):
609
642
  if at_types in view.metadata.contains:
610
643
  return view
611
644
  return None
612
-
645
+
646
+ def _is_in_time_range(self, ann: Annotation, range_s: Union[int, float], range_e: Union[int, float]) -> bool:
647
+ """
648
+ Checks if the annotation is anchored within the given time range. Any overlap is considered included.
649
+
650
+ :param ann: the Annotation object to check, must be time-based itself or anchored to time-based annotations
651
+ :param range_s: the start time point of the range (in milliseconds)
652
+ :param range_e: the end time point of the range (in milliseconds)
653
+
654
+ :return: True if the annotation is anchored within the time range, False otherwise
655
+ """
656
+ ann_s, ann_e = self.get_start(ann), self.get_end(ann)
657
+ return (ann_s < range_s < ann_e) or (ann_s < range_e < ann_e) or (ann_s > range_s and ann_e < range_e)
658
+
659
+ def get_annotations_between_time(self, start: Union[int, float], end: Union[int, float], time_unit: str = "ms",
660
+ at_types: List[Union[ThingTypesBase, str]] = []) -> Iterator[Annotation]:
661
+ """
662
+ Finds annotations that are anchored between the given time points.
663
+
664
+ :param start: the start time point in the unit of `input_unit`
665
+ :param end: the end time point in the unit of `input_unit`
666
+ :param time_unit: the unit of the input time points. Default is `ms`.
667
+ :param at_types: a list of annotation types to filter with. Any type in this list will be included in the return.
668
+ :return: an iterator of Annotation objects that are anchored between the given time points
669
+ """
670
+ assert start < end, f"Start time point must be smaller than the end time point, given {start} and {end}"
671
+ assert start >= 0, f"Start time point must be non-negative, given {start}"
672
+ assert end >= 0, f"End time point must be non-negative, given {end}"
673
+
674
+ from mmif.utils.timeunit_helper import convert
675
+
676
+ time_anchors_in_range = []
677
+ at_types = set(at_types)
678
+
679
+ for view in self.get_all_views_contain(AnnotationTypes.TimeFrame) + self.get_all_views_contain(AnnotationTypes.TimePoint):
680
+ time_unit_in_view = view.metadata.contains.get(AnnotationTypes.TimeFrame)["timeUnit"]
681
+
682
+ start_time = convert(start, time_unit, time_unit_in_view, 1)
683
+ end_time = convert(end, time_unit, time_unit_in_view, 1)
684
+ for ann in view.get_annotations():
685
+ if ann.at_type in (AnnotationTypes.TimeFrame, AnnotationTypes.TimePoint) and self._is_in_time_range(ann, start_time, end_time):
686
+ time_anchors_in_range.append(ann)
687
+ time_anchors_in_range.sort(key=lambda x: self.get_start(x))
688
+ for time_anchor in time_anchors_in_range:
689
+ if not at_types or time_anchor.at_type in at_types:
690
+ yield time_anchor
691
+ for aligned in time_anchor.get_all_aligned():
692
+ if not at_types or aligned.at_type in at_types:
693
+ yield aligned
694
+
613
695
  def _get_linear_anchor_point(self, ann: Annotation, targets_sorted=False, start: bool = True) -> Union[int, float]:
614
696
  # TODO (krim @ 2/5/24): Update the return type once timeunits are unified to `ms` as integers (https://github.com/clamsproject/mmif/issues/192)
615
697
  """
@@ -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,12 @@ import json
9
9
  from datetime import datetime
10
10
  from typing import Dict, Union, Optional, Generator, List, cast
11
11
 
12
- from mmif.serialize.model import PRMTV_TYPES
13
- from mmif.vocabulary import ThingTypesBase, ClamsTypesBase
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.annotation import Annotation, Document
14
+ from mmif.serialize.model import PRMTV_TYPES, MmifObject, DataList, DataDict
16
15
 
17
16
  __all__ = ['View', 'ViewMetadata', 'Contain']
18
17
 
19
- from .. import DocumentTypes
20
-
21
18
 
22
19
  class View(MmifObject):
23
20
  """
@@ -32,10 +29,12 @@ class View(MmifObject):
32
29
  :param view_obj: the JSON data that defines the view
33
30
  """
34
31
 
35
- def __init__(self, view_obj: Optional[Union[bytes, str, dict]] = None) -> None:
32
+ def __init__(self, view_obj: Optional[Union[bytes, str, dict]] = None, parent_mmif=None, *_) -> None:
36
33
  # used to autogenerate annotation ids
37
34
  self._id_counts = {}
38
- self.reserved_names.add("_id_counts")
35
+ # used to access the parent MMIF object
36
+ self._parent_mmif = parent_mmif
37
+ self.reserved_names.update(("_parent_mmif", "_id_counts"))
39
38
  self._exclude_from_diff = {"_id_counts"}
40
39
 
41
40
  self.id: str = ''
@@ -64,7 +63,7 @@ class View(MmifObject):
64
63
  else:
65
64
  return self.metadata.new_contain(at_type, **contains_metadata)
66
65
 
67
- def _set_id(self, annotation: Annotation, identifier):
66
+ def _set_ann_id(self, annotation: Annotation, identifier):
68
67
  if identifier is not None:
69
68
  annotation.id = identifier
70
69
  else:
@@ -73,7 +72,7 @@ class View(MmifObject):
73
72
  new_id = f'{prefix}_{new_num}'
74
73
  self._id_counts[prefix] = new_num
75
74
  annotation.id = new_id
76
-
75
+
77
76
  def new_annotation(self, at_type: Union[str, ThingTypesBase], aid: Optional[str] = None,
78
77
  overwrite=False, **properties) -> 'Annotation':
79
78
  """
@@ -96,7 +95,7 @@ class View(MmifObject):
96
95
  """
97
96
  new_annotation = Annotation()
98
97
  new_annotation.at_type = at_type
99
- self._set_id(new_annotation, aid)
98
+ self._set_ann_id(new_annotation, aid)
100
99
  for propk, propv in properties.items():
101
100
  new_annotation.add_property(propk, propv)
102
101
  for propk, propv in self.metadata.contains.get(at_type, {}).items():
@@ -122,8 +121,10 @@ class View(MmifObject):
122
121
  annotation.parent = self.id
123
122
  self.annotations.append(annotation, overwrite)
124
123
  self.new_contain(annotation.at_type)
124
+ if annotation.at_type == AnnotationTypes.Alignment:
125
+ self._parent_mmif._cache_alignment(annotation)
125
126
  return annotation
126
-
127
+
127
128
  def new_textdocument(self, text: str, lang: str = "en", did: Optional[str] = None,
128
129
  overwrite=False, **properties) -> 'Document':
129
130
  """
@@ -147,7 +148,7 @@ class View(MmifObject):
147
148
  """
148
149
  new_document = Document()
149
150
  new_document.at_type = DocumentTypes.TextDocument
150
- self._set_id(new_document, did)
151
+ self._set_ann_id(new_document, did)
151
152
  new_document.text_language = lang
152
153
  new_document.text_value = text
153
154
  for propk, propv in properties.items():
@@ -189,9 +190,18 @@ class View(MmifObject):
189
190
  yield annotation
190
191
 
191
192
  def get_annotation_by_id(self, ann_id) -> Annotation:
192
- ann_found = self.annotations.get(ann_id)
193
+ if self.id_delimiter in ann_id and not ann_id.startswith(self.id):
194
+ try:
195
+ ann_found = self._parent_mmif[ann_id]
196
+ except KeyError:
197
+ ann_found = None
198
+ else:
199
+ ann_found = self.annotations.get(ann_id.split(self.id_delimiter)[-1])
193
200
  if ann_found is None or not isinstance(ann_found, Annotation):
194
- raise KeyError(f"Annotation \"{ann_id}\" is not found in view {self.id}.")
201
+ if self.id_delimiter in ann_id:
202
+ raise KeyError(f"Annotation \"{ann_id}\" is not found in the MMIF.")
203
+ else:
204
+ raise KeyError(f"Annotation \"{ann_id}\" is not found in view {self.id}.")
195
205
  else:
196
206
  return ann_found
197
207
 
@@ -257,7 +267,7 @@ class ViewMetadata(MmifObject):
257
267
  :param viewmetadata_obj: the JSON data that defines the metadata
258
268
  """
259
269
 
260
- def __init__(self, viewmetadata_obj: Optional[Union[bytes, str, dict]] = None) -> None:
270
+ def __init__(self, viewmetadata_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
261
271
  self.document: str = ''
262
272
  self.timestamp: Optional[datetime] = None
263
273
  self.app: str = ''
@@ -377,7 +387,7 @@ class ErrorDict(MmifObject):
377
387
  """
378
388
  Error object that stores information about error occurred during processing.
379
389
  """
380
- def __init__(self, error_obj: Optional[Union[bytes, str, dict]] = None) -> None:
390
+ def __init__(self, error_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
381
391
  self.message: str = ''
382
392
  self.stackTrace: str = ''
383
393
  super().__init__(error_obj)
@@ -0,0 +1,12 @@
1
+ import mmif
2
+ from mmif import Annotation
3
+
4
+
5
+ def slice_text(mmif_obj, start: int, end: int, unit: str = "milliseconds") -> str:
6
+ token_type = "http://vocab.lappsgrid.org/Token"
7
+ anns_found = mmif_obj.get_annotations_between_time(start, end, unit)
8
+ tokens_sliced = []
9
+ for ann in anns_found:
10
+ if ann.is_type(token_type):
11
+ tokens_sliced.append(ann.get_property('word'))
12
+ return ' '.join(tokens_sliced)
@@ -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, AnnotationTypes
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) -> Union[Tuple[Union[int, float, str], Union[int, float, 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 (integer) or seconds/milliseconds (float) of input start and end
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
- return convert(mmif.get_start(time_frame), in_unit, out_unit, get_framerate(vd)), \
227
- convert(mmif.get_end(time_frame), in_unit, out_unit, get_framerate(vd))
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):
@@ -0,0 +1,2 @@
1
+ __version__ = "1.0.17"
2
+ __specver__ = "1.0.5"
@@ -1,4 +1,4 @@
1
- # Spec version 1.0.4
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.4 as class variables.
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.4 as a class variable.
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.4
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.4 as class variables.
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.15
3
+ Version: 1.0.17
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
@@ -18,6 +18,7 @@ mmif/serialize/model.py
18
18
  mmif/serialize/view.py
19
19
  mmif/utils/__init__.py
20
20
  mmif/utils/sequence_helper.py
21
+ mmif/utils/text_document_helper.py
21
22
  mmif/utils/timeunit_helper.py
22
23
  mmif/utils/video_document_helper.py
23
24
  mmif/ver/__init__.py
@@ -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://www.gnu.org/licenses/gpl-3.0.txt"
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
@@ -342,6 +353,67 @@ class TestMmif(unittest.TestCase):
342
353
  self.assertEqual(e_view.id, f'{p}4')
343
354
  self.assertEqual(len(mmif_obj.views), 5)
344
355
 
356
+ def test_get_annotations_between_time(self):
357
+ token_type = "http://vocab.lappsgrid.org/Token"
358
+ # Below tokens are obtained by 'jq' in CLI using command:
359
+ # jq '[
360
+ # .views[3].annotations |
361
+ # .[] |
362
+ # select(."@type"=="http://vocab.lappsgrid.org/Token")] |
363
+ # sort_by(.properties.id | ltrimstr("t") | tonumber) |
364
+ # map(.properties.word)' <examples>.json
365
+ tokens_in_order = ["Hello",
366
+ ",",
367
+ "this",
368
+ "is",
369
+ "Jim",
370
+ "Lehrer",
371
+ "with",
372
+ "the",
373
+ "NewsHour",
374
+ "on",
375
+ "PBS",
376
+ ".",
377
+ "In",
378
+ "the",
379
+ "nineteen",
380
+ "eighties",
381
+ ",",
382
+ "barking",
383
+ "dogs",
384
+ "have",
385
+ "increasingly",
386
+ "become",
387
+ "a",
388
+ "problem",
389
+ "in",
390
+ "urban",
391
+ "areas",
392
+ "."]
393
+ mmif_obj = Mmif(MMIF_EXAMPLES['everything'])
394
+
395
+ # Test case 1: All token annotations are selected
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))
398
+ for i, ann in enumerate(selected_token_anns):
399
+ self.assertEqual(tokens_in_order[i], ann.get_property("word"))
400
+
401
+ # Test case 2: No token annotation are selected
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))
406
+
407
+ # Test case 3(a): Partial tokens are selected (involve partial overlap)
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])
411
+
412
+ # Test case 3(b): Partial tokens are selected (only full overlap)
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])
416
+
345
417
  def test_add_document(self):
346
418
  mmif_obj = Mmif(MMIF_EXAMPLES['everything'])
347
419
  med_obj = Document(FRACTIONAL_EXAMPLES['doc_only'])
@@ -604,6 +676,7 @@ class TestView(unittest.TestCase):
604
676
  self.assertEqual(view_from_str, view_from_bytes)
605
677
  self.assertEqual(json.loads(view_from_json.serialize()), json.loads(view_from_str.serialize()))
606
678
  self.assertEqual(json.loads(view_from_bytes.serialize()), json.loads(view_from_str.serialize()))
679
+
607
680
 
608
681
  def test_annotation_order_preserved(self):
609
682
  view_serial = self.view_obj.serialize()
@@ -827,13 +900,12 @@ class TestAnnotation(unittest.TestCase):
827
900
  # TODO (angus-lherrou @ 7/27/2020): testing should include validation for required attrs
828
901
  # once that is implemented (issue #23)
829
902
  def setUp(self) -> None:
830
- self.data = {i: {'string': example,
831
- 'json': json.loads(example),
832
- 'mmif': Mmif(example),
833
- 'annotations': [annotation
834
- for view in json.loads(example)['views']
835
- for annotation in view['annotations']]}
836
- 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']]}
837
909
 
838
910
  def test_annotation_properties(self):
839
911
  ann_json = self.data['everything']['annotations'][0]
@@ -4,14 +4,15 @@ 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
10
+ from tests.mmif_examples import *
9
11
 
10
12
 
11
13
  class TestTimeunitHelper(unittest.TestCase):
12
-
13
14
  FPS = 30
14
-
15
+
15
16
  def test_convert(self):
16
17
  self.assertEqual(1000, tuh.convert(1, 's', 'ms', self.FPS))
17
18
  self.assertEqual(1.1, tuh.convert(1100, 'ms', 's', self.FPS))
@@ -35,7 +36,7 @@ class TestVideoDocumentHelper(unittest.TestCase):
35
36
  })
36
37
  self.video_doc.add_property('fps', self.fps)
37
38
  self.mmif_obj.add_document(self.video_doc)
38
-
39
+
39
40
  def test_extract_mid_frame(self):
40
41
  tf = self.a_view.new_annotation(AnnotationTypes.TimeFrame, start=100, end=200, timeUnit='frame', document='d1')
41
42
  self.assertEqual(150, vdh.get_mid_framenum(self.mmif_obj, tf))
@@ -92,11 +93,12 @@ class TestVideoDocumentHelper(unittest.TestCase):
92
93
  s_frame = vdh.second_to_framenum(self.video_doc, 3)
93
94
  e_frame = vdh.second_to_framenum(self.video_doc, 5)
94
95
  self.assertEqual(1, len(vdh.sample_frames(s_frame, e_frame, 60)))
95
-
96
+
96
97
  def test_convert_timepoint(self):
97
- timepoint_ann = self.a_view.new_annotation(AnnotationTypes.BoundingBox, timePoint=3, timeUnit='second', document='d1')
98
+ timepoint_ann = self.a_view.new_annotation(AnnotationTypes.BoundingBox, timePoint=3, timeUnit='second',
99
+ document='d1')
98
100
  self.assertEqual(vdh.convert(3, 's', 'f', self.fps), vdh.convert_timepoint(self.mmif_obj, timepoint_ann, 'f'))
99
-
101
+
100
102
  def test_convert_timeframe(self):
101
103
  self.a_view.metadata.new_contain(AnnotationTypes.TimeFrame, timeUnit='frame', document='d1')
102
104
  timeframe_ann = self.a_view.new_annotation(AnnotationTypes.TimeFrame, start=100, end=200)
@@ -105,7 +107,7 @@ class TestVideoDocumentHelper(unittest.TestCase):
105
107
 
106
108
 
107
109
  class TestSequenceHelper(unittest.TestCase):
108
-
110
+
109
111
  def test_validate_labelset(self):
110
112
  mmif_obj = Mmif(validate=False)
111
113
  view = mmif_obj.new_view()
@@ -172,5 +174,23 @@ class TestSequenceHelper(unittest.TestCase):
172
174
  sqh.smooth_outlying_short_intervals(scores, 1, 1))
173
175
 
174
176
 
177
+ class TestTextDocHelper(unittest.TestCase):
178
+ mmif_obj = Mmif(MMIF_EXAMPLES['everything'])
179
+
180
+ @pytest.mark.skip("The only valid test cases come from kaldi app which annotates wrong property")
181
+ def test_slice_text(self):
182
+ sliced_text_full_overlap = tdh.slice_text(self.mmif_obj, 11500, 14600)
183
+ sliced_text_partial_overlap = tdh.slice_text(self.mmif_obj, 7, 10, unit="seconds")
184
+ no_sliced_text = tdh.slice_text(self.mmif_obj, 0, 5000)
185
+ full_sliced_text = tdh.slice_text(self.mmif_obj, 0, 22, unit="seconds")
186
+ self.assertEqual("In the nineteen eighties ,", sliced_text_full_overlap)
187
+ self.assertEqual("is Jim Lehrer with the NewsHour", sliced_text_partial_overlap)
188
+ self.assertEqual("", no_sliced_text)
189
+ self.assertEqual(
190
+ "Hello , this is Jim Lehrer with the NewsHour on PBS . "
191
+ "In the nineteen eighties , barking dogs have increasingly become a problem in urban areas .",
192
+ full_sliced_text)
193
+
194
+
175
195
  if __name__ == '__main__':
176
196
  unittest.main()
@@ -1 +0,0 @@
1
- 1.0.15
@@ -1,2 +0,0 @@
1
- __version__ = "1.0.15"
2
- __specver__ = "1.0.4"
File without changes
File without changes
File without changes
File without changes
File without changes