Repository: eyecan-ai/pipelime (language: python)

Issue:
added pytset, added cli to fill workflow with cwl node paths



Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index 48a4e18..583ec58 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,7 +1,13 @@
 [report]
 exclude_lines =
+    pass
+    __str__
+    __repr__
     # Have to re-enable the standard pragma
     pragma: no cover
 
     # Don't complain if tests don't hit defensive assertion code:
-    raise NotImplementedError
\ No newline at end of file
+    raise NotImplementedError
+
+omit =
+    */cli/*
\ No newline at end of file
diff --git a/examples/garbage/_schema.schema b/examples/garbage/_schema.schema
new file mode 100644
index 0000000..a114c07
--- /dev/null
+++ b/examples/garbage/_schema.schema
@@ -0,0 +1,20 @@
+from typing import Any
+from schema import And, Optional
+import numpy as np
+
+# schema = {
+#     'image': And(
+#         np.ndarray,
+#         lambda x: len(x.shape) >= 3
+#     ),
+#     'metadata': {
+#         any: object,
+#         'counter': int
+#     },
+#     any: object
+# }
+schema = {
+    'image': object,
+    'metadata': object,
+    Optional(str): Optional(object)
+}
diff --git a/examples/garbage/json_schema_example.py b/examples/garbage/json_schema_example.py
new file mode 100644
index 0000000..697c56c
--- /dev/null
+++ b/examples/garbage/json_schema_example.py
@@ -0,0 +1,15 @@
+from pipelime.sequences.validation import SchemaLoader
+from pipelime.sequences.samples import Sample
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+
+
+s = SchemaLoader().load(filename='_schema.schema')
+
+folder = '/home/daniele/Desktop/experiments/2021-01-28.PlaygroundDatasets/lego_00'
+reader = UnderfolderReader(folder=folder)
+
+for sample in reader:
+    sample: Sample
+    print(sample.id)
+
+    sample.validate(s, deep=False)
diff --git a/examples/samples/validation/deep_schema.schema b/examples/samples/validation/deep_schema.schema
new file mode 100644
index 0000000..98be1f5
--- /dev/null
+++ b/examples/samples/validation/deep_schema.schema
@@ -0,0 +1,9 @@
+from schema import And
+import numpy as np
+
+deep = True
+schema = {
+    'counter': int,
+    'name': str,
+    'data': And(np.ndarray, lambda x: len(x.shape) == 3)
+}
diff --git a/examples/samples/validation/simple_schema.schema b/examples/samples/validation/simple_schema.schema
new file mode 100644
index 0000000..406892f
--- /dev/null
+++ b/examples/samples/validation/simple_schema.schema
@@ -0,0 +1,8 @@
+from schema import Optional
+
+deep = False
+schema = {
+    'counter': object,
+    'name': object,
+    Optional(str): object
+}
diff --git a/examples/samples/validation/validate_samples.py b/examples/samples/validation/validate_samples.py
new file mode 100644
index 0000000..b0f2f3c
--- /dev/null
+++ b/examples/samples/validation/validate_samples.py
@@ -0,0 +1,31 @@
+from pipelime.sequences.validation import SchemaLoader
+from pipelime.sequences.samples import PlainSample, SamplesSequence
+import numpy as np
+import rich
+
+# Creates samples sequence
+N = 25
+samples = []
+for i in range(N):
+    samples.append(
+        PlainSample({
+            'counter': i,
+            'name': f'sample_{str(i).zfill(10)}',
+            'data': np.random.uniform(-1, 1, (100, 100, 3))
+        })
+    )
+sequence = SamplesSequence(samples=samples)
+
+# validate sample (should be used to check keys only)
+rich.print("Simple validation:")
+schema = SchemaLoader.load('simple_schema.schema')
+for idx, sample in enumerate(sequence):
+    schema.validate(sample)
+    rich.print("\tValidating", idx, 'OK')
+
+# validate sample in depth
+rich.print("In-depth validation:")
+schema = SchemaLoader.load('deep_schema.schema')
+for idx, sample in enumerate(sequence):
+    schema.validate(sample)
+    rich.print("\tValidating", idx, 'OK')
diff --git a/pipelime/__init__.py b/pipelime/__init__.py
index b0489b2..62fa373 100644
--- a/pipelime/__init__.py
+++ b/pipelime/__init__.py
@@ -1,11 +1,19 @@
 """Top-level package for pipelime."""
 
-__author__ = """daniele de gregorio"""
+__author__ = "eyecan"
 __email__ = 'daniele.degregorio@eyecan.ai'
 __version__ = '0.0.1'
 
 
 # Import transforms in order to register classes for serialization
+from pathlib import Path
 from pipelime.augmentations.transforms import PadIfNeededV2
 from pipelime.sequences.readers.filesystem import UnderfolderReader
 from pipelime.sequences.writers.filesystem import UnderfolderWriter
+from appdirs import AppDirs
+import pipelime
+
+
+def user_data_dir() -> Path:
+    # TODO: esurface and eyecan: move strings as package dunder
+    return Path(AppDirs(appname=pipelime.__name__, appauthor=__author__).user_data_dir)
diff --git a/pipelime/cli/datasets/datasets.py b/pipelime/cli/datasets/datasets.py
new file mode 100644
index 0000000..ee66817
--- /dev/null
+++ b/pipelime/cli/datasets/datasets.py
@@ -0,0 +1,12 @@
+import click
+
+from pipelime.cli.datasets.generate_toy_dataset import generate_toy_dataset
+
+
+
+@click.group()
+def datasets():
+    pass
+
+
+datasets.add_command(generate_toy_dataset)
diff --git a/pipelime/cli/main.py b/pipelime/cli/main.py
new file mode 100644
index 0000000..94bedc4
--- /dev/null
+++ b/pipelime/cli/main.py
@@ -0,0 +1,13 @@
+import click
+
+from pipelime.cli.datasets.datasets import datasets
+from pipelime.cli.workflow.workflow import workflow
+
+
+@click.group()
+def pipelime():
+    pass
+
+
+pipelime.add_command(datasets)
+pipelime.add_command(workflow)
diff --git a/pipelime/cli/workflow/__init__.py b/pipelime/cli/workflow/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pipelime/cli/workflow/click2cwl.py b/pipelime/cli/workflow/click2cwl.py
new file mode 100644
index 0000000..234e02e
--- /dev/null
+++ b/pipelime/cli/workflow/click2cwl.py
@@ -0,0 +1,62 @@
+import click
+
+
+@click.command("click2cwl", help="Converts a click script to a cwl file")
+@click.option("--script", required=True, type=str, help="Script filename")
+@click.option("--name", required=True, type=str, help="Cwl template output name")
+@click.option("--alias", required=True, type=str, help="Alias command associated to the cwl, e.g.: 'command1 subcommand leaf' with quotes.")
+@click.option("--fchoice/--no-fchoice", default=True, type=bool, help="TRUE to activate forward interactive choice")
+@click.option("-f", multiple=True, type=str, help="Forwards, used only if the interactive choice is disabled")
+@click.option("--folder", default="", type=str, help="Output folder to save the cwl. Leave empty to use default folder")
+def click2cwl(
+    script,
+    name,
+    alias,
+    fchoice,
+    f,
+    folder
+):
+    from pipelime.workflow.cwl import CwlNodesManager, CwlTemplate
+
+    # Create template
+    cwl_template = CwlTemplate(
+        script=script,
+        alias=alias.split(' '),
+        forwards=None
+    )
+
+    if fchoice:
+        import inquirer
+        if len(cwl_template.inputs_keys) > 0:
+
+            # prompt
+            answers = inquirer.prompt([
+                inquirer.Checkbox(
+                    'forwards',
+                    message="Which input forward onto output?",
+                    choices=cwl_template.inputs_keys,
+                ),
+            ])
+            forwards = answers['forwards']
+    else:
+        forwards = list(f)
+
+    # Create template
+    cwl_template = CwlTemplate(
+        script=script,
+        alias=alias.split(' '),
+        forwards=forwards
+    )
+
+    try:
+        CwlNodesManager.create_node(
+            name=name,
+            cwl_template=cwl_template,
+            folder=folder if len(folder) > 0 else None
+        )
+    except Exception as e:
+        print(e)
+
+
+if __name__ == "__main__":
+    click2cwl()
diff --git a/pipelime/cli/workflow/delete_nodes.py b/pipelime/cli/workflow/delete_nodes.py
new file mode 100644
index 0000000..6b0fcef
--- /dev/null
+++ b/pipelime/cli/workflow/delete_nodes.py
@@ -0,0 +1,29 @@
+import click
+
+
+@click.command("delete_nodes", help="Delete nodes")
+@click.option("--folder", default=None, type=str, help="Folder of the cwl. Leave empty to use default folder")
+def delete_nodes(folder):
+
+    from pipelime.workflow.cwl import CwlNodesManager
+    import inquirer
+
+    # retrieves available nodes
+    nodes = CwlNodesManager.available_nodes(folder=folder)
+
+    # prompt
+    answers = inquirer.prompt([
+        inquirer.Checkbox(
+            'nodes',
+            message="Which nodes do you want to delete?",
+            choices=nodes.keys(),
+        ),
+    ])
+
+    # delete nodes
+    for node in answers['nodes']:
+        CwlNodesManager.delete_node(node, folder)
+
+
+if __name__ == "__main__":
+    delete_nodes()
diff --git a/pipelime/cli/workflow/fill_workflow.py b/pipelime/cli/workflow/fill_workflow.py
new file mode 100644
index 0000000..2147087
--- /dev/null
+++ b/pipelime/cli/workflow/fill_workflow.py
@@ -0,0 +1,19 @@
+import click
+
+
+@click.command("fill", help="Fill a workflow with the cwl paths")
+@click.option("--workflow", required=True, type=str, help="Output filename of the cwl workflow file")
+@click.option("--output_filename", required=True, type=str, help="Output filename of the cwl workflow file")
+@click.option("--folder", default=None, type=str, help="Folder of the cwl. Leave empty to use default folder")
+def fill(workflow, output_filename, folder):
+
+    from pipelime.workflow.cwl import CwlNodesManager
+    from pipelime.workflow.cwl import CwlWorkflowTemplate
+
+    workflow_template = CwlWorkflowTemplate.from_file(workflow)
+    workflow_template = CwlNodesManager.fill_workflow(workflow_template)
+    workflow_template.dumps(output_filename)
+
+
+if __name__ == "__main__":
+    fill()
diff --git a/pipelime/cli/workflow/init_workflow.py b/pipelime/cli/workflow/init_workflow.py
new file mode 100644
index 0000000..6563bb5
--- /dev/null
+++ b/pipelime/cli/workflow/init_workflow.py
@@ -0,0 +1,31 @@
+import click
+
+
+@click.command("init", help="Initialize a workflow")
+@click.option("--output_filename", required=True, type=str, help="Output filename of the cwl workflow file")
+@click.option("--folder", default=None, type=str, help="Folder of the cwl. Leave empty to use default folder")
+def init(output_filename, folder):
+
+    import inquirer
+    from pipelime.workflow.cwl import CwlNodesManager
+
+    # retrieves available nodes
+    nodes = CwlNodesManager.available_nodes(folder=folder)
+
+    choosen_nodes = []
+    available_nodes = [k for k, v in nodes.items() if v.is_valid]
+    available_nodes.append('END WORKFLOW')
+    while True:
+        answer = inquirer.prompt([
+            inquirer.List('nodes', message='Choose a node', choices=available_nodes)
+        ])
+        if answer['nodes'] == 'END WORKFLOW':
+            break
+        choosen_nodes.append(answer['nodes'])
+
+    worlkflow_template = CwlNodesManager.initialize_workflow(choosen_nodes)
+    worlkflow_template.dumps(output_filename)
+
+
+if __name__ == "__main__":
+    init()
diff --git a/pipelime/cli/workflow/list_nodes.py b/pipelime/cli/workflow/list_nodes.py
new file mode 100644
index 0000000..ae4f7c0
--- /dev/null
+++ b/pipelime/cli/workflow/list_nodes.py
@@ -0,0 +1,33 @@
+import click
+
+
+@click.command("list_nodes", help="List available nodes")
+@click.option("--folder", default=None, type=str, help="Folder of the cwl. Leave empty to use default folder")
+def list_nodes(folder):
+
+    import rich
+    from pipelime.workflow.cwl import CwlNodesManager
+    from rich.tree import Tree
+
+    # retrieves available nodes
+    nodes = CwlNodesManager.available_nodes(folder=folder)
+
+    # draw tree
+    t_root = Tree('🧩 [green]Nodes')
+    for name, node in nodes.items():
+        if not node.is_valid:
+            t_node = Tree(f"[red][u]{name} (node NOT valid, can't load the script)[/u][/red]")
+        else:
+            t_node = Tree(f'[u]{name}[/u]')
+        t_node.add(f'[blue]Path:[/blue] {node.cwl_path}')
+        t_template = Tree('[blue]Template[/blue]')
+        t_template.add(f'Script: {node.cwl_template.script}')
+        t_template.add(f'Alias: {node.cwl_template.alias}')
+        t_template.add(f'Forwards: {node.cwl_template.forwards}')
+        t_node.add(t_template)
+        t_root.add(t_node)
+    rich.print(t_root)
+
+
+if __name__ == "__main__":
+    list_nodes()
diff --git a/pipelime/cli/workflow/run_workflow.py b/pipelime/cli/workflow/run_workflow.py
new file mode 100644
index 0000000..04ad4fe
--- /dev/null
+++ b/pipelime/cli/workflow/run_workflow.py
@@ -0,0 +1,40 @@
+import click
+
+
+@click.command("run", help="Runs a cwl workflow")
+@click.option("--cwl_file", required=True, type=str, help="The cwl workflow file")
+@click.option("--yml_file", required=True, type=str, help="The XConfig input file, placeholders will be prompted to the user")
+@click.option("--parallel", required=False, is_flag=True, help="Enable parallel execution")
+@click.option("--debug", required=False, is_flag=True, help="Print even more logging")
+def run(
+    cwl_file,
+    yml_file,
+    parallel,
+    debug
+):
+    import tempfile
+    import subprocess
+    from choixe.configurations import XConfig
+    from choixe.inquirer import XInquirer
+
+    # reads and prompts the XConfig
+    cfg = XConfig(filename=yml_file)
+    cfg = XInquirer.prompt(cfg)
+    compiled_cfg_file = f'{tempfile.NamedTemporaryFile().name}.yml'
+    cfg.save_to(compiled_cfg_file)
+
+    # prepares the cwl-runner
+    cmd = ['cwl-runner']
+    if parallel:
+        cmd.append('--parallel')
+    if debug:
+        cmd.append('--debug')
+    cmd.append(cwl_file)
+    cmd.append(compiled_cfg_file)
+
+    # runs workflow
+    subprocess.run(cmd)
+
+
+if __name__ == "__main__":
+    run()
diff --git a/pipelime/cli/workflow/show_graph.py b/pipelime/cli/workflow/show_graph.py
new file mode 100644
index 0000000..3b15fd8
--- /dev/null
+++ b/pipelime/cli/workflow/show_graph.py
@@ -0,0 +1,27 @@
+import click
+
+
+@click.command("show_graph", help="Show cwl workflow graph with using dot notation")
+@click.option("--cwl_file", required=True, type=str, help="The cwl workflow file")
+def show_graph(cwl_file):
+    import subprocess
+    import tempfile
+    import os
+    from networkx.drawing.nx_agraph import read_dot, to_agraph
+
+    def show_nx_graph(graph):
+        agraph = to_agraph(graph)
+        agraph.layout('dot')
+        agraph_file = f'{tempfile.NamedTemporaryFile().name}.svg'
+        agraph.draw(agraph_file)
+        os.system(f'eog {agraph_file}')
+
+    dot_file = f'{tempfile.NamedTemporaryFile().name}.txt'
+    with open(dot_file, 'w') as f:
+        subprocess.run(['cwl-runner', '--print-dot', cwl_file], stdout=f)
+    graph = read_dot(dot_file)
+    show_nx_graph(graph)
+
+
+if __name__ == "__main__":
+    show_graph()
diff --git a/pipelime/cli/workflow/update_nodes.py b/pipelime/cli/workflow/update_nodes.py
new file mode 100644
index 0000000..39917b4
--- /dev/null
+++ b/pipelime/cli/workflow/update_nodes.py
@@ -0,0 +1,47 @@
+import click
+
+
+@click.command("update_nodes", help="Updates nodes")
+@click.option("--folder", default=None, type=str, help="Folder of the cwl. Leave empty to use default folder")
+def update_nodes(folder):
+
+    import inquirer
+    import rich
+    from pipelime.workflow.cwl import CwlNodesManager
+    from pipelime.workflow.cwl import CwlTemplate
+
+    # retrieves available nodes
+    nodes = CwlNodesManager.available_nodes(folder=folder)
+
+    # prompt
+    answers = inquirer.prompt([
+        inquirer.Checkbox(
+            'nodes',
+            message="Which nodes do you want to update?",
+            choices=nodes.keys(),
+        ),
+    ])
+
+    # update nodes
+    for name, node in nodes.items():
+        if name in answers['nodes']:
+            rich.print(f'[green] Update node "{name}"')
+            available_names = [x for x in nodes.keys() if x != name]
+            update_answers = inquirer.prompt([
+                inquirer.Text('name', message='Update name', default=node.name, validate=lambda _, x: x not in available_names),
+                inquirer.Text('script', message='Update script', default=node.cwl_template.script),
+                inquirer.Text('alias', message='Update alias', default=" ".join(node.cwl_template.alias)),
+                inquirer.Checkbox('forwards', message='Update forwards', choices=node.cwl_template.inputs_keys, default=node.cwl_template.forwards)
+            ])
+
+            CwlNodesManager.delete_node(name, folder=folder)
+            cwl_template = CwlTemplate(
+                script=update_answers['script'],
+                alias=update_answers['alias'].split(' '),
+                forwards=update_answers['forwards']
+            )
+            CwlNodesManager.create_node(update_answers['name'], cwl_template=cwl_template, folder=folder)
+
+
+if __name__ == "__main__":
+    update_nodes()
diff --git a/pipelime/cli/workflow/workflow.py b/pipelime/cli/workflow/workflow.py
new file mode 100644
index 0000000..89b193a
--- /dev/null
+++ b/pipelime/cli/workflow/workflow.py
@@ -0,0 +1,25 @@
+import click
+
+from pipelime.cli.workflow.run_workflow import run
+from pipelime.cli.workflow.init_workflow import init
+from pipelime.cli.workflow.fill_workflow import fill
+from pipelime.cli.workflow.show_graph import show_graph
+from pipelime.cli.workflow.click2cwl import click2cwl
+from pipelime.cli.workflow.list_nodes import list_nodes
+from pipelime.cli.workflow.delete_nodes import delete_nodes
+from pipelime.cli.workflow.update_nodes import update_nodes
+
+
+@click.group()
+def workflow():
+    pass
+
+
+workflow.add_command(run)
+workflow.add_command(init)
+workflow.add_command(fill)
+workflow.add_command(show_graph)
+workflow.add_command(click2cwl)
+workflow.add_command(list_nodes)
+workflow.add_command(delete_nodes)
+workflow.add_command(update_nodes)
diff --git a/pipelime/sequences/readers/filesystem.py b/pipelime/sequences/readers/filesystem.py
index 5666517..dc27048 100644
--- a/pipelime/sequences/readers/filesystem.py
+++ b/pipelime/sequences/readers/filesystem.py
@@ -23,7 +23,8 @@ def __init__(
 class UnderfolderReader(BaseReader):
     DATA_SUBFOLDER = 'data'
 
-    def __init__(self, folder: str, copy_root_files: bool = True) -> None:
+    def __init__(self, folder: str, copy_root_files: bool = True, lazy_samples: bool = True) -> None:
+
         self._folder = Path(folder)
         self._copy_root_files = copy_root_files
         self._datafolder = self._folder / self.DATA_SUBFOLDER
@@ -46,7 +47,7 @@ def __init__(self, folder: str, copy_root_files: bool = True) -> None:
             if self._copy_root_files:
                 data.update(self._root_data)
 
-            sample = FileSystemSample(data_map=data, lazy=True, id=self._ids[idx])
+            sample = FileSystemSample(data_map=data, lazy=lazy_samples, id=self._ids[idx])
             samples.append(sample)
 
         super().__init__(samples=samples)
diff --git a/pipelime/sequences/samples.py b/pipelime/sequences/samples.py
index 975c2e1..264e676 100644
--- a/pipelime/sequences/samples.py
+++ b/pipelime/sequences/samples.py
@@ -62,6 +62,10 @@ def copy(self):
     def metaitem(self, key: any) -> MetaItem:
         pass
 
+    @property
+    def skeleton(self) -> dict:
+        return {x: None for x in self.keys()}
+
 
 class GroupedSample(Sample):
 
@@ -231,6 +235,10 @@ def metaitem(self, key: any):
         else:
             return MemoryItem()
 
+    @property
+    def skeleton(self) -> dict:
+        return {x: None for x in self._filesmap.keys()}
+
 
 class SamplesSequence(Sequence):
 
diff --git a/pipelime/sequences/validation.py b/pipelime/sequences/validation.py
new file mode 100644
index 0000000..d4ab5f7
--- /dev/null
+++ b/pipelime/sequences/validation.py
@@ -0,0 +1,49 @@
+from pipelime.sequences.samples import Sample
+from schema import Schema
+import uuid
+import types
+import importlib.machinery
+
+
+class SampleSchema:
+
+    def __init__(self, schema: Schema, deep: bool = True):
+        self._schema = schema
+        self._deep = deep
+
+    @property
+    def deep(self) -> bool:
+        return self._deep
+
+    @property
+    def schema(self) -> Schema:
+        return self._schema
+
+    def validate(self, sample: Sample):
+        if not self.deep:
+            self.schema.validate(sample.skeleton)
+        else:
+            self.schema.validate(dict(sample))
+
+
+class SchemaLoader:
+    DEFAULT_SCHEMA_VARIABLE_NAME = 'schema'
+    DEFAULT_DEEP_VARIABLE_NAME = 'deep'
+
+    @classmethod
+    def load(cls, filename: str) -> SampleSchema:
+        module = cls._load_module(str(filename))
+        sname = SchemaLoader.DEFAULT_SCHEMA_VARIABLE_NAME
+        if not hasattr(module, sname):
+            raise ModuleNotFoundError(f'No variable with name {sname} found in file: {filename}')
+
+        schema = Schema(getattr(module, sname))
+        deep = getattr(module, SchemaLoader.DEFAULT_DEEP_VARIABLE_NAME, True)
+        return SampleSchema(schema=schema, deep=deep)
+
+    @classmethod
+    def _load_module(cls, path: str):
+        loader = importlib.machinery.SourceFileLoader(str(uuid.uuid1()), path)
+        mod = types.ModuleType(loader.name)
+        loader.exec_module(mod)
+        return mod
diff --git a/pipelime/workflow/cwl.py b/pipelime/workflow/cwl.py
new file mode 100644
index 0000000..a6815e0
--- /dev/null
+++ b/pipelime/workflow/cwl.py
@@ -0,0 +1,631 @@
+import typing
+import os
+from choixe.spooks import Spook
+import importlib.util
+from typing import Dict, Union, Sequence
+from pathlib import Path
+import click
+from schema import Or
+import yaml
+from pipelime import user_data_dir
+from choixe.configurations import XConfig
+
+
+class CwlNodesManager(object):
+
+    DEFAULT_CWL_EXTENSION = 'cwl'
+    DEFAULT_META_EXTENSION = 'yml'
+
+    @classmethod
+    def nodes_folder(cls) -> Path:
+        """ Default nodes folder
+
+        :return: folder
+        :rtype: Path
+        """
+
+        folder = user_data_dir() / cls.__name__
+        if not folder.exists():
+            folder.mkdir(parents=True, exist_ok=True)
+        return folder
+
+    @classmethod
+    def get_associated_meta_path(cls, p: Path) -> Path:
+        """ Gets cwl file associated metadata path
+
+        :param p: cwl file
+        :type p: Path
+        :return: metadata file
+        :rtype: Path
+        """
+
+        return p.parent / f'{p.stem}.{cls.DEFAULT_META_EXTENSION}'
+
+    @classmethod
+    def is_valid_cwl_file(cls, p: Path) -> bool:
+        """ Checks for valid cwl file
+
+        :param p: cwl file
+        :type p: Path
+        :return: TRUE if is valid cwl file
+        :rtype: bool
+        """
+
+        return p.is_file() and cls.get_associated_meta_path(p).exists()
+
+    @classmethod
+    def load_cwl_node(cls, p: Path) -> 'CwlNode':
+        """ Loads cwl node from path
+
+        :return: loaded CwlNode
+        :rtype: CwlNode
+        """
+
+        if cls.is_valid_cwl_file(p):
+            meta_p = cls.get_associated_meta_path(p)
+            cwl_template = Spook.create(d=XConfig(filename=meta_p).to_dict())
+            cwl_node = CwlNode(name=p.stem, cwl_path=str(p), cwl_template=cwl_template)
+            return cwl_node
+        return None
+
+    @classmethod
+    def available_nodes(cls, folder: Union[str, None] = None) -> Dict[str, 'CwlNode']:
+        """ Gets a map of stored CwlNodes
+
+        :return: stored nodes map [node_name: node]
+        :rtype: Dict[str, 'CwlNode']
+        """
+
+        folder = Path(folder) if folder is not None else cls.nodes_folder()
+        cwls = folder.glob(f'*.{cls.DEFAULT_CWL_EXTENSION}')
+        cwls = [x for x in cwls if cls.is_valid_cwl_file(x)]
+        nodes = [cls.load_cwl_node(x) for x in cwls]
+        nodes = {x.name: x for x in nodes}
+        return nodes
+
+    @classmethod
+    def get_node_by_name(cls, name: str, folder: Union[str, None] = None) -> Union['CwlNode', None]:
+        """ Gets a node with the given name
+
+        :param name: node name
+        :type name: str
+        :param folder: if None the default folder will be used, defaults to None
+        :type folder: Union[str, None], optional
+        :return: the node with given name if exists, else None
+        :rtype: Union['CwlNode', None]
+        """
+
+        nodes = cls.available_nodes(folder)
+        node = [v for k, v in nodes.items() if k == name]
+        if len(node) == 1:
+            node = node[0]
+        else:
+            node = None
+
+        return node
+
+    @classmethod
+    def create_node(cls, name: str, cwl_template: 'CwlTemplate', folder: Union[str, None] = None) -> 'CwlNode':
+        """ Creates a node with input name and CwlTemplate
+
+        :param name: input node name
+        :type name: str
+        :param cwl_template: input cwl template
+        :type cwl_template: CwlTemplate
+        :param folder: if None the default folder will be used, defaults to None
+        :type folder: Union[str, None], optional
+        :raises RuntimeError: if a node with same name found
+        :return: created node
+        :rtype: CwlNode
+        """
+
+        nodes = cls.available_nodes(folder=folder)
+        if name in nodes:
+            raise RuntimeError(f'Node with same name "{name}" found')
+
+        folder = Path(folder) if folder is not None else cls.nodes_folder()
+        folder.mkdir(parents=True, exist_ok=True)
+        cwl_filename = folder / f'{name}.{cls.DEFAULT_CWL_EXTENSION}'
+        meta_filename = folder / f'{name}.{cls.DEFAULT_META_EXTENSION}'
+
+        cwl_template.dumps(cwl_filename)
+        XConfig.from_dict(d=cwl_template.serialize()).save_to(meta_filename)
+
+        return CwlNode(
+            name=name,
+            cwl_path=str(cwl_filename),
+            cwl_template=cwl_template
+        )
+
+    @classmethod
+    def delete_node(cls, name: str, folder: Union[str, None] = None):
+        """ Deletes a node given the name
+
+        :param name: node to delete name
+        :type name: str
+        :param folder: if None the default folder will be used, defaults to None
+        :type folder: Union[str, None], optional
+        :raises RuntimeError: if a node with this name not found
+        """
+
+        nodes = cls.available_nodes(folder=folder)
+        if name not in nodes:
+            raise RuntimeError(f'Node with name "{name}" not found')
+
+        folder = Path(folder) if folder is not None else cls.nodes_folder()
+        cwl_filename = folder / f'{name}.{cls.DEFAULT_CWL_EXTENSION}'
+        meta_filename = folder / f'{name}.{cls.DEFAULT_META_EXTENSION}'
+
+        os.remove(cwl_filename)
+        os.remove(meta_filename)
+
+    @classmethod
+    def initialize_workflow(cls, names: Sequence[str], folder: Union[str, None] = None) -> 'CwlWorkflowTemplate':
+        """ Initializes a cwl worflow given a list of node names
+
+        :param names: names of the nodes to create the cwl workflow
+        :type names: Sequence[str]
+        :param folder: if None the default folder will be used, defaults to None
+        :type folder: Union[str, None], optional
+        :raises RuntimeError: if a node with one of these names not found
+        :return: the cwl workflow
+        :rtype: CwlWorkflowTemplate
+        """
+
+        nodes = cls.available_nodes(folder)
+        workflow_nodes = []
+        for name in names:
+            if name not in nodes:
+                raise RuntimeError(f'Node with name "{name}" not found')
+            workflow_nodes.append(cls.get_node_by_name(name, folder=folder))
+
+        workflow_template = CwlWorkflowTemplate(workflow_nodes)
+
+        return workflow_template
+
+    @classmethod
+    def fill_workflow(cls, workflow: 'CwlWorkflowTemplate', folder: Union[str, None] = None) -> 'CwlWorkflowTemplate':
+        """ Fills a cwl workflow adding the cwl path for each step
+
+        :param workflow: thw cwl workflow to be filled
+        :type workflow: CwlWorkflowTemplate
+        :param folder: if None the default folder will be used, defaults to None
+        :type folder: Union[str, None], optional
+        :return: the cwl workflow
+        :rtype: CwlWorkflowTemplate
+        """
+
+        for _, opts in workflow.template['steps'].items():
+            node_name = opts['run']
+            node = cls.get_node_by_name(node_name, folder=folder)
+            opts['run'] = node.cwl_path
+
+        return workflow
+
+
+class CwlTemplate(Spook):
+
+    def __init__(self, script: str = None, alias: Sequence[str] = None, forwards: Sequence[str] = None):
+        """ Creates a cwl template from script containing click.Command
+
+        :param script: the script, defaults to None
+        :type script: str, optional
+        :param alias: the commands needed to call the script, defaults to None
+        :type alias: Sequence[str], optional
+        :param forwards: the input parameters to forward to output, defaults to None
+        :type forwards: Sequence[str], optional
+        """
+
+        super().__init__()
+
+        self._script = script
+        self._alias = alias
+        self._forwards = forwards
+
+        self._cmd = None
+        if self._script is not None:
+            self._cmd = self._load_click_command(self.resolved_script)
+        self._template = dict()
+        if self._cmd is not None:
+            self._fill()
+
+    @ classmethod
+    def spook_schema(cls) -> typing.Union[None, dict]:
+        return {
+            'script': Or(str, None),
+            'alias': Or([str], None),
+            'forwards': Or([str], None),
+        }
+
+    @ classmethod
+    def from_dict(cls, d: dict):
+        return cls(**d)
+
+    def to_dict(self) -> dict:
+        return {
+            'script': self._script,
+            'alias': self._alias,
+            'forwards': self._forwards
+        }
+
+    @property
+    def script(self):
+        return self._script
+
+    @property
+    def resolved_script(self):
+        return Path(self._script).absolute().resolve()
+
+    @property
+    def command(self):
+        return self._cmd
+
+    @property
+    def template(self):
+        return self._template
+
+    @property
+    def alias(self):
+        return self._alias
+
+    @property
+    def forwards(self):
+        return self._forwards
+
+    @property
+    def inputs(self):
+        return self._template.get('inputs', None)
+
+    @property
+    def inputs_keys(self):
+        if self.inputs is not None:
+            return list(self.inputs.keys())
+        return []
+
+    @property
+    def outputs(self):
+        return self._template.get('outputs', None)
+
+    @property
+    def outputs_keys(self):
+        if self.outputs is not None:
+            return list(self.outputs.keys())
+        return []
+
+    @classmethod
+    def from_command(cls, command: click.Command, alias: list = None, forwards: list = None) -> 'CwlTemplate':
+        """ Creates a cwl template directly from click.Command
+
+        :param command: the click command
+        :type command: click.Command
+        :param alias: the commands needed to call the script, defaults to None
+        :type alias: list, optional
+        :param forwards: the input parameters to forward to output, defaults to None
+        :type forwards: list, optional
+        :return: a cwl template
+        :rtype: CwlTemplate
+        """
+
+        cwl_template = CwlTemplate(alias=alias, forwards=forwards)
+        cwl_template._cmd = command
+        cwl_template._fill()
+        return cwl_template
+
+    def _fill(self):
+        """ Fills the template
+        """
+
+        self._init_template()
+        if self._alias is not None:
+            self._fill_command(self._alias)
+        self._fill_inputs(self._cmd)
+        if self._forwards is not None:
+            self._fill_outputs(self._forwards)
+
+    def _load_click_command(self, script: str) -> Union[click.Command, None]:
+        """ Loads a click.Command from file, it assumes
+        that the script contains only one command,
+        if there are no commands it will return None
+
+        :param script: the script filename
+        :type script: str
+        :return: the click command
+        :rtype: Union[click.Command, None]
+        """
+        try:
+            spec = importlib.util.spec_from_file_location(script.stem, script)
+            module = importlib.util.module_from_spec(spec)
+            spec.loader.exec_module(module)
+            cmd = None
+            for x in dir(module):
+                x = getattr(module, x)
+                if isinstance(x, click.core.Command):
+                    cmd = x
+                    break
+        except Exception:
+            cmd = None
+
+        return cmd
+
+    def _init_template(self):
+        """ Initializes the cwl template for a CommandLineTool
+        """
+
+        self._template['cwlVersion'] = 'v1.0'
+        self._template['class'] = 'CommandLineTool'
+        self._template['doc'] = ''
+        self._template['baseCommand'] = ''
+        self._template['inputs'] = dict()
+        self._template['outputs'] = dict()
+
+    def _fill_command(self, commands: list):
+        """ Fills the cwl template with the commands informations
+
+        :param commands: the commands needed to call the script
+        :type commands: list
+        """
+
+        self._template['baseCommand'] = commands
+
+    def _fill_inputs(self, cmd: click.Command):
+        """ Fills the cwl template with input informations
+
+        :param cmd: the click command
+        :type cmd: click.Command
+        """
+
+        self._template['doc'] = cmd.help
+        for param in cmd.params:
+            param: click.Option
+            cwl_param = dict()
+            cwl_param['doc'] = param.help
+            cwl_type = self._convert_type(param)
+            cwl_param.update(cwl_type)
+            self._template['inputs'][param.name] = cwl_param
+
+    def _convert_type(self, param: click.Option) -> dict:
+        """ Converts a click type into a cwl type
+
+        :param param: the click option
+        :type param: click.Option
+        :return: the cwl type
+        :rtype: dict
+        """
+
+        cwl_types = dict()
+        cwl_types['type'] = []
+        if not param.required:
+            cwl_types['type'].append('null')
+
+        if not param.multiple and param.nargs == 1:
+            if isinstance(param.type, click.types.StringParamType) or isinstance(param.type, click.Choice):
+                cwl_type = 'string'
+            elif isinstance(param.type, click.types.IntParamType):
+                cwl_type = 'int'
+            elif isinstance(param.type, click.types.FloatParamType):
+                cwl_type = 'float'
+            elif isinstance(param.type, click.types.BoolParamType):
+                cwl_type = 'boolean'
+            elif isinstance(param.type, click.Tuple):
+                cwl_type = 'string'
+
+        elif (not param.multiple and param.nargs > 1) or (param.multiple and param.nargs == 1):
+            cwl_type = dict()
+            cwl_type['type'] = 'array'
+
+            if isinstance(param.type, click.types.StringParamType) or isinstance(param.type, click.Choice):
+                cwl_type['items'] = 'string'
+            elif isinstance(param.type, click.types.IntParamType):
+                cwl_type['items'] = 'int'
+            elif isinstance(param.type, click.types.FloatParamType):
+                cwl_type['items'] = 'float'
+            elif isinstance(param.type, click.types.BoolParamType):
+                cwl_type['items'] = 'boolean'
+            elif isinstance(param.type, click.Tuple):
+                cwl_type['items'] = 'string'
+
+        # can't have bool with nargs > 1
+        elif param.multiple and param.nargs > 1:
+            cwl_type = dict()
+            cwl_type['type'] = 'array'
+
+            if isinstance(param.type, click.types.StringParamType) or isinstance(param.type, click.Choice):
+                cwl_type['items'] = dict()
+                cwl_type['items']['type'] = 'array'
+                cwl_type['items']['items'] = 'string'
+            elif isinstance(param.type, click.types.IntParamType):
+                cwl_type['items'] = dict()
+                cwl_type['items']['type'] = 'array'
+                cwl_type['items']['items'] = 'int'
+            elif isinstance(param.type, click.types.FloatParamType):
+                cwl_type['items'] = dict()
+                cwl_type['items']['type'] = 'array'
+                cwl_type['items']['items'] = 'float'
+            elif isinstance(param.type, click.Tuple):
+                cwl_type['items'] = dict()
+                cwl_type['items']['type'] = 'array'
+                cwl_type['items']['items'] = 'string'
+
+        cwl_types['type'].append(cwl_type)
+
+        if not param.multiple:
+            if not param.required and param.default is not None:
+                cwl_types['default'] = param.default
+            cwl_types['inputBinding'] = dict()
+            cwl_types['inputBinding']['prefix'] = param.opts[0]
+        elif param.multiple:
+            if not param.required and param.default is not None:
+                cwl_types['type'][-1]['default'] = param.default
+            cwl_types['type'][-1]['inputBinding'] = dict()
+            cwl_types['type'][-1]['inputBinding']['prefix'] = param.opts[0]
+
+        return cwl_types
+
+    def _fill_outputs(self, forwards: list):
+        """ Fills the cwl template with outputs informations
+
+        :param forwards: the input parameters to forward to output
+        :type forwards: list
+        """
+
+        for fw in forwards:
+            output_name = f'_{fw}'
+            self._template['outputs'][output_name] = dict()
+            output_type = self._template['inputs'][fw]['type']
+            output_type = output_type[-1]
+            if isinstance(output_type, str):
+                self._template['outputs'][output_name]['type'] = output_type
+            elif isinstance(output_type, dict):
+                self._template['outputs'][output_name]['type'] = dict()
+                self._template['outputs'][output_name]['type']['type'] = output_type['type']
+                self._template['outputs'][output_name]['type']['items'] = output_type['items']
+            self._template['outputs'][output_name]['outputBinding'] = dict()
+            self._template['outputs'][output_name]['outputBinding']['outputEval'] = f'$(inputs.{fw})'
+
+    def dumps(self, path: str):
+        """ Dumps the cwl template to target file
+
+        :param path: the output complete filename
+        :type path: str
+        """
+
+        class NoAliasDumper(yaml.SafeDumper):
+            def ignore_aliases(self, data):
+                return True
+
+        with open(path, 'w') as f:
+            yaml.dump(self._template, f, Dumper=NoAliasDumper, sort_keys=False)
+        # reads and writes again to add the first line
+        with open(path, 'r') as f:
+            lines = f.readlines()
+        lines.insert(0, '#!/usr/bin/env cwl-runner\n')
+        with open(path, 'w') as f:
+            f.writelines(lines)
+
+
+class CwlNode:
+
+    def __init__(self, name: str, cwl_path: str, cwl_template: CwlTemplate) -> None:
+        self._name = name
+        self._cwl_path = cwl_path
+        self._cwl_template = cwl_template
+        self._is_valid = cwl_template.command is not None
+
+    @property
+    def name(self):
+        return self._name
+
+    @property
+    def cwl_path(self):
+        return self._cwl_path
+
+    @property
+    def cwl_template(self):
+        return self._cwl_template
+
+    @property
+    def is_valid(self):
+        return self._is_valid
+
+
+class CwlWorkflowTemplate(object):
+
+    def __init__(self, nodes: Sequence[CwlNode] = None):
+        """ Creates a cwl workflow template from list of CwlNode
+
+        :param nodes: steps of the workflow
+        :type nodes: Sequence[CwlNode]
+        """
+
+        self._nodes = nodes
+        self._template = dict()
+
+        if self._nodes is not None:
+            self._fill()
+
+    @property
+    def template(self):
+        return self._template
+
+    @classmethod
+    def from_file(cls, filename: str) -> 'CwlWorkflowTemplate':
+        """ Creates a cwl workflow template from a file
+
+        :param filename: the file template
+        :type filename: str
+        :return: a cwl workflow template
+        :rtype: CwlWorkflowTemplate
+        """
+
+        with open(filename, 'r') as f:
+            template = yaml.safe_load(f)
+
+        workflow_template = CwlWorkflowTemplate()
+        workflow_template._template = template
+        return workflow_template
+
+    def _fill(self):
+        """ Fills the template
+        """
+
+        self._init_template()
+        self._fill_steps(self._nodes)
+
+    def _init_template(self):
+        """ Initializes the cwl workflow template for a CommandLineTool
+        """
+
+        self._template['cwlVersion'] = 'v1.0'
+        self._template['class'] = 'Workflow'
+        self._template['requirements'] = {
+            'StepInputExpressionRequirement': dict(),
+            'InlineJavascriptRequirement': dict(),
+            'MultipleInputFeatureRequirement': dict(),
+            'ScatterFeatureRequirement': dict()
+        }
+        self._template['inputs'] = dict()
+        self._template['outputs'] = list()
+        self._template['steps'] = dict()
+
+    def _fill_steps(self, steps: Sequence[CwlNode]):
+        """ Fills the cwl workflow template with steps informations
+
+        :param steps: the steos of the workflow
+        :type steps: Sequence[CwlNode]
+        """
+
+        # avoids name conflicts when a node is used more than one time
+        counter = {x.name: 0 for x in steps}
+
+        for step in steps:
+            cwl_step = dict()
+            cwl_step['run'] = step.name
+            cwl_step['in'] = {k: '' for k in step.cwl_template.inputs_keys}
+            cwl_step['out'] = step.cwl_template.outputs_keys
+            step_name = f'{step.name}{counter[step.name]}'
+            self._template['steps'][step_name] = cwl_step
+            counter[step.name] += 1
+
+            # fill worfkflow inputs based on step inputs
+            for input_name, input_opt in step.cwl_template.inputs.items():
+                self._template['inputs'][f'{step_name}_{input_name}'] = input_opt['type']
+
+    def dumps(self, path: str):
+        """ Dumps the cwl workflow template to target file
+
+        :param path: the output complete filename
+        :type path: str
+        """
+
+        class NoAliasDumper(yaml.SafeDumper):
+            def ignore_aliases(self, data):
+                return True
+
+        with open(path, 'w') as f:
+            yaml.dump(self._template, f, Dumper=NoAliasDumper, sort_keys=False)
+        # reads and writes again to add the first line
+        with open(path, 'r') as f:
+            lines = f.readlines()
+        lines.insert(0, '#!/usr/bin/env cwl-runner\n')
+        with open(path, 'w') as f:
+            f.writelines(lines)
diff --git a/requirements.txt b/requirements.txt
index 7fa2d96..0ed6bf8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,4 +7,5 @@ choixe
 imageio
 pydash
 Pillow>=8.0.0
-pygraphviz
\ No newline at end of file
+pygraphviz
+appdirs
\ No newline at end of file
diff --git a/setup.py b/setup.py
index ca07be8..7df6b46 100644
--- a/setup.py
+++ b/setup.py
@@ -32,7 +32,7 @@
     description="data pipeline 101",
     entry_points={
         'console_scripts': [
-            'pipelime=pipelime.cli:main',
+            'pipelime=pipelime.cli.main:pipelime',
         ],
     },
     install_requires=requirements,


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 60e6caf..ced2333 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -51,6 +51,28 @@ def filesystem_datasets(data_folder):
     return {
         'minimnist_underfolder': {
             'folder': Path(data_folder) / 'datasets' / 'underfolder_minimnist',
-            'type': 'Undefolder'
+            'type': 'Undefolder',
+            'schemas': {
+                'simple': {
+                    'filename': Path(data_folder) / 'datasets' / 'underfolder_minimnist_schemas' / 'simple.schema',
+                    'valid': True,
+                    'should_pass': True
+                },
+                'deep': {
+                    'filename': Path(data_folder) / 'datasets' / 'underfolder_minimnist_schemas' / 'deep.schema',
+                    'valid': True,
+                    'should_pass': True
+                },
+                'invalid': {
+                    'filename': Path(data_folder) / 'datasets' / 'underfolder_minimnist_schemas' / 'invalid.schema',
+                    'valid': True,
+                    'should_pass': False
+                },
+                'bad_file': {
+                    'filename': Path(data_folder) / 'datasets' / 'underfolder_minimnist_schemas' / 'bad.schema',
+                    'valid': False,
+                    'should_pass': False
+                }
+            }
         }
     }
diff --git a/tests/pipelime/sequences/test_samples.py b/tests/pipelime/sequences/test_samples.py
index 0c1abd2..ccec1dc 100644
--- a/tests/pipelime/sequences/test_samples.py
+++ b/tests/pipelime/sequences/test_samples.py
@@ -1,18 +1,76 @@
+import numpy as np
+from pipelime.sequences.readers.filesystem import UnderfolderReader
 import rich
-from pipelime.sequences.samples import GroupedSample, PlainSample, SamplesSequence
+from pipelime.sequences.samples import FileSystemSample, FilesystemItem, GroupedSample, MemoryItem, PlainSample, SamplesSequence
+
+
+class TestPlainSamples(object):
+
+    def test_plain_sample(self, plain_samples_generator):
+
+        dataset = plain_samples_generator('d0_', 10)
+        for sample in dataset:
+            assert set(sample.keys()) == set(sample.skeleton.keys())
+            for key in sample.keys():
+                assert isinstance(sample.metaitem(key), MemoryItem)
 
 
 class TestGroupedSamples(object):
 
+    def _empty(*args):
+        pass
+
     def test_groupby_samples(self, plain_samples_generator):
 
         dataset = plain_samples_generator('d0_', 10)
 
-        g = GroupedSample(samples=dataset[::2])
-
-        for sample in dataset:
-            for key in sample.keys():
-                print(key, sample[key])
+        samples = dataset[::2]
+        g = GroupedSample(samples=samples)
+        g_copy = g.copy()
 
         for key in g.keys():
-            rich.print(g[key])
+            self._empty(key, g[key], len(g))
+            assert isinstance(g.metaitem(key), type(samples[0].metaitem(key)))
+
+        g.rename('image', 'NEW_IMAGE')
+        assert 'image' not in g
+
+        for key in list(g.keys()):
+            del g[key]
+
+        assert len(g.keys()) == 0
+        assert len(g_copy.keys()) == 0
+
+        g['my_new_key'] = 112.3
+        assert len(g.keys()) == 1
+
+        assert len(GroupedSample(samples=[])) == 0
+        assert len(GroupedSample(samples=[]).keys()) == 0
+
+
+class TestFilesystemSample(object):
+
+    def test_filesystem_sample(self, filesystem_datasets, tmp_path_factory):
+
+        dataset_folder = filesystem_datasets['minimnist_underfolder']['folder']
+
+        reader = UnderfolderReader(folder=dataset_folder)
+
+        sample = reader[0]
+
+        for key in sample.keys():
+            assert isinstance(sample.metaitem(key), FilesystemItem)
+
+        assert len(sample) > 0
+        assert isinstance(sample['image'], np.ndarray)
+        assert isinstance(sample, FileSystemSample)
+
+        sample.rename('image', '_NEW_IMAGE_')
+        assert 'image' not in sample
+
+        del sample['_NEW_IMAGE_']
+
+    def test_filesystem_sample_nonlazy(self, filesystem_datasets, tmp_path_factory):
+
+        dataset_folder = filesystem_datasets['minimnist_underfolder']['folder']
+        reader = UnderfolderReader(folder=dataset_folder, lazy_samples=False)
diff --git a/tests/pipelime/sequences/test_sequence.py b/tests/pipelime/sequences/test_sequence.py
index 4f3273a..067f895 100644
--- a/tests/pipelime/sequences/test_sequence.py
+++ b/tests/pipelime/sequences/test_sequence.py
@@ -1,7 +1,7 @@
 from pipelime.sequences.samples import SamplesSequence
 
 
-class TestOperationSum(object):
+class TestSequenceNormalized(object):
 
     def test_normalized(self, plain_samples_sequence_generator):
 
diff --git a/tests/pipelime/sequences/test_validation.py b/tests/pipelime/sequences/test_validation.py
new file mode 100644
index 0000000..eaede92
--- /dev/null
+++ b/tests/pipelime/sequences/test_validation.py
@@ -0,0 +1,35 @@
+
+
+from schema import SchemaError
+from pipelime.sequences.validation import SchemaLoader
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+import pytest
+
+
+class TestSampleSchemaValidation(object):
+
+    def test_samples_validation(self, filesystem_datasets, tmp_path_factory):
+
+        dataset_folder = filesystem_datasets['minimnist_underfolder']['folder']
+        schemas = filesystem_datasets['minimnist_underfolder']['schemas']
+
+        reader = UnderfolderReader(folder=dataset_folder)
+
+        for schema_name, schema in schemas.items():
+            filename = schema['filename']
+            valid = schema['valid']
+            should_pass = schema['should_pass']
+
+            if valid:
+                schema = SchemaLoader.load(filename)
+            else:
+                with pytest.raises(ModuleNotFoundError):
+                    SchemaLoader.load(filename)
+                continue
+
+            for sample in reader:
+                if should_pass:
+                    schema.validate(sample)
+                else:
+                    with pytest.raises(SchemaError):
+                        schema.validate(sample)
diff --git a/tests/pipelime/workflow/tools/test_cwl_template.py b/tests/pipelime/workflow/tools/test_cwl_template.py
new file mode 100644
index 0000000..3363ab2
--- /dev/null
+++ b/tests/pipelime/workflow/tools/test_cwl_template.py
@@ -0,0 +1,507 @@
+from pathlib import Path
+from re import template
+from pipelime.cli.workflow.workflow import workflow
+import click
+import yaml
+from pipelime.workflow.cwl import CwlNode, CwlNodesManager, CwlTemplate, CwlWorkflowTemplate
+
+
+class TestClick2Cwl(object):
+
+    def test_cwl_template(self, tmpdir_factory):
+
+        # int
+        opt01 = click.Option(param_decls=['--opt01'], required=True, type=int, help='help opt01')
+        opt01not = click.Option(param_decls=['--opt01not'], required=False, default=10, type=int, help='help opt01not')
+        opt02 = click.Option(param_decls=['--opt02'], required=True, nargs=2, type=int, help='help opt02')
+        opt03 = click.Option(param_decls=['--opt03'], required=True, multiple=True, type=int, help='help opt03')
+        opt04 = click.Option(param_decls=['--opt04'], required=True, nargs=2, multiple=True, type=int, help='help opt04')
+        # float
+        opt11 = click.Option(param_decls=['--opt11'], required=True, type=float, help='help opt11')
+        opt12 = click.Option(param_decls=['--opt12'], required=True, nargs=2, type=float, help='help opt12')
+        opt13 = click.Option(param_decls=['--opt13'], required=True, multiple=True, type=float, help='help opt13')
+        opt14 = click.Option(param_decls=['--opt14'], required=True, nargs=2, multiple=True, type=float, help='help opt14')
+        # str
+        opt21 = click.Option(param_decls=['--opt21'], required=True, type=str, help='help opt21')
+        opt22 = click.Option(param_decls=['--opt22'], required=True, nargs=2, type=str, help='help opt22')
+        opt23 = click.Option(param_decls=['--opt23'], required=True, multiple=True, type=str, help='help opt23')
+        opt24 = click.Option(param_decls=['--opt24'], required=True, nargs=2, multiple=True, type=str, help='help opt24')
+        # bool
+        opt31 = click.Option(param_decls=['--opt31'], required=True, is_flag=True, help='help opt31')
+        opt33 = click.Option(param_decls=['--opt33'], required=True, multiple=True, is_flag=True, help='help opt33')
+        # choice
+        opt41 = click.Option(param_decls=['--opt41'], required=True, type=click.Choice(['a', 'b']), help='help opt41')
+        opt42 = click.Option(param_decls=['--opt42'], required=True, nargs=2, type=click.Choice(['a', 'b']), help='help opt42')
+        opt43 = click.Option(param_decls=['--opt43'], required=True, multiple=True, type=click.Choice(['a', 'b']), help='help opt43')
+        opt44 = click.Option(param_decls=['--opt44'], required=True, nargs=2, multiple=True, type=click.Choice(['a', 'b']), help='help opt44')
+        # tuple
+        opt51 = click.Option(param_decls=['--opt51'], required=True, type=(str, str), help='help opt51')
+        opt52 = click.Option(param_decls=['--opt52'], required=True, nargs=2, type=(str, str), help='help opt52')
+        opt53 = click.Option(param_decls=['--opt53'], required=True, multiple=True, type=(str, str), help='help opt53')
+        opt54 = click.Option(param_decls=['--opt54'], required=True, nargs=2, multiple=True, type=(str, str), help='help opt54')
+
+        params = [opt01, opt01not, opt02, opt03, opt04,
+                  opt11, opt12, opt13, opt14,
+                  opt21, opt22, opt23, opt24,
+                  opt31, opt33,
+                  opt41, opt42, opt43, opt44,
+                  opt51, opt52, opt53, opt54]
+
+        click_help = 'this is the click help'
+        cmd = click.Command('this is the click name', help=click_help, params=params)
+        alias = ['first', 'second', 'third']
+        forwards = ['opt01', 'opt02', 'opt03', 'opt04']
+        cwl_template = CwlTemplate.from_command(cmd, alias=alias, forwards=forwards)
+
+        cwl_keys = ['cwlVersion', 'class', 'doc', 'baseCommand', 'inputs', 'outputs']
+        for key in cwl_keys:
+            assert key in cwl_template.template.keys()
+        assert cwl_template.template['doc'] == click_help
+        assert cwl_template.template['baseCommand'][0] == 'first'
+        assert cwl_template.template['baseCommand'][1] == 'second'
+        assert cwl_template.template['baseCommand'][2] == 'third'
+
+        # int
+        expected_cwl = {
+            'doc': 'help opt01',
+            'type': [
+                'int'
+            ],
+            'inputBinding': {'prefix': '--opt01'}
+        }
+        assert 'opt01' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt01'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt01not',
+            'type': [
+                'null',
+                'int'
+            ],
+            'default': 10,
+            'inputBinding': {'prefix': '--opt01not'}
+        }
+        assert 'opt01not' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt01not'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt02',
+            'type': [{
+                'type': 'array',
+                'items': 'int'
+            }],
+            'inputBinding': {'prefix': '--opt02'}
+        }
+        assert 'opt02' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt02'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt03',
+            'type': [{
+                'type': 'array',
+                'items': 'int',
+                'inputBinding': {'prefix': '--opt03'}
+            }]
+        }
+        assert 'opt03' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt03'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt04',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'int'
+                },
+                'inputBinding': {'prefix': '--opt04'}
+            }]
+        }
+        assert 'opt04' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt04'] == expected_cwl
+
+        # float
+        expected_cwl = {
+            'doc': 'help opt11',
+            'type': [
+                'float'
+            ],
+            'inputBinding': {'prefix': '--opt11'}
+        }
+        assert 'opt11' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt11'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt12',
+            'type': [{
+                'type': 'array',
+                'items': 'float'
+            }],
+            'inputBinding': {'prefix': '--opt12'}
+        }
+        assert 'opt12' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt12'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt13',
+            'type': [{
+                'type': 'array',
+                'items': 'float',
+                'inputBinding': {'prefix': '--opt13'}
+            }]
+        }
+        assert 'opt13' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt13'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt14',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'float'
+                },
+                'inputBinding': {'prefix': '--opt14'}
+            }]
+        }
+        assert 'opt14' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt14'] == expected_cwl
+
+        # string
+        expected_cwl = {
+            'doc': 'help opt21',
+            'type': [
+                'string'
+            ],
+            'inputBinding': {'prefix': '--opt21'}
+        }
+        assert 'opt21' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt21'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt22',
+            'type': [{
+                'type': 'array',
+                'items': 'string'
+            }],
+            'inputBinding': {'prefix': '--opt22'}
+        }
+        assert 'opt22' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt22'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt23',
+            'type': [{
+                'type': 'array',
+                'items': 'string',
+                'inputBinding': {'prefix': '--opt23'}
+            }]
+        }
+        assert 'opt23' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt23'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt24',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'string'
+                },
+                'inputBinding': {'prefix': '--opt24'}
+            }]
+        }
+        assert 'opt24' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt24'] == expected_cwl
+
+        # bool
+        expected_cwl = {
+            'doc': 'help opt31',
+            'type': [
+                'boolean'
+            ],
+            'inputBinding': {'prefix': '--opt31'}
+        }
+        assert 'opt31' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt31'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt33',
+            'type': [{
+                'type': 'array',
+                'items': 'boolean',
+                'inputBinding': {'prefix': '--opt33'}
+            }]
+        }
+        assert 'opt33' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt33'] == expected_cwl
+
+        # choice
+        expected_cwl = {
+            'doc': 'help opt41',
+            'type': [
+                'string'
+            ],
+            'inputBinding': {'prefix': '--opt41'}
+        }
+        assert 'opt41' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt41'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt42',
+            'type': [{
+                'type': 'array',
+                'items': 'string'
+            }],
+            'inputBinding': {'prefix': '--opt42'}
+        }
+        assert 'opt42' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt42'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt43',
+            'type': [{
+                'type': 'array',
+                'items': 'string',
+                'inputBinding': {'prefix': '--opt43'}
+            }]
+        }
+        assert 'opt43' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt43'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt44',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'string'
+                },
+                'inputBinding': {'prefix': '--opt44'}
+            }]
+        }
+        assert 'opt44' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt44'] == expected_cwl
+
+        # tuple
+        expected_cwl = {
+            'doc': 'help opt51',
+            'type': [{
+                'type': 'array',
+                'items': 'string'
+            }],
+            'inputBinding': {'prefix': '--opt51'}
+        }
+        assert 'opt51' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt51'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt52',
+            'type': [{
+                'type': 'array',
+                'items': 'string'
+            }],
+            'inputBinding': {'prefix': '--opt52'}
+        }
+        assert 'opt52' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt52'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt53',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'string'
+                },
+                'inputBinding': {'prefix': '--opt53'}
+            }]
+        }
+        assert 'opt53' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt53'] == expected_cwl
+
+        expected_cwl = {
+            'doc': 'help opt54',
+            'type': [{
+                'type': 'array',
+                'items': {
+                        'type': 'array',
+                        'items': 'string'
+                },
+                'inputBinding': {'prefix': '--opt54'}
+            }]
+        }
+        assert 'opt54' in cwl_template.template['inputs'].keys()
+        assert cwl_template.template['inputs']['opt54'] == expected_cwl
+
+        assert '_opt01' in cwl_template.template['outputs'].keys()
+        assert cwl_template.template['outputs']['_opt01']['type'] == 'int'
+        assert cwl_template.template['outputs']['_opt01']['outputBinding']['outputEval'] == '$(inputs.opt01)'
+
+        assert '_opt02' in cwl_template.template['outputs'].keys()
+        assert cwl_template.template['outputs']['_opt02']['type']['type'] == 'array'
+        assert cwl_template.template['outputs']['_opt02']['type']['items'] == 'int'
+        assert cwl_template.template['outputs']['_opt02']['outputBinding']['outputEval'] == '$(inputs.opt02)'
+
+        assert '_opt03' in cwl_template.template['outputs'].keys()
+        assert cwl_template.template['outputs']['_opt03']['type']['type'] == 'array'
+        assert cwl_template.template['outputs']['_opt03']['type']['items'] == 'int'
+        assert cwl_template.template['outputs']['_opt03']['outputBinding']['outputEval'] == '$(inputs.opt03)'
+
+        assert '_opt04' in cwl_template.template['outputs'].keys()
+        assert cwl_template.template['outputs']['_opt04']['type']['type'] == 'array'
+        assert cwl_template.template['outputs']['_opt04']['type']['items']['type'] == 'array'
+        assert cwl_template.template['outputs']['_opt04']['type']['items']['items'] == 'int'
+        assert cwl_template.template['outputs']['_opt04']['outputBinding']['outputEval'] == '$(inputs.opt04)'
+
+        # cwl_filled2 = Click2Cwl.convert_click_to_cwl(cmd, commands, forwards)
+        # assert cwl_filled == cwl_filled2
+
+        output_cwl_file = Path(tmpdir_factory.mktemp('cwl')) / f'output.cwl'
+        cwl_template.dumps(output_cwl_file)
+        with open(output_cwl_file, 'r') as f:
+            lines = f.readlines()
+            assert lines[0] == '#!/usr/bin/env cwl-runner\n'
+            loaded_cwl = yaml.safe_load(''.join(lines))
+            assert cwl_template.template == loaded_cwl
+
+        click_command = f"""
+import click
+@click.command('this is the click name', help='{click_help}')
+@click.option('--opt0', required=True, type=int, help='help opt0')
+@click.option('--opt1', required=True, type=float, help='help opt1')
+@click.option('--opt2', required=False, type=str, default='default2', help='help opt2')
+@click.option('--opt3', required=False, is_flag=True, help='help opt3')
+@click.option('--opt4', required=False, type=(int, str), help='help opt4')
+@click.option('--opt5', required=False, default='c1', type=click.Choice(['c1', 'c2']), help='help opt5')
+@click.option('--opt6', required=False, type=int, nargs=3, help='help opt6')
+@click.option('--opt7', required=False, multiple=True, type=int, help='help opt7')
+@click.option('--opt8', required=False, multiple=True, type=(int, str), help='help opt7')
+def a_click_command(opt0, opt1, opt2, opt3, opt4, opt5, opt6, opt7, opt8):
+    pass
+        """
+
+        output_click_file = Path(tmpdir_factory.mktemp('click')) / f'output.py'
+        with open(output_click_file, 'w') as f:
+            f.write(click_command)
+        loaded_template = CwlTemplate(output_click_file)
+
+        cwl_keys = ['cwlVersion', 'class', 'doc', 'baseCommand', 'inputs', 'outputs']
+        for key in cwl_keys:
+            assert key in loaded_template.template.keys()
+        assert loaded_template.template['doc'] == click_help
+        assert len(loaded_template.template['inputs'].keys()) == 9
+
+    def test_cwl_workflow_template(self, tmpdir_factory):
+        opt0_1 = click.Option(param_decls=['--opt01'], required=True, type=int, help='help opt01')
+        opt0_2 = click.Option(param_decls=['--opt02'], required=True, type=str, help='help opt02')
+        command0 = click.Command(name='cmd0', params=[opt0_1, opt0_2])
+        cwl_template0 = CwlTemplate.from_command(command0, forwards=['opt01'])
+        cwl_node0 = CwlNode('node0', '/path/to/cwl0', cwl_template0)
+
+        opt1_1 = click.Option(param_decls=['--opt11'], required=True, type=int, help='help opt11')
+        opt1_2 = click.Option(param_decls=['--opt12'], required=True, type=str, help='help opt12')
+        command1 = click.Command(name='cmd1', params=[opt1_1, opt1_2])
+        cwl_template1 = CwlTemplate.from_command(command1, forwards=['opt11'])
+        cwl_node1 = CwlNode('node1', '/path/to/cwl1', cwl_template1)
+
+        cwl_nodes = [cwl_node0, cwl_node1, cwl_node0]
+        cwl_workflow_template = CwlWorkflowTemplate(cwl_nodes)
+
+        cwl_keys = ['cwlVersion', 'class', 'requirements', 'inputs', 'outputs', 'steps']
+        for key in cwl_keys:
+            assert key in cwl_workflow_template.template.keys()
+
+        assert len(cwl_workflow_template.template['steps'].keys()) == 3
+
+        assert 'node00' in cwl_workflow_template.template['steps'].keys()
+        assert cwl_workflow_template.template['steps']['node00']['run'] == 'node0'
+        assert 'opt01' in cwl_workflow_template.template['steps']['node00']['in'].keys()
+        assert 'opt02' in cwl_workflow_template.template['steps']['node00']['in'].keys()
+        assert '_opt01' in cwl_workflow_template.template['steps']['node00']['out']
+
+        assert 'node10' in cwl_workflow_template.template['steps'].keys()
+        assert cwl_workflow_template.template['steps']['node10']['run'] == 'node1'
+        assert 'opt11' in cwl_workflow_template.template['steps']['node10']['in'].keys()
+        assert 'opt12' in cwl_workflow_template.template['steps']['node10']['in'].keys()
+        assert '_opt11' in cwl_workflow_template.template['steps']['node10']['out']
+
+        assert 'node01' in cwl_workflow_template.template['steps'].keys()
+        assert cwl_workflow_template.template['steps']['node01']['run'] == 'node0'
+        assert 'opt01' in cwl_workflow_template.template['steps']['node01']['in'].keys()
+        assert 'opt02' in cwl_workflow_template.template['steps']['node01']['in'].keys()
+        assert '_opt01' in cwl_workflow_template.template['steps']['node01']['out']
+
+        assert 'node00_opt01' in cwl_workflow_template.template['inputs']
+        assert 'int' in cwl_workflow_template.template['inputs']['node00_opt01']
+        assert 'node00_opt02' in cwl_workflow_template.template['inputs']
+        assert 'string' in cwl_workflow_template.template['inputs']['node00_opt02']
+        assert 'node10_opt11' in cwl_workflow_template.template['inputs']
+        assert 'int' in cwl_workflow_template.template['inputs']['node10_opt11']
+        assert 'node10_opt12' in cwl_workflow_template.template['inputs']
+        assert 'string' in cwl_workflow_template.template['inputs']['node10_opt12']
+        assert 'node01_opt01' in cwl_workflow_template.template['inputs']
+        assert 'int' in cwl_workflow_template.template['inputs']['node01_opt01']
+        assert 'node01_opt02' in cwl_workflow_template.template['inputs']
+        assert 'string' in cwl_workflow_template.template['inputs']['node01_opt02']
+
+        output_template = Path(tmpdir_factory.mktemp('workflow')) / f'template.cwl'
+        cwl_workflow_template.dumps(output_template)
+        loaded_template = CwlWorkflowTemplate.from_file(output_template)
+        assert cwl_workflow_template.template == loaded_template.template
+
+    def test_cwl_nodes_manager(self, tmpdir_factory):
+        output_folder = Path(tmpdir_factory.mktemp('cwl'))
+
+        nodes = CwlNodesManager.available_nodes(folder=output_folder)
+        assert len(nodes) == 0
+        click_command = click.Command('command', params=[click.Option(param_decls=['--opt'], type=int)])
+        cwl_template = CwlTemplate.from_command(click_command)
+        CwlNodesManager.create_node('first', cwl_template, folder=output_folder)
+        nodes = CwlNodesManager.available_nodes(folder=output_folder)
+        assert len(nodes) == 1
+        node = CwlNodesManager.get_node_by_name('first', folder=output_folder)
+        assert node is not None
+        assert node.name == 'first'
+        assert CwlNodesManager.get_node_by_name('second', folder=output_folder) is None
+        CwlNodesManager.delete_node('first', folder=output_folder)
+        nodes = CwlNodesManager.available_nodes(folder=output_folder)
+        assert len(nodes) == 0
+
+        click_command1 = f"""
+import click
+@click.command('command1')
+@click.option('--opt0', required=True, type=int, help='help opt0')
+def command1(opt0):
+    pass
+        """
+        output_click_file1 = Path(tmpdir_factory.mktemp('click')) / f'command1.py'
+        with open(output_click_file1, 'w') as f:
+            f.write(click_command1)
+        cwl_template1 = CwlTemplate(script=str(output_click_file1))  #, alias=['one', 'two'], forwards=['opt0'])
+        CwlNodesManager.create_node('first', cwl_template1, folder=output_folder)
+
+        click_command2 = f"""
+import click
+@click.command('command2')
+@click.option('--opt0', required=True, type=int, help='help opt0')
+def command2(opt0):
+    pass
+        """
+        output_click_file2 = Path(tmpdir_factory.mktemp('click')) / f'command2.py'
+        with open(output_click_file2, 'w') as f:
+            f.write(click_command2)
+        cwl_template2 = CwlTemplate(script=str(output_click_file2))
+        CwlNodesManager.create_node('second', cwl_template2, folder=output_folder)
+
+        workflow_template = CwlNodesManager.initialize_workflow(['first', 'first', 'second'], folder=output_folder)
+        assert workflow_template.template['steps']['first0']['run'] == 'first'
+        assert workflow_template.template['steps']['first1']['run'] == 'first'
+        assert workflow_template.template['steps']['second0']['run'] == 'second'
+
+        first_node_path = CwlNodesManager.get_node_by_name('first', folder=output_folder).cwl_path
+        second_node_path = CwlNodesManager.get_node_by_name('second', folder=output_folder).cwl_path
+        filled_workflow_template = CwlNodesManager.fill_workflow(workflow_template, folder=output_folder)
+        assert filled_workflow_template.template['steps']['first0']['run'] == first_node_path
+        assert filled_workflow_template.template['steps']['first1']['run'] == first_node_path
+        assert filled_workflow_template.template['steps']['second0']['run'] == second_node_path
diff --git a/tests/sample_data/datasets/underfolder_minimnist_schemas/bad.schema b/tests/sample_data/datasets/underfolder_minimnist_schemas/bad.schema
new file mode 100644
index 0000000..1c4704b
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist_schemas/bad.schema
@@ -0,0 +1,1 @@
+print("😈")
\ No newline at end of file
diff --git a/tests/sample_data/datasets/underfolder_minimnist_schemas/deep.schema b/tests/sample_data/datasets/underfolder_minimnist_schemas/deep.schema
new file mode 100644
index 0000000..bfe456c
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist_schemas/deep.schema
@@ -0,0 +1,12 @@
+import numpy as np
+from schema import And, Optional
+
+deep = True
+schema = {
+    'cfg': object,
+    'numbers': object,
+    'image': np.ndarray,
+    'image_mask': And(np.ndarray, lambda x: len(x.shape) >= 2),
+    'image_maskinv': And(np.ndarray, lambda x: x.shape[0] == x.shape[1]),
+    Optional(str): object
+}
diff --git a/tests/sample_data/datasets/underfolder_minimnist_schemas/invalid.schema b/tests/sample_data/datasets/underfolder_minimnist_schemas/invalid.schema
new file mode 100644
index 0000000..a5aa0d6
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist_schemas/invalid.schema
@@ -0,0 +1,7 @@
+from schema import Optional
+
+
+deep = False
+schema = {
+    'image': object
+}
diff --git a/tests/sample_data/datasets/underfolder_minimnist_schemas/simple.schema b/tests/sample_data/datasets/underfolder_minimnist_schemas/simple.schema
new file mode 100644
index 0000000..23cfbdc
--- /dev/null
+++ b/tests/sample_data/datasets/underfolder_minimnist_schemas/simple.schema
@@ -0,0 +1,9 @@
+from schema import Optional
+
+deep = False
+schema = {
+    'cfg': object,
+    'numbers': object,
+    'image': object,
+    Optional(str): object
+}