From 3c9fafcad0bc87217c70c8b154f1082294a0a859 Mon Sep 17 00:00:00 2001 From: Keigh Rim Date: Sun, 1 Nov 2026 09:52:09 -0500 Subject: [PATCH 1/2] updating `annotate` chain to use new context manager in mmif-py 1.7.0 --- clams/app/__init__.py | 98 +++++++++++++++--------------------------- pyproject.toml | 2 +- tests/test_clamsapp.py | 64 ++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 66 deletions(-) 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/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): From 3df4e4a9a565c9b787cb841402e1f33730c2a85e Mon Sep 17 00:00:00 2001 From: Keigh Rim Date: Sun, 1 Nov 2026 12:31:37 -0500 Subject: [PATCH 2/2] preparing 1.8.2 release --- documentation/target-versions.csv | 1 + 1 file changed, 1 insertion(+) 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 `__