Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 33 additions & 24 deletions clams/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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] = {}
Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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])))
Expand Down
21 changes: 18 additions & 3 deletions clams/develop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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):
Expand All @@ -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]')
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions clams/develop/templates/utl-tf/timeframe.py.template
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions documentation/target-versions.csv
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"``clams-python`` version","``mmif-python`` version","Target MMIF Specification"
`1.8.1 <https://pypi.org/project/clams-python/1.8.1/>`__,`1.6.0 <https://pypi.org/project/mmif-python/1.6.0/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
`1.8.0 <https://pypi.org/project/clams-python/1.8.0/>`__,`1.5.4 <https://pypi.org/project/mmif-python/1.5.4/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
`1.7.4 <https://pypi.org/project/clams-python/1.7.4/>`__,`1.5.3 <https://pypi.org/project/mmif-python/1.5.3/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
`1.7.3 <https://pypi.org/project/clams-python/1.7.3/>`__,`1.5.1 <https://pypi.org/project/mmif-python/1.5.1/>`__,`1.2.0 <https://mmif.clams.ai/1.2.0/>`__
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
112 changes: 99 additions & 13 deletions tests/test_clamsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 15 additions & 1 deletion tests/test_develop.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import sys
import tempfile
import unittest
import warnings
from pathlib import Path
from unittest import mock

Expand All @@ -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'
Expand Down
Loading