mmif-python 1.5.1__tar.gz → 1.5.3__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 (101) hide show
  1. {mmif_python-1.5.1 → mmif_python-1.5.3}/CHANGELOG.md +11 -0
  2. {mmif_python-1.5.1 → mmif_python-1.5.3}/CONTRIBUTING.md +1 -1
  3. {mmif_python-1.5.1 → mmif_python-1.5.3}/PKG-INFO +1 -1
  4. {mmif_python-1.5.1 → mmif_python-1.5.3}/build-tools/docs.py +23 -8
  5. {mmif_python-1.5.1 → mmif_python-1.5.3}/documentation/target-versions.csv +2 -0
  6. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/serialize/mmif.py +81 -14
  7. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/serialize/view.py +19 -0
  8. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif_python.egg-info/PKG-INFO +1 -1
  9. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif_python.egg-info/SOURCES.txt +2 -0
  10. mmif_python-1.5.3/mmif_python.egg-info/scm_file_list.json +95 -0
  11. mmif_python-1.5.3/mmif_python.egg-info/scm_version.json +8 -0
  12. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/test_serialize.py +177 -4
  13. {mmif_python-1.5.1 → mmif_python-1.5.3}/.github/workflows/codecov.yml +0 -0
  14. {mmif_python-1.5.1 → mmif_python-1.5.3}/.github/workflows/issue-assign.yml +0 -0
  15. {mmif_python-1.5.1 → mmif_python-1.5.3}/.github/workflows/issue-close.yml +0 -0
  16. {mmif_python-1.5.1 → mmif_python-1.5.3}/.github/workflows/issue-infra-project.yml +0 -0
  17. {mmif_python-1.5.1 → mmif_python-1.5.3}/.github/workflows/publish.yml +0 -0
  18. {mmif_python-1.5.1 → mmif_python-1.5.3}/.github/workflows/release-check.yml +0 -0
  19. {mmif_python-1.5.1 → mmif_python-1.5.3}/.gitignore +0 -0
  20. {mmif_python-1.5.1 → mmif_python-1.5.3}/LICENSE +0 -0
  21. {mmif_python-1.5.1 → mmif_python-1.5.3}/README.md +0 -0
  22. {mmif_python-1.5.1 → mmif_python-1.5.3}/build-tools/build.py +0 -0
  23. {mmif_python-1.5.1 → mmif_python-1.5.3}/build-tools/clean.py +0 -0
  24. {mmif_python-1.5.1 → mmif_python-1.5.3}/build-tools/prep_release.py +0 -0
  25. {mmif_python-1.5.1 → mmif_python-1.5.3}/build-tools/publish.py +0 -0
  26. {mmif_python-1.5.1 → mmif_python-1.5.3}/build-tools/test.py +0 -0
  27. {mmif_python-1.5.1 → mmif_python-1.5.3}/documentation/_mmif_example_builder/__init__.py +0 -0
  28. {mmif_python-1.5.1 → mmif_python-1.5.3}/documentation/_templates/versions.html +0 -0
  29. {mmif_python-1.5.1 → mmif_python-1.5.3}/documentation/cli.rst +0 -0
  30. {mmif_python-1.5.1 → mmif_python-1.5.3}/documentation/conf.py +0 -0
  31. {mmif_python-1.5.1 → mmif_python-1.5.3}/documentation/consumer-tutorial.rst +0 -0
  32. {mmif_python-1.5.1 → mmif_python-1.5.3}/documentation/index.rst +0 -0
  33. {mmif_python-1.5.1 → mmif_python-1.5.3}/documentation/introduction.rst +0 -0
  34. {mmif_python-1.5.1 → mmif_python-1.5.3}/documentation/plugins.rst +0 -0
  35. {mmif_python-1.5.1 → mmif_python-1.5.3}/documentation/summarizer.rst +0 -0
  36. {mmif_python-1.5.1 → mmif_python-1.5.3}/documentation/target-versions.rst +0 -0
  37. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/__init__.py +0 -0
  38. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/res/__init__.py +0 -0
  39. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/res/mmif.json +0 -0
  40. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/serialize/__init__.py +0 -0
  41. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/serialize/annotation.py +0 -0
  42. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/serialize/model.py +0 -0
  43. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/__init__.py +0 -0
  44. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/cli/__init__.py +0 -0
  45. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/cli/describe.py +0 -0
  46. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/cli/rewind.py +0 -0
  47. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/cli/source.py +0 -0
  48. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/cli/summarize.py +0 -0
  49. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/sequence_helper.py +0 -0
  50. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/summarizer/__init__.py +0 -0
  51. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/summarizer/config.py +0 -0
  52. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/summarizer/graph.py +0 -0
  53. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/summarizer/nodes.py +0 -0
  54. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/summarizer/summary.py +0 -0
  55. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/summarizer/utils.py +0 -0
  56. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/text_document_helper.py +0 -0
  57. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/timeunit_helper.py +0 -0
  58. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/video_document_helper.py +0 -0
  59. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/utils/workflow_helper.py +0 -0
  60. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/ver/__init__.py +0 -0
  61. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/vocabulary/__init__.py +0 -0
  62. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/vocabulary/annotation_types.py +0 -0
  63. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/vocabulary/base_types.py +0 -0
  64. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif/vocabulary/document_types.py +0 -0
  65. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif_docloc_http/__init__.py +0 -0
  66. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif_python.egg-info/dependency_links.txt +0 -0
  67. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif_python.egg-info/entry_points.txt +0 -0
  68. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif_python.egg-info/requires.txt +0 -0
  69. {mmif_python-1.5.1 → mmif_python-1.5.3}/mmif_python.egg-info/top_level.txt +0 -0
  70. {mmif_python-1.5.1 → mmif_python-1.5.3}/pyproject.toml +0 -0
  71. {mmif_python-1.5.1 → mmif_python-1.5.3}/setup.cfg +0 -0
  72. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/black-2997fps.mp4 +0 -0
  73. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/black-2997fps.webm +0 -0
  74. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/1.0.5-old-shortid.json +0 -0
  75. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/README.md +0 -0
  76. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/bars-tones-slates/raw.json +0 -0
  77. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/east-tesseract-typing/raw.json +0 -0
  78. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/everything/raw.json +0 -0
  79. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/examples.json +0 -0
  80. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/README.md +0 -0
  81. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/image-box-ocr-timex.json +0 -0
  82. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/image-box-ocr.json +0 -0
  83. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/image-box.json +0 -0
  84. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/image.json +0 -0
  85. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/slate-detect.json +0 -0
  86. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/slate-reco.json +0 -0
  87. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/video-demux.json +0 -0
  88. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/video-transcript-demux-fa.json +0 -0
  89. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/video-transcript-demux-fa.short.json +0 -0
  90. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/video-transcript-demux.json +0 -0
  91. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/video-transcript-sentencesplit.json +0 -0
  92. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/video-transcript.json +0 -0
  93. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/others/video.json +0 -0
  94. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/segmenter-kaldi-ner/raw.json +0 -0
  95. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif-examples/swt-1.0.mmif +0 -0
  96. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/mmif_examples.py +0 -0
  97. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/test_specver.py +0 -0
  98. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/test_utils.py +0 -0
  99. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/test_utils_cli.py +0 -0
  100. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/test_vocab.py +0 -0
  101. {mmif_python-1.5.1 → mmif_python-1.5.3}/tests/testsrc-2997fps-ptsoffset.mp4 +0 -0
@@ -1,4 +1,15 @@
1
1
 
2
+ ## releasing 1.5.2 (2026-07-07)
3
+ ### Overview
4
+ 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.
5
+
6
+
7
+
8
+ ## releasing 1.5.1 (2026-06-19)
9
+ ### Overview
10
+ 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.
11
+
12
+
2
13
  ## releasing 1.5.0 (2026-05-27)
3
14
  ### Overview
4
15
 
@@ -162,7 +162,7 @@ To build documentation for a specific historical version (e.g., `v1.0.0`):
162
162
  python3 build-tools/docs.py --build-ver v1.0.0
163
163
  ```
164
164
 
165
- This runs the build in a sandboxed temporary directory. The output will be in `docs-test/<version>`.
165
+ This runs the build in a sandboxed temporary directory. A single `--build-ver` places the built docs **flat** in the output directory (`docs-test/` by default). The batch `--build-all-since` mode instead nests each version under a `<version>/` subdirectory of the output directory, so multiple versions can coexist. The flat single-version layout is what the CI docs-publish workflow relies on — it invokes `docs.py --build-ver <ver> --output-dir _docs` and uploads `_docs/` as-is, without needing to know whether the project is versioned.
166
166
 
167
167
  > [!NOTE]
168
168
  > In CI, documentation is built and published automatically by the `publish.yml` workflow via the shared `sdk-docs.yml`. The CI calls `docs.py --build-ver <version> --output-dir _docs`. All CLAMS SDK repos use the same `docs.py` CLI interface (`--build-ver`, `--output-dir`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mmif-python
3
- Version: 1.5.1
3
+ Version: 1.5.3
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
@@ -188,7 +188,8 @@ def linkcode_resolve(domain, info):
188
188
  return docs_build_dir
189
189
 
190
190
 
191
- def build_docs_for_version(version: str, output_base_dir: Path, repo_path: Path = None):
191
+ def build_docs_for_version(version: str, output_base_dir: Path, repo_path: Path = None,
192
+ versioned_subdir: bool = True):
192
193
  """
193
194
  Builds docs for a specific version in a sandbox.
194
195
  Uses local repo copy instead of cloning for efficiency.
@@ -197,6 +198,12 @@ def build_docs_for_version(version: str, output_base_dir: Path, repo_path: Path
197
198
  version: Git tag/ref to build
198
199
  output_base_dir: Directory to output built docs
199
200
  repo_path: Path to local repo to copy from (defaults to cwd)
201
+ versioned_subdir: when True (batch/from-scratch rebuild), the built
202
+ docs are placed under ``output_base_dir/<version>/`` so multiple
203
+ versions can coexist. When False (single build, as used by CI),
204
+ the docs are placed flat into ``output_base_dir`` so the shared
205
+ docs-publish workflow can upload them as-is without knowing about
206
+ versioning.
200
207
  """
201
208
  print(f"--- Running in Version Build Mode for version: {version} ---")
202
209
 
@@ -226,13 +233,19 @@ def build_docs_for_version(version: str, output_base_dir: Path, repo_path: Path
226
233
  built_docs_path = build_versioned_docs(env, source_path, version)
227
234
 
228
235
  print(f"\n--- Step 4: Copying built artifacts to output directory ---")
229
- version_output_dir = output_base_dir / version
230
- if version_output_dir.exists():
231
- print(f"Removing existing directory: {version_output_dir}")
232
- shutil.rmtree(version_output_dir)
233
- shutil.copytree(built_docs_path, version_output_dir)
236
+ if versioned_subdir:
237
+ dest = output_base_dir / version
238
+ if dest.exists():
239
+ print(f"Removing existing directory: {dest}")
240
+ shutil.rmtree(dest)
241
+ shutil.copytree(built_docs_path, dest)
242
+ else:
243
+ # flat: place the built docs directly into output_base_dir
244
+ output_base_dir.mkdir(parents=True, exist_ok=True)
245
+ shutil.copytree(built_docs_path, output_base_dir, dirs_exist_ok=True)
246
+ dest = output_base_dir
234
247
 
235
- print(f"\nDocumentation for {version} built successfully in: {version_output_dir}")
248
+ print(f"\nDocumentation for {version} built successfully in: {dest}")
236
249
 
237
250
 
238
251
  def get_all_tags():
@@ -317,7 +330,9 @@ def main():
317
330
  for version, error in failed:
318
331
  print(f" {version}: {error}")
319
332
  elif args.build_ver:
320
- build_docs_for_version(args.build_ver, output_dir)
333
+ # single build (CI + local single-version): flat output so the
334
+ # shared docs-publish workflow uploads it version-agnostically
335
+ build_docs_for_version(args.build_ver, output_dir, versioned_subdir=False)
321
336
  else:
322
337
  build_docs_local(Path.cwd(), output_dir)
323
338
 
@@ -1,4 +1,6 @@
1
1
  "``mmif-python`` version","Target MMIF Specification"
2
+ `1.5.3 <https://pypi.org/project/mmif-python/1.5.3/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
3
+ `1.5.2 <https://pypi.org/project/mmif-python/1.5.2/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
2
4
  `1.5.1 <https://pypi.org/project/mmif-python/1.5.1/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
3
5
  `1.5.0 <https://pypi.org/project/mmif-python/1.5.0/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
4
6
  `1.4.0 <https://pypi.org/project/mmif-python/1.4.0/>`__,`1.1.1 <https://mmif.clams.ai/1.1.1/>`__
@@ -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
  """
@@ -261,9 +285,16 @@ class Mmif(MmifObject):
261
285
  for ann in view.get_annotations():
262
286
  ## for "capital" Annotation properties
263
287
  # first add all extrinsic properties to the Annotation objects
264
- # as "ephemeral" properties
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.
265
295
  for prop_key, prop_value in extrinsic_props[ann.at_type].items():
266
- ann._props_ephemeral[prop_key] = prop_value
296
+ if prop_key not in ann._props_ephemeral:
297
+ ann._props_ephemeral[prop_key] = prop_value
267
298
  # then, do the same to associated Document objects. Note that,
268
299
  # in a view, it is guaranteed that all Annotation objects are not duplicates
269
300
  if ann.at_type == AnnotationTypes.Annotation:
@@ -297,7 +328,7 @@ class Mmif(MmifObject):
297
328
  f"Alignment {alignment_ann.id} has `source` and `target` properties that do not point to Annotation objects.",
298
329
  RuntimeWarning)
299
330
  ## caching alignments
300
- if all(map(lambda x: x in alignment_ann.properties, ('source', 'target'))):
331
+ if all(map(lambda x: x in alignment_ann, ('source', 'target'))):
301
332
  try:
302
333
  source_ann = self.__getitem__(alignment_ann.get('source'))
303
334
  target_ann = self.__getitem__(alignment_ann.get('target'))
@@ -383,15 +414,51 @@ class Mmif(MmifObject):
383
414
  if view_to_write.metadata.app == current_app and doc_id in view_to_write.annotations:
384
415
  view_to_write[doc_id].properties.update(props)
385
416
  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
417
+ props['document'] = doc_id
393
418
  view_to_write.new_annotation(AnnotationTypes.Annotation, **props)
394
419
 
420
+
421
+ def factor_out_shared_properties(self):
422
+ """
423
+ Factor properties that are shared (identical value) across all
424
+ annotations of a type within a view up into that view's ``contains``
425
+ metadata, per the MMIF spec recommendation to prefer view-level
426
+ defaults for properties shared among annotations of a type.
427
+
428
+ A property is factored when it is present, with the same value, on
429
+ **every** annotation of a given ``@type`` in the view, there are at
430
+ least two such annotations (so the value is genuinely shared), and the
431
+ key is not in the :attr:`_NON_FACTORABLE_PROPS` protection list.
432
+ Factored values are mirrored into each annotation's ephemeral props at
433
+ deserialization time, so in-memory access is unchanged, while the
434
+ serialized output carries the value once, in ``contains``.
435
+ """
436
+ for view in self.views:
437
+ by_type = defaultdict(list)
438
+ for ann in view.get_annotations():
439
+ by_type[ann.at_type].append(ann)
440
+ for at_type, anns in by_type.items():
441
+ if len(anns) < 2:
442
+ continue
443
+ shared = {}
444
+ for key in list(anns[0].properties.keys()):
445
+ if key in self._NON_FACTORABLE_PROPS:
446
+ continue
447
+ value = anns[0].properties[key]
448
+ if all(key in a.properties and a.properties[key] == value
449
+ for a in anns):
450
+ shared[key] = value
451
+ if not shared:
452
+ continue
453
+ if at_type not in view.metadata.contains:
454
+ view.metadata.new_contain(at_type)
455
+ contain = view.metadata.contains[at_type]
456
+ for key, value in shared.items():
457
+ contain[key] = value
458
+ for a in anns:
459
+ del a.properties[key]
460
+ a._props_ephemeral[key] = value
461
+
395
462
  def sanitize(self):
396
463
  """
397
464
  Sanitizes a Mmif object by running some safeguards.
@@ -82,6 +82,25 @@ class View(MmifObject):
82
82
  self._required_attributes = ["id", "metadata", "annotations"]
83
83
  super().__init__(view_obj)
84
84
  self._fix_old_short_ids()
85
+ self._rebuild_id_counts()
86
+
87
+ def _rebuild_id_counts(self):
88
+ """
89
+ Re-seed the per-view auto-ID counters from the annotations loaded during
90
+ deserialization.
91
+
92
+ ``_id_counts`` is not serialized, so a view read back from JSON starts
93
+ with an empty counter. Without this, the next :meth:`new_annotation`
94
+ regenerates an ID (e.g. ``an_1``) that already exists in the
95
+ deserialized view and :meth:`AnnotationsList.append` raises ``KeyError``.
96
+ See issue #393.
97
+ """
98
+ for annotation in self.annotations:
99
+ local_id = annotation.id.split(self.id_delimiter)[-1]
100
+ prefix, _, num = local_id.rpartition('_')
101
+ if prefix and num.isdigit():
102
+ self._id_counts[prefix] = max(
103
+ self._id_counts.get(prefix, 0), int(num))
85
104
 
86
105
  def _fix_old_short_ids(self):
87
106
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mmif-python
3
- Version: 1.5.1
3
+ Version: 1.5.3
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
@@ -63,6 +63,8 @@ mmif_python.egg-info/SOURCES.txt
63
63
  mmif_python.egg-info/dependency_links.txt
64
64
  mmif_python.egg-info/entry_points.txt
65
65
  mmif_python.egg-info/requires.txt
66
+ mmif_python.egg-info/scm_file_list.json
67
+ mmif_python.egg-info/scm_version.json
66
68
  mmif_python.egg-info/top_level.txt
67
69
  tests/black-2997fps.mp4
68
70
  tests/black-2997fps.webm
@@ -0,0 +1,95 @@
1
+ {
2
+ "files": [
3
+ "README.md",
4
+ "LICENSE",
5
+ "pyproject.toml",
6
+ "CHANGELOG.md",
7
+ "CONTRIBUTING.md",
8
+ ".gitignore",
9
+ "build-tools/publish.py",
10
+ "build-tools/prep_release.py",
11
+ "build-tools/build.py",
12
+ "build-tools/docs.py",
13
+ "build-tools/clean.py",
14
+ "build-tools/test.py",
15
+ "documentation/target-versions.csv",
16
+ "documentation/consumer-tutorial.rst",
17
+ "documentation/target-versions.rst",
18
+ "documentation/index.rst",
19
+ "documentation/introduction.rst",
20
+ "documentation/cli.rst",
21
+ "documentation/plugins.rst",
22
+ "documentation/summarizer.rst",
23
+ "documentation/conf.py",
24
+ "documentation/_mmif_example_builder/__init__.py",
25
+ "documentation/_templates/versions.html",
26
+ "mmif/__init__.py",
27
+ "mmif/vocabulary/__init__.py",
28
+ "mmif/vocabulary/base_types.py",
29
+ "mmif/vocabulary/document_types.py",
30
+ "mmif/vocabulary/annotation_types.py",
31
+ "mmif/utils/__init__.py",
32
+ "mmif/utils/text_document_helper.py",
33
+ "mmif/utils/timeunit_helper.py",
34
+ "mmif/utils/workflow_helper.py",
35
+ "mmif/utils/video_document_helper.py",
36
+ "mmif/utils/sequence_helper.py",
37
+ "mmif/utils/summarizer/__init__.py",
38
+ "mmif/utils/summarizer/config.py",
39
+ "mmif/utils/summarizer/utils.py",
40
+ "mmif/utils/summarizer/summary.py",
41
+ "mmif/utils/summarizer/graph.py",
42
+ "mmif/utils/summarizer/nodes.py",
43
+ "mmif/utils/cli/__init__.py",
44
+ "mmif/utils/cli/source.py",
45
+ "mmif/utils/cli/rewind.py",
46
+ "mmif/utils/cli/summarize.py",
47
+ "mmif/utils/cli/describe.py",
48
+ "mmif/serialize/model.py",
49
+ "mmif/serialize/__init__.py",
50
+ "mmif/serialize/view.py",
51
+ "mmif/serialize/annotation.py",
52
+ "mmif/serialize/mmif.py",
53
+ "mmif/res/__init__.py",
54
+ "mmif/res/mmif.json",
55
+ "mmif/ver/__init__.py",
56
+ "tests/mmif_examples.py",
57
+ "tests/test_specver.py",
58
+ "tests/test_utils.py",
59
+ "tests/test_utils_cli.py",
60
+ "tests/test_vocab.py",
61
+ "tests/testsrc-2997fps-ptsoffset.mp4",
62
+ "tests/test_serialize.py",
63
+ "tests/black-2997fps.mp4",
64
+ "tests/black-2997fps.webm",
65
+ "tests/mmif-examples/README.md",
66
+ "tests/mmif-examples/examples.json",
67
+ "tests/mmif-examples/1.0.5-old-shortid.json",
68
+ "tests/mmif-examples/swt-1.0.mmif",
69
+ "tests/mmif-examples/segmenter-kaldi-ner/raw.json",
70
+ "tests/mmif-examples/everything/raw.json",
71
+ "tests/mmif-examples/others/video-transcript.json",
72
+ "tests/mmif-examples/others/image-box-ocr-timex.json",
73
+ "tests/mmif-examples/others/README.md",
74
+ "tests/mmif-examples/others/video.json",
75
+ "tests/mmif-examples/others/video-transcript-demux-fa.json",
76
+ "tests/mmif-examples/others/image.json",
77
+ "tests/mmif-examples/others/video-transcript-sentencesplit.json",
78
+ "tests/mmif-examples/others/video-transcript-demux-fa.short.json",
79
+ "tests/mmif-examples/others/slate-detect.json",
80
+ "tests/mmif-examples/others/image-box.json",
81
+ "tests/mmif-examples/others/slate-reco.json",
82
+ "tests/mmif-examples/others/video-transcript-demux.json",
83
+ "tests/mmif-examples/others/image-box-ocr.json",
84
+ "tests/mmif-examples/others/video-demux.json",
85
+ "tests/mmif-examples/bars-tones-slates/raw.json",
86
+ "tests/mmif-examples/east-tesseract-typing/raw.json",
87
+ "mmif_docloc_http/__init__.py",
88
+ ".github/workflows/issue-close.yml",
89
+ ".github/workflows/codecov.yml",
90
+ ".github/workflows/release-check.yml",
91
+ ".github/workflows/issue-infra-project.yml",
92
+ ".github/workflows/issue-assign.yml",
93
+ ".github/workflows/publish.yml"
94
+ ]
95
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "1.5.3",
3
+ "distance": 0,
4
+ "node": "g6a1f4c6c1350de7a1fd9de42ededd26f771400ca",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-08"
8
+ }
@@ -76,7 +76,7 @@ class TestMmif(unittest.TestCase):
76
76
  mmif_obj = Mmif(example)
77
77
  except ValidationError:
78
78
  self.fail(f"example {i}")
79
- self.assertEqual(mmif_obj.serialize(True), Mmif(mmif_obj.serialize()).serialize(True), f'Failed on {i}')
79
+ self.assertEqual(mmif_obj.serialize(pretty=True), Mmif(mmif_obj.serialize()).serialize(pretty=True), f'Failed on {i}')
80
80
 
81
81
  def test_json_mmif_deserialize(self):
82
82
  for i, example in MMIF_EXAMPLES.items():
@@ -91,7 +91,7 @@ class TestMmif(unittest.TestCase):
91
91
  for annotation in view.annotations:
92
92
  self.assertIn('_type', annotation.__dict__)
93
93
  self.assertTrue('id' in list(mmif_obj.views._items.values())[0].__dict__)
94
- self.assertEqual(mmif_obj.serialize(True), Mmif(json.loads(mmif_obj.serialize())).serialize(True), f'Failed on {i}')
94
+ self.assertEqual(mmif_obj.serialize(pretty=True), Mmif(json.loads(mmif_obj.serialize())).serialize(pretty=True), f'Failed on {i}')
95
95
 
96
96
  def test_str_vs_json_deserialize(self):
97
97
  for i, example in MMIF_EXAMPLES.items():
@@ -99,7 +99,7 @@ class TestMmif(unittest.TestCase):
99
99
  continue
100
100
  str_mmif_obj = Mmif(example)
101
101
  json_mmif_obj = Mmif(json.loads(example))
102
- self.assertEqual(str_mmif_obj.serialize(True), json_mmif_obj.serialize(True), f'Failed on {i}')
102
+ self.assertEqual(str_mmif_obj.serialize(pretty=True), json_mmif_obj.serialize(pretty=True), f'Failed on {i}')
103
103
 
104
104
  def test_bad_mmif_deserialize_no_metadata(self):
105
105
  self.mmif_examples_json['everything'].pop('metadata')
@@ -1194,6 +1194,35 @@ class TestAnnotation(unittest.TestCase):
1194
1194
  self.assertTrue("nonspeech", tf3.get_property('label'))
1195
1195
  self.assertTrue("speech", tf3.get_property('frameLabel'))
1196
1196
 
1197
+ def test_alias_precedes_view_contains_default(self):
1198
+ # An annotation that sets a property via a deprecated alias (frameType
1199
+ # aliases label) provides an annotation-level value. A view-level
1200
+ # `contains` default for the canonical name must NOT override it -- the
1201
+ # annotation-level value takes precedence over the view-level default.
1202
+ tf = "http://mmif.clams.ai/vocabulary/TimeFrame/v6"
1203
+ raw = {
1204
+ "metadata": {"mmif": "http://mmif.clams.ai/1.2.0"},
1205
+ "documents": [{"@type": "http://mmif.clams.ai/vocabulary/VideoDocument/v1",
1206
+ "properties": {"id": "m1", "mime": "video/mp4",
1207
+ "location": "file:///v.mp4"}}],
1208
+ "views": [{"id": "v1",
1209
+ "metadata": {"app": "http://x/1", "contains": {tf: {"label": "X"}}},
1210
+ "annotations": [{"@type": tf,
1211
+ "properties": {"id": "v1:a1", "start": 0,
1212
+ "end": 5, "frameType": "bars"}}]}]
1213
+ }
1214
+ mmif_obj = Mmif(json.dumps(raw), validate=False)
1215
+ ann = mmif_obj['v1:a1']
1216
+ # the annotation's own frameType (alias of label) wins over contains
1217
+ self.assertEqual('bars', ann.get_property('label'))
1218
+ self.assertEqual('bars', ann.get_property('frameType'))
1219
+ # a genuine view-level default (not shadowed by any annotation value)
1220
+ # still applies
1221
+ raw['views'][0]['metadata']['contains'][tf] = {"timeUnit": "milliseconds"}
1222
+ raw['views'][0]['annotations'][0]['properties'] = {"id": "v1:a1", "start": 0, "end": 5}
1223
+ ann = Mmif(json.dumps(raw), validate=False)['v1:a1']
1224
+ self.assertEqual('milliseconds', ann.get_property('timeUnit'))
1225
+
1197
1226
  def test_change_id(self):
1198
1227
  anno_obj: Annotation = self.data['everything']['mmif']['v5:bb1']
1199
1228
 
@@ -1451,7 +1480,151 @@ class TestDocument(unittest.TestCase):
1451
1480
  'author'))
1452
1481
  self.assertEqual('you', list(mmif_roundtrip2.views.get_last_contentful_view().get_annotations(AnnotationTypes.Annotation))[
1453
1482
  0].get_property('author'))
1454
-
1483
+
1484
+ def test_view_id_counts_rebuilt_on_deserialize(self):
1485
+ # regression for issue #393: `_id_counts` is not serialized, so after
1486
+ # deserialization the next generated id must not collide with an existing
1487
+ # one already present in the view.
1488
+ mmif = Mmif(validate=False)
1489
+ doc = Document(); doc.at_type = DocumentTypes.VideoDocument
1490
+ doc.id = 'd1'; doc.location = 'file:///v.mp4'
1491
+ mmif.add_document(doc)
1492
+ v = mmif.new_view()
1493
+ v.metadata.app = tester_appname
1494
+ a1 = v.new_annotation(AnnotationTypes.Annotation, document='d1', frameCount=10)
1495
+ self.assertTrue(a1.id.endswith('an_1'))
1496
+ # round-trip: the reloaded view still contains `an_1` but starts with an
1497
+ # empty counter unless rebuilt
1498
+ v2 = Mmif(mmif.serialize())[v.id]
1499
+ a2 = v2.new_annotation(AnnotationTypes.Annotation, document='d1', duration=20)
1500
+ self.assertTrue(a2.id.endswith('an_2')) # must not regenerate an_1
1501
+
1502
+ def test_capital_annotation_no_collision_across_apps(self):
1503
+ # regression for issue #393: a downstream app re-deriving a document
1504
+ # property with a *different* value must not crash on serialize; the
1505
+ # distinct value is recorded as an additional capital annotation
1506
+ # (value-based de-dup preserves both assertions), not a colliding one.
1507
+ m = Mmif(validate=False)
1508
+ d = Document()
1509
+ d.at_type = DocumentTypes.VideoDocument
1510
+ d.id = 'd1'
1511
+ d.location = 'file:///v.mp4'
1512
+ m.add_document(d)
1513
+ v1 = m.new_view(); v1.metadata.app = 'http://app/producer'
1514
+ v1.new_annotation(AnnotationTypes.TimeFrame, label='bars')
1515
+ d.add_property('frameCount', 51248.0)
1516
+ d.add_property('duration', 1709977.0)
1517
+
1518
+ # downstream app loads it and re-derives a *different* duration value
1519
+ m2 = Mmif(m.serialize())
1520
+ d2 = m2['d1']
1521
+ v2 = m2.new_view(); v2.metadata.app = 'http://app/consumer'
1522
+ v2.new_annotation(AnnotationTypes.TimeFrame, label='caption')
1523
+ d2.add_property('frameCount', 51248) # same value -> de-duped away
1524
+ d2.add_property('duration', 1709975) # different value -> new annotation
1525
+ out = Mmif(m2.serialize()) # must not raise KeyError
1526
+
1527
+ caps = [a for view in out.views
1528
+ for a in view.get_annotations(AnnotationTypes.Annotation)]
1529
+ durations = {a.get_property('duration') for a in caps if 'duration' in a}
1530
+ # both distinct duration assertions coexist; neither is lost or overwritten
1531
+ self.assertEqual(2, len(caps))
1532
+ self.assertEqual({1709977.0, 1709975}, durations)
1533
+
1534
+ def test_factor_out_shared_properties(self):
1535
+ # A view-level metadata property (document/timeUnit/labelset) shared
1536
+ # across all annotations of a type is factored up into the view's
1537
+ # `contains` on serialize, while instance-level properties (label) and
1538
+ # anchors stay on the annotations.
1539
+ m = Mmif(validate=False)
1540
+ v = m.new_view(); v.metadata.app = tester_appname
1541
+ v.new_annotation(AnnotationTypes.TimeFrame, document='m1', timeUnit='ms',
1542
+ start=0, end=5, label='bars')
1543
+ v.new_annotation(AnnotationTypes.TimeFrame, document='m1', timeUnit='ms',
1544
+ start=5, end=9, label='slate')
1545
+ out = json.loads(m.serialize())
1546
+ vout = out['views'][0]
1547
+ tf_key = next(k for k in vout['metadata']['contains'] if 'TimeFrame' in k)
1548
+ contains = vout['metadata']['contains'][tf_key]
1549
+ # shared `document`/`timeUnit` are factored up ...
1550
+ self.assertEqual('m1', contains.get('document'))
1551
+ self.assertEqual('ms', contains.get('timeUnit'))
1552
+ for a in vout['annotations']:
1553
+ # ... and removed from each annotation ...
1554
+ self.assertNotIn('document', a['properties'])
1555
+ self.assertNotIn('timeUnit', a['properties'])
1556
+ # ... while anchors and the differing label stay per-annotation
1557
+ self.assertIn('start', a['properties'])
1558
+ self.assertIn('label', a['properties'])
1559
+ # round-trip: the factored values still resolve per annotation
1560
+ m2 = Mmif(m.serialize())
1561
+ for a in m2.views[0].get_annotations(AnnotationTypes.TimeFrame):
1562
+ self.assertEqual('m1', a.get_property('document'))
1563
+ self.assertEqual('ms', a.get_property('timeUnit'))
1564
+
1565
+ def test_factor_out_protects_listed_props_and_skips_singletons(self):
1566
+ # Blocklisted props (anchors/links) are never factored even when
1567
+ # identical across annotations; a type with a single annotation is
1568
+ # skipped (needs > 1 to be genuinely shared).
1569
+ m = Mmif(validate=False)
1570
+ v = m.new_view(); v.metadata.app = tester_appname
1571
+ tf = v.new_annotation(AnnotationTypes.TimeFrame, start=0, end=5)
1572
+ td1 = v.new_textdocument(text='a'); td2 = v.new_textdocument(text='b')
1573
+ # two Alignments share the same `source` -- must NOT be factored out
1574
+ v.new_annotation(AnnotationTypes.Alignment, source=tf.id, target=td1.id)
1575
+ v.new_annotation(AnnotationTypes.Alignment, source=tf.id, target=td2.id)
1576
+ out = json.loads(m.serialize())
1577
+ aligns = [a for a in out['views'][0]['annotations'] if 'Alignment' in a['@type']]
1578
+ for a in aligns:
1579
+ self.assertIn('source', a['properties'])
1580
+ # a single-annotation type is not factored (threshold)
1581
+ m2 = Mmif(validate=False); v2 = m2.new_view(); v2.metadata.app = tester_appname
1582
+ v2.new_annotation(AnnotationTypes.TimeFrame, document='m1', start=0, end=5)
1583
+ out2 = json.loads(m2.serialize())
1584
+ self.assertIn('document', out2['views'][0]['annotations'][0]['properties'])
1585
+
1586
+ def test_alignment_cached_from_view_level_source_target(self):
1587
+ # regression for the `_cache_alignment` fix: `source`/`target` supplied
1588
+ # as view-level `contains` defaults (distributed to ephemeral, absent
1589
+ # from the annotation's own properties) must still be honored.
1590
+ al = "http://mmif.clams.ai/vocabulary/Alignment/v1"
1591
+ tf = "http://mmif.clams.ai/vocabulary/TimeFrame/v6"
1592
+ td = "http://mmif.clams.ai/vocabulary/TextDocument/v2"
1593
+ raw = {
1594
+ "metadata": {"mmif": "http://mmif.clams.ai/1.2.0"},
1595
+ "documents": [],
1596
+ "views": [{"id": "v1",
1597
+ "metadata": {"app": "http://x/1", "contains": {
1598
+ al: {"source": "v1:tf1", "target": "v1:td1"}}},
1599
+ "annotations": [
1600
+ {"@type": tf, "properties": {"id": "v1:tf1", "start": 0, "end": 5}},
1601
+ {"@type": td, "properties": {"id": "v1:td1", "text": {"@value": "x"}}},
1602
+ {"@type": al, "properties": {"id": "v1:a1"}}]}]
1603
+ }
1604
+ m = Mmif(json.dumps(raw), validate=False)
1605
+ self.assertTrue(list(m['v1:tf1'].get_all_aligned()))
1606
+
1607
+ def test_factor_out_shared_properties_flag(self):
1608
+ # `factor_out_shared_properties` gates factoring independently of
1609
+ # `autogenerate_capital_annotations`.
1610
+ m = Mmif(validate=False)
1611
+ v = m.new_view(); v.metadata.app = tester_appname
1612
+ v.new_annotation(AnnotationTypes.TimeFrame, document='m1', start=0, end=5)
1613
+ v.new_annotation(AnnotationTypes.TimeFrame, document='m1', start=5, end=9)
1614
+ # disabled -> shared `document` stays on each annotation
1615
+ off = json.loads(m.serialize(factor_out_shared_properties=False))
1616
+ for a in off['views'][0]['annotations']:
1617
+ self.assertIn('document', a['properties'])
1618
+ # independent of generation: a reloaded MMIF (props already in
1619
+ # `.properties`) is still factored with capital-annotation generation OFF
1620
+ reloaded = Mmif(m.serialize(factor_out_shared_properties=False))
1621
+ on = json.loads(reloaded.serialize(autogenerate_capital_annotations=False,
1622
+ factor_out_shared_properties=True))
1623
+ tf_key = next(k for k in on['views'][0]['metadata']['contains'] if 'TimeFrame' in k)
1624
+ self.assertEqual('m1', on['views'][0]['metadata']['contains'][tf_key].get('document'))
1625
+ for a in on['views'][0]['annotations']:
1626
+ self.assertNotIn('document', a['properties'])
1627
+
1455
1628
  def test_capital_annotation_generation_viewfinder(self):
1456
1629
  mmif = Mmif(validate=False)
1457
1630
  for i in range(1, 3):
File without changes
File without changes
File without changes
File without changes
File without changes