mmif-python 1.5.4__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.4 → mmif_python-1.6.0}/CHANGELOG.md +15 -0
  2. {mmif_python-1.5.4 → mmif_python-1.6.0}/PKG-INFO +1 -1
  3. {mmif_python-1.5.4 → mmif_python-1.6.0}/documentation/introduction.rst +2 -2
  4. {mmif_python-1.5.4 → mmif_python-1.6.0}/documentation/target-versions.csv +1 -0
  5. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/serialize/annotation.py +6 -0
  6. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/serialize/mmif.py +126 -16
  7. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/serialize/model.py +19 -1
  8. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/serialize/view.py +9 -0
  9. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/cli/rewind.py +11 -6
  10. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/video_document_helper.py +92 -3
  11. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/workflow_helper.py +44 -28
  12. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif_python.egg-info/PKG-INFO +1 -1
  13. mmif_python-1.6.0/mmif_python.egg-info/scm_version.json +8 -0
  14. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/test_serialize.py +236 -2
  15. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/test_utils.py +172 -3
  16. mmif_python-1.5.4/mmif_python.egg-info/scm_version.json +0 -8
  17. {mmif_python-1.5.4 → mmif_python-1.6.0}/.github/workflows/codecov.yml +0 -0
  18. {mmif_python-1.5.4 → mmif_python-1.6.0}/.github/workflows/issue-assign.yml +0 -0
  19. {mmif_python-1.5.4 → mmif_python-1.6.0}/.github/workflows/issue-close.yml +0 -0
  20. {mmif_python-1.5.4 → mmif_python-1.6.0}/.github/workflows/issue-infra-project.yml +0 -0
  21. {mmif_python-1.5.4 → mmif_python-1.6.0}/.github/workflows/publish.yml +0 -0
  22. {mmif_python-1.5.4 → mmif_python-1.6.0}/.github/workflows/release-check.yml +0 -0
  23. {mmif_python-1.5.4 → mmif_python-1.6.0}/.gitignore +0 -0
  24. {mmif_python-1.5.4 → mmif_python-1.6.0}/CONTRIBUTING.md +0 -0
  25. {mmif_python-1.5.4 → mmif_python-1.6.0}/LICENSE +0 -0
  26. {mmif_python-1.5.4 → mmif_python-1.6.0}/README.md +0 -0
  27. {mmif_python-1.5.4 → mmif_python-1.6.0}/build-tools/build.py +0 -0
  28. {mmif_python-1.5.4 → mmif_python-1.6.0}/build-tools/clean.py +0 -0
  29. {mmif_python-1.5.4 → mmif_python-1.6.0}/build-tools/docs.py +0 -0
  30. {mmif_python-1.5.4 → mmif_python-1.6.0}/build-tools/prep_release.py +0 -0
  31. {mmif_python-1.5.4 → mmif_python-1.6.0}/build-tools/publish.py +0 -0
  32. {mmif_python-1.5.4 → mmif_python-1.6.0}/build-tools/test.py +0 -0
  33. {mmif_python-1.5.4 → mmif_python-1.6.0}/documentation/_mmif_example_builder/__init__.py +0 -0
  34. {mmif_python-1.5.4 → mmif_python-1.6.0}/documentation/_templates/versions.html +0 -0
  35. {mmif_python-1.5.4 → mmif_python-1.6.0}/documentation/cli.rst +0 -0
  36. {mmif_python-1.5.4 → mmif_python-1.6.0}/documentation/conf.py +0 -0
  37. {mmif_python-1.5.4 → mmif_python-1.6.0}/documentation/consumer-tutorial.rst +0 -0
  38. {mmif_python-1.5.4 → mmif_python-1.6.0}/documentation/index.rst +0 -0
  39. {mmif_python-1.5.4 → mmif_python-1.6.0}/documentation/plugins.rst +0 -0
  40. {mmif_python-1.5.4 → mmif_python-1.6.0}/documentation/summarizer.rst +0 -0
  41. {mmif_python-1.5.4 → mmif_python-1.6.0}/documentation/target-versions.rst +0 -0
  42. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/__init__.py +0 -0
  43. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/res/__init__.py +0 -0
  44. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/res/mmif.json +0 -0
  45. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/serialize/__init__.py +0 -0
  46. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/__init__.py +0 -0
  47. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/cli/__init__.py +0 -0
  48. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/cli/describe.py +0 -0
  49. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/cli/source.py +0 -0
  50. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/cli/summarize.py +0 -0
  51. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/sequence_helper.py +0 -0
  52. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/summarizer/__init__.py +0 -0
  53. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/summarizer/config.py +0 -0
  54. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/summarizer/graph.py +0 -0
  55. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/summarizer/nodes.py +0 -0
  56. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/summarizer/summary.py +0 -0
  57. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/summarizer/utils.py +0 -0
  58. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/text_document_helper.py +0 -0
  59. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/utils/timeunit_helper.py +0 -0
  60. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/ver/__init__.py +0 -0
  61. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/vocabulary/__init__.py +0 -0
  62. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/vocabulary/annotation_types.py +0 -0
  63. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/vocabulary/base_types.py +0 -0
  64. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif/vocabulary/document_types.py +0 -0
  65. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif_docloc_http/__init__.py +0 -0
  66. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif_python.egg-info/SOURCES.txt +0 -0
  67. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif_python.egg-info/dependency_links.txt +0 -0
  68. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif_python.egg-info/entry_points.txt +0 -0
  69. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif_python.egg-info/requires.txt +0 -0
  70. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif_python.egg-info/scm_file_list.json +0 -0
  71. {mmif_python-1.5.4 → mmif_python-1.6.0}/mmif_python.egg-info/top_level.txt +0 -0
  72. {mmif_python-1.5.4 → mmif_python-1.6.0}/pyproject.toml +0 -0
  73. {mmif_python-1.5.4 → mmif_python-1.6.0}/setup.cfg +0 -0
  74. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/black-2997fps.mp4 +0 -0
  75. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/black-2997fps.webm +0 -0
  76. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/1.0.5-old-shortid.json +0 -0
  77. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/README.md +0 -0
  78. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/bars-tones-slates/raw.json +0 -0
  79. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/east-tesseract-typing/raw.json +0 -0
  80. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/everything/raw.json +0 -0
  81. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/examples.json +0 -0
  82. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/README.md +0 -0
  83. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/image-box-ocr-timex.json +0 -0
  84. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/image-box-ocr.json +0 -0
  85. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/image-box.json +0 -0
  86. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/image.json +0 -0
  87. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/slate-detect.json +0 -0
  88. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/slate-reco.json +0 -0
  89. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/video-demux.json +0 -0
  90. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/video-transcript-demux-fa.json +0 -0
  91. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/video-transcript-demux-fa.short.json +0 -0
  92. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/video-transcript-demux.json +0 -0
  93. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/video-transcript-sentencesplit.json +0 -0
  94. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/video-transcript.json +0 -0
  95. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/others/video.json +0 -0
  96. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/segmenter-kaldi-ner/raw.json +0 -0
  97. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif-examples/swt-1.0.mmif +0 -0
  98. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/mmif_examples.py +0 -0
  99. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/test_specver.py +0 -0
  100. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/test_utils_cli.py +0 -0
  101. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/test_vocab.py +0 -0
  102. {mmif_python-1.5.4 → mmif_python-1.6.0}/tests/testsrc-2997fps-ptsoffset.mp4 +0 -0
@@ -1,4 +1,19 @@
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
+
2
17
  ## releasing 1.5.3 (2026-07-08)
3
18
  ### Overview
4
19
  This patch includes improvements in automatic conversion between contains-metadata properties and annotation properties (#396)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mmif-python
3
- Version: 1.5.4
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
@@ -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,5 @@
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/>`__
2
3
  `1.5.4 <https://pypi.org/project/mmif-python/1.5.4/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
3
4
  `1.5.3 <https://pypi.org/project/mmif-python/1.5.3/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
4
5
  `1.5.2 <https://pypi.org/project/mmif-python/1.5.2/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
@@ -368,6 +368,12 @@ class Document(Annotation):
368
368
  # around the use of these three dictionaries
369
369
  # (names changed since, `existing` >> `ephemeral` and `temporary` >> `pending`)
370
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.
371
377
  self._props_pending: AnnotationProperties = AnnotationProperties(None, self)
372
378
  self.reserved_names.update(('_props_original', '_props_pending'))
373
379
 
@@ -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
  """
@@ -336,7 +345,9 @@ class Mmif(MmifObject):
336
345
  behind this behavior and discussion.
337
346
  """
338
347
  # this view will be the default kitchen sink for all generated annotations.
339
- 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)
340
351
 
341
352
  # proceed only when there's at least one view
342
353
  if last_view:
@@ -356,18 +367,12 @@ class Mmif(MmifObject):
356
367
  # new properties to record in the current serialization call
357
368
  anns_to_write = defaultdict(dict)
358
369
  for view in self.views:
359
- doc_id = None
360
- if AnnotationTypes.Annotation in view.metadata.contains:
361
- if 'document' in view.metadata.contains[AnnotationTypes.Annotation]:
362
- doc_id = view.metadata.contains[AnnotationTypes.Annotation]['document']
363
- for ann in view.get_annotations(AnnotationTypes.Annotation):
364
- if doc_id is None:
365
- # note that in the input MMIF that generated with old implementation,
366
- # this value can be in the "short" form, i.e., without view ID prefix
367
- # however, after deserialization, all document IDs should have the
368
- # view ID prefix, at least within in-memory representation.
369
- doc_id = ann.get_property('document')
370
- 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())
371
376
  for doc in view.get_documents():
372
377
  anns_to_write[doc.id].update(doc._props_pending.items())
373
378
  for doc in self.documents:
@@ -763,6 +768,111 @@ class Mmif(MmifObject):
763
768
  return view
764
769
  return None
765
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
+
766
876
  def _is_in_time_range(self, ann: Annotation, range_s: Union[int, float], range_e: Union[int, float]) -> bool:
767
877
  """
768
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]:
@@ -418,6 +418,14 @@ class View(MmifObject):
418
418
  def has_warnings(self):
419
419
  return self.metadata.has_warnings()
420
420
 
421
+ def has_content(self) -> bool:
422
+ """
423
+ Returns True if the view has at least one annotation. Per the MMIF
424
+ specification, error views and warning views carry no annotations,
425
+ so a view with content has neither an error nor warnings.
426
+ """
427
+ return len(self.annotations) > 0
428
+
421
429
 
422
430
  class ViewMetadata(MmifObject):
423
431
  """
@@ -430,6 +438,7 @@ class ViewMetadata(MmifObject):
430
438
  self.document: str = ''
431
439
  self.timestamp: Optional[datetime] = None
432
440
  self.app: str = ''
441
+ self.appTags: List[str] = []
433
442
  self.contains: ContainsDict = ContainsDict()
434
443
  self.parameters: Dict[str, str] = {}
435
444
  self.appConfiguration: Dict[str, Union[PRMTV_TYPES, List[PRMTV_TYPES]]] = {}
@@ -31,11 +31,14 @@ def prompt_user(mmif_obj: mmif.Mmif) -> int:
31
31
 
32
32
  def rewind_mmif(mmif_obj: mmif.Mmif, choice: int, choice_is_viewnum: bool = True) -> mmif.Mmif:
33
33
  """
34
- Rewind MMIF by deleting the last N views.
35
- The number of views to rewind is given as a number of "views", or number of "producer apps".
36
- By default, the number argument is interpreted as the number of "views".
37
- Note that when the same app is repeatedly run in a CLAMS workflow and produces multiple views in a row,
38
- rewinding in "app" mode will rewind all those views at once.
34
+ Rewind MMIF by deleting the last N views.
35
+ The number of views to rewind is given as a number of "views", or number of "producer apps".
36
+ By default, the number argument is interpreted as the number of "views".
37
+ In "app" mode, one app is one app execution as grouped by
38
+ :func:`mmif.utils.workflow_helper.group_views_by_app`: all views that
39
+ share the same ``app`` and ``timestamp``, including warning and error
40
+ views. Rewinding one app removes the last execution only; earlier runs
41
+ of the same app are kept.
39
42
 
40
43
  :param mmif_obj: mmif object
41
44
  :param choice: number of views to rewind
@@ -59,7 +62,9 @@ def describe_argparser():
59
62
  oneliner = 'provides CLI to rewind a MMIF from a CLAMS workflow.'
60
63
  additional = textwrap.dedent("""
61
64
  MMIF rewinder rewinds a MMIF by deleting the last N views.
62
- N can be specified as a number of views, or a number of producer apps. """)
65
+ N can be specified as a number of views, or a number of producer apps.
66
+ In app mode, one app is one app execution: all views that share the
67
+ same app and timestamp, including warning and error views. """)
63
68
  return oneliner, oneliner + '\n\n' + additional
64
69
 
65
70
 
@@ -9,12 +9,12 @@ import math
9
9
  import warnings
10
10
  from io import StringIO
11
11
  from typing import Iterable # todo: replace with collections.abc.Iterable in Python 3.9
12
- from typing import List, Optional, Union, Tuple
12
+ from typing import Iterator, List, Optional, Union, Tuple
13
13
 
14
14
  import mmif
15
- from mmif import Annotation, Document, Mmif
15
+ from mmif import Annotation, Document, Mmif, View
16
16
  from mmif.utils.timeunit_helper import convert
17
- from mmif.vocabulary import DocumentTypes
17
+ from mmif.vocabulary import AnnotationTypes, DocumentTypes
18
18
 
19
19
  _CV_DEPS = ('av', 'cv2', 'PIL', 'wurlitzer')
20
20
  _cv_import_warning = (
@@ -699,6 +699,95 @@ def extract_images_by_mode_with_sources(
699
699
  return images, sources
700
700
 
701
701
 
702
+ def _to_timepoints(
703
+ output_view: View,
704
+ video_document: Document,
705
+ sources: List[Union[str, int]],
706
+ ) -> List[str]:
707
+ """
708
+ Turns per-image sources, as returned by
709
+ :py:func:`extract_images_by_mode_with_sources`, into ``TimePoint``
710
+ annotation ids, parallel to ``sources``.
711
+
712
+ A ``str`` source is the id of an existing ``TimePoint`` and is passed
713
+ through. An ``int`` source is a timepoint in milliseconds that no
714
+ ``TimePoint`` backs; a new ``TimePoint`` is created for it in
715
+ ``output_view`` and its id is used. No lookup for an existing
716
+ ``TimePoint`` at the same time is done, so two calls on the same
717
+ fallback timepoint create two annotations.
718
+
719
+ :param output_view: the view new ``TimePoint`` annotations are added to
720
+ :param video_document: the video the timepoints belong to
721
+ :param sources: per-image sources
722
+ :return: ``TimePoint`` ids, parallel to ``sources``
723
+ """
724
+ ids: List[str] = []
725
+ for source in sources:
726
+ if isinstance(source, str):
727
+ ids.append(source)
728
+ else:
729
+ tp = output_view.new_annotation(
730
+ AnnotationTypes.TimePoint, document=video_document.id,
731
+ timePoint=int(source), timeUnit='milliseconds')
732
+ ids.append(tp.id)
733
+ return ids
734
+
735
+
736
+ def iter_timeframe_images(
737
+ mmif: Mmif,
738
+ output_view: View,
739
+ include_labels: Iterable[str] = (),
740
+ exclude_labels: Iterable[str] = (),
741
+ mode: Union[SamplingMode, None] = None,
742
+ as_PIL: bool = False,
743
+ ) -> Iterator[Tuple[Annotation, List, List[str]]]:
744
+ """
745
+ Samples images from every ``TimeFrame`` in the MMIF, filtered by label,
746
+ and yields them with the ``TimePoint`` each image was taken from.
747
+
748
+ ``TimeFrame`` annotations are selected with
749
+ :py:meth:`~mmif.serialize.mmif.Mmif.get_annotations_of_labels`, and
750
+ images are sampled from each with
751
+ :py:func:`extract_images_by_mode_with_sources`. Images that cannot be
752
+ read from the video (``None`` in that function's result, already
753
+ reported there in a warning) are left out together with their sources.
754
+ A ``TimeFrame`` with no readable image is skipped.
755
+
756
+ Every image comes with the id of a ``TimePoint``. When an image was
757
+ sampled at a time no ``TimePoint`` backs (``tfSamplingMode`` fallback),
758
+ a new ``TimePoint`` is created in ``output_view`` as a side effect of
759
+ iterating, so pass the view the calling app writes its own output into.
760
+ The ids are meant for the ``origins`` of whatever the app derives from
761
+ the images.
762
+
763
+ :param mmif: :py:class:`~mmif.serialize.mmif.Mmif` instance
764
+ :param output_view: the view new ``TimePoint`` annotations are added to
765
+ :param include_labels: ``TimeFrame`` labels to keep; empty means all
766
+ :param exclude_labels: ``TimeFrame`` labels to leave out
767
+ :param mode: :py:class:`SamplingMode`, or None to use the context
768
+ default (the ``tfSamplingMode`` runtime parameter in CLAMS apps)
769
+ :param as_PIL: return :py:class:`PIL.Image.Image` instead of
770
+ :py:class:`~numpy.ndarray`
771
+ :return: an iterator of ``(time_frame, images, timepoint_ids)``, where
772
+ ``images`` and ``timepoint_ids`` are parallel, non-empty lists
773
+ """
774
+ # select first, so TimePoints added to ``output_view`` while iterating
775
+ # never interfere with the views being read
776
+ time_frames = list(mmif.get_annotations_of_labels(
777
+ AnnotationTypes.TimeFrame, include_labels, exclude_labels))
778
+ for time_frame in time_frames:
779
+ images, sources = extract_images_by_mode_with_sources(
780
+ mmif, time_frame, mode=mode, as_PIL=as_PIL)
781
+ readable = [(image, source) for image, source in zip(images, sources)
782
+ if image is not None]
783
+ if not readable:
784
+ continue
785
+ video_document = _resolve_video_document(mmif, time_frame)
786
+ timepoint_ids = _to_timepoints(
787
+ output_view, video_document, [source for _, source in readable])
788
+ yield time_frame, [image for image, _ in readable], timepoint_ids
789
+
790
+
702
791
  def extract_images_by_mode(
703
792
  mmif: Mmif,
704
793
  time_frame: Annotation,
@@ -14,8 +14,22 @@ def group_views_by_app(views: ViewsList) -> List[List[Any]]:
14
14
  """
15
15
  Groups views into app executions based on app and timestamp.
16
16
 
17
- An "app" is a set of views produced by the same app at the
18
- exact same timestamp.
17
+ An app execution is the set of views produced by a single run of an
18
+ app. Apps based on ``clams-python`` give every view from one run the
19
+ same ``metadata.timestamp``, so views that share ``app`` and
20
+ ``timestamp`` form one execution, and two runs of the same app form
21
+ two executions. Every execution counts, whether its views hold
22
+ annotations, only warnings, or an error. Views without ``app`` or
23
+ ``timestamp`` cannot be grouped and are left out.
24
+
25
+ The executions are the common unit of
26
+ :func:`generate_workflow_identifier`, :func:`describe_single_mmif`,
27
+ and app-mode rewinding (:func:`mmif.utils.cli.rewind.rewind_mmif`),
28
+ so all three agree on the number and order of executions.
29
+
30
+ :param views: views to group
31
+ :return: list of app executions, each a list of views, ordered by
32
+ timestamp
19
33
  """
20
34
  # Filter out views that don't have a timestamp or app, as they can't be grouped.
21
35
  groupable_views = [
@@ -121,9 +135,15 @@ def generate_workflow_identifier(mmif_input: Union[str, Path, Mmif],
121
135
  document mix as ``Type-N`` pairs joined by ``-`` and sorted by type
122
136
  name (e.g. ``TextDocument-1-VideoDocument-1``).
123
137
 
138
+ Each app execution, as grouped by :func:`group_views_by_app`, adds
139
+ exactly one ``app_name/version/param_hash`` segment, including
140
+ executions that produced only warnings, only an error, or no
141
+ annotations. Hence, rewinding N apps from a MMIF (see
142
+ :func:`mmif.utils.cli.rewind.rewind_mmif`) yields the identifier with
143
+ its last N segments removed.
144
+
124
145
  Uses view.metadata.parameters (raw user-passed values) for hashing
125
- to ensure reproducibility. Views with errors or warnings are excluded
126
- from the identifier; empty views are included.
146
+ to ensure reproducibility.
127
147
 
128
148
  :param mmif_input: Path to MMIF file (str or Path) or a Mmif object
129
149
  :param return_param_dicts: If True, also return the parameter dictionaries
@@ -144,10 +164,6 @@ def generate_workflow_identifier(mmif_input: Union[str, Path, Mmif],
144
164
  # Use the first view in the run as representative for metadata
145
165
  first_view = app_execution[0]
146
166
 
147
- # Skip runs where the representative view has errors or warnings
148
- if first_view.has_error() or first_view.has_warnings():
149
- continue
150
-
151
167
  app = first_view.metadata.get("app")
152
168
  if app is None:
153
169
  continue
@@ -183,7 +199,7 @@ class SingleMmifStats(BaseModel):
183
199
  app_count: int = Field(..., alias="appCount", description="Total number of app executions identified.")
184
200
  error_views: List[str] = Field(default_factory=list, alias="errorViews", description="List of view IDs that contain errors.")
185
201
  warning_views: List[str] = Field(default_factory=list, alias="warningViews", description="List of view IDs that contain warnings.")
186
- empty_views: List[str] = Field(default_factory=list, alias="emptyViews", description="List of view IDs that contain no annotations.")
202
+ empty_views: List[str] = Field(default_factory=list, alias="emptyViews", description="List of view IDs that contain no annotations, error, or warnings.")
187
203
  annotation_count_by_type: Dict[str, int] = Field(default_factory=dict, alias="annotationCountByType", description="Total annotation counts across the file.")
188
204
 
189
205
  class AppProfiling(BaseModel):
@@ -254,7 +270,15 @@ def describe_single_mmif(mmif_input: Union[str, Path, Mmif]) -> dict:
254
270
  conceptual hierarchy is that a **workflow** is a sequence of **apps**,
255
271
  and each **app** execution can produce one or more **views**. This function
256
272
  groups views that share the same ``app`` and ``metadata.timestamp`` into
257
- a single logical "app execution".
273
+ a single logical "app execution" (see :func:`group_views_by_app`).
274
+
275
+ Every app execution is listed in ``apps``, including executions whose
276
+ views hold only warnings or an error; the IDs of such views are also
277
+ listed in ``stats.warningViews`` and ``stats.errorViews``. The
278
+ ``workflowId`` is computed from the same executions by
279
+ :func:`generate_workflow_identifier`, so its segments match the
280
+ ``apps`` entries one to one. The only exception is the trailing
281
+ ``non-existing-app`` entry that collects views that cannot be grouped.
258
282
 
259
283
  .. note::
260
284
  For MMIF files generated by apps based on ``clams-python`` <= 1.3.3, all
@@ -276,9 +300,17 @@ def describe_single_mmif(mmif_input: Union[str, Path, Mmif]) -> dict:
276
300
  """
277
301
  mmif = _read_mmif_from_path(mmif_input)
278
302
 
303
+ # view status is a property of each view, independent of grouping
279
304
  error_view_ids = []
280
305
  warning_view_ids = []
281
306
  empty_view_ids = []
307
+ for view in mmif.views:
308
+ if view.has_error():
309
+ error_view_ids.append(view.id)
310
+ elif view.has_warnings():
311
+ warning_view_ids.append(view.id)
312
+ elif not view.has_content():
313
+ empty_view_ids.append(view.id)
282
314
 
283
315
  # Generate the new "apps" list
284
316
  grouped_apps = []
@@ -286,14 +318,9 @@ def describe_single_mmif(mmif_input: Union[str, Path, Mmif]) -> dict:
286
318
  view_groups = group_views_by_app(mmif.views)
287
319
  for group in view_groups:
288
320
  first_view = group[0]
289
- # skip executions with errors or warnings
290
- if first_view.has_error() or first_view.has_warnings():
291
- continue
292
321
 
293
322
  execution_ann_counter = Counter()
294
323
  for view in group:
295
- if len(view.annotations) == 0:
296
- empty_view_ids.append(view.id)
297
324
  execution_ann_counter.update(Counter(str(ann.at_type) for ann in view.annotations))
298
325
 
299
326
  execution_view_ids = [v.id for v in group]
@@ -315,19 +342,8 @@ def describe_single_mmif(mmif_input: Union[str, Path, Mmif]) -> dict:
315
342
  annotationCountByType=count_dict
316
343
  ))
317
344
 
318
- # Handle unassigned and problematic views
319
- all_view_ids = set(v.id for v in mmif.views)
320
-
321
- for view in mmif.views:
322
- if view.id not in processed_view_ids:
323
- if view.has_error():
324
- error_view_ids.append(view.id)
325
- elif view.has_warnings():
326
- warning_view_ids.append(view.id)
327
- elif len(view.annotations) == 0:
328
- empty_view_ids.append(view.id)
329
-
330
- unassigned_view_ids = all_view_ids - processed_view_ids - set(error_view_ids) - set(warning_view_ids)
345
+ # views that cannot be grouped (no app or timestamp), whatever they hold
346
+ unassigned_view_ids = set(v.id for v in mmif.views) - processed_view_ids
331
347
 
332
348
  # Store app_count before potentially adding the special entry
333
349
  app_count = len(grouped_apps)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mmif-python
3
- Version: 1.5.4
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
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "1.6.0",
3
+ "distance": 0,
4
+ "node": "gc6ccccda0690ca67b16b2ab988234217d55aba74",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-27"
8
+ }