diff --git a/clams/app/__init__.py b/clams/app/__init__.py
index 3c8d2cc..d004ce4 100644
--- a/clams/app/__init__.py
+++ b/clams/app/__init__.py
@@ -17,7 +17,9 @@
from typing import Union, Any, Optional, Dict, List, Mapping, Tuple, cast
-from mmif import Mmif, Document, DocumentTypes, View, AnnotationTypes
+from mmif import (
+ Mmif, Document, DocumentTypes, View, AnnotationTypes, EmptyOutputWarning,
+)
from mmif.utils.video_document_helper import (
SamplingMode, SAMPLING_MODE_DESCRIPTIONS, SAMPLING_MODE_DEFAULT,
_sampling_mode,
@@ -53,10 +55,6 @@
]
-class EmptyOutputWarning(UserWarning):
- """Raised when an app's ``_annotate()`` produces no non-empty views."""
-
-
class ImagesPerPromptMode(Enum):
"""
How :py:meth:`ClamsPromptableApp.chunk_tasks` sizes the chunks of a
@@ -224,7 +222,6 @@ def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params: List[str]) ->
if not isinstance(mmif, Mmif):
mmif, runtime_params = unwrap_if_envelope(mmif, runtime_params)
mmif = Mmif(mmif)
- existing_view_ids = {view.id for view in mmif.views}
issued_warnings: List[Union[Warning, warnings.WarningMessage]] = []
for key in runtime_params:
if key not in self.annotate_param_spec:
@@ -238,70 +235,43 @@ def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params: List[str]) ->
if sampling_mode_str is not None:
_sampling_mode.set(SamplingMode(sampling_mode_str))
t = datetime.now()
- with warnings.catch_warnings(record=True) as ws:
- annotated, cuda_profiler = self._profile_cuda_memory(self._annotate)(mmif, **refined)
- # turn pending document properties into `Annotation`s now, so a
- # view whose only output is document properties is not empty;
- # `serialize()` repeats this later, skipping values already written
- annotated.generate_capital_annotations()
- new_views = [v for v in annotated.views if v.id not in existing_view_ids]
- # an app-set error or warnings view has no annotations, but is kept
- is_empty = lambda v: not (v.has_content() or v.has_error() or v.has_warnings())
- for v in new_views:
- if is_empty(v):
- del annotated.views._items[v.id] # no public API to remove a view (mmif-python)
- if all(is_empty(v) for v in new_views):
- warnings.warn(f'App "{self.metadata.identifier}" produced no annotations for this '
- f'input; the input may lack expected content, or there may genuinely '
- f'be nothing to annotate.', EmptyOutputWarning)
- if ws:
- issued_warnings.extend(ws)
- if issued_warnings:
- warnings_view = annotated.new_view()
- self.sign_view(warnings_view, refined)
- warnings_view.metadata.warnings = issued_warnings
- td = datetime.now() - t
- runningTime = refined.get('runningTime', False)
- hwFetch = refined.get('hwFetch', False)
- runtime_recs: Dict[str, Any] = {}
- if hwFetch:
- import multiprocessing
- import platform, shutil, subprocess
- runtime_recs['cpu'] = f"{platform.machine()}, {multiprocessing.cpu_count()} cores"
- runtime_recs['cuda'] = []
- # Use cuda_profiler data if available, otherwise fallback to nvidia-smi
- if cuda_profiler:
- for gpu_name, mem_info in cuda_profiler.items():
- total_str = self._cuda_memory_to_str(mem_info['total'])
- available_str = self._cuda_memory_to_str(mem_info['available_before'])
- peak_str = self._cuda_memory_to_str(mem_info['peak'])
- runtime_recs['cuda'].append(
- f"{gpu_name}, {total_str} total, {available_str} available, {peak_str} peak used"
- )
- elif shutil.which('nvidia-smi'):
- for gpu in subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader'],
- stdout=subprocess.PIPE).stdout.decode('utf-8').strip().split('\n'):
- name, mem = gpu.split(', ')
- runtime_recs['cuda'].append(self._cuda_device_name_concat(name, mem))
- new_app_views = [v for v in annotated.views
- if v.id not in existing_view_ids
- and v.metadata.app == str(self.metadata.identifier)]
- # all views from this run share the timestamp that mmif-python
- # assigned to the first of them, serving as the "run ID"
- run_id = next((v.metadata.timestamp for v in new_app_views
- if v.metadata.timestamp is not None), None)
- for annotated_view in new_app_views:
- if run_id is not None:
- annotated_view.metadata.timestamp = run_id
+ signature = View()
+ self.sign_view(signature, refined)
+ with mmif.set_producer(signature) as producer:
+ with warnings.catch_warnings(record=True) as ws:
+ annotated, cuda_profiler = self._profile_cuda_memory(self._annotate)(mmif, **refined)
+ producer.add_warnings(*issued_warnings, *ws)
+ td = datetime.now() - t
+ runningTime = refined.get('runningTime', False)
+ hwFetch = refined.get('hwFetch', False)
+ runtime_recs: Dict[str, Any] = {}
+ if hwFetch:
+ import multiprocessing
+ import platform, shutil, subprocess
+ runtime_recs['cpu'] = f"{platform.machine()}, {multiprocessing.cpu_count()} cores"
+ runtime_recs['cuda'] = []
+ # Use cuda_profiler data if available, otherwise fallback to nvidia-smi
+ if cuda_profiler:
+ for gpu_name, mem_info in cuda_profiler.items():
+ total_str = self._cuda_memory_to_str(mem_info['total'])
+ available_str = self._cuda_memory_to_str(mem_info['available_before'])
+ peak_str = self._cuda_memory_to_str(mem_info['peak'])
+ runtime_recs['cuda'].append(
+ f"{gpu_name}, {total_str} total, {available_str} available, {peak_str} peak used"
+ )
+ elif shutil.which('nvidia-smi'):
+ for gpu in subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader'],
+ stdout=subprocess.PIPE).stdout.decode('utf-8').strip().split('\n'):
+ name, mem = gpu.split(', ')
+ runtime_recs['cuda'].append(self._cuda_device_name_concat(name, mem))
profiling_data: Dict[str, Any] = {}
if runningTime:
profiling_data['runningTime'] = str(td)
if len(runtime_recs) > 0:
profiling_data['hardware'] = runtime_recs
if profiling_data:
- annotated_view.metadata.set_additional_property('appProfiling', profiling_data)
-
- return annotated.serialize(pretty=pretty, sanitize=True)
+ producer.set_view_metadata('appProfiling', profiling_data)
+ return annotated.serialize(pretty=pretty, sanitize=True)
@abstractmethod
def _annotate(self, mmif: Mmif, _raw_parameters=None, **refined_parameters) -> Mmif:
diff --git a/documentation/target-versions.csv b/documentation/target-versions.csv
index f57a30b..7ed5ca5 100644
--- a/documentation/target-versions.csv
+++ b/documentation/target-versions.csv
@@ -1,4 +1,5 @@
"``clams-python`` version","``mmif-python`` version","Target MMIF Specification"
+`1.8.2 `__,`1.7.0 `__,`1.2.0 `__
`1.8.1 `__,`1.6.0 `__,`1.2.0 `__
`1.8.0 `__,`1.5.4 `__,`1.2.0 `__
`1.7.4 `__,`1.5.3 `__,`1.2.0 `__
diff --git a/pyproject.toml b/pyproject.toml
index d0731a5..e5728db 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,7 +19,7 @@ classifiers = [
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
- "mmif-python==1.6.0",
+ "mmif-python==1.7.0",
"Flask>=2",
"Flask-RESTful>=0.3.9",
"gunicorn>=20",
diff --git a/tests/test_clamsapp.py b/tests/test_clamsapp.py
index 829fe23..9af9c5f 100644
--- a/tests/test_clamsapp.py
+++ b/tests/test_clamsapp.py
@@ -11,7 +11,10 @@
import jsonschema
import pytest
-from mmif import Mmif, Document, DocumentTypes, AnnotationTypes, View, __specver__
+from mmif import (
+ Mmif, Document, DocumentTypes, AnnotationTypes, View, EmptyOutputWarning,
+ __specver__,
+)
from mmif.serialize.mmif import ViewsList
import clams.app
@@ -518,6 +521,65 @@ def _annotate(self, mmif, **kwargs):
self.assertEqual(len(view.annotations), 1)
self.assertFalse(view.has_warnings())
+ def _in_mmif_with_earlier_view(self):
+ in_mmif = Mmif(self.in_mmif)
+ doc_id = next(iter(in_mmif.documents)).id
+ earlier = in_mmif.new_view()
+ earlier.metadata.app = 'http://apps.clams.ai/earlier-app/v1'
+ earlier.new_contain(AnnotationTypes.TimeFrame, document=doc_id)
+ earlier.new_annotation(AnnotationTypes.TimeFrame, start=0, end=1)
+ return in_mmif.serialize()
+
+ def test_document_properties_go_to_a_new_signed_view(self):
+ class DocPropClamsApp(clams.app.ClamsApp):
+ def _appmetadata(self):
+ pass
+ def _annotate(self, mmif, **kwargs):
+ next(iter(mmif.documents)).add_property('duration', 1000)
+ return mmif
+ app = DocPropClamsApp()
+ in_str = self._in_mmif_with_earlier_view()
+ out_str = app.annotate(in_str)
+ self.assertEqual(json.loads(in_str)['views'][0],
+ json.loads(out_str)['views'][0])
+ out_views = list(Mmif(out_str).views)
+ self.assertEqual(len(out_views), 2)
+ self.assertEqual(out_views[1].metadata.app,
+ str(app.metadata.identifier))
+ records = list(out_views[1].get_annotations(AnnotationTypes.Annotation))
+ self.assertEqual(len(records), 1)
+ self.assertEqual(records[0].get_property('duration'), 1000)
+ self.assertFalse(out_views[1].has_warnings())
+
+ def test_earlier_views_are_written_unchanged(self):
+ in_str = self._in_mmif_with_earlier_view()
+ out_str = self.app.annotate(in_str)
+ self.assertEqual(json.loads(in_str)['views'][0],
+ json.loads(out_str)['views'][0])
+
+ def test_failed_run_leaves_mmif_reusable(self):
+ class FlakyClamsApp(clams.app.ClamsApp):
+ attempts = 0
+ def _appmetadata(self):
+ pass
+ def _annotate(self, mmif, **kwargs):
+ self.attempts += 1
+ if self.attempts == 1:
+ raise ValueError('first attempt fails')
+ new_view = mmif.new_view()
+ self.sign_view(new_view, kwargs)
+ new_view.new_contain(AnnotationTypes.TimeFrame)
+ new_view.new_annotation(AnnotationTypes.TimeFrame, start=0, end=1)
+ return mmif
+ app = FlakyClamsApp()
+ in_mmif = Mmif(self.in_mmif)
+ with self.assertRaises(ValueError):
+ app.annotate(in_mmif)
+ self.assertEqual(len(Mmif(app.annotate(in_mmif)).views), 1)
+
+ def test_empty_output_warning_is_importable_from_clams(self):
+ self.assertIs(clams.app.EmptyOutputWarning, EmptyOutputWarning)
+
def test_self_reported_error_view_is_not_pruned(self):
class ErroringClamsApp(clams.app.ClamsApp):
def _appmetadata(self):