diff --git a/clams/app/__init__.py b/clams/app/__init__.py index 454f89b..031eaab 100644 --- a/clams/app/__init__.py +++ b/clams/app/__init__.py @@ -49,6 +49,10 @@ ] +class EmptyOutputWarning(UserWarning): + """Raised when an app's ``_annotate()`` produces no non-empty views.""" + + class ClamsApp(ABC): """ An abstract class to define API's for ClamsApps. A CLAMS app should inherit @@ -191,6 +195,15 @@ 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) + 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() + 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: diff --git a/tests/test_clamsapp.py b/tests/test_clamsapp.py index 412aa5d..71e4d49 100644 --- a/tests/test_clamsapp.py +++ b/tests/test_clamsapp.py @@ -417,6 +417,55 @@ def test_error_handling(self): self.assertEqual(len(last_view.metadata.contains), 0) self.assertEqual(len(last_view.metadata.error), 2) + def test_empty_view_becomes_warning_view(self): + class EmptyClamsApp(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.new_contain(AnnotationTypes.TimeFrame) + return mmif + out_mmif = Mmif(EmptyClamsApp().annotate(self.in_mmif)) + self.assertEqual(len(out_mmif.views), 1) + view = next(iter(out_mmif.views)) + self.assertEqual(len(view.annotations), 0) + self.assertTrue(any('EmptyOutputWarning' in w for w in view.metadata.warnings)) + + def test_partial_empty_views_are_pruned_without_warning(self): + class MixedClamsApp(clams.app.ClamsApp): + def _appmetadata(self): + pass + def _annotate(self, mmif, **kwargs): + populated = mmif.new_view() + self.sign_view(populated, kwargs) + populated.new_contain(AnnotationTypes.TimeFrame) + populated.new_annotation(AnnotationTypes.TimeFrame, start=0, end=1) + empty = mmif.new_view() + self.sign_view(empty, kwargs) + empty.new_contain(AnnotationTypes.TimeFrame) + return mmif + out_mmif = Mmif(MixedClamsApp().annotate(self.in_mmif)) + self.assertEqual(len(out_mmif.views), 1) + view = next(iter(out_mmif.views)) + self.assertEqual(len(view.annotations), 1) + self.assertFalse(view.has_warnings()) + + def test_self_reported_error_view_is_not_pruned(self): + class ErroringClamsApp(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.set_error('simulated failure', 'trace') + return mmif + out_mmif = Mmif(ErroringClamsApp().annotate(self.in_mmif)) + self.assertEqual(len(out_mmif.views), 1) + view = next(iter(out_mmif.views)) + self.assertTrue(view.has_error()) + self.assertFalse(view.has_warnings()) + def test_gpu_mem_fields_default_zero(self): """GPU memory fields default to 0.""" metadata = AppMetadata(