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'