mmif-python 1.5.3__tar.gz → 1.6.0__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.3 → mmif_python-1.6.0}/CHANGELOG.md +27 -0
  2. {mmif_python-1.5.3 → mmif_python-1.6.0}/PKG-INFO +2 -2
  3. {mmif_python-1.5.3 → mmif_python-1.6.0}/documentation/introduction.rst +2 -2
  4. {mmif_python-1.5.3 → mmif_python-1.6.0}/documentation/target-versions.csv +2 -0
  5. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/serialize/annotation.py +131 -65
  6. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/serialize/mmif.py +129 -33
  7. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/serialize/model.py +19 -1
  8. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/serialize/view.py +26 -8
  9. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/cli/rewind.py +11 -6
  10. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/summarizer/nodes.py +64 -25
  11. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/summarizer/summary.py +3 -3
  12. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/video_document_helper.py +309 -40
  13. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/workflow_helper.py +44 -28
  14. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif_python.egg-info/PKG-INFO +2 -2
  15. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif_python.egg-info/requires.txt +1 -1
  16. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif_python.egg-info/scm_file_list.json +64 -64
  17. mmif_python-1.6.0/mmif_python.egg-info/scm_version.json +8 -0
  18. {mmif_python-1.5.3 → mmif_python-1.6.0}/pyproject.toml +1 -1
  19. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/test_serialize.py +371 -68
  20. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/test_utils.py +597 -60
  21. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/test_utils_cli.py +23 -0
  22. mmif_python-1.5.3/mmif_python.egg-info/scm_version.json +0 -8
  23. {mmif_python-1.5.3 → mmif_python-1.6.0}/.github/workflows/codecov.yml +0 -0
  24. {mmif_python-1.5.3 → mmif_python-1.6.0}/.github/workflows/issue-assign.yml +0 -0
  25. {mmif_python-1.5.3 → mmif_python-1.6.0}/.github/workflows/issue-close.yml +0 -0
  26. {mmif_python-1.5.3 → mmif_python-1.6.0}/.github/workflows/issue-infra-project.yml +0 -0
  27. {mmif_python-1.5.3 → mmif_python-1.6.0}/.github/workflows/publish.yml +0 -0
  28. {mmif_python-1.5.3 → mmif_python-1.6.0}/.github/workflows/release-check.yml +0 -0
  29. {mmif_python-1.5.3 → mmif_python-1.6.0}/.gitignore +0 -0
  30. {mmif_python-1.5.3 → mmif_python-1.6.0}/CONTRIBUTING.md +0 -0
  31. {mmif_python-1.5.3 → mmif_python-1.6.0}/LICENSE +0 -0
  32. {mmif_python-1.5.3 → mmif_python-1.6.0}/README.md +0 -0
  33. {mmif_python-1.5.3 → mmif_python-1.6.0}/build-tools/build.py +0 -0
  34. {mmif_python-1.5.3 → mmif_python-1.6.0}/build-tools/clean.py +0 -0
  35. {mmif_python-1.5.3 → mmif_python-1.6.0}/build-tools/docs.py +0 -0
  36. {mmif_python-1.5.3 → mmif_python-1.6.0}/build-tools/prep_release.py +0 -0
  37. {mmif_python-1.5.3 → mmif_python-1.6.0}/build-tools/publish.py +0 -0
  38. {mmif_python-1.5.3 → mmif_python-1.6.0}/build-tools/test.py +0 -0
  39. {mmif_python-1.5.3 → mmif_python-1.6.0}/documentation/_mmif_example_builder/__init__.py +0 -0
  40. {mmif_python-1.5.3 → mmif_python-1.6.0}/documentation/_templates/versions.html +0 -0
  41. {mmif_python-1.5.3 → mmif_python-1.6.0}/documentation/cli.rst +0 -0
  42. {mmif_python-1.5.3 → mmif_python-1.6.0}/documentation/conf.py +0 -0
  43. {mmif_python-1.5.3 → mmif_python-1.6.0}/documentation/consumer-tutorial.rst +0 -0
  44. {mmif_python-1.5.3 → mmif_python-1.6.0}/documentation/index.rst +0 -0
  45. {mmif_python-1.5.3 → mmif_python-1.6.0}/documentation/plugins.rst +0 -0
  46. {mmif_python-1.5.3 → mmif_python-1.6.0}/documentation/summarizer.rst +0 -0
  47. {mmif_python-1.5.3 → mmif_python-1.6.0}/documentation/target-versions.rst +0 -0
  48. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/__init__.py +0 -0
  49. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/res/__init__.py +0 -0
  50. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/res/mmif.json +0 -0
  51. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/serialize/__init__.py +0 -0
  52. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/__init__.py +0 -0
  53. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/cli/__init__.py +0 -0
  54. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/cli/describe.py +0 -0
  55. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/cli/source.py +0 -0
  56. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/cli/summarize.py +0 -0
  57. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/sequence_helper.py +0 -0
  58. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/summarizer/__init__.py +0 -0
  59. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/summarizer/config.py +0 -0
  60. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/summarizer/graph.py +0 -0
  61. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/summarizer/utils.py +0 -0
  62. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/text_document_helper.py +0 -0
  63. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/utils/timeunit_helper.py +0 -0
  64. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/ver/__init__.py +0 -0
  65. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/vocabulary/__init__.py +0 -0
  66. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/vocabulary/annotation_types.py +0 -0
  67. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/vocabulary/base_types.py +0 -0
  68. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif/vocabulary/document_types.py +0 -0
  69. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif_docloc_http/__init__.py +0 -0
  70. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif_python.egg-info/SOURCES.txt +0 -0
  71. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif_python.egg-info/dependency_links.txt +0 -0
  72. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif_python.egg-info/entry_points.txt +0 -0
  73. {mmif_python-1.5.3 → mmif_python-1.6.0}/mmif_python.egg-info/top_level.txt +0 -0
  74. {mmif_python-1.5.3 → mmif_python-1.6.0}/setup.cfg +0 -0
  75. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/black-2997fps.mp4 +0 -0
  76. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/black-2997fps.webm +0 -0
  77. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/1.0.5-old-shortid.json +0 -0
  78. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/README.md +0 -0
  79. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/bars-tones-slates/raw.json +0 -0
  80. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/east-tesseract-typing/raw.json +0 -0
  81. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/everything/raw.json +0 -0
  82. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/examples.json +0 -0
  83. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/README.md +0 -0
  84. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/image-box-ocr-timex.json +0 -0
  85. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/image-box-ocr.json +0 -0
  86. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/image-box.json +0 -0
  87. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/image.json +0 -0
  88. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/slate-detect.json +0 -0
  89. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/slate-reco.json +0 -0
  90. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/video-demux.json +0 -0
  91. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/video-transcript-demux-fa.json +0 -0
  92. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/video-transcript-demux-fa.short.json +0 -0
  93. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/video-transcript-demux.json +0 -0
  94. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/video-transcript-sentencesplit.json +0 -0
  95. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/video-transcript.json +0 -0
  96. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/others/video.json +0 -0
  97. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/segmenter-kaldi-ner/raw.json +0 -0
  98. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif-examples/swt-1.0.mmif +0 -0
  99. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/mmif_examples.py +0 -0
  100. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/test_specver.py +0 -0
  101. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/test_vocab.py +0 -0
  102. {mmif_python-1.5.3 → mmif_python-1.6.0}/tests/testsrc-2997fps-ptsoffset.mp4 +0 -0
@@ -1,4 +1,31 @@
1
1
 
2
+ ## releasing 1.5.4 (2026-10-05)
3
+ ### Overview
4
+
5
+ This patch fixes image extraction from truncated or otherwise incomplete video files, and the handling of deprecated annotation property names.
6
+
7
+ ### Changes
8
+
9
+ * `video_document_helper`: image extraction no longer fails or returns wrong images on incomplete video files. Timepoints with no image now come back as `None`, with a warning that tells how to check the file (https://github.com/clamsproject/mmif-python/issues/400).
10
+ * *Note: apps that process every returned image must handle `None`.*
11
+ * `av` is now pinned to `>= 15` for `mmif-python[cv]`
12
+ * Deprecated property names (e.g., `frameType` for `label`) work again for values stored in a view's `contains` metadata, including in `mmif summarize` output (https://github.com/clamsproject/mmif-python/issues/401).
13
+
14
+
15
+
16
+
17
+ ## releasing 1.5.3 (2026-07-08)
18
+ ### Overview
19
+ This patch includes improvements in automatic conversion between contains-metadata properties and annotation properties (#396)
20
+
21
+ ### Additions
22
+ * 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)
23
+
24
+ ### Changes
25
+ * 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)
26
+
27
+
28
+
2
29
  ## releasing 1.5.2 (2026-07-07)
3
30
  ### Overview
4
31
  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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mmif-python
3
- Version: 1.5.3
3
+ Version: 1.6.0
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
@@ -94,6 +94,6 @@ For a full list of available helper methods, please refer to the API documentati
94
94
  MMIF usage in CLAMS Workflows
95
95
  -----------------------------
96
96
 
97
- In the context of CLAMS, a **Workflow** refers to the sequence of CLAMS applications that have been executed to generate the views and annotations within a MMIF file.
97
+ In the context of CLAMS, a **Workflow** refers to the sequence of CLAMS applications that have been executed to generate the views and annotations within a MMIF file. Each step of a workflow is one *app execution*: the views produced by a single run of an app, which share the same ``app`` and ``timestamp`` values in their metadata. A step counts whether its views hold annotations, only warnings, or an error, so the workflow records every app that ran, including ones that failed or found nothing to annotate.
98
98
 
99
- When using the ``mmif-python`` SDK, a unique identifier for a workflow (``workflowId``) is calculated based on the applications involved. This identifier is constructed by concatenating the application name, version, and a hash of the runtime parameters for each step in the sequence. This ensures that the identifier uniquely represents not just the apps used, but their specific configurations, aiding in reproducibility.
99
+ When using the ``mmif-python`` SDK, a unique identifier for a workflow (``workflowId``) is calculated based on the applications involved. This identifier is constructed by concatenating a prefix that describes the source documents, followed by the application name, version, and a hash of the runtime parameters for each step in the sequence. This ensures that the identifier uniquely represents not just the apps used, but their specific configurations, aiding in reproducibility. The ``mmif rewind`` command in app mode counts the same steps, so rewinding N apps from a MMIF yields the ``workflowId`` with its last N steps removed.
@@ -1,4 +1,6 @@
1
1
  "``mmif-python`` version","Target MMIF Specification"
2
+ `1.6.0 <https://pypi.org/project/mmif-python/1.6.0/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
3
+ `1.5.4 <https://pypi.org/project/mmif-python/1.5.4/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
2
4
  `1.5.3 <https://pypi.org/project/mmif-python/1.5.3/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
3
5
  `1.5.2 <https://pypi.org/project/mmif-python/1.5.2/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
4
6
  `1.5.1 <https://pypi.org/project/mmif-python/1.5.1/>`__,`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,14 @@ 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
+ # pending props are never cleared: every `Mmif.serialize()` call
372
+ # converts them into `Annotation`s again, and
373
+ # `Mmif.generate_capital_annotations()` relies on its duplicate check
374
+ # to skip values already written. Calling it more than once (e.g.,
375
+ # early in clams-python's `ClamsApp.annotate()`) is safe only while
376
+ # that check holds.
377
+ self._props_pending: AnnotationProperties = AnnotationProperties(None, self)
361
378
  self.reserved_names.update(('_props_original', '_props_pending'))
362
379
 
363
380
  self._type: Union[ThingTypesBase, DocumentTypesBase] = ThingTypesBase('')
@@ -578,6 +595,7 @@ class AnnotationProperties(MmifObject, MutableMapping[str, T]):
578
595
  """
579
596
 
580
597
  def __delitem__(self, key: str) -> None:
598
+ key = self._canonical(key)
581
599
  frm = None
582
600
  for k in self.__iter__():
583
601
  if k == key:
@@ -593,36 +611,83 @@ class AnnotationProperties(MmifObject, MutableMapping[str, T]):
593
611
  del frm[key]
594
612
  else:
595
613
  raise KeyError(f'Key "{key}" not found.')
596
-
614
+
597
615
  def __iter__(self) -> Iterator[str]:
598
616
  """
599
- ``__iter__`` on Mapping should basically work as ``keys()`` method
617
+ ``__iter__`` on Mapping should basically work as ``keys()`` method
600
618
  of vanilla dict.
601
619
  """
602
620
  for key in itertools.chain(self._named_attributes(), self._unnamed_attributes):
603
621
  yield key
604
-
622
+
605
623
  def __getitem__(self, key):
606
624
  """
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.
625
+ Parent MmifObject class has a __getitem__ method that checks if
626
+ the value is empty when asked for an unnamed attribute. But for
627
+ AnnotationProperties, any arbitrary property that's added
628
+ explicitly by the user (developer) should not be ignored and
629
+ returned even the value is empty.
612
630
  """
631
+ key = self._canonical(key)
613
632
  if key in self._named_attributes():
614
633
  return self.__dict__[key]
615
634
  else:
616
635
  return self._unnamed_attributes[key]
617
636
 
618
- def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
637
+ def __setitem__(self, key: str, value) -> None:
638
+ """
639
+ Sets a property. A deprecated spelling of an aliased property (e.g.
640
+ ``frameType``) is stored under its canonical name (``label``), with
641
+ a :class:`DeprecationWarning`.
642
+ """
643
+ canonical = self._canonical(key)
644
+ if canonical != key:
645
+ warnings.warn(f'"{key}" is a deprecated alias of "{canonical}", storing the value as "{canonical}".',
646
+ DeprecationWarning)
647
+ super().__setitem__(canonical, value)
648
+
649
+ def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None,
650
+ parent: Optional['Annotation'] = None, *_) -> None:
619
651
  self.id: str = ''
620
652
  # any individual at_type (subclassing this class) can have its own set of required attributes
621
653
  self._required_attributes = ["id"]
622
654
  # allowing additional attributes for arbitrary annotation properties
623
655
  self._unnamed_attributes = {}
656
+ # the Annotation these properties belong to; its `@type` decides which
657
+ # property names are aliases of each other (see `canonicalize_prop_name`)
658
+ self._parent_annotation = parent
659
+ self.reserved_names.add('_parent_annotation')
624
660
  super().__init__(mmif_obj)
625
661
 
662
+ def _canonical(self, key: str) -> str:
663
+ if self._parent_annotation is None:
664
+ return key
665
+ return canonicalize_prop_name(self._parent_annotation._type, key)
666
+
667
+ def _deserialize(self, input_dict: dict) -> None:
668
+ """
669
+ Deserializes the properties, rewriting deprecated spellings to their
670
+ canonical names so that there is one key per property. When two
671
+ spellings of the same property are present, the canonical one wins
672
+ (or the first one seen, when neither is canonical), with a warning.
673
+ """
674
+ canonical_dict = {}
675
+ # canonical name -> the deprecated spelling it was read from
676
+ from_alias = {}
677
+ for key, value in input_dict.items():
678
+ canonical = self._canonical(key)
679
+ if canonical in canonical_dict and (canonical != key or canonical in from_alias):
680
+ alias = key if canonical != key else from_alias[canonical]
681
+ warnings.warn(f'Both "{alias}" and "{canonical}" are in the properties of '
682
+ f'"{input_dict.get("id", "")}", but "{alias}" is an alias of "{canonical}". '
683
+ f'Keeping the value of "{canonical}".', UserWarning)
684
+ if canonical != key:
685
+ continue
686
+ canonical_dict[canonical] = value
687
+ if canonical != key:
688
+ from_alias[canonical] = key
689
+ super()._deserialize(canonical_dict)
690
+
626
691
 
627
692
  class DocumentProperties(AnnotationProperties):
628
693
  """
@@ -632,7 +697,8 @@ class DocumentProperties(AnnotationProperties):
632
697
  :param mmif_obj: the JSON data that defines the properties
633
698
  """
634
699
 
635
- def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
700
+ def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None,
701
+ parent: Optional['Annotation'] = None, *_) -> None:
636
702
  self.mime: str = ''
637
703
  # note the trailing underscore here. I wanted to use the name `location`
638
704
  # for @property in this class and `Document` class, so had to use a diff
@@ -643,9 +709,9 @@ class DocumentProperties(AnnotationProperties):
643
709
  self._attribute_classes = {'text': Text}
644
710
  # in theory, either `location` or `text` should appear in a `document`
645
711
  # 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)
712
+ # for `oneOf` requirement
713
+ # see MmifObject::_required_attributes in model.py
714
+ super().__init__(mmif_obj, parent)
649
715
 
650
716
  def _deserialize(self, input_dict: dict) -> None:
651
717
  if "location" in input_dict:
@@ -15,7 +15,8 @@ import math
15
15
  import warnings
16
16
  from collections import defaultdict
17
17
  from datetime import datetime, timezone
18
- from typing import Any, List, Union, Optional, Dict, cast, Iterator
18
+ from typing import Any, List, Union, Optional, Dict, cast, Iterable, Iterator
19
+ from urllib.parse import urlparse
19
20
 
20
21
  import jsonschema.validators
21
22
 
@@ -28,6 +29,10 @@ from mmif.vocabulary import AnnotationTypes, DocumentTypesBase
28
29
 
29
30
  __all__ = ['Mmif']
30
31
 
32
+ # base URL of first-party CLAMS app identifiers
33
+ # (e.g., ``http://apps.clams.ai/swt-detection/v8.6``)
34
+ app_directory_baseurl = "http://apps.clams.ai"
35
+
31
36
 
32
37
  class MmifMetadata(MmifObject):
33
38
  """
@@ -122,11 +127,15 @@ class ViewsList(DataList[View]):
122
127
 
123
128
  def get_last_contentful_view(self) -> Optional[View]:
124
129
  """
125
- Returns the last view that is contentful, i.e., has no error or warning .
130
+ Returns the last view that is contentful, i.e., has at least one
131
+ annotation (see :meth:`~mmif.serialize.view.View.has_content`).
132
+ Views with no annotations, including error and warning views, are
133
+ skipped.
126
134
  """
127
135
  for view in reversed(self._items.values()):
128
- if 'error' not in view.metadata and 'warnings' not in view.metadata:
136
+ if view.has_content():
129
137
  return view
138
+ return None
130
139
 
131
140
  def get_last_view(self) -> Optional[View]:
132
141
  """
@@ -276,25 +285,11 @@ class Mmif(MmifObject):
276
285
  super()._deserialize(input_dict)
277
286
  for view in self.views:
278
287
  view._parent_mmif = self
279
- # this dict will be populated with properties
280
- # that are not encoded in individual annotations objects themselves
281
- extrinsic_props = defaultdict(dict)
282
- for at_type, type_lv_props in view.metadata.contains.items():
283
- for prop_key, prop_value in type_lv_props.items():
284
- extrinsic_props[at_type][prop_key] = prop_value
285
288
  for ann in view.get_annotations():
286
289
  ## for "capital" Annotation properties
287
- # first add all extrinsic properties to the Annotation objects
288
- # as "ephemeral" properties. A view-level `contains` default must
289
- # not override an annotation-level value that is already present
290
- # in `_props_ephemeral` -- namely an alias of a property set on the
291
- # annotation itself (put there by `_add_prop_aliases` during the
292
- # annotation's own deserialization, which runs earlier). Per the
293
- # spec, the annotation-level value takes precedence over the
294
- # view-level default.
295
- for prop_key, prop_value in extrinsic_props[ann.at_type].items():
296
- if prop_key not in ann._props_ephemeral:
297
- ann._props_ephemeral[prop_key] = prop_value
290
+ # first, distribute view-level defaults from `contains` metadata
291
+ # to the Annotation objects as "ephemeral" properties
292
+ ann._add_ephemeral_defaults(view.metadata.contains.get(ann.at_type, {}))
298
293
  # then, do the same to associated Document objects. Note that,
299
294
  # in a view, it is guaranteed that all Annotation objects are not duplicates
300
295
  if ann.at_type == AnnotationTypes.Annotation:
@@ -350,7 +345,9 @@ class Mmif(MmifObject):
350
345
  behind this behavior and discussion.
351
346
  """
352
347
  # this view will be the default kitchen sink for all generated annotations.
353
- last_view = self.views.get_last_contentful_view()
348
+ # this can be an empty view, from an app that only adds document properties
349
+ last_view = next((v for v in reversed(self.views)
350
+ if not v.has_error() and not v.has_warnings()), None)
354
351
 
355
352
  # proceed only when there's at least one view
356
353
  if last_view:
@@ -370,18 +367,12 @@ class Mmif(MmifObject):
370
367
  # new properties to record in the current serialization call
371
368
  anns_to_write = defaultdict(dict)
372
369
  for view in self.views:
373
- doc_id = None
374
- if AnnotationTypes.Annotation in view.metadata.contains:
375
- if 'document' in view.metadata.contains[AnnotationTypes.Annotation]:
376
- doc_id = view.metadata.contains[AnnotationTypes.Annotation]['document']
377
- for ann in view.get_annotations(AnnotationTypes.Annotation):
378
- if doc_id is None:
379
- # note that in the input MMIF that generated with old implementation,
380
- # this value can be in the "short" form, i.e., without view ID prefix
381
- # however, after deserialization, all document IDs should have the
382
- # view ID prefix, at least within in-memory representation.
383
- doc_id = ann.get_property('document')
384
- existing_anns[doc_id].update(ann.properties.items())
370
+ for ann in view.get_annotations(AnnotationTypes.Annotation):
371
+ # view-level `contains` defaults are exposed on each
372
+ # annotation as ephemeral properties; in MMIF made by old
373
+ # SDKs, the id may be in the short form (no view prefix)
374
+ existing_anns[ann.get_property('document')].update(
375
+ ann.properties.items())
385
376
  for doc in view.get_documents():
386
377
  anns_to_write[doc.id].update(doc._props_pending.items())
387
378
  for doc in self.documents:
@@ -777,6 +768,111 @@ class Mmif(MmifObject):
777
768
  return view
778
769
  return None
779
770
 
771
+ @staticmethod
772
+ def _full_app_id(app_id: str) -> str:
773
+ """
774
+ Expands a first-party app ID shorthand (e.g.,
775
+ ``swt-detection/v8.6``) into a full URI under
776
+ :data:`app_directory_baseurl`. An ``http(s)`` URL is returned as is.
777
+ """
778
+ if urlparse(app_id).scheme in ('http', 'https'):
779
+ return app_id
780
+ return f"{app_directory_baseurl}/{app_id.strip('/')}"
781
+
782
+ def get_all_views_by_app(self, *app_ids: str) -> List[View]:
783
+ """
784
+ Returns the list of all views produced by any of the given apps.
785
+
786
+ Each app ID is either a full app URI (required for third-party
787
+ apps) or a first-party shorthand such as ``swt-detection/v8.6``,
788
+ which is expanded under :data:`app_directory_baseurl`. Matching is
789
+ exact: an ID without a version matches no view, and there are no
790
+ wildcards, so list every version to cover a version range. To group
791
+ views into app runs instead, see
792
+ :func:`mmif.utils.workflow_helper.group_views_by_app`.
793
+
794
+ :param app_ids: one or more app IDs; a view matches if it was
795
+ produced by any of them
796
+ :return: the list of matching views
797
+ """
798
+ full_ids = {self._full_app_id(app_id) for app_id in app_ids}
799
+ return [view for view in self.views if view.metadata.app in full_ids]
800
+
801
+ get_views_by_app = get_all_views_by_app
802
+
803
+ def get_view_by_app(self, *app_ids: str) -> Optional[View]:
804
+ """
805
+ Returns the last view appended that was produced by any of the given
806
+ apps. App IDs are matched as in :meth:`get_all_views_by_app`.
807
+
808
+ :param app_ids: one or more app IDs
809
+ :return: the view, or None if no view matches
810
+ """
811
+ matches = self.get_all_views_by_app(*app_ids)
812
+ return matches[-1] if matches else None
813
+
814
+ def get_all_views_by_apptag(self, *tags: str) -> List[View]:
815
+ """
816
+ Returns the list of all views whose ``appTags`` metadata contains
817
+ any of the given tags.
818
+
819
+ :param tags: one or more tags; a view matches if it carries any of
820
+ them
821
+ :return: the list of matching views
822
+ """
823
+ return [view for view in self.views
824
+ if any(tag in view.metadata.appTags for tag in tags)]
825
+
826
+ get_views_by_apptag = get_all_views_by_apptag
827
+
828
+ def get_view_by_apptag(self, *tags: str) -> Optional[View]:
829
+ """
830
+ Returns the last view appended whose ``appTags`` metadata contains
831
+ any of the given tags.
832
+
833
+ :param tags: one or more tags
834
+ :return: the view, or None if no view matches
835
+ """
836
+ matches = self.get_all_views_by_apptag(*tags)
837
+ return matches[-1] if matches else None
838
+
839
+ def get_annotations_of_labels(
840
+ self, at_type: Union[ThingTypesBase, str],
841
+ include_labels: Iterable[str] = (),
842
+ exclude_labels: Iterable[str] = ()) -> Iterator[Annotation]:
843
+ """
844
+ Finds annotations of a type across all views, filtered by their
845
+ ``label`` property.
846
+
847
+ With both label lists empty (the default), every annotation of
848
+ ``at_type`` is returned, including annotations that have no
849
+ ``label``. A non-empty ``include_labels`` keeps only annotations
850
+ whose ``label`` is listed, so annotations without a ``label`` are
851
+ dropped. ``exclude_labels`` is applied after ``include_labels`` and
852
+ never drops annotations without a ``label``.
853
+
854
+ The ``label`` value is read with
855
+ :py:meth:`~mmif.serialize.annotation.Annotation.get_property`, so
856
+ the deprecated spellings of the property (``frameType`` on
857
+ ``TimeFrame``, ``boxType`` on ``BoundingBox``) are honored.
858
+
859
+ :param at_type: the type of the annotations to look for
860
+ :param include_labels: labels to keep; empty means no restriction
861
+ :param exclude_labels: labels to leave out
862
+ :return: an iterator of matching annotations, in view order and
863
+ then in annotation order within each view
864
+ """
865
+ include = tuple(include_labels)
866
+ exclude = tuple(exclude_labels)
867
+ for view in self.get_all_views_contain(at_type):
868
+ for ann in view.get_annotations(at_type):
869
+ label = ann.get_property('label')
870
+ if include and label not in include:
871
+ continue
872
+ if label is not None and label in exclude:
873
+ continue
874
+ yield ann
875
+
780
876
  def _is_in_time_range(self, ann: Annotation, range_s: Union[int, float], range_e: Union[int, float]) -> bool:
781
877
  """
782
878
  Checks if the annotation is anchored within the given time range. Any overlap is considered included.
@@ -134,13 +134,31 @@ class MmifObject(object):
134
134
  """
135
135
  Method to set values in _unnamed_attributes.
136
136
 
137
+ A key that matches a declared (named) attribute is stored in that
138
+ attribute instead, with a warning, so that a key never lives in both
139
+ places. A leading ``@`` in the key is read as the ``_`` prefix of
140
+ the attribute name, following the JSON-LD mapping used in
141
+ serialization.
142
+
137
143
  :param key: the attribute name
138
144
  :param value: the desired value
139
145
  :return: None
140
146
  :raise: AttributeError if additional properties are disallowed by :func:`disallow_additional_properties`
147
+ :raise: KeyError if the key is a reserved name
141
148
  """
149
+ attr = f'_{key[1:]}' if key.startswith('@') else key
142
150
  if self._unnamed_attributes is None:
143
- raise AttributeError(f"Additional properties are disallowed by {self.__class__}")
151
+ hint = (f' "{key}" is a declared attribute; use obj["{attr}"] = value.'
152
+ if attr in self._named_attributes() else '')
153
+ raise AttributeError(f"Additional properties are disallowed by {self.__class__}.{hint}")
154
+ if key in self.reserved_names or attr in self.reserved_names:
155
+ raise KeyError(f"can't set a reserved name: {key}")
156
+ if attr in self._named_attributes():
157
+ warnings.warn(f'"{key}" is a declared attribute of {type(self).__name__}; '
158
+ f'storing it as the attribute, not as an additional property.',
159
+ stacklevel=2)
160
+ self[attr] = value
161
+ return
144
162
  self._unnamed_attributes[key] = value # pytype: disable=unsupported-operands
145
163
 
146
164
  def _named_attributes(self) -> Generator[str, None, None]: