diff --git a/clams/app/__init__.py b/clams/app/__init__.py index 0f4bac3..3c8d2cc 100644 --- a/clams/app/__init__.py +++ b/clams/app/__init__.py @@ -240,8 +240,13 @@ def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params: List[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] - is_empty = lambda v: len(v.annotations) == 0 and not v.has_error() and not v.has_warnings() + # 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) @@ -255,8 +260,7 @@ def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params: List[str]) -> warnings_view = annotated.new_view() self.sign_view(warnings_view, refined) warnings_view.metadata.warnings = issued_warnings - run_id = datetime.now() - td = run_id - t + td = datetime.now() - t runningTime = refined.get('runningTime', False) hwFetch = refined.get('hwFetch', False) runtime_recs: Dict[str, Any] = {} @@ -279,16 +283,23 @@ def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params: List[str]) -> 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)) - for annotated_view in annotated.views: - if annotated_view.id not in existing_view_ids and annotated_view.metadata.app == str(self.metadata.identifier): + 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 - 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) + 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) @@ -391,7 +402,9 @@ def sign_view(self, view: View, runtime_conf: dict) -> None: """ view.metadata.app = str(self.metadata.identifier) if self.metadata.app_tags: - view.metadata.set_additional_property('appTags', list(self.metadata.app_tags)) + # item assignment stores the tags in the declared `appTags` field + # when mmif-python has one, and as an additional property otherwise + view.metadata['appTags'] = list(self.metadata.app_tags) if self._RAW_PARAMS_KEY in runtime_conf: for k, v in runtime_conf.items(): if k == self._RAW_PARAMS_KEY: @@ -436,9 +449,11 @@ def add_parameters_to_metadata(self, view: View, parameters: dict): def set_error_view(self, mmif: Union[str, dict, Mmif], **runtime_conf: List[str]) -> Mmif: """ - A method to record an error instead of annotation results in the view - this app generated. For logging purpose, the runtime parameters used - when the error occurred must be passed as well. + A method to record an error instead of annotation results in a new + view appended to the input MMIF. Existing views are left untouched, + including views from earlier runs of the same app. For logging + purpose, the runtime parameters used when the error occurred must be + passed as well. :param mmif: input MMIF object :param runtime_conf: parameters passed to annotate when the app encountered the error @@ -448,14 +463,8 @@ def set_error_view(self, mmif: Union[str, dict, Mmif], **runtime_conf: List[str] if isinstance(mmif, (bytes, str, dict)): mmif, runtime_conf = unwrap_if_envelope(mmif, runtime_conf) mmif = Mmif(mmif) - error_view: Optional[View] = None - for view in reversed(mmif.views): - if view.metadata.app == str(self.metadata.identifier): - error_view = view - break - if error_view is None: - error_view = mmif.new_view() - self.sign_view(error_view, runtime_conf) + error_view = mmif.new_view() + self.sign_view(error_view, runtime_conf) exc_info = sys.exc_info() error_view.set_error(f'{exc_info[0]}: {exc_info[1]}', '\t\n'.join(traceback.format_tb(exc_info[2]))) diff --git a/clams/develop/__init__.py b/clams/develop/__init__.py index cb682e4..0bc4601 100644 --- a/clams/develop/__init__.py +++ b/clams/develop/__init__.py @@ -2,12 +2,15 @@ import pathlib import re import shutil +import warnings from string import Template from typing import List import clams update_tmp_suffix = '.tmp' +# Recipes with a ``deprecated`` message are left out of the default recipe +# set and warn when baked on request. available_recipes = { 'app': { 'description': 'Skeleton code for a CLAMS app', @@ -20,12 +23,18 @@ 'targetdir': '.github', }, 'utl-tf': { - 'description': 'Local helper module for iterating TimeFrames and collecting per-TF frame tasks ' + 'description': 'DEPRECATED. Local helper module for iterating TimeFrames and collecting per-TF frame tasks ' '(baked into ``utils/timeframe.py``; backend-agnostic, safe to edit/delete)', 'sourcedir': 'utl-tf', 'targetdir': 'utils', + 'deprecated': 'The `utl-tf` recipe is deprecated and is no longer baked by default. mmif-python now ' + 'provides its functions: use `Mmif.get_annotations_of_labels()` to filter TimeFrames by ' + 'label, and `mmif.utils.video_document_helper.iter_timeframe_images()` to sample images ' + 'per TimeFrame with the TimePoint each image came from.', }, } +default_recipes = [name for name, recipe in available_recipes.items() + if 'deprecated' not in recipe] class CookieCutter(object): @@ -37,7 +46,13 @@ def __init__(self, name: str, outdir: str, recipes: List[str]): if recipes: self.recipes = recipes else: - self.recipes = list(available_recipes.keys()) + self.recipes = list(default_recipes) + for recipe in self.recipes: + message = available_recipes.get(recipe, {}).get('deprecated') + if message: + # FutureWarning, unlike DeprecationWarning, is shown to CLI + # users by default + warnings.warn(message, FutureWarning, stacklevel=2) def tokenize_rawname(self): word_pat = re.compile('[A-Z][a-z]+|[0-9A-Z]+(?=[A-Z][a-z])|[0-9A-Z]{2,}|[a-z0-9]{2,}|[a-zA-Z0-9]') @@ -158,7 +173,7 @@ def prep_argparser(**kwargs): action='store', nargs='+', default=[], - help=f"Pick recipes to bake. DEFAULT: {list(available_recipes.keys())}" + help=f"Pick recipes to bake. DEFAULT: {default_recipes}" ) parser.add_argument( '-n', '--name', diff --git a/clams/develop/templates/utl-tf/timeframe.py.template b/clams/develop/templates/utl-tf/timeframe.py.template index 5dc9585..1a380eb 100644 --- a/clams/develop/templates/utl-tf/timeframe.py.template +++ b/clams/develop/templates/utl-tf/timeframe.py.template @@ -1,6 +1,14 @@ """ TimeFrame iteration / frame-sampling helpers, local to this app. +DEPRECATED: mmif-python now provides these functions. Use +``Mmif.get_annotations_of_labels()`` in place of +:func:`iter_timeframes`, and +``mmif.utils.video_document_helper.iter_timeframe_images()`` in place of +:func:`collect_timeframes_of_interest` (it yields +``(time_frame, images, timepoint_ids)``, and wrapping those into +``clams.app.PromptTask`` is left to the app). + Generated by ``clams develop -r utl-tf``. The code in this file is part of your app, not the SDK; edit it freely, refactor as needed, or delete the whole file if your app does not iterate TimeFrame annotations. diff --git a/documentation/target-versions.csv b/documentation/target-versions.csv index 9fa6ba8..f57a30b 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.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 `__ `1.7.3 `__,`1.5.1 `__,`1.2.0 `__ diff --git a/pyproject.toml b/pyproject.toml index 28989b7..d0731a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", ] dependencies = [ - "mmif-python==1.5.4", + "mmif-python==1.6.0", "Flask>=2", "Flask-RESTful>=0.3.9", "gunicorn>=20", diff --git a/tests/test_clamsapp.py b/tests/test_clamsapp.py index 2b7a4cf..829fe23 100644 --- a/tests/test_clamsapp.py +++ b/tests/test_clamsapp.py @@ -5,7 +5,9 @@ import tempfile import unittest import warnings +from datetime import datetime, timezone from typing import Union +from unittest.mock import patch import jsonschema import pytest @@ -334,11 +336,6 @@ def test_annotate(self): self.assertEqual(len(v.metadata.parameters), 1) # 'pretty` parameter was passed out_mmif = Mmif(self.app.annotate(out_mmif)) self.assertEqual(len(out_mmif.views), 4) - views = list(out_mmif.views) - # insertion order is kept - self.assertEqual(views[0].metadata.timestamp, views[1].metadata.timestamp) - self.assertEqual(views[2].metadata.timestamp, views[3].metadata.timestamp) - self.assertTrue(views[1].metadata.timestamp < views[2].metadata.timestamp) def test_run_id(self): # first run @@ -360,6 +357,31 @@ def test_run_id(self): else: self.assertEqual(second_timestamp, view.metadata.timestamp) + def test_run_id_reuses_mmif_timestamp(self): + # the run ID is the timestamp mmif-python assigned to the first view + # of the run; clams-python does not generate its own + sentinel = datetime(2001, 2, 3, 4, 5, 6, tzinfo=timezone.utc) + original_annotate = self.app._annotate + + def _annotate(mmif, **kwargs): + out = original_annotate(mmif, **kwargs) + next(reversed(out.views)).metadata.timestamp = sentinel + return out + + # serialize the sentinel the same way mmif-python does + ref = ExampleInputMMIF.get_rawmmif() + ref.new_view().metadata.timestamp = sentinel + expected = next(reversed( + Mmif(ref.serialize(), validate=False).views)).metadata.timestamp + with patch.object(self.app, '_annotate', new=_annotate): + out_mmif = Mmif(self.app.annotate(self.in_mmif)) + app_views = [v for v in out_mmif.views + if v.metadata.app == str(self.app.metadata.identifier)] + # one contentful view and one warning view + self.assertEqual(len(app_views), 2) + for view in app_views: + self.assertEqual(expected, view.metadata.timestamp) + def test_annotate_returns_invalid_mmif(self): m = Mmif(self.in_mmif) v = m.new_view() @@ -430,21 +452,38 @@ def test_refine_parameters(self): def test_error_handling(self): params = {'raise_error': ['true'], 'pretty': ['true']} - in_mmif = Mmif(self.in_mmif) try: - out_mmif = self.app.annotate(in_mmif, **params) + out_mmif = self.app.annotate(self.in_mmif, **params) except Exception as e: - out_mmif_from_str = self.app.set_error_view(self.in_mmif, **params) - out_mmif_from_mmif = self.app.set_error_view(in_mmif, **params) - self.assertEqual( - out_mmif_from_mmif.views.get_last_contentful_view(), - out_mmif_from_str.views.get_last_contentful_view()) - out_mmif = out_mmif_from_str + out_mmif = self.app.set_error_view(self.in_mmif, **params) self.assertIsNotNone(out_mmif) + self.assertEqual(len(out_mmif.views), 1) last_view: View = next(reversed(out_mmif.views)) self.assertEqual(len(last_view.metadata.contains), 0) self.assertEqual(len(last_view.metadata.error), 2) + def test_error_view_keeps_previous_run(self): + # an error in a later run of the same app must not overwrite + # the views from an earlier, successful run + first = Mmif(self.app.annotate(self.in_mmif)) + params = {'raise_error': ['true']} + try: + self.app.annotate(first.serialize(), **params) + except Exception: + out_mmif = self.app.set_error_view(first.serialize(), **params) + self.assertEqual(len(out_mmif.views), len(first.views) + 1) + for before, after in zip(first.views, out_mmif.views): + self.assertEqual(before.id, after.id) + self.assertFalse(after.has_error()) + self.assertEqual(len(before.annotations), len(after.annotations)) + self.assertEqual(before.metadata.timestamp, + after.metadata.timestamp) + error_view: View = next(reversed(out_mmif.views)) + self.assertTrue(error_view.has_error()) + self.assertEqual(len(error_view.annotations), 0) + self.assertNotIn(error_view.metadata.timestamp, + [v.metadata.timestamp for v in first.views]) + def test_empty_view_becomes_warning_view(self): class EmptyClamsApp(clams.app.ClamsApp): def _appmetadata(self): @@ -494,6 +533,53 @@ def _annotate(self, mmif, **kwargs): self.assertTrue(view.has_error()) self.assertFalse(view.has_warnings()) + def test_self_reported_warning_view_is_not_pruned(self): + class WarningClamsApp(clams.app.ClamsApp): + def _appmetadata(self): + pass + def _annotate(self, mmif, **kwargs): + new_view = mmif.new_view() + self.sign_view(new_view, kwargs) + new_view.metadata.warnings = ['simulated warning'] + return mmif + out_mmif = Mmif(WarningClamsApp().annotate(self.in_mmif)) + self.assertEqual(len(out_mmif.views), 1) + view = next(iter(out_mmif.views)) + self.assertEqual(view.metadata.warnings, ['simulated warning']) + + def test_document_properties_stay_in_own_view(self): + # an app whose only output is document properties keeps its own view; + # the generated `Annotation`s must not go to an earlier app's view + class DocPropsClamsApp(clams.app.ClamsApp): + def _appmetadata(self): + pass + def _annotate(self, mmif, **kwargs): + new_view = mmif.new_view() + self.sign_view(new_view, kwargs) + mmif['v1'].add_property('duration', 1234) + mmif['t1'].add_property('author', 'me') + return mmif + in_mmif = Mmif(self.in_mmif) + earlier = in_mmif.new_view() + earlier.metadata.app = 'http://apps.clams.ai/earlier-app/v1' + earlier.new_annotation(AnnotationTypes.TimeFrame, start=0, end=10) + app = DocPropsClamsApp() + out_mmif = Mmif(app.annotate(in_mmif.serialize())) + self.assertEqual( + 0, len(list(out_mmif[earlier.id].get_annotations(AnnotationTypes.Annotation)))) + own_views = [v for v in out_mmif.views + if v.metadata.app == str(app.metadata.identifier)] + self.assertEqual(len(own_views), 1) + self.assertFalse(own_views[0].has_warnings()) + # one `Annotation` per document, even though `serialize()` converts + # the same pending properties a second time + anns = list(own_views[0].get_annotations(AnnotationTypes.Annotation)) + self.assertEqual(len(anns), 2) + props = {a.get_property('document'): + {k: v for k, v in a.properties.items() if k not in ('id', 'document')} + for a in anns} + self.assertEqual({'v1': {'duration': 1234}, 't1': {'author': 'me'}}, props) + def test_gpu_mem_fields_default_zero(self): """GPU memory fields default to 0.""" metadata = AppMetadata( diff --git a/tests/test_develop.py b/tests/test_develop.py index 95a8c02..4d58ed0 100644 --- a/tests/test_develop.py +++ b/tests/test_develop.py @@ -7,6 +7,7 @@ import sys import tempfile import unittest +import warnings from pathlib import Path from unittest import mock @@ -16,12 +17,25 @@ from clams.develop import CookieCutter +class TestRecipeDefaults(unittest.TestCase): + + def test_default_recipes_leave_out_deprecated(self): + with warnings.catch_warnings(): + warnings.simplefilter('error') + cutter = CookieCutter( + name='app', outdir=tempfile.gettempdir(), recipes=[]) + self.assertTrue(cutter.recipes) + self.assertNotIn('utl-tf', cutter.recipes) + + class TestUtlTfRecipe(unittest.TestCase): def setUp(self): tmp = tempfile.TemporaryDirectory() self.addCleanup(tmp.cleanup) - with contextlib.redirect_stdout(io.StringIO()): + # `utl-tf` is deprecated, and asking for it warns + with self.assertWarns(FutureWarning), \ + contextlib.redirect_stdout(io.StringIO()): CookieCutter( name='tfapp', outdir=tmp.name, recipes=['utl-tf']).bake() path = Path(tmp.name) / 'tfapp' / 'utils' / 'timeframe.py'