mmif-python 1.5.2__tar.gz → 1.5.4__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 (102) hide show
  1. {mmif_python-1.5.2 → mmif_python-1.5.4}/CHANGELOG.md +18 -0
  2. {mmif_python-1.5.2 → mmif_python-1.5.4}/PKG-INFO +2 -2
  3. {mmif_python-1.5.2 → mmif_python-1.5.4}/documentation/target-versions.csv +2 -0
  4. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/serialize/annotation.py +125 -65
  5. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/serialize/mmif.py +75 -22
  6. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/serialize/view.py +17 -8
  7. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/summarizer/nodes.py +64 -25
  8. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/summarizer/summary.py +3 -3
  9. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/video_document_helper.py +217 -37
  10. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif_python.egg-info/PKG-INFO +2 -2
  11. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif_python.egg-info/requires.txt +1 -1
  12. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif_python.egg-info/scm_file_list.json +64 -64
  13. mmif_python-1.5.4/mmif_python.egg-info/scm_version.json +8 -0
  14. {mmif_python-1.5.2 → mmif_python-1.5.4}/pyproject.toml +1 -1
  15. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/test_serialize.py +227 -35
  16. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/test_utils.py +425 -57
  17. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/test_utils_cli.py +23 -0
  18. mmif_python-1.5.2/mmif_python.egg-info/scm_version.json +0 -8
  19. {mmif_python-1.5.2 → mmif_python-1.5.4}/.github/workflows/codecov.yml +0 -0
  20. {mmif_python-1.5.2 → mmif_python-1.5.4}/.github/workflows/issue-assign.yml +0 -0
  21. {mmif_python-1.5.2 → mmif_python-1.5.4}/.github/workflows/issue-close.yml +0 -0
  22. {mmif_python-1.5.2 → mmif_python-1.5.4}/.github/workflows/issue-infra-project.yml +0 -0
  23. {mmif_python-1.5.2 → mmif_python-1.5.4}/.github/workflows/publish.yml +0 -0
  24. {mmif_python-1.5.2 → mmif_python-1.5.4}/.github/workflows/release-check.yml +0 -0
  25. {mmif_python-1.5.2 → mmif_python-1.5.4}/.gitignore +0 -0
  26. {mmif_python-1.5.2 → mmif_python-1.5.4}/CONTRIBUTING.md +0 -0
  27. {mmif_python-1.5.2 → mmif_python-1.5.4}/LICENSE +0 -0
  28. {mmif_python-1.5.2 → mmif_python-1.5.4}/README.md +0 -0
  29. {mmif_python-1.5.2 → mmif_python-1.5.4}/build-tools/build.py +0 -0
  30. {mmif_python-1.5.2 → mmif_python-1.5.4}/build-tools/clean.py +0 -0
  31. {mmif_python-1.5.2 → mmif_python-1.5.4}/build-tools/docs.py +0 -0
  32. {mmif_python-1.5.2 → mmif_python-1.5.4}/build-tools/prep_release.py +0 -0
  33. {mmif_python-1.5.2 → mmif_python-1.5.4}/build-tools/publish.py +0 -0
  34. {mmif_python-1.5.2 → mmif_python-1.5.4}/build-tools/test.py +0 -0
  35. {mmif_python-1.5.2 → mmif_python-1.5.4}/documentation/_mmif_example_builder/__init__.py +0 -0
  36. {mmif_python-1.5.2 → mmif_python-1.5.4}/documentation/_templates/versions.html +0 -0
  37. {mmif_python-1.5.2 → mmif_python-1.5.4}/documentation/cli.rst +0 -0
  38. {mmif_python-1.5.2 → mmif_python-1.5.4}/documentation/conf.py +0 -0
  39. {mmif_python-1.5.2 → mmif_python-1.5.4}/documentation/consumer-tutorial.rst +0 -0
  40. {mmif_python-1.5.2 → mmif_python-1.5.4}/documentation/index.rst +0 -0
  41. {mmif_python-1.5.2 → mmif_python-1.5.4}/documentation/introduction.rst +0 -0
  42. {mmif_python-1.5.2 → mmif_python-1.5.4}/documentation/plugins.rst +0 -0
  43. {mmif_python-1.5.2 → mmif_python-1.5.4}/documentation/summarizer.rst +0 -0
  44. {mmif_python-1.5.2 → mmif_python-1.5.4}/documentation/target-versions.rst +0 -0
  45. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/__init__.py +0 -0
  46. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/res/__init__.py +0 -0
  47. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/res/mmif.json +0 -0
  48. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/serialize/__init__.py +0 -0
  49. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/serialize/model.py +0 -0
  50. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/__init__.py +0 -0
  51. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/cli/__init__.py +0 -0
  52. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/cli/describe.py +0 -0
  53. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/cli/rewind.py +0 -0
  54. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/cli/source.py +0 -0
  55. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/cli/summarize.py +0 -0
  56. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/sequence_helper.py +0 -0
  57. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/summarizer/__init__.py +0 -0
  58. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/summarizer/config.py +0 -0
  59. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/summarizer/graph.py +0 -0
  60. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/summarizer/utils.py +0 -0
  61. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/text_document_helper.py +0 -0
  62. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/timeunit_helper.py +0 -0
  63. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/utils/workflow_helper.py +0 -0
  64. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/ver/__init__.py +0 -0
  65. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/vocabulary/__init__.py +0 -0
  66. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/vocabulary/annotation_types.py +0 -0
  67. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/vocabulary/base_types.py +0 -0
  68. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif/vocabulary/document_types.py +0 -0
  69. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif_docloc_http/__init__.py +0 -0
  70. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif_python.egg-info/SOURCES.txt +0 -0
  71. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif_python.egg-info/dependency_links.txt +0 -0
  72. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif_python.egg-info/entry_points.txt +0 -0
  73. {mmif_python-1.5.2 → mmif_python-1.5.4}/mmif_python.egg-info/top_level.txt +0 -0
  74. {mmif_python-1.5.2 → mmif_python-1.5.4}/setup.cfg +0 -0
  75. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/black-2997fps.mp4 +0 -0
  76. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/black-2997fps.webm +0 -0
  77. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/1.0.5-old-shortid.json +0 -0
  78. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/README.md +0 -0
  79. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/bars-tones-slates/raw.json +0 -0
  80. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/east-tesseract-typing/raw.json +0 -0
  81. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/everything/raw.json +0 -0
  82. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/examples.json +0 -0
  83. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/README.md +0 -0
  84. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/image-box-ocr-timex.json +0 -0
  85. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/image-box-ocr.json +0 -0
  86. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/image-box.json +0 -0
  87. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/image.json +0 -0
  88. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/slate-detect.json +0 -0
  89. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/slate-reco.json +0 -0
  90. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/video-demux.json +0 -0
  91. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/video-transcript-demux-fa.json +0 -0
  92. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/video-transcript-demux-fa.short.json +0 -0
  93. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/video-transcript-demux.json +0 -0
  94. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/video-transcript-sentencesplit.json +0 -0
  95. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/video-transcript.json +0 -0
  96. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/others/video.json +0 -0
  97. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/segmenter-kaldi-ner/raw.json +0 -0
  98. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif-examples/swt-1.0.mmif +0 -0
  99. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/mmif_examples.py +0 -0
  100. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/test_specver.py +0 -0
  101. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/test_vocab.py +0 -0
  102. {mmif_python-1.5.2 → mmif_python-1.5.4}/tests/testsrc-2997fps-ptsoffset.mp4 +0 -0
@@ -1,4 +1,22 @@
1
1
 
2
+ ## releasing 1.5.3 (2026-07-08)
3
+ ### Overview
4
+ This patch includes improvements in automatic conversion between contains-metadata properties and annotation properties (#396)
5
+
6
+ ### Additions
7
+ * automatic "factoring" of value-shared properties from annotation-level to contains-level at serialization time works much more generally now (formerly only worked for `Annotation` type and `document` prop)
8
+
9
+ ### Changes
10
+ * fixed a bug in reading old MMIF files with non-canonical prop names (contains-level prop value was not properly propagated at deserialization for those non-canonical named props)
11
+
12
+
13
+
14
+ ## releasing 1.5.2 (2026-07-07)
15
+ ### Overview
16
+ This is a quick patch on the bug that when MMIF is deserialized, the annotation ID counts in the views weren't properly initialized. This should not be a real bug since new annotations should always written to new views (of current processing app) but not to existing views (where annotation count > 0), however, bugs in app code can trigger this condition, and this patch preventively fixes such cases.
17
+
18
+
19
+
2
20
  ## releasing 1.5.1 (2026-06-19)
3
21
  ### Overview
4
22
  This patch contains a bug fix in VDH that couldn't handle some mkv-contained video files (https://github.com/clamsproject/mmif-python/issues/390) , and some other minor documentation generation bugs.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mmif-python
3
- Version: 1.5.2
3
+ Version: 1.5.4
4
4
  Summary: Python implementation of MultiMedia Interchange Format specification. (https://mmif.clams.ai)
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: homepage, https://mmif.clams.ai
@@ -21,7 +21,7 @@ Requires-Dist: pillow; extra == "cv"
21
21
  Requires-Dist: opencv-python; extra == "cv"
22
22
  Requires-Dist: ffmpeg-python; extra == "cv"
23
23
  Requires-Dist: wurlitzer; extra == "cv"
24
- Requires-Dist: av; extra == "cv"
24
+ Requires-Dist: av>=15; extra == "cv"
25
25
  Provides-Extra: seq
26
26
  Requires-Dist: numpy; extra == "seq"
27
27
  Provides-Extra: docs
@@ -1,4 +1,6 @@
1
1
  "``mmif-python`` version","Target MMIF Specification"
2
+ `1.5.4 <https://pypi.org/project/mmif-python/1.5.4/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
3
+ `1.5.3 <https://pypi.org/project/mmif-python/1.5.3/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
2
4
  `1.5.2 <https://pypi.org/project/mmif-python/1.5.2/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
3
5
  `1.5.1 <https://pypi.org/project/mmif-python/1.5.1/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
4
6
  `1.5.0 <https://pypi.org/project/mmif-python/1.5.0/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
@@ -6,6 +6,7 @@ In MMIF, annotations are created by apps in a workflow as a part
6
6
  of a view. For documentation on how views are represented, see
7
7
  :mod:`mmif.serialize.view`.
8
8
  """
9
+ import functools
9
10
  import importlib
10
11
  import itertools
11
12
  import os
@@ -22,7 +23,7 @@ from .model import MmifObject, PRMTV_TYPES
22
23
  from .. import DocumentTypes, AnnotationTypes
23
24
  import mmif_docloc_http
24
25
 
25
- __all__ = ['Annotation', 'AnnotationProperties', 'Document', 'DocumentProperties', 'Text']
26
+ __all__ = ['Annotation', 'AnnotationProperties', 'Document', 'DocumentProperties', 'Text', 'canonicalize_prop_name']
26
27
 
27
28
  T = TypeVar('T')
28
29
  LIST_PRMTV = typing.List[PRMTV_TYPES] # list of values (most cases for annotation props)
@@ -41,6 +42,40 @@ discovered_docloc_plugins.update({
41
42
  })
42
43
 
43
44
 
45
+ @functools.lru_cache(maxsize=None)
46
+ def _prop_alias_map(shortname: str) -> Dict[str, str]:
47
+ """
48
+ Maps every spelling of an aliased property of a type to the canonical
49
+ name. The table is read from the latest version of the type in the
50
+ vocabulary (``AnnotationTypes.<shortname>``), which is the single source
51
+ of truth for property aliases.
52
+
53
+ :param shortname: shortname of the annotation type (e.g. ``TimeFrame``)
54
+ :return: ``{spelling: canonical_name}``, empty for types without aliases
55
+ """
56
+ latest = getattr(AnnotationTypes, shortname, None)
57
+ groups = getattr(latest, '_property_aliases', None) or {}
58
+ return {alias: canonical for canonical, group in groups.items() for alias in group}
59
+
60
+
61
+ def canonicalize_prop_name(at_type: ThingTypesBase, prop_name: str) -> str:
62
+ """
63
+ Resolves a property name to its canonical spelling for the given type.
64
+ Property aliases were introduced in MMIF 1.0.2, when the general
65
+ ``label`` property replaced ``frameType`` (``TimeFrame``) and ``boxType``
66
+ (``BoundingBox``). Names that are not aliases, and types without an alias
67
+ table, are returned unchanged.
68
+
69
+ :param at_type: the ``@type`` of the annotation
70
+ :param prop_name: a property name, canonical or deprecated
71
+ :return: the canonical property name
72
+ """
73
+ shortname = getattr(at_type, 'shortname', None)
74
+ if not shortname:
75
+ return prop_name
76
+ return _prop_alias_map(shortname).get(prop_name, prop_name)
77
+
78
+
44
79
  class Annotation(MmifObject):
45
80
  """
46
81
  MmifObject that represents an annotation in a MMIF view.
@@ -49,11 +84,11 @@ class Annotation(MmifObject):
49
84
  def __init__(self, anno_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
50
85
  self._type: ThingTypesBase = ThingTypesBase('')
51
86
  # to store the parent view ID
52
- self._props_ephemeral: AnnotationProperties = AnnotationProperties()
87
+ self._props_ephemeral: AnnotationProperties = AnnotationProperties(None, self)
53
88
  self._alignments = {} # to hold alignment information (Alignment anno long_id -> aligned anno long_id)
54
89
  self.reserved_names.update(('_props_ephemeral', '_alignments'))
55
90
  if not hasattr(self, 'properties'): # don't overwrite DocumentProperties on super() call
56
- self.properties: AnnotationProperties = AnnotationProperties()
91
+ self.properties: AnnotationProperties = AnnotationProperties(None, self)
57
92
  self._attribute_classes = {'properties': AnnotationProperties}
58
93
  self.disallow_additional_properties()
59
94
  self._required_attributes = ["_type", "properties"]
@@ -69,9 +104,7 @@ class Annotation(MmifObject):
69
104
  # here is the place to parse formatted IDs and store prefixes in the parent mmif object.
70
105
  # (see https://github.com/clamsproject/mmif/issues/64#issuecomment-849241309 for discussion)
71
106
  super()._deserialize(input_dict)
72
- for k, v in self.properties.items():
73
- self._add_prop_aliases(k, v)
74
-
107
+
75
108
  def _cache_alignment(self, alignment_ann: 'Annotation', alignedto_ann: 'Annotation') -> None:
76
109
  """
77
110
  Cache alignment information. This cache will not be serialized.
@@ -107,29 +140,20 @@ class Annotation(MmifObject):
107
140
  yield aligned
108
141
 
109
142
 
110
- def _add_prop_aliases(self, key_to_add, val_to_add):
111
- """
112
- Method to handle aliases of the same property.
113
- Annotation property aliases were first introduced in MMIF 1.0.2,
114
- with addition of general `label` property to all `Annotation`
115
- subtypes, and effectively deprecated `frameType` and `boxType`
116
- in `TimeFrame` and `BoundingBox` respectively.
117
- """
118
- prop_aliases = AnnotationTypes._prop_aliases.get(self._type.shortname, {})
119
- for alias_rep, alias_group in prop_aliases.items():
120
- if key_to_add in alias_group:
121
- for alias in alias_group:
122
- if alias != key_to_add:
123
- self._props_ephemeral[alias] = val_to_add
124
- if alias in self.properties.keys():
125
- warning_msg = (f'Both "{key_to_add}" and "{alias}" are in the properties of "{self.id}", '
126
- f'however ')
127
- if alias == alias_rep:
128
- warning_msg += f'"{key_to_add}" is an alias of "{alias_rep}".'
129
- else:
130
- warning_msg += f'"{key_to_add}" and "{alias}" are both aliases of "{alias_rep}".'
131
- warning_msg += f'Having two synonyms in the same annotation can cause unexpected behavior. '
132
- warnings.warn(warning_msg, UserWarning)
143
+ def _add_ephemeral_defaults(self, defaults: typing.Mapping[str, typing.Any]) -> None:
144
+ """
145
+ Distributes view-level defaults (from the ``contains`` metadata of the
146
+ parent view) to this annotation as ephemeral properties, under their
147
+ canonical names. An annotation-level value takes precedence over a
148
+ view-level default, so a default is skipped when the annotation
149
+ carries the property itself.
150
+
151
+ :param defaults: property defaults for this annotation's ``@type``
152
+ """
153
+ for name, value in defaults.items():
154
+ name = canonicalize_prop_name(self._type, name)
155
+ if name not in self.properties:
156
+ self._props_ephemeral[name] = value
133
157
 
134
158
  def is_type(self, at_type: Union[str, ThingTypesBase]) -> bool:
135
159
  """
@@ -239,7 +263,9 @@ class Annotation(MmifObject):
239
263
  def add_property(self, name: str,
240
264
  value: Union[PRMTV_TYPES, LIST_PRMTV, LIST_LIST_PRMTV, DICT_PRMTV, DICT_LIST_PRMTV]) -> None:
241
265
  """
242
- Adds a property to the annotation's properties.
266
+ Adds a property to the annotation's properties. A deprecated spelling
267
+ of an aliased property (e.g. ``frameType``) is stored under its
268
+ canonical name (``label``), with a :class:`DeprecationWarning`.
243
269
 
244
270
  :param name: the name of the property
245
271
  :param value: the property's desired value
@@ -252,14 +278,14 @@ class Annotation(MmifObject):
252
278
  # "either string, number, boolean, None, a JSON array of them, "
253
279
  # "or a JSON object of them keyed by strings."
254
280
  # f"(\"{name}\": \"{str(value)}\"")
255
- self._add_prop_aliases(name, value)
256
281
 
257
282
  def __getitem__(self, prop_name: str):
258
283
  if prop_name in {'at_type', '@type'}:
259
284
  return str(self._type)
260
285
  elif prop_name == 'properties':
261
286
  return self.properties
262
- elif prop_name in self.properties:
287
+ prop_name = canonicalize_prop_name(self._type, prop_name)
288
+ if prop_name in self.properties:
263
289
  return self.properties[prop_name]
264
290
  elif prop_name in self._props_ephemeral:
265
291
  return self._props_ephemeral[prop_name]
@@ -279,7 +305,9 @@ class Annotation(MmifObject):
279
305
  3. Special fields (``@type``, ``properties``)
280
306
 
281
307
  This allows convenient access to properties without explicitly
282
- checking the ``properties`` object or view-level metadata.
308
+ checking the ``properties`` object or view-level metadata. A
309
+ deprecated spelling of an aliased property (e.g. ``frameType``)
310
+ resolves to the canonical property (``label``).
283
311
 
284
312
  :param prop_name: The name of the property to retrieve
285
313
  :param default: The value to return if the property is not found (default: None)
@@ -318,23 +346,6 @@ class Annotation(MmifObject):
318
346
  except KeyError:
319
347
  return False
320
348
 
321
- def _get_label(self) -> str:
322
- """
323
- Another prototypical method to handle property aliases.
324
- See :meth:`.Annotation._add_prop_aliases` for more details on
325
- what property aliases are.
326
- Not recommended to use this method as `_add_prop_aliases` method
327
- is preferred.
328
- """
329
- if 'label' in self:
330
- return str(self.get('label'))
331
- elif self._type.shortname == 'TimeFrame' and 'frameType' in self:
332
- return str(self.get('frameType'))
333
- elif self._type.shortname == 'BoundingBox' and 'boxType' in self:
334
- return str(self.get('boxType'))
335
- else:
336
- raise KeyError("No label found in this annotation.")
337
-
338
349
  def is_document(self):
339
350
  return isinstance(self._type, DocumentTypesBase)
340
351
 
@@ -356,8 +367,8 @@ class Document(Annotation):
356
367
  # see https://github.com/clamsproject/mmif-python/issues/226 for discussion
357
368
  # around the use of these three dictionaries
358
369
  # (names changed since, `existing` >> `ephemeral` and `temporary` >> `pending`)
359
- self._props_original: DocumentProperties = DocumentProperties()
360
- self._props_pending: AnnotationProperties = AnnotationProperties()
370
+ self._props_original: DocumentProperties = DocumentProperties(None, self)
371
+ self._props_pending: AnnotationProperties = AnnotationProperties(None, self)
361
372
  self.reserved_names.update(('_props_original', '_props_pending'))
362
373
 
363
374
  self._type: Union[ThingTypesBase, DocumentTypesBase] = ThingTypesBase('')
@@ -578,6 +589,7 @@ class AnnotationProperties(MmifObject, MutableMapping[str, T]):
578
589
  """
579
590
 
580
591
  def __delitem__(self, key: str) -> None:
592
+ key = self._canonical(key)
581
593
  frm = None
582
594
  for k in self.__iter__():
583
595
  if k == key:
@@ -593,36 +605,83 @@ class AnnotationProperties(MmifObject, MutableMapping[str, T]):
593
605
  del frm[key]
594
606
  else:
595
607
  raise KeyError(f'Key "{key}" not found.')
596
-
608
+
597
609
  def __iter__(self) -> Iterator[str]:
598
610
  """
599
- ``__iter__`` on Mapping should basically work as ``keys()`` method
611
+ ``__iter__`` on Mapping should basically work as ``keys()`` method
600
612
  of vanilla dict.
601
613
  """
602
614
  for key in itertools.chain(self._named_attributes(), self._unnamed_attributes):
603
615
  yield key
604
-
616
+
605
617
  def __getitem__(self, key):
606
618
  """
607
- Parent MmifObject class has a __getitem__ method that checks if
608
- the value is empty when asked for an unnamed attribute. But for
609
- AnnotationProperties, any arbitrary property that's added
610
- explicitly by the user (developer) should not be ignored and
611
- returned even the value is empty.
619
+ Parent MmifObject class has a __getitem__ method that checks if
620
+ the value is empty when asked for an unnamed attribute. But for
621
+ AnnotationProperties, any arbitrary property that's added
622
+ explicitly by the user (developer) should not be ignored and
623
+ returned even the value is empty.
612
624
  """
625
+ key = self._canonical(key)
613
626
  if key in self._named_attributes():
614
627
  return self.__dict__[key]
615
628
  else:
616
629
  return self._unnamed_attributes[key]
617
630
 
618
- def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
631
+ def __setitem__(self, key: str, value) -> None:
632
+ """
633
+ Sets a property. A deprecated spelling of an aliased property (e.g.
634
+ ``frameType``) is stored under its canonical name (``label``), with
635
+ a :class:`DeprecationWarning`.
636
+ """
637
+ canonical = self._canonical(key)
638
+ if canonical != key:
639
+ warnings.warn(f'"{key}" is a deprecated alias of "{canonical}", storing the value as "{canonical}".',
640
+ DeprecationWarning)
641
+ super().__setitem__(canonical, value)
642
+
643
+ def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None,
644
+ parent: Optional['Annotation'] = None, *_) -> None:
619
645
  self.id: str = ''
620
646
  # any individual at_type (subclassing this class) can have its own set of required attributes
621
647
  self._required_attributes = ["id"]
622
648
  # allowing additional attributes for arbitrary annotation properties
623
649
  self._unnamed_attributes = {}
650
+ # the Annotation these properties belong to; its `@type` decides which
651
+ # property names are aliases of each other (see `canonicalize_prop_name`)
652
+ self._parent_annotation = parent
653
+ self.reserved_names.add('_parent_annotation')
624
654
  super().__init__(mmif_obj)
625
655
 
656
+ def _canonical(self, key: str) -> str:
657
+ if self._parent_annotation is None:
658
+ return key
659
+ return canonicalize_prop_name(self._parent_annotation._type, key)
660
+
661
+ def _deserialize(self, input_dict: dict) -> None:
662
+ """
663
+ Deserializes the properties, rewriting deprecated spellings to their
664
+ canonical names so that there is one key per property. When two
665
+ spellings of the same property are present, the canonical one wins
666
+ (or the first one seen, when neither is canonical), with a warning.
667
+ """
668
+ canonical_dict = {}
669
+ # canonical name -> the deprecated spelling it was read from
670
+ from_alias = {}
671
+ for key, value in input_dict.items():
672
+ canonical = self._canonical(key)
673
+ if canonical in canonical_dict and (canonical != key or canonical in from_alias):
674
+ alias = key if canonical != key else from_alias[canonical]
675
+ warnings.warn(f'Both "{alias}" and "{canonical}" are in the properties of '
676
+ f'"{input_dict.get("id", "")}", but "{alias}" is an alias of "{canonical}". '
677
+ f'Keeping the value of "{canonical}".', UserWarning)
678
+ if canonical != key:
679
+ continue
680
+ canonical_dict[canonical] = value
681
+ if canonical != key:
682
+ from_alias[canonical] = key
683
+ super()._deserialize(canonical_dict)
684
+
626
685
 
627
686
  class DocumentProperties(AnnotationProperties):
628
687
  """
@@ -632,7 +691,8 @@ class DocumentProperties(AnnotationProperties):
632
691
  :param mmif_obj: the JSON data that defines the properties
633
692
  """
634
693
 
635
- def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
694
+ def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None,
695
+ parent: Optional['Annotation'] = None, *_) -> None:
636
696
  self.mime: str = ''
637
697
  # note the trailing underscore here. I wanted to use the name `location`
638
698
  # for @property in this class and `Document` class, so had to use a diff
@@ -643,9 +703,9 @@ class DocumentProperties(AnnotationProperties):
643
703
  self._attribute_classes = {'text': Text}
644
704
  # in theory, either `location` or `text` should appear in a `document`
645
705
  # but with current implementation, there's no easy way to set a condition
646
- # for `oneOf` requirement
647
- # see MmifObject::_required_attributes in model.py
648
- super().__init__(mmif_obj)
706
+ # for `oneOf` requirement
707
+ # see MmifObject::_required_attributes in model.py
708
+ super().__init__(mmif_obj, parent)
649
709
 
650
710
  def _deserialize(self, input_dict: dict) -> None:
651
711
  if "location" in input_dict:
@@ -175,6 +175,14 @@ class Mmif(MmifObject):
175
175
  all_views = mmif.views[1:4] # Slice of views
176
176
  """
177
177
 
178
+ #: Hardcoded list of property names that must stay at the individual
179
+ #: annotation level, and never "factored" into the view's ``contains``
180
+ #: metadata, even when redundant across annotations.
181
+ _NON_FACTORABLE_PROPS = frozenset({
182
+ 'start', 'end', 'targets', 'source', 'target', 'representatives',
183
+ 'mime', 'location', 'location_', 'text',
184
+ })
185
+
178
186
  def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None, *, validate: bool = True) -> None:
179
187
  self.metadata: MmifMetadata = MmifMetadata()
180
188
  self.documents: DocumentsList = DocumentsList()
@@ -211,26 +219,42 @@ class Mmif(MmifObject):
211
219
  json_str = json.loads(json_str)
212
220
  jsonschema.validators.validate(json_str, schema)
213
221
 
214
- def serialize(self, sanitize: bool = False, autogenerate_capital_annotations: bool = True, **kwargs) -> str:
222
+ def serialize(self,
223
+ pretty: bool = False,
224
+ include_context: bool = True,
225
+ *,
226
+ sanitize: bool = False,
227
+ autogenerate_capital_annotations: bool = True,
228
+ factor_out_shared_properties: bool = True) -> str:
215
229
  """
216
230
  Serializes the MMIF object to a JSON string.
217
231
 
232
+ :param pretty: If True, returns the string with indentation.
233
+ :param include_context: If False, excludes contextual attributes (e.g.
234
+ runtime timestamps) from serialization.
218
235
  :param sanitize: If True, performs some sanitization of before returning
219
236
  the JSON string. See :meth:`sanitize` for details.
220
237
  :param autogenerate_capital_annotations: If True, automatically convert
221
238
  any "pending" temporary properties from `Document` objects to
222
239
  `Annotation` objects. See :meth:`generate_capital_annotations` for
223
240
  details.
224
- :param kwargs: Keyword arguments to pass to the parent's ``serialize``
225
- method (e.g., ``pretty=True``, ``include_context=False``).
241
+ :param factor_out_shared_properties: If True, lift properties shared
242
+ (same value) across all annotations of a type in a view into the
243
+ view's ``contains`` metadata. See
244
+ :meth:`factor_out_shared_properties` for details. Independent of
245
+ ``autogenerate_capital_annotations``, but runs after it (so
246
+ freshly-materialized pending properties are included) when both are
247
+ enabled.
226
248
  :return: JSON string of the MMIF object.
227
249
  """
228
250
  if autogenerate_capital_annotations:
229
251
  self.generate_capital_annotations()
252
+ if factor_out_shared_properties:
253
+ self.factor_out_shared_properties()
230
254
  # sanitization should be done after `Annotation` annotations are generated
231
255
  if sanitize:
232
256
  self.sanitize()
233
- return super().serialize(**kwargs)
257
+ return super().serialize(pretty=pretty, include_context=include_context)
234
258
 
235
259
  def _deserialize(self, input_dict: dict) -> None:
236
260
  """
@@ -252,18 +276,11 @@ class Mmif(MmifObject):
252
276
  super()._deserialize(input_dict)
253
277
  for view in self.views:
254
278
  view._parent_mmif = self
255
- # this dict will be populated with properties
256
- # that are not encoded in individual annotations objects themselves
257
- extrinsic_props = defaultdict(dict)
258
- for at_type, type_lv_props in view.metadata.contains.items():
259
- for prop_key, prop_value in type_lv_props.items():
260
- extrinsic_props[at_type][prop_key] = prop_value
261
279
  for ann in view.get_annotations():
262
280
  ## for "capital" Annotation properties
263
- # first add all extrinsic properties to the Annotation objects
264
- # as "ephemeral" properties
265
- for prop_key, prop_value in extrinsic_props[ann.at_type].items():
266
- ann._props_ephemeral[prop_key] = prop_value
281
+ # first, distribute view-level defaults from `contains` metadata
282
+ # to the Annotation objects as "ephemeral" properties
283
+ ann._add_ephemeral_defaults(view.metadata.contains.get(ann.at_type, {}))
267
284
  # then, do the same to associated Document objects. Note that,
268
285
  # in a view, it is guaranteed that all Annotation objects are not duplicates
269
286
  if ann.at_type == AnnotationTypes.Annotation:
@@ -297,7 +314,7 @@ class Mmif(MmifObject):
297
314
  f"Alignment {alignment_ann.id} has `source` and `target` properties that do not point to Annotation objects.",
298
315
  RuntimeWarning)
299
316
  ## caching alignments
300
- if all(map(lambda x: x in alignment_ann.properties, ('source', 'target'))):
317
+ if all(map(lambda x: x in alignment_ann, ('source', 'target'))):
301
318
  try:
302
319
  source_ann = self.__getitem__(alignment_ann.get('source'))
303
320
  target_ann = self.__getitem__(alignment_ann.get('target'))
@@ -383,15 +400,51 @@ class Mmif(MmifObject):
383
400
  if view_to_write.metadata.app == current_app and doc_id in view_to_write.annotations:
384
401
  view_to_write[doc_id].properties.update(props)
385
402
  else:
386
- if len(anns_to_write) == 1:
387
- # if there's only one document, we can record the doc_id in the contains metadata
388
- view_to_write.metadata.new_contain(AnnotationTypes.Annotation, document=doc_id)
389
- props.pop('document', None)
390
- else:
391
- # otherwise, doc_id needs to be recorded in the annotation property
392
- props['document'] = doc_id
403
+ props['document'] = doc_id
393
404
  view_to_write.new_annotation(AnnotationTypes.Annotation, **props)
394
405
 
406
+
407
+ def factor_out_shared_properties(self):
408
+ """
409
+ Factor properties that are shared (identical value) across all
410
+ annotations of a type within a view up into that view's ``contains``
411
+ metadata, per the MMIF spec recommendation to prefer view-level
412
+ defaults for properties shared among annotations of a type.
413
+
414
+ A property is factored when it is present, with the same value, on
415
+ **every** annotation of a given ``@type`` in the view, there are at
416
+ least two such annotations (so the value is genuinely shared), and the
417
+ key is not in the :attr:`_NON_FACTORABLE_PROPS` protection list.
418
+ Factored values are mirrored into each annotation's ephemeral props at
419
+ deserialization time, so in-memory access is unchanged, while the
420
+ serialized output carries the value once, in ``contains``.
421
+ """
422
+ for view in self.views:
423
+ by_type = defaultdict(list)
424
+ for ann in view.get_annotations():
425
+ by_type[ann.at_type].append(ann)
426
+ for at_type, anns in by_type.items():
427
+ if len(anns) < 2:
428
+ continue
429
+ shared = {}
430
+ for key in list(anns[0].properties.keys()):
431
+ if key in self._NON_FACTORABLE_PROPS:
432
+ continue
433
+ value = anns[0].properties[key]
434
+ if all(key in a.properties and a.properties[key] == value
435
+ for a in anns):
436
+ shared[key] = value
437
+ if not shared:
438
+ continue
439
+ if at_type not in view.metadata.contains:
440
+ view.metadata.new_contain(at_type)
441
+ contain = view.metadata.contains[at_type]
442
+ for key, value in shared.items():
443
+ contain[key] = value
444
+ for a in anns:
445
+ del a.properties[key]
446
+ a._props_ephemeral[key] = value
447
+
395
448
  def sanitize(self):
396
449
  """
397
450
  Sanitizes a Mmif object by running some safeguards.
@@ -17,7 +17,7 @@ from datetime import datetime
17
17
  from typing import Dict, Union, Optional, Generator, List, cast
18
18
 
19
19
  from mmif import DocumentTypes, AnnotationTypes, ThingTypesBase, ClamsTypesBase
20
- from mmif.serialize.annotation import Annotation, Document
20
+ from mmif.serialize.annotation import Annotation, Document, canonicalize_prop_name
21
21
  from mmif.serialize.model import PRMTV_TYPES, MmifObject, DataList, DataDict
22
22
  from mmif.vocabulary.base_types import AnnotationTypesBase
23
23
 
@@ -197,8 +197,6 @@ class View(MmifObject):
197
197
  self._set_ann_id(new_annotation, aid)
198
198
  for propk, propv in properties.items():
199
199
  new_annotation.add_property(propk, propv)
200
- for propk, propv in self.metadata.contains.get(at_type, {}).items():
201
- new_annotation._props_ephemeral[propk] = propv
202
200
  return self.add_annotation(new_annotation, overwrite)
203
201
 
204
202
  def add_annotation(self, annotation: 'Annotation', overwrite=False) -> 'Annotation':
@@ -222,6 +220,7 @@ class View(MmifObject):
222
220
  annotation.id = f"{self.id}{self.id_delimiter}{annotation.id}"
223
221
  self.annotations.append(annotation, overwrite)
224
222
  self.new_contain(annotation.at_type)
223
+ annotation._add_ephemeral_defaults(self.metadata.contains.get(annotation.at_type, {}))
225
224
  if annotation.at_type == AnnotationTypes.Alignment:
226
225
  self._parent_mmif._cache_alignment(annotation)
227
226
  return annotation
@@ -280,13 +279,14 @@ class View(MmifObject):
280
279
  must match. Note that annotation type metadata are specified in the `contains` view metadata, not in individual \
281
280
  annotation objects.
282
281
  """
283
- def prop_check(k, v, *props):
284
- return any(k in prop and prop[k] == v for prop in props)
282
+ def prop_check(k, v, annotation, at_type_metadata):
283
+ k = canonicalize_prop_name(annotation.at_type, k)
284
+ return any(k in prop and prop[k] == v for prop in (annotation.properties, at_type_metadata))
285
285
 
286
286
  for annotation in self.annotations:
287
287
  at_type_metadata = self.metadata.contains.get(annotation.at_type, {})
288
288
  if not at_type or (at_type and annotation.at_type == at_type):
289
- if all(map(lambda kv: prop_check(kv[0], kv[1], annotation.properties, at_type_metadata),
289
+ if all(map(lambda kv: prop_check(kv[0], kv[1], annotation, at_type_metadata),
290
290
  properties.items())):
291
291
  yield annotation
292
292
 
@@ -485,7 +485,7 @@ class ViewMetadata(MmifObject):
485
485
  at_type = ThingTypesBase.from_str(at_type)
486
486
 
487
487
  if at_type not in self.contains:
488
- new_contain = Contain(contains_metadata)
488
+ new_contain = Contain._for_type(at_type, contains_metadata)
489
489
  self.add_contain(new_contain, at_type)
490
490
  return new_contain
491
491
 
@@ -563,6 +563,15 @@ class Contain(DataDict[str, str]):
563
563
  annotation type in the ``contains`` metadata of a MMIF view.
564
564
  """
565
565
 
566
+ @classmethod
567
+ def _for_type(cls, at_type: ThingTypesBase, contains_metadata: dict) -> 'Contain':
568
+ """
569
+ Builds a ``Contain`` for the given ``@type``, with property names
570
+ rewritten to their canonical spellings (see
571
+ :func:`mmif.serialize.annotation.canonicalize_prop_name`).
572
+ """
573
+ return cls({canonicalize_prop_name(at_type, k): v for k, v in contains_metadata.items()})
574
+
566
575
 
567
576
  class AnnotationsList(DataList[Union[Annotation, Document]]):
568
577
  """
@@ -608,7 +617,7 @@ class ContainsDict(DataDict[ThingTypesBase, Contain]):
608
617
  for key, value in input_dict.items():
609
618
  if isinstance(key, str):
610
619
  key = ThingTypesBase.from_str(key)
611
- self._items[key] = Contain(value)
620
+ self._items[key] = Contain._for_type(key, value)
612
621
 
613
622
  def update(self, other: Union[dict, 'ContainsDict'], overwrite=False):
614
623
  for k, v in other.items():