diff --git a/src/rachis/core/archive/tests/test_citations.py b/src/rachis/core/archive/tests/test_citations.py index 3ab3b403b..2cd47bf62 100644 --- a/src/rachis/core/archive/tests/test_citations.py +++ b/src/rachis/core/archive/tests/test_citations.py @@ -39,7 +39,7 @@ def test_import(self): obs = list(map(lambda item: (item[0], item[1].fields['title']), archiver.citations.items())) - self.assertEqual(obs, expected) + self.assertEqual(sorted(obs), sorted(expected)) with (archiver.provenance_dir / 'action' / 'action.yaml').open() as fh: action_yaml = fh.read() @@ -77,7 +77,7 @@ def test_action(self): obs = list(map(lambda item: (item[0], item[1].fields['title']), archiver.citations.items())) - self.assertEqual(obs, expected) + self.assertEqual(sorted(obs), sorted(expected)) with (archiver.provenance_dir / 'action' / 'action.yaml').open() as fh: action_yaml = fh.read() diff --git a/src/rachis/core/testing/format.py b/src/rachis/core/testing/format.py index ecc34dc4c..b36d42fe2 100644 --- a/src/rachis/core/testing/format.py +++ b/src/rachis/core/testing/format.py @@ -171,3 +171,28 @@ class ExportableOnlyFormat(TextFileFormat): """ A format that can only be transformed to. """ + +class FirstStepFormat(model.DirectoryFormat): + """ + A format for testing transitive transformers + """ + +class SecondStepFormat(model.DirectoryFormat): + """ + A format for testing transitive transformers + """ + +class ThirdStepFormat(model.DirectoryFormat): + """ + A format for testing transitive transformers + """ + +class FourthStepFormat(model.DirectoryFormat): + """ + A format for testing transitive transformers + """ + +class FifthStepFormat(model.DirectoryFormat): + """ + A format for testing transitive transformers + """ diff --git a/src/rachis/core/testing/plugin.py b/src/rachis/core/testing/plugin.py index 91ac0cb2e..a53214498 100644 --- a/src/rachis/core/testing/plugin.py +++ b/src/rachis/core/testing/plugin.py @@ -30,12 +30,18 @@ Cephalapod, CephalapodDirectoryFormat, ImportableOnlyFormat, - ExportableOnlyFormat + ExportableOnlyFormat, + FirstStepFormat, + SecondStepFormat, + ThirdStepFormat, + FourthStepFormat, + FifthStepFormat, ) from .type import (IntSequence1, IntSequence2, IntSequence3, Mapping, FourInts, SingleInt, Kennel, Dog, Cat, C1, C2, C3, Foo, Bar, Baz, - AscIntSequence, Squid, Octopus, Cuttlefish) + AscIntSequence, Squid, Octopus, Cuttlefish, FirstStep, + SecondStep, ThirdStep, FourthStep, FifthStep) from .method import (concatenate_ints, split_ints, merge_mappings, identity_with_metadata, identity_with_metadata_column, identity_with_categorical_metadata_column, @@ -120,7 +126,9 @@ IntSequenceFormatV2, MappingFormat, IntSequenceV2DirectoryFormat, IntSequenceMultiFileDirectoryFormat, MappingDirectoryFormat, EchoDirectoryFormat, EchoFormat, Cephalapod, CephalapodDirectoryFormat, - ImportableOnlyFormat, ExportableOnlyFormat) + ImportableOnlyFormat, ExportableOnlyFormat, FirstStepFormat, + SecondStepFormat, ThirdStepFormat, FourthStepFormat, FifthStepFormat, +) dummy_plugin.register_formats( FourIntsDirectoryFormat, UnimportableDirectoryFormat, UnimportableFormat, @@ -182,6 +190,37 @@ def factory(): description="The second IntSequence", examples={'IntSequence2 import example': is2_use} ) + +dummy_plugin.register_artifact_class( + FirstStep, + directory_format=FirstStepFormat, + description="First step" +) + +dummy_plugin.register_artifact_class( + SecondStep, + directory_format=SecondStepFormat, + description="Second step" +) + +dummy_plugin.register_artifact_class( + ThirdStep, + directory_format=ThirdStepFormat, + description="Third step" +) + +dummy_plugin.register_artifact_class( + FourthStep, + directory_format=FourthStepFormat, + description="Fourth step" +) + +dummy_plugin.register_artifact_class( + FifthStep, + directory_format=FifthStepFormat, + description="Fifth step" +) + dummy_plugin.register_semantic_type_to_format( IntSequence3, directory_format=IntSequenceMultiFileDirectoryFormat diff --git a/src/rachis/core/testing/transformer.py b/src/rachis/core/testing/transformer.py index f3e039cd2..722dc6123 100644 --- a/src/rachis/core/testing/transformer.py +++ b/src/rachis/core/testing/transformer.py @@ -23,7 +23,12 @@ RedundantSingleIntDirectoryFormat, EchoFormat, ImportableOnlyFormat, - ExportableOnlyFormat + ExportableOnlyFormat, + FirstStepFormat, + SecondStepFormat, + ThirdStepFormat, + FourthStepFormat, + FifthStepFormat, ) from .plugin import dummy_plugin, citations @@ -206,3 +211,27 @@ def _4242(data: ImportableOnlyFormat) -> IntSequenceDirectoryFormat: @dummy_plugin.register_transformer() def _4243(data: IntSequenceDirectoryFormat) -> ExportableOnlyFormat: return ExportableOnlyFormat() + + +# only for testing transitive transformers +@dummy_plugin.register_transformer(upgrade=True) +def _6000(data: FirstStepFormat) -> SecondStepFormat: + return SecondStepFormat() + + +# only for testing transitive transformers +@dummy_plugin.register_transformer(upgrade=True) +def _6001(data: SecondStepFormat) -> ThirdStepFormat: + return ThirdStepFormat() + + +# only for testing transitive transformers +@dummy_plugin.register_transformer +def _6002(data: ThirdStepFormat) -> FourthStepFormat: + return FourthStepFormat() + + +# only for testing transitive transformers +@dummy_plugin.register_transformer(upgrade=False) +def _6003(data: ThirdStepFormat) -> FifthStepFormat: + return FifthStepFormat() diff --git a/src/rachis/core/testing/type.py b/src/rachis/core/testing/type.py index 19ce2bcd9..44603e08e 100644 --- a/src/rachis/core/testing/type.py +++ b/src/rachis/core/testing/type.py @@ -45,3 +45,9 @@ Squid = plugin.SemanticType('Squid') Octopus = plugin.SemanticType('Octopus') Cuttlefish = plugin.SemanticType('Cuttlefish') + +FirstStep = plugin.SemanticType('FirstStep') +SecondStep = plugin.SemanticType('SecondStep') +ThirdStep = plugin.SemanticType('ThirdStep') +FourthStep = plugin.SemanticType('FourthStep') +FifthStep = plugin.SemanticType('FifthStep') diff --git a/src/rachis/core/tests/test_transform.py b/src/rachis/core/tests/test_transform.py new file mode 100644 index 000000000..9c276fadc --- /dev/null +++ b/src/rachis/core/tests/test_transform.py @@ -0,0 +1,60 @@ +import unittest +from typing import Union +from tempfile import TemporaryDirectory + +from rachis import Artifact +from rachis.core.testing.format import ( + ThirdStepFormat, FourthStepFormat, FifthStepFormat, Cephalapod) + + +class TestTransitiveTransfomrers(unittest.TestCase): + @classmethod + def setUpClass(cls): + with TemporaryDirectory() as tempdir: + cls.first_format = Artifact.import_data( + type='FirstStep', view=tempdir + ) + + cls.int_sequence = Artifact.import_data( + type='IntSequence1', view=[1, 2, 3] + ) + + def test_first_to_third(self): + """ + Path exists and is upgraded. + FirstStepFormat -> SecondStepFormat -> ThirdStepFormat + """ + view = self.first_format.view(ThirdStepFormat) + self.assertEqual(type(view), ThirdStepFormat) + + def test_first_to_fourth_fails(self): + """ + Path exists but is not upgraded. + FirstStepFormat -> SecondStepFormat -> ThirdStepFormat -None-> Fourth + """ + with self.assertRaisesRegex(Exception, 'No transformation from'): + self.first_format.view(FourthStepFormat) + + def test_union_transitivity(self): + """ + Path exists between FirstStepFormat and ThirdStepFormat but not between + FirsStepFormat and Cephalapod. + """ + view = self.first_format.view(Union[Cephalapod, ThirdStepFormat]) + self.assertEqual(type(view), ThirdStepFormat) + + def test_int_sequence_dir_to_list(self): + """ + Tests that unwrapping a format and then transforming does not require + upgrading the path. + """ + view = self.int_sequence.view(list) + self.assertEqual(type(view), list) + + def test_lossy_transformer(self): + """ + Tests that path with lossy steps still completes. + First -> Second -> Third -lossy-> Fifth + """ + view = self.first_format.view(FifthStepFormat) + self.assertEqual(type(view), FifthStepFormat) diff --git a/src/rachis/core/transform.py b/src/rachis/core/transform.py index 30edca41c..8378dd88c 100644 --- a/src/rachis/core/transform.py +++ b/src/rachis/core/transform.py @@ -5,7 +5,13 @@ # # The full license is in the file LICENSE, distributed with this software. # ---------------------------------------------------------------------------- +from __future__ import annotations + +from enum import Enum import pathlib +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from rachis.plugin.plugin import TransformerRecord from rachis import sdk from rachis.plugin import model @@ -52,32 +58,15 @@ def __init__(self, view_type): self._record = self._pm.views[self._view_name] def make_transformation(self, other, recorder=None): - # record may be None in case of identity transformer - transformer, transformer_record = self._get_transformer_to(other) - if transformer is None: + target_node = find_transformation_path( + self._view_type, other._view_type + ) + + if target_node is None: raise Exception("No transformation from %r to %r" % (self._view_type, other._view_type)) - if recorder is not None: - recorder(transformer_record, input_name=self._view_name, - input_record=self._record, output_name=other._view_name, - output_record=other._record) - - def transformation(view, validate_level='min'): - view = self.coerce_view(view) - self.validate(view, level=validate_level) - - new_view = transformer(view) - - new_view = other.coerce_view(new_view) - other.validate(new_view) - - if transformer is not identity_transformer: - other.set_user_owned(new_view, False) - - return new_view - - return transformation + return compose_transformation(target_node, recorder=recorder) def _get_transformer_to(self, other): transformer, record = self._lookup_transformer(self._view_type, @@ -113,11 +102,11 @@ def coerce_view(self, view): def _lookup_transformer(self, from_, to_): if from_ == to_: return identity_transformer, None - try: - record = self._pm.transformers[from_][to_] - return record.transformer, record - except KeyError: + + search_node = find_transformation_path(from_, to_) + if search_node is None or search_node.record is None: return None, None + return search_node.record.transformer, search_node.record def set_user_owned(self, view, value): pass @@ -232,3 +221,408 @@ def validate(self, view, level=None): if not isinstance(view, self._view_type): raise TypeError("%r is not of type %r, cannot transform further." % (view, self._view_type)) + + +class TransformType(Enum): + ''' + Annotates a `SearchNode` as being transformed from its parent in one of + three ways: + + - `registered` means that the transformation was a typical, registered + transformer + - `wrap` means that the transformation converts a `FormatBase` into a + `SingleFileDirectoryFormatBase` + - `unwrap` type means that the transformation converts a + `SingleFileDirectoryFormatBase` into a `FormatBase` + ''' + registered = 1 + wrap = 2 + unwrap = 3 + + +class PathType(Enum): + ''' + Annotates the path encoded in a `SearchNode` as belonging to one of three + categories: + - upgrade_only: only implicit and `upgrade=True` transformations + - includes_false: implicit, `upgrade=True`, and `upgrade=False` + transformations + - includes_none: implicit, `upgrade=True`, `upgrade=False` and + `upgrade=None` transformations + ''' + upgrade_only = 1 + includes_false = 2 + includes_none = 3 + + +class SearchNode: + def __init__( + self, + type_: type, + parent: SearchNode | None, + record: TransformerRecord | None = None, + transform_type: TransformType = TransformType.registered, + wrapped: bool = False + ): + ''' + Parameters + ---------- + type_ : type + The type of the node. + parent : SearchNode | None + The node from which this node has been transformed, or None if this + is the first node in the path. + record : TransformerRecord | None + The `TransformerRecord` as registered in `Plugin.transformers` when + the transformation from parent to self was registered, or None for + wrap/unwrap transformations. + transform_type : TransformType + See `TransformType`. + wrapped : bool + + ''' + self.type_ = type_ + self.parent = parent + self.record = record + self.transform_type = transform_type + self.wrapped = wrapped + + def __len__(self): + ''' + Returns the number of registered transformers up to this node. + ''' + length = 0 + n = self + while n.parent is not None: + if n.record is not None: + length += 1 + + n = n.parent + + return length + + def __repr__(self): + return ( + f'SearchNode(id={id(self)}, type_={repr(self.type_)}, ' + f'parent={None if self.parent is None else id(self.parent)}, ' + f'transform_type={self.transform_type})' + ) + + def classify(self) -> PathType: + ''' + Classify the path encoded in a `SearchNode` as one of `PathType`. + ''' + status = PathType.upgrade_only + node = self + while node is not None: + if node.record is not None and node.record.upgrade is False: + status = PathType.includes_false + elif node.record is not None and node.record.upgrade is None: + status = PathType.includes_none + return status + + node = node.parent + + return status + + def validate_path(self) -> bool: + ''' + Validates the transformation path encoded in the chain of parents. + Ensures that there is at most one `upgrade=None` transformation step + which, if present, occurs at one of the ends of the path. Implicit + transformations (defined elsewhere) are not considered when determining + the ends of the path. + + Accounting for `None`s is done separately here because it is + impractical to look ahead or look backwards when managing the queue in + `NodeQueue`. + + Returns + ------- + bool + Whether the path is valid. + ''' + explicit_steps = self.steps(explicit=True) + none_count = 0 + for i, n in enumerate(explicit_steps): + if n.record.upgrade is None: + none_count += 1 + if i not in {0, len(explicit_steps) - 1}: + return False + + if none_count > 1: + return False + + return True + + def steps(self, explicit=False) -> list[SearchNode]: + ''' + Converts the ancestors of self into a list of `SearchNodes`s. + + Parameters + ---------- + explicit : bool + Whether to include only explicit transformation steps (i.e. those + that are registered). + + Returns + ------- + list[SearchNode] + The `SearchNode` ancestry. + ''' + steps = [] + node = self + while node is not None: + steps.insert(0, node) + node = node.parent + + if explicit: + return [n for n in steps if n.record is not None] + + return steps + + def has_ancestor(self, node: SearchNode) -> bool: + ''' + Searches for the type of `node` among the ancestors of `self`. Used to + prevent cycles during the transformation path search. + + Paremeters + ---------- + node : SearchNode + The node the type of which will be searched for among ancestors of + `self`. + + Returns + ------- + bool + Whether a matching ancestor exists. + ''' + current = self + while current is not None: + if current.type_ == node.type_: + return True + + current = current.parent + + return False + + +class NodeQueue: + def __init__(self): + self.nodes = [] + + def push(self, node: SearchNode) -> None: + ''' + Inserts a node and resorts the queue. + + The queue is sorted primarily by `PathType` and secondarily by + path length. In both cases lower values are preferred. This ensures + that `upgrade=True`-only paths are exhausted before including + `upgrade=False` steps, and so on. + ''' + self.nodes.append(node) + + def primary(node): + return int(node.classify().value) + + def secondary(node): + return len(node) + + self.nodes.sort(key=lambda n: (primary(n), secondary(n)), reverse=True) + + def pop(self) -> SearchNode | None: + if not self.nodes: + return None + + return self.nodes.pop() + + def insert_neighbors(self, node: SearchNode) -> None: + ''' + Find explicit and implicit neighbors to `node` and add them to the + queue. + + Parameters + ---------- + node : SearchNode + The node the neighbors of which should be added. + node_queue : NodeQueue + The remaining nodes to search while finding a transformation path. + Neighbors are pushed into this queue. + ''' + pm = sdk.PluginManager() + + # explicit neighbors + for neighbor, transform_record in pm.transformers.get( + node.type_, {} + ).items(): + neighbor = SearchNode( + type_=neighbor, + parent=node, + record=transform_record, + ) + if not node.has_ancestor(neighbor): + self.push(neighbor) + + # add synthetic link for Dx -> x + if issubclass(node.type_, model.SingleFileDirectoryFormatBase): + neighbor = SearchNode( + type_=node.type_.file.format, + parent=node, + record=None, + transform_type=TransformType.unwrap, + ) + node.wrapped = True + if not node.has_ancestor(neighbor): + self.push(neighbor) + + # add synthetic link(s) x -> Dx + elif issubclass(node.type_, model.base.FormatBase): + for sfdf in pm._ff_to_sfdf.get(node.type_, []): + neighbor = SearchNode( + type_=sfdf, + parent=node, + record=None, + transform_type=TransformType.wrap, + ) + node.wrapped = True + if not node.has_ancestor(neighbor): + self.push(neighbor) + + +def find_transformation_path(start: type, target: type) -> SearchNode | None: + ''' + Searches for a transformation path from `start` to `target`. The path is + encoded in the chain of parents of the returned `SearchNode`. + + Parameters + ---------- + start : type + The type we wish to transform from. + target : type + The type we wish to transform to. + + Returns + ------- + SearchNode | None + A SearchNode of the target type, if reachable, otherwise None. + ''' + current = SearchNode(type_=start, parent=None) + + node_queue = NodeQueue() + node_queue.push(current) + while True: + current = node_queue.pop() + + if current is None: + return None + + if not current.validate_path(): + continue + + if current.type_ == target: + return current + + node_queue.insert_neighbors(current) + + +def compose_transformation(target: SearchNode | None, recorder = None): + if target is None: + return None + + pm = sdk.PluginManager() + + steps = target.steps() + + if recorder is not None: + for i in range(len(steps) - 1): + name = util.get_view_name(steps[i].type_) + view = pm.views.get(name) + if steps[i].wrapped: + continue + elif steps[i + 1].wrapped: + try: + parent_name = util.get_view_name(steps[i + 2].type_) + parent_view = pm.views.get(parent_name) + recorder( + steps[i + 1].record, + name, + view, + parent_name, + parent_view + ) + except IndexError: + pass + else: + parent_name = util.get_view_name(steps[i + 1].type_) + parent_view = pm.views.get(parent_name) + recorder( + steps[i + 1].record, + name, + view, + parent_name, + parent_view + ) + + if len(steps) == 1: + def identity_transformation(view, validate_level='min'): + from_mt = ModelType.from_view_type(steps[0].type_) + current = from_mt.coerce_view(view) + from_mt.validate(current, level=validate_level) + + return current + + return identity_transformation + + def transformation(view, validate_level='min'): + current = view + for i in range(len(steps) - 1): + from_type = steps[i].type_ + to_type = steps[i + 1].type_ + + if steps[i + 1].transform_type == TransformType.wrap: + transformer = wrap_transformer(from_type, to_type) + elif steps[i + 1].transform_type == TransformType.unwrap: + transformer = unwrap_transformer(from_type) + else: + transformer = pm.transformers[from_type][to_type].transformer + + from_mt = ModelType.from_view_type(from_type) + to_mt = ModelType.from_view_type(to_type) + + current = from_mt.coerce_view(current) + from_mt.validate(current, level=validate_level) + current = transformer(current) + + current = to_mt.coerce_view(current) + to_mt.validate(current, level=validate_level) + to_mt.set_user_owned(current, False) + + return current + + return transformation + + +def wrap_transformer(file_type: type, sfdf_type: type): + ''' + A transformer used to convert any `FileFormat` into its associated + `SingleFileDirectoryFormat`. + ''' + def transformer(view): + sfdf = sfdf_type() + sfdf.file.write_data(view, file_type) + return sfdf + + return transformer + + +def unwrap_transformer(sfdf_type: type): + ''' + A transformer used to convert any `SingleFileDirectoryFormat` into the + contained `FileFormat`. + ''' + file_type = sfdf_type.file.format + + def transformer(view): + return view.file.view(file_type) + + return transformer diff --git a/src/rachis/plugin/plugin.py b/src/rachis/plugin/plugin.py index 6052606f1..3e1e098e3 100644 --- a/src/rachis/plugin/plugin.py +++ b/src/rachis/plugin/plugin.py @@ -74,7 +74,7 @@ TransformerRecord = collections.namedtuple( - 'TransformerRecord', ['transformer', 'plugin', 'citations']) + 'TransformerRecord', ['transformer', 'plugin', 'citations', 'upgrade']) SemanticTypeRecord = collections.namedtuple( 'SemanticTypeRecord', ['semantic_type', 'plugin']) SemanticTypeFragmentRecord = collections.namedtuple( @@ -341,11 +341,9 @@ def decorator(validator): return validator return decorator - def register_transformer(self, _fn=None, *, citations=None): + def register_transformer(self, _fn=None, *, citations=None, upgrade=None): """ **Decorator** which registers a transformer to convert data - This decorator may be used with or without arguments. - Parameters ---------- _fn : Callable @@ -354,6 +352,20 @@ def register_transformer(self, _fn=None, *, citations=None): citations : CitationRecord or list of CitationRecord Citation(s) to associate with a result whenever this transformer is used internally. Can also use an entire :py:class:`Citations` object. + upgrade : Bool | None + How to consider this transformer when searching for transformation + paths between formats. There are three options: + + `True` indicates that the transformer is not lossy, is usable at + any step in transformer search paths, and takes precedence over + `False`. + + `False` indicates that the transformer is lossy but can be used in + transformer search paths only if there are no other satisfactory + paths that include only `upgrade=True` transformers. + + None indicates that the transformer is lossy and can be used only + at the terminal ends of search paths. This is the default. Returns ------- @@ -372,7 +384,7 @@ def register_transformer(self, _fn=None, *, citations=None): Examples -------- - >>> @plugin.register_transformer + >>> @plugin.register_transformer(upgrade=True) ... def _0(data: pd.DataFrame) -> CSVFormat: ... ff = CSVFormat() ... with ff.open() as fh: @@ -426,7 +438,11 @@ def decorator(transformer): % (transformer, input, output)) self.transformers[input, output] = TransformerRecord( - transformer=transformer, plugin=self, citations=citations) + transformer=transformer, + plugin=self, + citations=citations, + upgrade=upgrade + ) return transformer if _fn is None: diff --git a/src/rachis/sdk/result.py b/src/rachis/sdk/result.py index 29241a42d..4900371c3 100644 --- a/src/rachis/sdk/result.py +++ b/src/rachis/sdk/result.py @@ -461,10 +461,6 @@ def view(self, view_type): return self._view(view_type) def _view(self, view_type, recorder=None): - if view_type is rachis.Metadata and not self.has_metadata(): - raise TypeError( - "Artifact %r cannot be viewed as Rachis Metadata." % self) - from_type = transform.ModelType.from_view_type(self.format) if isinstance(get_origin(view_type), type(Union)): diff --git a/src/rachis/sdk/tests/test_artifact.py b/src/rachis/sdk/tests/test_artifact.py index 0add93172..5dca5bd66 100644 --- a/src/rachis/sdk/tests/test_artifact.py +++ b/src/rachis/sdk/tests/test_artifact.py @@ -614,9 +614,8 @@ def test_artifact_validate_max(self): self.assertTrue(True) # Checkpoint assertion A.validate(level='max') self.assertTrue(True) # Checkpoint assertion - A = Artifact.import_data('IntSequence1', [1, 2, 3, 4, 5, 6, 7, 10]) with self.assertRaisesRegex(ValidationError, '3 more'): - A.validate(level='max') + A = Artifact.import_data('IntSequence1', [1, 2, 3, 4, 5, 6, 7, 10]) def test_artifact_validate_max_on_import(self): fp = get_data_path('intsequence-fail-max-validation.txt') @@ -661,9 +660,7 @@ def test_view_as_metadata(self): def test_cannot_be_viewed_as_metadata(self): A = Artifact.import_data('IntSequence1', [1, 2, 3, 4]) - with self.assertRaisesRegex(TypeError, - 'Artifact.*IntSequence1.*cannot be viewed ' - 'as Rachis Metadata'): + with self.assertRaisesRegex(Exception, 'No transformation from '): A.view(Metadata) diff --git a/src/rachis/sdk/tests/test_plugin_manager.py b/src/rachis/sdk/tests/test_plugin_manager.py index 40ef145d1..0f738bfc6 100644 --- a/src/rachis/sdk/tests/test_plugin_manager.py +++ b/src/rachis/sdk/tests/test_plugin_manager.py @@ -32,7 +32,13 @@ EchoDirectoryFormat, CephalapodDirectoryFormat, ImportableOnlyFormat, - ExportableOnlyFormat) + ExportableOnlyFormat, + FirstStepFormat, + SecondStepFormat, + ThirdStepFormat, + FourthStepFormat, + FifthStepFormat +) from rachis.core.testing.validator import (validator_example_null1, validate_ascending_seq, @@ -225,6 +231,21 @@ def test_get_formats_no_type_or_filter(self): 'ExportableOnlyFormat': FormatRecord(format=ExportableOnlyFormat, plugin=self.plugin), + 'FirstStepFormat': + FormatRecord(format=FirstStepFormat, + plugin=self.plugin), + 'SecondStepFormat': + FormatRecord(format=SecondStepFormat, + plugin=self.plugin), + 'ThirdStepFormat': + FormatRecord(format=ThirdStepFormat, + plugin=self.plugin), + 'FourthStepFormat': + FormatRecord(format=FourthStepFormat, + plugin=self.plugin), + 'FifthStepFormat': + FormatRecord(format=FifthStepFormat, + plugin=self.plugin), } obs = self.pm.get_formats()