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

Issue:
Feature/operations missing docs cu k57au8
## Added
- Sphinx requirements for RTD theme
- Docstrings to many `SequenceOperation` subclasses.
- Generated RTD docs.

## Changed
- All classes inheriting from `Bean` now inherit from choixe `Spook`. This enables seamless factory registration without having to manually register them or applying a decorator that deletes their docstrings.  
- `SampleStage` is now a `Spook` too. All stages must now be serializable.
- `OperationStage` is now serializable.
- Black formatting for all python files.

## Fixed
- FSToolkit error on imageio.imwrite. Constrained imageio version to be <= 2.10.
- Missing tqdm requirement.
- Warning on hd5 file opening in SWMR (Single Writer Multiple Reader) mode. 

## Removed
- `Bean` and `BeanFactory` classes. The same functionalities are provided by choixe `Spook`.
- Progress bars from `UnderfolderReader`.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 43091aa..aecd8ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,9 @@
 __pycache__/
 *.py[cod]
 *$py.class
-
+.DS_Store
+pip-wheel-metadata
+ 
 # C extensions
 *.so
 
diff --git a/docs/conf.py b/docs/conf.py
index 41206d4..bf36319 100755
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -19,7 +19,8 @@
 #
 import os
 import sys
-sys.path.insert(0, os.path.abspath('..'))
+
+sys.path.insert(0, os.path.abspath(".."))
 
 import pipelime
 
@@ -31,22 +32,22 @@
 
 # Add any Sphinx extension module names here, as strings. They can be
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
-extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode']
+extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"]
 
 # Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
 
 # The suffix(es) of source filenames.
 # You can specify multiple suffix as a list of string:
 #
 # source_suffix = ['.rst', '.md']
-source_suffix = '.rst'
+source_suffix = ".rst"
 
 # The master toctree document.
-master_doc = 'index'
+master_doc = "index"
 
 # General information about the project.
-project = 'pipelime'
+project = "pipelime"
 copyright = "2021, daniele de gregorio"
 author = "daniele de gregorio"
 
@@ -69,10 +70,10 @@
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
 # This patterns also effect to html_static_path and html_extra_path
-exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
 
 # The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
+pygments_style = "sphinx"
 
 # If true, `todo` and `todoList` produce output, else they produce nothing.
 todo_include_todos = False
@@ -83,7 +84,7 @@
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 #
-html_theme = 'alabaster'
+html_theme = "sphinx_rtd_theme"
 
 # Theme options are theme-specific and customize the look and feel of a
 # theme further.  For a list of options available for each theme, see the
@@ -94,13 +95,13 @@
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
 
 
 # -- Options for HTMLHelp output ---------------------------------------
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'pipelimedoc'
+htmlhelp_basename = "pipelimedoc"
 
 
 # -- Options for LaTeX output ------------------------------------------
@@ -109,15 +110,12 @@
     # The paper size ('letterpaper' or 'a4paper').
     #
     # 'papersize': 'letterpaper',
-
     # The font size ('10pt', '11pt' or '12pt').
     #
     # 'pointsize': '10pt',
-
     # Additional stuff for the LaTeX preamble.
     #
     # 'preamble': '',
-
     # Latex figure (float) alignment
     #
     # 'figure_align': 'htbp',
@@ -127,9 +125,13 @@
 # (source start file, target name, title, author, documentclass
 # [howto, manual, or own class]).
 latex_documents = [
-    (master_doc, 'pipelime.tex',
-     'pipelime Documentation',
-     'daniele de gregorio', 'manual'),
+    (
+        master_doc,
+        "pipelime.tex",
+        "pipelime Documentation",
+        "daniele de gregorio",
+        "manual",
+    ),
 ]
 
 
@@ -137,11 +139,7 @@
 
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
-man_pages = [
-    (master_doc, 'pipelime',
-     'pipelime Documentation',
-     [author], 1)
-]
+man_pages = [(master_doc, "pipelime", "pipelime Documentation", [author], 1)]
 
 
 # -- Options for Texinfo output ----------------------------------------
@@ -150,13 +148,13 @@
 # (source start file, target name, title, author,
 #  dir menu entry, description, category)
 texinfo_documents = [
-    (master_doc, 'pipelime',
-     'pipelime Documentation',
-     author,
-     'pipelime',
-     'One line description of project.',
-     'Miscellaneous'),
+    (
+        master_doc,
+        "pipelime",
+        "pipelime Documentation",
+        author,
+        "pipelime",
+        "One line description of project.",
+        "Miscellaneous",
+    ),
 ]
-
-
-
diff --git a/docs/contributing.rst b/docs/contributing.rst
deleted file mode 100644
index e582053..0000000
--- a/docs/contributing.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-.. include:: ../CONTRIBUTING.rst
diff --git a/docs/history.rst b/docs/history.rst
deleted file mode 100644
index 2506499..0000000
--- a/docs/history.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-.. include:: ../HISTORY.rst
diff --git a/docs/index.rst b/docs/index.rst
index c7b8040..399661e 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -9,8 +9,6 @@ Welcome to pipelime's documentation!
    installation
    usage
    modules
-   contributing
-   history
 
 Indices and tables
 ==================
diff --git a/docs/modules.rst b/docs/modules.rst
new file mode 100644
index 0000000..beb144e
--- /dev/null
+++ b/docs/modules.rst
@@ -0,0 +1,7 @@
+pipelime
+========
+
+.. toctree::
+   :maxdepth: 4
+
+   pipelime
diff --git a/docs/pipelime.augmentations.rst b/docs/pipelime.augmentations.rst
new file mode 100644
index 0000000..7bc03a6
--- /dev/null
+++ b/docs/pipelime.augmentations.rst
@@ -0,0 +1,30 @@
+pipelime.augmentations package
+==============================
+
+Submodules
+----------
+
+pipelime.augmentations.factory module
+-------------------------------------
+
+.. automodule:: pipelime.augmentations.factory
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.augmentations.transforms module
+----------------------------------------
+
+.. automodule:: pipelime.augmentations.transforms
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.augmentations
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.cli.conversions.rst b/docs/pipelime.cli.conversions.rst
new file mode 100644
index 0000000..d1481c5
--- /dev/null
+++ b/docs/pipelime.cli.conversions.rst
@@ -0,0 +1,38 @@
+pipelime.cli.conversions package
+================================
+
+Submodules
+----------
+
+pipelime.cli.conversions.conversions module
+-------------------------------------------
+
+.. automodule:: pipelime.cli.conversions.conversions
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.conversions.h5\_to\_underfolder module
+---------------------------------------------------
+
+.. automodule:: pipelime.cli.conversions.h5_to_underfolder
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.conversions.underfolder\_to\_h5 module
+---------------------------------------------------
+
+.. automodule:: pipelime.cli.conversions.underfolder_to_h5
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.cli.conversions
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.cli.datasets.rst b/docs/pipelime.cli.datasets.rst
new file mode 100644
index 0000000..ba2099d
--- /dev/null
+++ b/docs/pipelime.cli.datasets.rst
@@ -0,0 +1,30 @@
+pipelime.cli.datasets package
+=============================
+
+Submodules
+----------
+
+pipelime.cli.datasets.datasets module
+-------------------------------------
+
+.. automodule:: pipelime.cli.datasets.datasets
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.datasets.generate\_toy\_dataset module
+---------------------------------------------------
+
+.. automodule:: pipelime.cli.datasets.generate_toy_dataset
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.cli.datasets
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.cli.h5.rst b/docs/pipelime.cli.h5.rst
new file mode 100644
index 0000000..3622142
--- /dev/null
+++ b/docs/pipelime.cli.h5.rst
@@ -0,0 +1,30 @@
+pipelime.cli.h5 package
+=======================
+
+Submodules
+----------
+
+pipelime.cli.h5.h5 module
+-------------------------
+
+.. automodule:: pipelime.cli.h5.h5
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.h5.operations module
+---------------------------------
+
+.. automodule:: pipelime.cli.h5.operations
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.cli.h5
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.cli.rst b/docs/pipelime.cli.rst
new file mode 100644
index 0000000..3f2e825
--- /dev/null
+++ b/docs/pipelime.cli.rst
@@ -0,0 +1,41 @@
+pipelime.cli package
+====================
+
+Subpackages
+-----------
+
+.. toctree::
+
+    pipelime.cli.conversions
+    pipelime.cli.datasets
+    pipelime.cli.h5
+    pipelime.cli.underfolder
+    pipelime.cli.workflow
+
+Submodules
+----------
+
+pipelime.cli.main module
+------------------------
+
+.. automodule:: pipelime.cli.main
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.summary module
+---------------------------
+
+.. automodule:: pipelime.cli.summary
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.cli
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.cli.underfolder.rst b/docs/pipelime.cli.underfolder.rst
new file mode 100644
index 0000000..9202b4f
--- /dev/null
+++ b/docs/pipelime.cli.underfolder.rst
@@ -0,0 +1,30 @@
+pipelime.cli.underfolder package
+================================
+
+Submodules
+----------
+
+pipelime.cli.underfolder.operations module
+------------------------------------------
+
+.. automodule:: pipelime.cli.underfolder.operations
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.underfolder.underfolder module
+-------------------------------------------
+
+.. automodule:: pipelime.cli.underfolder.underfolder
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.cli.underfolder
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.cli.workflow.rst b/docs/pipelime.cli.workflow.rst
new file mode 100644
index 0000000..8acbe29
--- /dev/null
+++ b/docs/pipelime.cli.workflow.rst
@@ -0,0 +1,78 @@
+pipelime.cli.workflow package
+=============================
+
+Submodules
+----------
+
+pipelime.cli.workflow.click2cwl module
+--------------------------------------
+
+.. automodule:: pipelime.cli.workflow.click2cwl
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.workflow.delete\_nodes module
+------------------------------------------
+
+.. automodule:: pipelime.cli.workflow.delete_nodes
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.workflow.init\_workflow module
+-------------------------------------------
+
+.. automodule:: pipelime.cli.workflow.init_workflow
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.workflow.list\_nodes module
+----------------------------------------
+
+.. automodule:: pipelime.cli.workflow.list_nodes
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.workflow.run\_workflow module
+------------------------------------------
+
+.. automodule:: pipelime.cli.workflow.run_workflow
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.workflow.show\_graph module
+----------------------------------------
+
+.. automodule:: pipelime.cli.workflow.show_graph
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.workflow.update\_nodes module
+------------------------------------------
+
+.. automodule:: pipelime.cli.workflow.update_nodes
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.cli.workflow.workflow module
+-------------------------------------
+
+.. automodule:: pipelime.cli.workflow.workflow
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.cli.workflow
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.filesystem.rst b/docs/pipelime.filesystem.rst
new file mode 100644
index 0000000..fd7dd47
--- /dev/null
+++ b/docs/pipelime.filesystem.rst
@@ -0,0 +1,22 @@
+pipelime.filesystem package
+===========================
+
+Submodules
+----------
+
+pipelime.filesystem.toolkit module
+----------------------------------
+
+.. automodule:: pipelime.filesystem.toolkit
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.filesystem
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.h5.rst b/docs/pipelime.h5.rst
new file mode 100644
index 0000000..d0fe76e
--- /dev/null
+++ b/docs/pipelime.h5.rst
@@ -0,0 +1,22 @@
+pipelime.h5 package
+===================
+
+Submodules
+----------
+
+pipelime.h5.toolkit module
+--------------------------
+
+.. automodule:: pipelime.h5.toolkit
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.h5
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.rst b/docs/pipelime.rst
new file mode 100644
index 0000000..1b194da
--- /dev/null
+++ b/docs/pipelime.rst
@@ -0,0 +1,42 @@
+pipelime package
+================
+
+Subpackages
+-----------
+
+.. toctree::
+   :maxdepth: 4
+
+   pipelime.augmentations
+   pipelime.cli
+   pipelime.filesystem
+   pipelime.h5
+   pipelime.sequences
+   pipelime.tools
+
+Submodules
+----------
+
+pipelime.cli module
+-------------------
+
+.. automodule:: pipelime.cli
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pipelime.pipelime module
+------------------------
+
+.. automodule:: pipelime.pipelime
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: pipelime
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/pipelime.sequences.readers.rst b/docs/pipelime.sequences.readers.rst
new file mode 100644
index 0000000..0be262e
--- /dev/null
+++ b/docs/pipelime.sequences.readers.rst
@@ -0,0 +1,46 @@
+pipelime.sequences.readers package
+==================================
+
+Submodules
+----------
+
+pipelime.sequences.readers.base module
+--------------------------------------
+
+.. automodule:: pipelime.sequences.readers.base
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.sequences.readers.filesystem module
+--------------------------------------------
+
+.. automodule:: pipelime.sequences.readers.filesystem
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.sequences.readers.h5 module
+------------------------------------
+
+.. automodule:: pipelime.sequences.readers.h5
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.sequences.readers.summary module
+-----------------------------------------
+
+.. automodule:: pipelime.sequences.readers.summary
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.sequences.readers
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.sequences.rst b/docs/pipelime.sequences.rst
new file mode 100644
index 0000000..9dd84f5
--- /dev/null
+++ b/docs/pipelime.sequences.rst
@@ -0,0 +1,62 @@
+pipelime.sequences package
+==========================
+
+Subpackages
+-----------
+
+.. toctree::
+
+    pipelime.sequences.readers
+    pipelime.sequences.writers
+
+Submodules
+----------
+
+pipelime.sequences.operations module
+------------------------------------
+
+.. automodule:: pipelime.sequences.operations
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.sequences.pipes module
+-------------------------------
+
+.. automodule:: pipelime.sequences.pipes
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.sequences.samples module
+---------------------------------
+
+.. automodule:: pipelime.sequences.samples
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.sequences.stages module
+--------------------------------
+
+.. automodule:: pipelime.sequences.stages
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.sequences.validation module
+------------------------------------
+
+.. automodule:: pipelime.sequences.validation
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.sequences
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.sequences.writers.rst b/docs/pipelime.sequences.writers.rst
new file mode 100644
index 0000000..e37eae3
--- /dev/null
+++ b/docs/pipelime.sequences.writers.rst
@@ -0,0 +1,38 @@
+pipelime.sequences.writers package
+==================================
+
+Submodules
+----------
+
+pipelime.sequences.writers.base module
+--------------------------------------
+
+.. automodule:: pipelime.sequences.writers.base
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.sequences.writers.filesystem module
+--------------------------------------------
+
+.. automodule:: pipelime.sequences.writers.filesystem
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.sequences.writers.h5 module
+------------------------------------
+
+.. automodule:: pipelime.sequences.writers.h5
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.sequences.writers
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/pipelime.tools.rst b/docs/pipelime.tools.rst
new file mode 100644
index 0000000..a53b4c9
--- /dev/null
+++ b/docs/pipelime.tools.rst
@@ -0,0 +1,38 @@
+pipelime.tools package
+======================
+
+Submodules
+----------
+
+pipelime.tools.bytes module
+---------------------------
+
+.. automodule:: pipelime.tools.bytes
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.tools.idgenerators module
+----------------------------------
+
+.. automodule:: pipelime.tools.idgenerators
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+pipelime.tools.toydataset module
+--------------------------------
+
+.. automodule:: pipelime.tools.toydataset
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+
+Module contents
+---------------
+
+.. automodule:: pipelime.tools
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/readme.rst b/docs/readme.rst
index 72a3355..a2df3ac 100644
--- a/docs/readme.rst
+++ b/docs/readme.rst
@@ -1,1 +1,157 @@
-.. include:: ../README.rst
+===========
+🍋 Pipelime
+===========
+
+Data Pipeline 101
+
+.. image:: https://img.shields.io/pypi/v/pipelime.svg
+        :target: https://pypi.python.org/pypi/pipelime
+
+.. image:: https://img.shields.io/travis/eyecan-ai/pipelime.svg
+        :target: https://travis-ci.com/eyecan-ai/pipelime
+
+.. image:: https://readthedocs.org/projects/pipelime/badge/?version=latest
+        :target: https://pipelime.readthedocs.io/en/latest/?badge=latest
+        :alt: Documentation Status
+
+.. image:: https://pyup.io/repos/github/eyecan-ai/pipelime/shield.svg
+     :target: https://pyup.io/repos/github/eyecan-ai/pipelime/
+     :alt: Updates
+
+Installation
+============
+
+.. code-block:: bash
+
+        pip install pipelime
+
+Basic Usage
+===========
+
+Underfolder Format
+------------------
+
+The **Underfolder** format is one of the pipelime dataset formats: i.e. a flexible way to 
+(model and) store a generic dataset through **filesystem**. 
+
+.. image:: /images/underfolder.png
+  :width: 400
+  :align: center
+  :alt: underfolder structure
+
+An Underfolder **dataset** is a collection of samples. A **sample** is a collection of items. 
+An **item** is a unitary block of data, it can be a generic tensor (e.g. a multi-channel image, 
+or a plain matrix), a dictionary. or more.
+
+Underfolder datasets must contain a subfolder named ``data`` that will actually contain the 
+samples and items. Optionally you can store the items in the root folder directly, they 
+will act as “global” items injected into each sample.
+
+.. image:: /images/naming.png
+  :width: 400
+  :align: center
+  :alt: naming convention
+
+Items are named using the following naming convention:
+
+.. code-block:: bash
+
+        $ID_$ITEM.$EXT
+
+Where:
+
+* ``$ID`` is the sample identifier, must be a unique string for each sample. 
+* ``ITEM`` is the item name.
+* ``EXT`` is the item extension. Currently supported extensions are:
+  
+  * The most common image formats like PNG, JPEG, BMP, and many others...
+  * YAML and JSON for dictionary-like objects.
+  * TXT for numpy 2D matrix notation.
+  * NPY and NPZ for numpy arrays.
+  * PKL for generic pickable python objects.
+
+Root files follow the same convention but they lack the sample identifier part:
+
+.. code-block:: bash
+
+        $ITEM.$EXT
+
+Reading an Underfolder Dataset
+------------------------------
+
+Pipelime provides an intuitive interface to read, manipulate and write Underfolder Datasets.
+You don't have to memorize complex signatures, instantiate weird object iterators, or write
+tens of lines of boilerplate code. It all boils down to a **reader**, a **writer** and objects that 
+behave like built-in python types such as **lists** and **dictionaries**.
+
+.. code-block:: python
+
+        from pipelime.sequences.readers.filesystem import UnderfolderReader
+
+        # Read an underfolder dataset with a single line of code
+        dataset = UnderfolderReader('tests/sample_data/datasets/underfolder_minimnist')
+
+        # A dataset behaves like a Sequence
+        len(dataset) # The number of samples (20)
+        sample = dataset[4] # Get a Sample from the dataset
+
+        # A Sample is a MutableMapping
+        len(sample) # The number of items (10)
+        set(sample.keys()) # The set of all the item names {'cfg', 'image', 'image_mask', ...}
+        item = sample['image'] # Get an item from the sample
+
+        # An item can be any python object, depending on which extension is used to store it.
+        type(item) # numpy.ndarray
+        item.shape # (28, 28, 3)
+
+Writing an Underfolder Dataset
+------------------------------
+
+You can **write** a dataset by simply creating and running a writer object.
+
+.. code-block:: python
+
+        from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+        # Create the writer object from a destination path
+        writer = UnderfolderWriter('/tmp/my_output_dataset')
+        # Write the dataset to file system
+        writer(dataset)
+
+By default, ``UnderfolderWriter`` saves every sample with the extension it was originally read with.
+If for any reason it is unable to retrieve the original extension, it will use **pickle** to
+serialize the object.
+
+If you don't want to use pickle, you can choose a **custom extension** for each item name.
+You can also choose which items are going to be saved as **root files** (if the contained data 
+is the same for all samples).
+
+.. code-block:: python
+
+        from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+        # These items are going to be saved as root files
+        root_files = ['cfg', 'numbers', 'pose']
+
+        # Associate a custom extension to each item name
+        extensions = {
+                'image': 'jpg',
+                'image_mask': 'png',
+                'image_maskinv': 'png',
+                'label': 'txt',
+                'metadata': 'json',
+                'metadatay': 'yml',
+                'points': 'txt',
+                'numbers': 'txt',
+                'pose': 'txt',
+                'cfg': 'yml'
+        }
+
+        # Create a customized writer object
+        writer = UnderfolderWriter(
+                '/tmp/my_output_dataset',
+                root_files_keys=root_files,
+                extensions_map=extensions,
+        )
+        # Write the dataset to file system
+        writer(dataset)
\ No newline at end of file
diff --git a/examples/garbage/click_experiment.py b/examples/garbage/click_experiment.py
new file mode 100644
index 0000000..2aa74c8
--- /dev/null
+++ b/examples/garbage/click_experiment.py
@@ -0,0 +1,30 @@
+from os import name
+from typing import List
+import click
+
+import click
+
+
+class CLICommandInvoker(click.Command):
+
+    # def parse_args(self, ctx: click.Context, args: List[str]) -> List[str]:
+    #     print("PARSING", args)
+    #     return super().parse_args(ctx, args)
+
+    def invoke(self, ctx: click.Context):
+        command = ctx.command.name
+        print("Invoking ", ctx)
+
+        ctx.command = click.option('-p', '--pino', required=True, help='HELLOE')(ctx.command)
+        ret = super(CLICommandInvoker, self).invoke(ctx)
+        return ret
+
+
+@click.command('ciao', help='This is command', cls=CLICommandInvoker)
+@click.option('-o', '--mouse')
+def ciao(**kwargs):
+    print("Done", kwargs)
+
+
+if __name__ == '__main__':
+    ciao()
diff --git a/examples/garbage/pipeline_language/example.py b/examples/garbage/pipeline_language/example.py
new file mode 100644
index 0000000..de5e51f
--- /dev/null
+++ b/examples/garbage/pipeline_language/example.py
@@ -0,0 +1,22 @@
+class Port:
+    pass
+
+    def __le__(self, x):
+        pass
+
+
+class Node:
+
+    def __init__(self, **kwargs) -> None:
+        self.input = [Port()] * 100
+        self.output = [Port()] * 100
+
+    def __ge__(self, x):
+        print(self, '->', x)
+
+
+alfa = Node(folder='/tmp')
+beta = Node(folder='/tmp')
+sum = Node()
+
+alfa > sum.input[0]
diff --git a/examples/garbage/pipeline_language/example.txt b/examples/garbage/pipeline_language/example.txt
new file mode 100644
index 0000000..6acb8c9
--- /dev/null
+++ b/examples/garbage/pipeline_language/example.txt
@@ -0,0 +1,25 @@
+flow {
+  
+  a = Reader("/tmp/");
+  b = Reader($2);
+
+  out_train = Writer("/tmp/");
+  out_test = Writer("/tmp/");
+  out_val = Writer("/tmp/");
+
+  sum = OperationSum();
+  split = OperationSplit(a=0.2,b=0.8,);
+  split_by = OperationSplitByQuery(`a`);
+
+  a -> sum;
+  b -> sum;
+
+  sum -> split_by;
+
+  split_by:out[0] -> out_train;
+  split_by:out[1] -> split;
+
+  split:out[a] -> out_val;
+  split:out[b] -> out_test;
+
+}
\ No newline at end of file
diff --git a/examples/garbage/pipeline_language/languange_example.py b/examples/garbage/pipeline_language/languange_example.py
new file mode 100644
index 0000000..623f666
--- /dev/null
+++ b/examples/garbage/pipeline_language/languange_example.py
@@ -0,0 +1,71 @@
+from textx import metamodel_from_file
+
+
+class Reader:
+
+    def __init__(self, parent, folder):
+        self.parent = parent
+        self.folder = folder
+        print("NEW READER", self.folder)
+
+
+class Link:
+
+    def __init__(
+        self,
+        parent,
+        start,
+        end
+    ) -> None:
+        pass
+
+
+class Node:
+
+    def __init__(self, parent, name, port) -> None:
+        self.name = name
+        self.port = port
+
+
+class Port:
+
+    def __init__(self, parent, name, arity) -> None:
+        self.name = name
+        self.arity = arity
+
+
+class Arity:
+
+    def __init__(self, parent, idx, name):
+        self.parent = parent
+        self.idx = idx
+        self.name = name
+        #print("ARITY CREATED", parent, idx, name)
+
+
+class Declaration:
+
+    def __init__(self, parent, name, operation) -> None:
+        self.name = name
+        self.operation = operation
+
+
+class OperationSplitByQuery:
+
+    def __init__(self, parent, name, query) -> None:
+        self.name = name
+        self.query = query
+
+
+hello_meta = metamodel_from_file(
+    'model.tx',
+    classes=[Link, Node, Arity, Declaration, OperationSplitByQuery, Reader]
+)
+
+example_hello_model = hello_meta.model_from_file('example.txt')
+
+for c in example_hello_model.rows:
+    print(c, type(c))
+    if isinstance(c, Link):
+        link = c
+        print("LINK", link.start, link.end)
diff --git a/examples/garbage/pipeline_language/model.tx b/examples/garbage/pipeline_language/model.tx
new file mode 100644
index 0000000..e8f23c6
--- /dev/null
+++ b/examples/garbage/pipeline_language/model.tx
@@ -0,0 +1,92 @@
+
+Global:
+  'flow' '{'
+    rows *= GenericRow
+  '}'
+;
+
+GenericRow:
+  Link | Declaration | Comment
+;
+
+Comment:
+ '#' Text '\n'
+;
+
+Declaration:
+  name = NSTRING '=' operation = Operation ';'
+;
+
+OperationName:
+  'OperationSum' | 
+  'OperationSubsample' | 
+  'OperationSplit'  | 
+  'Reader' | 
+  'Writer'
+;
+
+OperationSum: name ='OperationSum' '(' arguments *= Argument ')';
+OperationSplit: name ='OperationSplit' '(' arguments *= Argument')';
+OperationSplitByQuery: name ='OperationSplitByQuery' '(' query = ASTRING ')';
+Reader: 'Reader' '(' folder = FOLDER ')';
+Writer: 'Writer' '(' folder = FOLDER ')';
+
+
+FOLDER: QSTRING | Placeholder;
+
+Operation:
+  OperationSum | OperationSplit | OperationSplitByQuery | Reader | Writer
+;
+
+Link:
+  start=Node
+  '->'
+  end=Node
+  ';'
+;
+
+Node:
+  name = NSTRING
+  port=Port?
+  
+;
+
+Port:
+  ':' name= NSTRING
+  arity=Arity?
+;
+
+Arity:
+  '['  idx=/[0-9]+/? name=NSTRING? ']'
+;
+
+
+
+ASTRING:
+  /([`])(?:(?=(\\?))\2.)*?\1/ 
+;
+
+QSTRING:
+  /(["'])(?:(?=(\\?))\2.)*?\1/ 
+;
+
+NSTRING:
+  /[A-Za-z0-9_.]+/
+;
+
+VariableValue:
+  NSTRING | Placeholder 
+;
+
+Placeholder:
+  name = /\$[A-Za-z0-9_]+/
+;
+
+
+Argument:
+  name = NSTRING '=' value = VariableValue ','
+;
+
+Text:
+  content = /[^\n]*/
+;
diff --git a/examples/garbage/pipes.py b/examples/garbage/pipes.py
index e507ade..7b0d2a4 100644
--- a/examples/garbage/pipes.py
+++ b/examples/garbage/pipes.py
@@ -1,149 +1,121 @@
-
-
-from pipelime.factories import BeanFactory
+from choixe.spooks import Spook
 from pipelime.sequences.pipes import NodeGraph
 
 graph = {
-    '__type__': 'NodeGraph',
-    'options': {
-        'nodes': [
+    "__spook__": "NodeGraph",
+    "options": {
+        "nodes": [
             {
-                '__type__': 'ReaderNode',
-                'options': {
-                    'output_data': 'A',
-                    'reader': {
-                        '__type__': 'UnderfolderReader',
-                        'options': {
-                            'folder': '/Users/daniele/Downloads/lego_dataset/lego_00',
-                        }
-                    }
-                }
+                "__spook__": "ReaderNode",
+                "options": {
+                    "output_data": "A",
+                    "reader": {
+                        "__spook__": "UnderfolderReader",
+                        "options": {
+                            "folder": "/Users/daniele/Downloads/lego_dataset/lego_00",
+                        },
+                    },
+                },
             },
             {
-                '__type__': 'ReaderNode',
-                'options': {
-                    'output_data': 'B',
-                    'reader': {
-                        '__type__': 'UnderfolderReader',
-                        'options': {
-                            'folder': '/Users/daniele/Downloads/lego_dataset/lego_01'
-                        }
-                    }
-                }
+                "__spook__": "ReaderNode",
+                "options": {
+                    "output_data": "B",
+                    "reader": {
+                        "__spook__": "UnderfolderReader",
+                        "options": {
+                            "folder": "/Users/daniele/Downloads/lego_dataset/lego_01"
+                        },
+                    },
+                },
             },
             {
-                '__type__': 'OperationNode',
-                'options': {
-                    'input_data': 'A',
-                    'output_data': 'A1',
-                    'operation': {
-                        '__type__': 'OperationFilterByQuery',
-                        'options': {
-                            'query': '`metadata.tag` == "black"'
-                        }
-                    }
-                }
+                "__spook__": "OperationNode",
+                "options": {
+                    "input_data": "A",
+                    "output_data": "A1",
+                    "operation": {
+                        "__spook__": "OperationFilterByQuery",
+                        "options": {"query": '`metadata.tag` == "black"'},
+                    },
+                },
             },
-
             {
-                '__type__': 'OperationNode',
-                'options': {
-                    'input_data': ['A1', 'B'],
-                    'output_data': 'C',
-                    'operation': {
-                        '__type__': 'OperationSum',
-                        'options': {}
-                    }
-                }
+                "__spook__": "OperationNode",
+                "options": {
+                    "input_data": ["A1", "B"],
+                    "output_data": "C",
+                    "operation": {"__spook__": "OperationSum", "options": {}},
+                },
             },
             {
-                '__type__': 'OperationNode',
-                'options': {
-                    'input_data': 'C',
-                    'output_data': 'D',
-                    'operation': {
-                        '__type__': 'OperationFilterByQuery',
-                        'options': {
-                            'query': '`metadata.tag` == "black"'
-                        }
-                    }
-                }
+                "__spook__": "OperationNode",
+                "options": {
+                    "input_data": "C",
+                    "output_data": "D",
+                    "operation": {
+                        "__spook__": "OperationFilterByQuery",
+                        "options": {"query": '`metadata.tag` == "black"'},
+                    },
+                },
             },
             {
-                '__type__': 'OperationNode',
-                'options': {
-                    'input_data': 'D',
-                    'output_data': 'D1',
-                    'operation': {
-                        '__type__': 'OperationSubsample',
-                        'options': {
-                            'factor': 0.1
-                        }
-                    }
-                }
+                "__spook__": "OperationNode",
+                "options": {
+                    "input_data": "D",
+                    "output_data": "D1",
+                    "operation": {
+                        "__spook__": "OperationSubsample",
+                        "options": {"factor": 0.1},
+                    },
+                },
             },
             {
-                '__type__': 'OperationNode',
-                'options': {
-                    'input_data': 'D1',
-                    'output_data': {
-                        'a': 'train',
-                        'b': 'test',
-                        'c': 'val'
+                "__spook__": "OperationNode",
+                "options": {
+                    "input_data": "D1",
+                    "output_data": {"a": "train", "b": "test", "c": "val"},
+                    "operation": {
+                        "__spook__": "OperationSplits",
+                        "options": {"split_map": {"a": 0.45, "b": 0.45, "c": 0.1}},
                     },
-                    'operation': {
-                        '__type__': 'OperationSplits',
-                        'options': {
-                            'split_map': {
-                                'a': 0.45,
-                                'b': 0.45,
-                                'c': 0.1
-                            }
-                        }
-                    }
-                }
+                },
             },
             {
-                '__type__': 'WriterNode',
-                'options': {
-                    'input_data': 'train',
-                    'writer': {
-                        '__type__': 'UnderfolderWriter',
-                        'options': {
-                            'folder': '/tmp/zizzino/train'
-                        }
-                    }
-                }
+                "__spook__": "WriterNode",
+                "options": {
+                    "input_data": "train",
+                    "writer": {
+                        "__spook__": "UnderfolderWriter",
+                        "options": {"folder": "/tmp/zizzino/train"},
+                    },
+                },
             },
             {
-                '__type__': 'WriterNode',
-                'options': {
-                    'input_data': 'test',
-                    'writer': {
-                        '__type__': 'UnderfolderWriter',
-                        'options': {
-                            'folder': '/tmp/zizzino/test'
-                        }
-                    }
-                }
+                "__spook__": "WriterNode",
+                "options": {
+                    "input_data": "test",
+                    "writer": {
+                        "__spook__": "UnderfolderWriter",
+                        "options": {"folder": "/tmp/zizzino/test"},
+                    },
+                },
             },
             {
-                '__type__': 'WriterNode',
-                'options': {
-                    'input_data': 'val',
-                    'writer': {
-                        '__type__': 'UnderfolderWriter',
-                        'options': {
-                            'folder': '/tmp/zizzino/val'
-                        }
-                    }
-                }
+                "__spook__": "WriterNode",
+                "options": {
+                    "input_data": "val",
+                    "writer": {
+                        "__spook__": "UnderfolderWriter",
+                        "options": {"folder": "/tmp/zizzino/val"},
+                    },
+                },
             },
         ]
-    }
+    },
 }
 
 
-graph: NodeGraph = BeanFactory.create(graph)
+graph: NodeGraph = Spook.create(graph)
 
 graph.execute()
diff --git a/examples/garbage/writer.py b/examples/garbage/writer.py
index 1fa0442..db37599 100644
--- a/examples/garbage/writer.py
+++ b/examples/garbage/writer.py
@@ -1,5 +1,4 @@
 import re
-from pipelime.factories import Bean, BeanFactory
 from pipelime.sequences.readers.filesystem import UnderfolderReader
 from pipelime.sequences.writers.filesystem import UnderfolderWriter
 from pipelime.sequences.operations import OperationFilterByQuery, OperationSubsample
diff --git a/examples/h5py/compare_speed.py b/examples/h5py/compare_speed.py
new file mode 100644
index 0000000..4bf46c7
--- /dev/null
+++ b/examples/h5py/compare_speed.py
@@ -0,0 +1,24 @@
+from pipelime.sequences.writers.h5 import H5Writer
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+from pipelime.sequences.readers.h5 import H5Reader
+import tempfile
+import rich
+from pathlib import Path
+import time
+
+
+# ➡️➡️➡️ Reader
+readers = {
+    'underfolder': UnderfolderReader(folder='/tmp/whilly', copy_root_files=True, lazy_samples=True),
+    'h5': H5Reader(filename='/tmp/dataset.h5', copy_root_files=True, lazy_samples=True)
+}
+
+for name, reader in readers.items():
+    t1 = time.perf_counter()
+    mixup = []
+    for sample in reader:
+        for key in sample.keys():
+            mixup.append((key, type(sample[key])))
+
+    t2 = time.perf_counter()
+    print(f"{name} [time]", t2 - t1)
diff --git a/examples/h5py/underfolder_to_h5.py b/examples/h5py/underfolder_to_h5.py
new file mode 100644
index 0000000..8386274
--- /dev/null
+++ b/examples/h5py/underfolder_to_h5.py
@@ -0,0 +1,18 @@
+from pipelime.sequences.writers.h5 import H5Writer
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+import tempfile
+import rich
+from pathlib import Path
+
+# ➡️➡️➡️ Reader
+reader = UnderfolderReader(folder='../data/underfolder/example_dataset', copy_root_files=True)
+reader_template = reader.get_reader_template()
+
+# ➡️➡️➡️ Writer with manual template input
+writer_filename = Path(tempfile.mkdtemp()) / 'dataset.h5'
+writer = H5Writer(
+    filename=writer_filename,
+    root_files_keys=reader_template.root_files_keys,
+)
+writer(reader)
+rich.print("H5 Writer output to:", writer_filename)
diff --git a/examples/underfolder/filter_by_script/filter_by_script.py b/examples/underfolder/filter_by_script/filter_by_script.py
index c63a55e..f12da74 100644
--- a/examples/underfolder/filter_by_script/filter_by_script.py
+++ b/examples/underfolder/filter_by_script/filter_by_script.py
@@ -14,7 +14,7 @@ def filter_by_script(dataset, output, script):
     reader = UnderfolderReader(folder=dataset)
     op = OperationFilterByScript(path_or_func=script)
 
-    reader_template = reader.get_filesystem_template()
+    reader_template = reader.get_reader_template()
 
     writer = UnderfolderWriter(
         folder=output,
diff --git a/examples/underfolder/reader_writer_template/example.py b/examples/underfolder/reader_writer_template/example.py
index 57b5b96..01a1c97 100644
--- a/examples/underfolder/reader_writer_template/example.py
+++ b/examples/underfolder/reader_writer_template/example.py
@@ -5,7 +5,7 @@
 
 # ➡️➡️➡️ Reader
 reader = UnderfolderReader(folder='../../data/underfolder/example_dataset', copy_root_files=True)
-reader_template = reader.get_filesystem_template()
+reader_template = reader.get_reader_template()
 
 # ➡️➡️➡️ Writer with manual template input
 writer_folder = tempfile.mkdtemp()
diff --git a/examples/underfolder/writer_multiprocessing/writer_multiprocessing.py b/examples/underfolder/writer_multiprocessing/writer_multiprocessing.py
new file mode 100644
index 0000000..44123e4
--- /dev/null
+++ b/examples/underfolder/writer_multiprocessing/writer_multiprocessing.py
@@ -0,0 +1,38 @@
+from pipelime.sequences.samples import PlainSample,  SamplesSequence
+from pipelime.sequences.writers.filesystem import UnderfolderWriter
+import tempfile
+import rich
+from rich.progress import track
+import numpy as np
+import time
+import shutil
+import multiprocessing
+
+
+def new_sample(i) -> PlainSample:
+    return PlainSample(data={'image': np.random.uniform(0, 255, (256, 256, 3)).astype(np.uint8)})
+
+
+# ➡️➡️➡️ Reader
+N = 1000
+pool = multiprocessing.Pool()
+samples = list(track(pool.imap_unordered(new_sample, range(N)), total=N))
+sequence = SamplesSequence(samples=samples)
+
+print(len(sequence))
+
+workers_options = [0, 1, 2, 3, 4, -1]
+
+for num_workers in workers_options:
+    # ➡️➡️➡️ Writer with manual template input
+    writer_folder = tempfile.mkdtemp()
+    writer = UnderfolderWriter(
+        folder=writer_folder,
+        extensions_map={'image': 'png'},
+        num_workers=num_workers
+    )
+    t1 = time.perf_counter()
+    writer(sequence)
+    t2 = time.perf_counter()
+    rich.print(f"num_workers={num_workers} -> Time: {t2-t1} s")
+    shutil.rmtree(writer_folder)
diff --git a/pipelime/__init__.py b/pipelime/__init__.py
index 3084cde..b92b20d 100644
--- a/pipelime/__init__.py
+++ b/pipelime/__init__.py
@@ -1,8 +1,8 @@
 """Top-level package for pipelime."""
 
 __author__ = "eyecan"
-__email__ = 'daniele.degregorio@eyecan.ai'
-__version__ = '0.1.1'
+__email__ = "daniele.degregorio@eyecan.ai"
+__version__ = "0.1.2"
 
 
 # Import transforms in order to register classes for serialization
diff --git a/pipelime/augmentations/factory.py b/pipelime/augmentations/factory.py
index 650aad9..60f85d8 100644
--- a/pipelime/augmentations/factory.py
+++ b/pipelime/augmentations/factory.py
@@ -1,15 +1,11 @@
-
+import albumentations as A
 from choixe.configurations import XConfig
 from schema import Optional, Schema
-import albumentations as A
 
 
 class AugmentationsFactory(object):
 
-    AUGMENTATIONS_CFG_SCHEMA = Schema({
-        Optional('__version__'): str,
-        'transform': dict
-    })
+    AUGMENTATIONS_CFG_SCHEMA = Schema({Optional("__version__"): str, "transform": dict})
 
     @classmethod
     def build_from_file(cls, filename: str):
diff --git a/pipelime/augmentations/transforms.py b/pipelime/augmentations/transforms.py
index 69fe444..9b6a91e 100644
--- a/pipelime/augmentations/transforms.py
+++ b/pipelime/augmentations/transforms.py
@@ -1,10 +1,8 @@
-
-import cv2
 import albumentations as A
+import cv2
 
 
 class PadIfNeededV2(A.PadIfNeeded):
-
     def __init__(
         self,
         min_height=1024,
@@ -24,7 +22,7 @@ def __init__(
             value=value,
             mask_value=mask_value,
             always_apply=always_apply,
-            p=p
+            p=p,
         )
         self.row_align = row_align
         self.col_align = col_align
@@ -45,7 +43,7 @@ def update_params(self, params, **kwargs):
                 h_pad_top = self.min_height - rows
                 h_pad_bottom = 0
             else:
-                raise ValueError(f'Invalid value \"{self.row_align}\" for row_align')
+                raise ValueError(f'Invalid value "{self.row_align}" for row_align')
         else:
             h_pad_top = 0
             h_pad_bottom = 0
@@ -61,15 +59,28 @@ def update_params(self, params, **kwargs):
                 w_pad_left = self.min_width - cols
                 w_pad_right = 0
             else:
-                raise ValueError(f"Invalid value \"{self.col_align}\" for col_align")
+                raise ValueError(f'Invalid value "{self.col_align}" for col_align')
         else:
             w_pad_left = 0
             w_pad_right = 0
 
         params.update(
-            {"pad_top": h_pad_top, "pad_bottom": h_pad_bottom, "pad_left": w_pad_left, "pad_right": w_pad_right}
+            {
+                "pad_top": h_pad_top,
+                "pad_bottom": h_pad_bottom,
+                "pad_left": w_pad_left,
+                "pad_right": w_pad_right,
+            }
         )
         return params
 
     def get_transform_init_args_names(self):
-        return ("min_height", "min_width", "border_mode", "value", "mask_value", "row_align", "col_align")
+        return (
+            "min_height",
+            "min_width",
+            "border_mode",
+            "value",
+            "mask_value",
+            "row_align",
+            "col_align",
+        )
diff --git a/pipelime/cli.py b/pipelime/cli.py
index 80f8cea..9d49a33 100644
--- a/pipelime/cli.py
+++ b/pipelime/cli.py
@@ -1,13 +1,13 @@
 """Console script for pipelime."""
 import sys
+
 import click
 
 
 @click.command()
 def main(args=None):
     """Console script for pipelime."""
-    click.echo("Replace this message by putting your code into "
-               "pipelime.cli.main")
+    click.echo("Replace this message by putting your code into " "pipelime.cli.main")
     click.echo("See click documentation at https://click.palletsprojects.com/")
     return 0
 
diff --git a/pipelime/cli/conversions/__init__.py b/pipelime/cli/conversions/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pipelime/cli/conversions/conversions.py b/pipelime/cli/conversions/conversions.py
new file mode 100644
index 0000000..20bbe5b
--- /dev/null
+++ b/pipelime/cli/conversions/conversions.py
@@ -0,0 +1,13 @@
+import click
+
+from pipelime.cli.conversions.h5_to_underfolder import h5_to_underfolder
+from pipelime.cli.conversions.underfolder_to_h5 import underfolder_to_h5
+
+
+@click.group()
+def conversions():
+    pass
+
+
+conversions.add_command(underfolder_to_h5)
+conversions.add_command(h5_to_underfolder)
diff --git a/pipelime/cli/conversions/h5_to_underfolder.py b/pipelime/cli/conversions/h5_to_underfolder.py
new file mode 100644
index 0000000..01b5131
--- /dev/null
+++ b/pipelime/cli/conversions/h5_to_underfolder.py
@@ -0,0 +1,48 @@
+import click
+
+
+@click.command("h5_to_underfolder", help="Sum input underfolders")
+@click.option(
+    "-i", "--input_filename", required=True, type=str, help="Input H5 Filename"
+)
+@click.option(
+    "-o", "--output_folder", required=True, type=str, help="Output Underfolder"
+)
+@click.option(
+    "-r",
+    "--reference_folder",
+    default="",
+    type=str,
+    help="Reference Underfolder to retrieve extensions and rootfiles",
+)
+@click.option(
+    "-c", "--copy_root_files", default=True, type=bool, help="Copy root files"
+)
+def h5_to_underfolder(input_filename, output_folder, reference_folder, copy_root_files):
+
+    import rich
+
+    from pipelime.sequences.readers.base import ReaderTemplate
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.readers.h5 import H5Reader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    template = ReaderTemplate(
+        extensions_map=None,
+        root_files_keys=None,
+    )
+    if len(reference_folder) > 0:
+        template = UnderfolderReader.get_reader_template_from_folder(reference_folder)
+
+    reader = H5Reader(
+        filename=input_filename, copy_root_files=copy_root_files, lazy_samples=True
+    )
+
+    writer = UnderfolderWriter(
+        folder=output_folder,
+        root_files_keys=template.root_files_keys,
+        extensions_map=template.extensions_map,
+        zfill=template.idx_length,
+    )
+    writer(reader)
+    rich.print("Underfolder Writer output to:", output_folder)
diff --git a/pipelime/cli/conversions/underfolder_to_h5.py b/pipelime/cli/conversions/underfolder_to_h5.py
new file mode 100644
index 0000000..6e0eebe
--- /dev/null
+++ b/pipelime/cli/conversions/underfolder_to_h5.py
@@ -0,0 +1,20 @@
+import click
+
+
+@click.command("underfolder_to_h5", help="Sum input underfolders")
+@click.option("-i", "--input_folder", required=True, type=str, help="Input Underfolder")
+@click.option("-o", "--output_filename", required=True, type=str, help="Output H5 File")
+@click.option(
+    "-c", "--copy_root_files", default=True, type=bool, help="Copy root files"
+)
+def underfolder_to_h5(input_folder, output_filename, copy_root_files):
+
+    import rich
+
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.h5 import H5Writer
+
+    reader = UnderfolderReader(folder=input_folder, copy_root_files=copy_root_files)
+    writer = H5Writer(filename=output_filename)
+    writer(reader)
+    rich.print("H5 Writer output to:", output_filename)
diff --git a/pipelime/cli/datasets/datasets.py b/pipelime/cli/datasets/datasets.py
index ee66817..2a10c59 100644
--- a/pipelime/cli/datasets/datasets.py
+++ b/pipelime/cli/datasets/datasets.py
@@ -3,7 +3,6 @@
 from pipelime.cli.datasets.generate_toy_dataset import generate_toy_dataset
 
 
-
 @click.group()
 def datasets():
     pass
diff --git a/pipelime/cli/datasets/generate_toy_dataset.py b/pipelime/cli/datasets/generate_toy_dataset.py
index 344ba18..689c5cd 100644
--- a/pipelime/cli/datasets/generate_toy_dataset.py
+++ b/pipelime/cli/datasets/generate_toy_dataset.py
@@ -1,20 +1,17 @@
-
 import click
+
 from pipelime.tools.toydataset import ToyDatasetGenerator
 
 
 @click.command("generate_toy_dataset")
-@click.option('--output_folder', type=str, required=True, help="Output folder")
-@click.option('--size', type=int, required=True, help="Number of output samples")
-@click.option('--image_size', type=int, default=256, help="Output image size")
-@click.option('--zfill', type=int, default=5, help="Zero padding digits")
+@click.option("--output_folder", type=str, required=True, help="Output folder")
+@click.option("--size", type=int, required=True, help="Number of output samples")
+@click.option("--image_size", type=int, default=256, help="Output image size")
+@click.option("--zfill", type=int, default=5, help="Zero padding digits")
 def generate_toy_dataset(output_folder, size, image_size, zfill):
 
     ToyDatasetGenerator.generate_toy_dataset(
-        output_folder=output_folder,
-        size=size,
-        image_size=image_size,
-        zfill=zfill
+        output_folder=output_folder, size=size, image_size=image_size, zfill=zfill
     )
 
 
diff --git a/pipelime/cli/h5/__init__.py b/pipelime/cli/h5/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pipelime/cli/h5/h5.py b/pipelime/cli/h5/h5.py
new file mode 100644
index 0000000..987f651
--- /dev/null
+++ b/pipelime/cli/h5/h5.py
@@ -0,0 +1,11 @@
+import click
+
+from pipelime.cli.h5.operations import summary
+
+
+@click.group()
+def h5():
+    pass
+
+
+h5.add_command(summary)
diff --git a/pipelime/cli/h5/operations.py b/pipelime/cli/h5/operations.py
new file mode 100644
index 0000000..d5ca24e
--- /dev/null
+++ b/pipelime/cli/h5/operations.py
@@ -0,0 +1,31 @@
+from pathlib import Path
+
+import click
+
+
+@click.command("summary", help="Prints the summary of a h5 dataset")
+@click.option("-i", "--input", "path", type=Path, required=True, help="Input dataset")
+@click.option(
+    "-o",
+    "--order_by",
+    type=click.Choice(["name", "root_item", "count", "encoding", "typeinfo"]),
+    default="name",
+    help="Sort by column value",
+)
+@click.option("-R", "--reversed", "reversed_", is_flag=True, help="Reverse sorting")
+@click.option(
+    "-k",
+    "--max_samples",
+    default=3,
+    type=int,
+    help="Maximum number of samples to read from the dataset",
+)
+def summary(path, order_by, reversed_, max_samples):
+
+    from pipelime.cli.summary import print_summary
+    from pipelime.sequences.readers.h5 import H5Reader
+
+    reader = H5Reader(path)
+    print_summary(
+        reader, order_by=order_by, reversed_=reversed_, max_samples=max_samples
+    )
diff --git a/pipelime/cli/main.py b/pipelime/cli/main.py
index 94bedc4..5151031 100644
--- a/pipelime/cli/main.py
+++ b/pipelime/cli/main.py
@@ -1,6 +1,9 @@
 import click
 
+from pipelime.cli.conversions.conversions import conversions
 from pipelime.cli.datasets.datasets import datasets
+from pipelime.cli.h5.h5 import h5
+from pipelime.cli.underfolder.underfolder import underfolder
 from pipelime.cli.workflow.workflow import workflow
 
 
@@ -11,3 +14,6 @@ def pipelime():
 
 pipelime.add_command(datasets)
 pipelime.add_command(workflow)
+pipelime.add_command(conversions)
+pipelime.add_command(underfolder)
+pipelime.add_command(h5)
diff --git a/pipelime/cli/summary.py b/pipelime/cli/summary.py
new file mode 100644
index 0000000..4f4e6ab
--- /dev/null
+++ b/pipelime/cli/summary.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from rich.console import Console
+from rich.table import Table
+
+from pipelime.sequences.readers.base import BaseReader
+from pipelime.sequences.readers.summary import ItemInfo, ReaderSummary, TypeInfo
+from pipelime.sequences.samples import SamplesSequence
+from pipelime.tools.bytes import DataCoding
+
+
+class SummaryPrinter:
+    TITLE_STYLE = "bold bright_blue"
+    ENC_CAT_STYLE_MAP = {
+        "image": "bold magenta",
+        "numpy": "bold cyan",
+        "markup": "bold yellow",
+        "pickle": "bold red",
+    }
+    ENC_CAT_MAP = {
+        "image": DataCoding.IMAGE_CODECS,
+        "numpy": DataCoding.NUMPY_CODECS + DataCoding.TEXT_CODECS,
+        "markup": DataCoding.METADATA_CODECS,
+        "pickle": DataCoding.PICKLE_CODECS,
+    }
+    ENC_STYLE_MAP = {}
+    for c in ENC_CAT_MAP:
+        for codec in ENC_CAT_MAP[c]:
+            ENC_STYLE_MAP[codec] = ENC_CAT_STYLE_MAP[c]
+    ITEM_NAME_STYLE = "bold blue"
+    TYPE_NAME_STYLE = "bold green"
+    DTYPE_STYLE = "green"
+    SHAPE_STYLE = "yellow"
+    BOOL_TRUE_STYLE = "bold green"
+    BOOL_FALSE_STYLE = "bold red"
+
+    def repr_name(self, name: str) -> str:
+        return f"[{self.ITEM_NAME_STYLE}]{name}[/{self.ITEM_NAME_STYLE}]"
+
+    def repr_typeinfo(self, t: TypeInfo) -> str:
+        style = self.TYPE_NAME_STYLE
+        typenames = [f"[{style}]{x.__name__}[/{style}]" for x in t.types]
+        str_ = " | ".join(typenames)
+        if t.dtype is not None:
+            str_ += f" [{self.DTYPE_STYLE}]{t.dtype}[/{self.DTYPE_STYLE}]"
+        if t.shape is not None:
+            style = self.SHAPE_STYLE
+            formatted_shape = [f"[{style}]{x}[/{style}]" for x in t.shape]
+            str_ += " [" + " × ".join(formatted_shape) + "]"
+        return str_
+
+    def repr_count(self, c: int, total: int) -> str:
+        style = self.BOOL_TRUE_STYLE if c == total else self.BOOL_FALSE_STYLE
+        return f"[{style}]{c}/{total}[/{style}]"
+
+    def repr_bool(self, val: bool) -> str:
+        style = self.BOOL_TRUE_STYLE if val else self.BOOL_FALSE_STYLE
+        return f"[{style}]{val}[/{style}]"
+
+    def repr_encoding(self, enc: str) -> str:
+        if enc not in self.ENC_STYLE_MAP:
+            return "Unknown"
+        style = self.ENC_STYLE_MAP[enc]
+        return f"[{style}]{enc}[/{style}]"
+
+    def repr_iteminfo(self, seq: SamplesSequence, iteminfo: ItemInfo) -> str:
+        repr_name_ = self.repr_name(iteminfo.name)
+        repr_typeinfo_ = self.repr_typeinfo(iteminfo.typeinfo)
+        repr_count_ = self.repr_count(iteminfo.count, len(seq))
+        repr_is_root_ = self.repr_bool(iteminfo.root_item)
+        repr_encoding_ = self.repr_encoding(iteminfo.encoding)
+        return repr_name_, repr_count_, repr_typeinfo_, repr_is_root_, repr_encoding_
+
+    def print(self, summary: ReaderSummary) -> None:
+        console = Console()
+        style = self.TITLE_STYLE
+        console.print(f"[{style}]Type[/{style}]: {summary.reader.__class__.__name__}")
+        console.print(f"[{style}]Length[/{style}]: {len(summary.reader)}")
+        console.print()
+        if summary.k > 1:
+            str_ = f"based on first {summary.k} samples"
+        elif summary.k == 1:
+            str_ = "based on first sample"
+        else:
+            str_ = "based on all samples"
+        console.print(f"[{style}]Contents[/{style}] \[{str_}]:")
+
+        table = Table(header_style=self.TITLE_STYLE)
+        table.add_column("Item Name")
+        table.add_column("Count")
+        table.add_column("Item Type")
+        table.add_column("Is Root Item")
+        table.add_column("Encoding")
+
+        for iteminfo in summary:
+            repr_iteminfo_ = self.repr_iteminfo(summary.reader, iteminfo)
+            table.add_row(*repr_iteminfo_)
+
+        console.print(table)
+        pass
+
+
+def print_summary(
+    reader: BaseReader,
+    max_samples: int = 3,
+    order_by: str = "name",
+    reversed_: bool = False,
+) -> None:
+    summary = ReaderSummary(reader, k=max_samples)
+    summary.sort(key=order_by, reverse=reversed_)
+    SummaryPrinter().print(summary)
diff --git a/pipelime/cli/underfolder/__init__.py b/pipelime/cli/underfolder/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pipelime/cli/underfolder/operations.py b/pipelime/cli/underfolder/operations.py
new file mode 100644
index 0000000..b50fefd
--- /dev/null
+++ b/pipelime/cli/underfolder/operations.py
@@ -0,0 +1,519 @@
+from pathlib import Path
+
+import click
+
+
+@click.command("sum", help="Sum input underfolders")
+@click.option(
+    "-i",
+    "--input_folders",
+    required=True,
+    multiple=True,
+    type=str,
+    help="Input Underfolder [multiple]",
+)
+@click.option(
+    "-o", "--output_folder", required=True, type=str, help="Output Underfolder"
+)
+@click.option(
+    "-c",
+    "--convert_root_file",
+    multiple=True,
+    type=str,
+    help="Convert a root file into an item to avoid conflicts [multiple]",
+)
+def operation_sum(input_folders, output_folder, convert_root_file):
+
+    from pipelime.sequences.operations import OperationResetIndices, OperationSum
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    if len(input_folders) > 0:
+        datasets = [
+            UnderfolderReader(folder=x, lazy_samples=True) for x in input_folders
+        ]
+
+        # operations
+        op_sum = OperationSum()
+        op_reindex = OperationResetIndices()
+        output_dataset = op_reindex(op_sum(datasets))
+
+        prototype_reader = datasets[0]
+        template = prototype_reader.get_reader_template()
+
+        writer = UnderfolderWriter(
+            folder=output_folder,
+            copy_files=True,
+            root_files_keys=list(
+                set(template.root_files_keys) - set(convert_root_file)
+            ),
+            extensions_map=template.extensions_map,
+            zfill=template.idx_length,
+        )
+        writer(output_dataset)
+
+
+@click.command("mix", help="Mix input underfolders")
+@click.option(
+    "-i",
+    "--input_folders",
+    required=True,
+    multiple=True,
+    type=str,
+    help="Input Underfolder [multiple]",
+)
+@click.option(
+    "-o", "--output_folder", required=True, type=str, help="Output Underfolder"
+)
+def operation_mix(input_folders, output_folder):
+
+    from click import ClickException
+
+    from pipelime.sequences.operations import OperationMix
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    if len(input_folders) > 0:
+        datasets = [
+            UnderfolderReader(folder=x, lazy_samples=True) for x in input_folders
+        ]
+
+        # operations
+        op_mix = OperationMix()
+        try:
+            output_dataset = op_mix(datasets)
+        except AssertionError as e:
+            raise ClickException(
+                "Input underfolders must have the same length and their item sets must be disjoint."
+            )
+
+        root_files = []
+        ext_map = {}
+        for d in datasets:
+            template = d.get_reader_template()
+            root_files.extend(template.root_files_keys)
+            ext_map.update(template.extensions_map)
+
+        writer = UnderfolderWriter(
+            folder=output_folder,
+            copy_files=True,
+            root_files_keys=root_files,
+            extensions_map=ext_map,
+        )
+        writer(output_dataset)
+
+
+@click.command("subsample", help="Subsample input underfolder")
+@click.option("-i", "--input_folder", required=True, type=str, help="Input Underfolder")
+@click.option(
+    "-f",
+    "--factor",
+    required=True,
+    type=float,
+    help="Subsamplig factor. INT (1,inf) or FLOAT [0.0,1.0]",
+)
+@click.option(
+    "-o", "--output_folder", required=True, type=str, help="Output Underfolder"
+)
+def operation_subsample(input_folder, factor, output_folder):
+
+    from pipelime.sequences.operations import OperationResetIndices, OperationSubsample
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    dataset = UnderfolderReader(folder=input_folder, lazy_samples=True)
+    template = dataset.get_reader_template()
+
+    # operations
+    op_subsample = OperationSubsample(factor=int(factor) if factor > 1 else factor)
+    op_reindex = OperationResetIndices()
+    output_dataset = op_reindex(op_subsample(dataset))
+
+    writer = UnderfolderWriter(
+        folder=output_folder,
+        copy_files=True,
+        root_files_keys=template.root_files_keys,
+        extensions_map=template.extensions_map,
+    )
+    writer(output_dataset)
+
+
+@click.command("shuffle", help="Shuffle input underfolder")
+@click.option("-i", "--input_folder", required=True, type=str, help="Input Underfolder")
+@click.option(
+    "-o", "--output_folder", required=True, type=str, help="Output Underfolder"
+)
+@click.option("-s", "--seed", default=-1, type=int, help="Random seed")
+def operation_shuffle(input_folder, output_folder, seed):
+
+    from pipelime.sequences.operations import OperationResetIndices, OperationShuffle
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    dataset = UnderfolderReader(folder=input_folder, lazy_samples=True)
+    template = dataset.get_reader_template()
+
+    # operations
+    op_shuffle = OperationShuffle(seed=seed)
+    op_reindex = OperationResetIndices()
+    output_dataset = op_reindex(op_shuffle(dataset))
+
+    writer = UnderfolderWriter(
+        folder=output_folder,
+        copy_files=True,
+        root_files_keys=template.root_files_keys,
+        extensions_map=template.extensions_map,
+    )
+    writer(output_dataset)
+
+
+@click.command("split", help="Split input underfolder")
+@click.option("-i", "--input_folder", required=True, type=str, help="Input Underfolder")
+@click.option(
+    "-s",
+    "--splits",
+    required=True,
+    multiple=True,
+    nargs=2,
+    help="Splits map, pairs (path, percentage) [multple]",
+)
+def operation_split(input_folder, splits):
+
+    from functools import reduce
+
+    from pipelime.sequences.operations import OperationResetIndices, OperationSplits
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    dataset = UnderfolderReader(folder=input_folder, lazy_samples=True)
+    template = dataset.get_reader_template()
+
+    # splitmap for nargs
+    split_map = {str(k): float(v) for k, v in splits}
+
+    cumulative = reduce(lambda a, b: a + b, split_map.values())
+    assert cumulative <= 1.0, "Sums of splits percentages must be <= 1.0"
+
+    # operations
+    op_splits = OperationSplits(split_map=split_map)
+    op_reindex = OperationResetIndices()
+    output_datasets_map = op_splits(dataset)
+
+    for path, dataset in output_datasets_map.items():
+        writer = UnderfolderWriter(
+            folder=path,
+            copy_files=True,
+            root_files_keys=template.root_files_keys,
+            extensions_map=template.extensions_map,
+        )
+        writer(op_reindex(dataset))
+
+
+@click.command("filter_by_query", help="Filter input underfolder by query")
+@click.option("-i", "--input_folder", required=True, type=str, help="Input Underfolder")
+@click.option("-q", "--query", required=True, type=str, help="Filtering query")
+@click.option(
+    "-o",
+    "--output_folder",
+    required=True,
+    type=str,
+    help="Output Underfolder for positive matches",
+)
+def operation_filterbyquery(input_folder, query, output_folder):
+
+    from pipelime.sequences.operations import (
+        OperationFilterByQuery,
+        OperationResetIndices,
+    )
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    dataset = UnderfolderReader(folder=input_folder, lazy_samples=True)
+    template = dataset.get_reader_template()
+
+    # operations
+    op_filterbyquery = OperationFilterByQuery(query=query)
+    op_reindex = OperationResetIndices()
+    output_dataset = op_reindex(op_filterbyquery(dataset))
+
+    writer = UnderfolderWriter(
+        folder=output_folder,
+        copy_files=True,
+        root_files_keys=template.root_files_keys,
+        extensions_map=template.extensions_map,
+    )
+    writer(output_dataset)
+
+
+@click.command("split_by_query", help="Filter input underfolder by query")
+@click.option("-i", "--input_folder", required=True, type=str, help="Input Underfolder")
+@click.option("-q", "--query", required=True, type=str, help="Filtering query")
+@click.option(
+    "-o1",
+    "--output_folder_1",
+    required=True,
+    type=str,
+    help="Output Underfolder for positive matches",
+)
+@click.option(
+    "-o2",
+    "--output_folder_2",
+    required=True,
+    type=str,
+    help="Output Underfolder for negative matches",
+)
+def operation_splitbyquery(input_folder, query, output_folder_1, output_folder_2):
+
+    from pipelime.sequences.operations import (
+        OperationResetIndices,
+        OperationSplitByQuery,
+    )
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    dataset = UnderfolderReader(folder=input_folder, lazy_samples=True)
+    template = dataset.get_reader_template()
+
+    # operations
+    op_splitbyquery = OperationSplitByQuery(query=query)
+    op_reindex = OperationResetIndices()
+    output_datasets = op_splitbyquery(dataset)
+
+    output_folders = [output_folder_1, output_folder_2]
+
+    for output_folder, output_dataset in zip(output_folders, output_datasets):
+        writer = UnderfolderWriter(
+            folder=output_folder,
+            copy_files=True,
+            root_files_keys=template.root_files_keys,
+            extensions_map=template.extensions_map,
+        )
+        writer(op_reindex(output_dataset))
+
+
+@click.command("filter_by_script", help="Filter input underfolder by query")
+@click.option("-i", "--input_folder", required=True, type=str, help="Input Underfolder")
+@click.option("-s", "--script", required=True, type=str, help="Filtering python script")
+@click.option(
+    "-o",
+    "--output_folder",
+    required=True,
+    type=str,
+    help="Output Underfolder for positive matches",
+)
+def operation_filterbyscript(input_folder, script, output_folder):
+
+    from pipelime.sequences.operations import (
+        OperationFilterByScript,
+        OperationResetIndices,
+    )
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    dataset = UnderfolderReader(folder=input_folder, lazy_samples=True)
+    template = dataset.get_reader_template()
+
+    # operations
+    op_filterbyquery = OperationFilterByScript(path_or_func=script)
+    op_reindex = OperationResetIndices()
+    output_dataset = op_reindex(op_filterbyquery(dataset))
+
+    writer = UnderfolderWriter(
+        folder=output_folder,
+        copy_files=True,
+        root_files_keys=template.root_files_keys,
+        extensions_map=template.extensions_map,
+    )
+    writer(output_dataset)
+
+
+@click.command("filter_keys", help="Filter input underfolder keys")
+@click.option("-i", "--input_folder", required=True, type=str, help="Input Underfolder")
+@click.option(
+    "-k",
+    "--keys",
+    required=True,
+    type=str,
+    multiple=True,
+    help="Filtering keys [multiple]",
+)
+@click.option(
+    "--negate/--no-negate",
+    required=False,
+    type=bool,
+    help="Negate filtering effets (i.e. all but selected keys)",
+)
+@click.option(
+    "-o",
+    "--output_folder",
+    required=True,
+    type=str,
+    help="Output Underfolder for positive matches",
+)
+def operation_filterkeys(input_folder, keys, negate, output_folder):
+
+    from pipelime.sequences.operations import OperationFilterKeys, OperationResetIndices
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    dataset = UnderfolderReader(folder=input_folder, lazy_samples=True)
+    template = dataset.get_reader_template()
+
+    # operations
+    op_filterbyquery = OperationFilterKeys(keys=keys, negate=negate)
+    op_reindex = OperationResetIndices()
+    output_dataset = op_reindex(op_filterbyquery(dataset))
+
+    writer = UnderfolderWriter(
+        folder=output_folder,
+        copy_files=True,
+        root_files_keys=template.root_files_keys,
+        extensions_map=template.extensions_map,
+    )
+    writer(output_dataset)
+
+
+@click.command("order_by", help="Order input underfolder samples")
+@click.option("-i", "--input_folder", required=True, type=str, help="Input Underfolder")
+@click.option(
+    "-k",
+    "--keys",
+    required=True,
+    type=str,
+    multiple=True,
+    help="Filtering keys [multiple]",
+)
+@click.option(
+    "-o",
+    "--output_folder",
+    required=True,
+    type=str,
+    help="Output Underfolder for positive matches",
+)
+def operation_orderby(input_folder, keys, output_folder):
+
+    from pipelime.sequences.operations import OperationOrderBy, OperationResetIndices
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    dataset = UnderfolderReader(folder=input_folder, lazy_samples=True)
+    template = dataset.get_reader_template()
+
+    # operations
+    op_filterbyquery = OperationOrderBy(order_keys=keys)
+    op_reindex = OperationResetIndices()
+    output_dataset = op_reindex(op_filterbyquery(dataset))
+
+    writer = UnderfolderWriter(
+        folder=output_folder,
+        copy_files=True,
+        root_files_keys=template.root_files_keys,
+        extensions_map=template.extensions_map,
+    )
+    writer(output_dataset)
+
+
+@click.command("split_by_value", help="Split input underfolder by value")
+@click.option(
+    "-i", "--input_folder", required=True, type=Path, help="Input Underfolder"
+)
+@click.option("-k", "--key", required=True, type=str, help="split key")
+@click.option(
+    "-o", "--output_folder", required=True, type=Path, help="Output parent folder"
+)
+@click.option(
+    "-p",
+    "--output_prefix",
+    default="split",
+    type=str,
+    help="Prefix for each output underfolder",
+)
+def operation_split_by_value(input_folder, key, output_folder, output_prefix):
+
+    from math import ceil, log10
+
+    from pipelime.sequences.operations import (
+        OperationResetIndices,
+        OperationSplitByValue,
+    )
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    dataset = UnderfolderReader(input_folder)
+    template = dataset.get_reader_template()
+
+    op_split = OperationSplitByValue(key=key)
+    splits = op_split(dataset)
+
+    zfill = max(ceil(log10(len(splits) + 1)), 1)
+    for i, split in enumerate(splits):
+        op_reindex = OperationResetIndices()
+        split = op_reindex(split)
+        UnderfolderWriter(
+            output_folder / f"{output_prefix}_{str(i).zfill(zfill)}",
+            root_files_keys=template.root_files_keys,
+            extensions_map=template.extensions_map,
+        )(split)
+
+
+@click.command("group_by", help="Group input underfolder by key")
+@click.option("-i", "--input_folder", required=True, type=str, help="Input Underfolder")
+@click.option("-k", "--key", required=True, type=str, help="Grouping keys")
+@click.option(
+    "-o",
+    "--output_folder",
+    required=True,
+    type=str,
+    help="Output Underfolder for positive matches",
+)
+def operation_groupby(input_folder, key, output_folder):
+
+    from pipelime.sequences.operations import OperationGroupBy, OperationResetIndices
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+    from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+    dataset = UnderfolderReader(folder=input_folder, lazy_samples=True)
+    template = dataset.get_reader_template()
+
+    # operations
+    op_filterbyquery = OperationGroupBy(field=key)
+    op_reindex = OperationResetIndices()
+    output_dataset = op_reindex(op_filterbyquery(dataset))
+
+    writer = UnderfolderWriter(
+        folder=output_folder,
+        copy_files=True,
+    )
+    writer(output_dataset)
+
+
+@click.command("summary", help="Prints the summary of an underfolder dataset")
+@click.option("-i", "--input", "path", type=Path, required=True, help="Input dataset")
+@click.option(
+    "-o",
+    "--order_by",
+    type=click.Choice(["name", "root_item", "count", "encoding", "typeinfo"]),
+    default="name",
+    help="Sort by column value",
+)
+@click.option("-R", "--reversed", "reversed_", is_flag=True, help="Reverse sorting")
+@click.option(
+    "-k",
+    "--max_samples",
+    default=3,
+    type=int,
+    help="Maximum number of samples to read from the dataset",
+)
+def summary(path, order_by, reversed_, max_samples):
+
+    from pipelime.cli.summary import print_summary
+    from pipelime.sequences.readers.filesystem import UnderfolderReader
+
+    reader = UnderfolderReader(path)
+    print_summary(
+        reader, order_by=order_by, reversed_=reversed_, max_samples=max_samples
+    )
+
+
+if __name__ == "__main__":
+    operation_filterbyquery()
diff --git a/pipelime/cli/underfolder/underfolder.py b/pipelime/cli/underfolder/underfolder.py
new file mode 100644
index 0000000..b4b8c25
--- /dev/null
+++ b/pipelime/cli/underfolder/underfolder.py
@@ -0,0 +1,37 @@
+import click
+
+from pipelime.cli.underfolder.operations import (
+    operation_filterbyquery,
+    operation_filterbyscript,
+    operation_filterkeys,
+    operation_groupby,
+    operation_mix,
+    operation_orderby,
+    operation_shuffle,
+    operation_split,
+    operation_split_by_value,
+    operation_splitbyquery,
+    operation_subsample,
+    operation_sum,
+    summary,
+)
+
+
+@click.group()
+def underfolder():
+    pass
+
+
+underfolder.add_command(operation_sum)
+underfolder.add_command(operation_subsample)
+underfolder.add_command(operation_shuffle)
+underfolder.add_command(operation_split)
+underfolder.add_command(operation_filterbyquery)
+underfolder.add_command(operation_splitbyquery)
+underfolder.add_command(operation_filterbyscript)
+underfolder.add_command(operation_filterkeys)
+underfolder.add_command(operation_orderby)
+underfolder.add_command(operation_split_by_value)
+underfolder.add_command(operation_groupby)
+underfolder.add_command(operation_mix)
+underfolder.add_command(summary)
diff --git a/pipelime/cli/workflow/click2cwl.py b/pipelime/cli/workflow/click2cwl.py
index 234e02e..8509c1a 100644
--- a/pipelime/cli/workflow/click2cwl.py
+++ b/pipelime/cli/workflow/click2cwl.py
@@ -4,55 +4,63 @@
 @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
-):
+@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
-    )
+    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']
+            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
-    )
+    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
+            folder=folder if len(folder) > 0 else None,
         )
     except Exception as e:
         print(e)
diff --git a/pipelime/cli/workflow/delete_nodes.py b/pipelime/cli/workflow/delete_nodes.py
index 6b0fcef..a8aadcf 100644
--- a/pipelime/cli/workflow/delete_nodes.py
+++ b/pipelime/cli/workflow/delete_nodes.py
@@ -2,26 +2,34 @@
 
 
 @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")
+@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
 
+    from pipelime.workflow.cwl import CwlNodesManager
+
     # 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(),
-        ),
-    ])
+    answers = inquirer.prompt(
+        [
+            inquirer.Checkbox(
+                "nodes",
+                message="Which nodes do you want to delete?",
+                choices=nodes.keys(),
+            ),
+        ]
+    )
 
     # delete nodes
-    for node in answers['nodes']:
+    for node in answers["nodes"]:
         CwlNodesManager.delete_node(node, folder)
 
 
diff --git a/pipelime/cli/workflow/init_workflow.py b/pipelime/cli/workflow/init_workflow.py
index 6563bb5..f80ff0f 100644
--- a/pipelime/cli/workflow/init_workflow.py
+++ b/pipelime/cli/workflow/init_workflow.py
@@ -2,11 +2,22 @@
 
 
 @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")
+@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
@@ -14,14 +25,14 @@ def init(output_filename, folder):
 
     choosen_nodes = []
     available_nodes = [k for k, v in nodes.items() if v.is_valid]
-    available_nodes.append('END WORKFLOW')
+    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':
+        answer = inquirer.prompt(
+            [inquirer.List("nodes", message="Choose a node", choices=available_nodes)]
+        )
+        if answer["nodes"] == "END WORKFLOW":
             break
-        choosen_nodes.append(answer['nodes'])
+        choosen_nodes.append(answer["nodes"])
 
     worlkflow_template = CwlNodesManager.initialize_workflow(choosen_nodes)
     worlkflow_template.dumps(output_filename)
diff --git a/pipelime/cli/workflow/list_nodes.py b/pipelime/cli/workflow/list_nodes.py
index ae4f7c0..3ab7dd4 100644
--- a/pipelime/cli/workflow/list_nodes.py
+++ b/pipelime/cli/workflow/list_nodes.py
@@ -2,28 +2,36 @@
 
 
 @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")
+@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
 
+    from pipelime.workflow.cwl import CwlNodesManager
+
     # retrieves available nodes
     nodes = CwlNodesManager.available_nodes(folder=folder)
 
     # draw tree
-    t_root = Tree('🧩 [green]Nodes')
+    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]")
+            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 = 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)
diff --git a/pipelime/cli/workflow/run_workflow.py b/pipelime/cli/workflow/run_workflow.py
index e77229f..db98d93 100644
--- a/pipelime/cli/workflow/run_workflow.py
+++ b/pipelime/cli/workflow/run_workflow.py
@@ -3,45 +3,58 @@
 
 @click.command("run", help="Runs a cwl workflow")
 @click.option("--workflow", required=True, type=str, help="The cwl workflow file")
-@click.option("--cfg_file", required=True, type=str, help="The configuration input file, XConfig placeholders will be prompted to the user")
-@click.option("--folder", default=None, type=str, help="Folder of the cwl nodes. Leave empty to use default folder")
-@click.option('--fill/--no-fill', default=True, type=bool, help='Do not fill the workflow steps with the cwl nodes path')
-@click.option("--parallel", required=False, is_flag=True, help="Enable parallel execution")
+@click.option(
+    "--cfg_file",
+    required=True,
+    type=str,
+    help="The configuration input file, XConfig placeholders will be prompted to the user",
+)
+@click.option(
+    "--folder",
+    default=None,
+    type=str,
+    help="Folder of the cwl nodes. Leave empty to use default folder",
+)
+@click.option(
+    "--fill/--no-fill",
+    default=True,
+    type=bool,
+    help="Do not fill the workflow steps with the cwl nodes path",
+)
+@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(
-    workflow,
-    cfg_file,
-    folder,
-    fill,
-    parallel,
-    debug
-):
-    import tempfile
+def run(workflow, cfg_file, folder, fill, parallel, debug):
     import subprocess
+    import tempfile
+
     from choixe.configurations import XConfig
     from choixe.inquirer import XInquirer
-    from pipelime.workflow.cwl import CwlWorkflowTemplate
-    from pipelime.workflow.cwl import CwlNodesManager
+
+    from pipelime.workflow.cwl import CwlNodesManager, CwlWorkflowTemplate
 
     # reads and prompts the XConfig
     cfg = XConfig(filename=cfg_file)
     cfg = XInquirer.prompt(cfg)
-    compiled_cfg_file = f'{tempfile.NamedTemporaryFile().name}.yml'
+    compiled_cfg_file = f"{tempfile.NamedTemporaryFile().name}.yml"
     cfg.save_to(compiled_cfg_file)
 
     if fill:
         workflow_template = CwlWorkflowTemplate.from_file(workflow)
-        workflow_template = CwlNodesManager.fill_workflow(workflow_template, folder=folder)
-        workflow_file = f'{tempfile.NamedTemporaryFile().name}.cwl'
+        workflow_template = CwlNodesManager.fill_workflow(
+            workflow_template, folder=folder
+        )
+        workflow_file = f"{tempfile.NamedTemporaryFile().name}.cwl"
         workflow_template.dumps(workflow_file)
         workflow = workflow_file
 
     # prepares the cwl-runner
-    cmd = ['cwl-runner']
+    cmd = ["cwl-runner"]
     if parallel:
-        cmd.append('--parallel')
+        cmd.append("--parallel")
     if debug:
-        cmd.append('--debug')
+        cmd.append("--debug")
     cmd.append(workflow)
     cmd.append(compiled_cfg_file)
 
diff --git a/pipelime/cli/workflow/show_graph.py b/pipelime/cli/workflow/show_graph.py
index 3b15fd8..e44bec4 100644
--- a/pipelime/cli/workflow/show_graph.py
+++ b/pipelime/cli/workflow/show_graph.py
@@ -4,21 +4,22 @@
 @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 os
     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.layout("dot")
+        agraph_file = f"{tempfile.NamedTemporaryFile().name}.svg"
         agraph.draw(agraph_file)
-        os.system(f'eog {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)
+    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)
 
diff --git a/pipelime/cli/workflow/update_nodes.py b/pipelime/cli/workflow/update_nodes.py
index 39917b4..2c6b879 100644
--- a/pipelime/cli/workflow/update_nodes.py
+++ b/pipelime/cli/workflow/update_nodes.py
@@ -2,45 +2,74 @@
 
 
 @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")
+@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
+
+    from pipelime.workflow.cwl import CwlNodesManager, 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(),
-        ),
-    ])
+    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']:
+        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)
-            ])
+            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']
+                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
             )
-            CwlNodesManager.create_node(update_answers['name'], cwl_template=cwl_template, folder=folder)
 
 
 if __name__ == "__main__":
diff --git a/pipelime/cli/workflow/workflow.py b/pipelime/cli/workflow/workflow.py
index 7acfb0a..73c4522 100644
--- a/pipelime/cli/workflow/workflow.py
+++ b/pipelime/cli/workflow/workflow.py
@@ -1,11 +1,11 @@
 import click
 
-from pipelime.cli.workflow.run_workflow import run
-from pipelime.cli.workflow.init_workflow import init
-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.init_workflow import init
+from pipelime.cli.workflow.list_nodes import list_nodes
+from pipelime.cli.workflow.run_workflow import run
+from pipelime.cli.workflow.show_graph import show_graph
 from pipelime.cli.workflow.update_nodes import update_nodes
 
 
diff --git a/pipelime/factories.py b/pipelime/factories.py
deleted file mode 100644
index 9784d4b..0000000
--- a/pipelime/factories.py
+++ /dev/null
@@ -1,100 +0,0 @@
-
-from functools import wraps
-import typing
-from abc import ABC
-from schema import Schema
-
-
-class Bean(ABC):
-    TYPE_FIELD = '__type__'
-    ARGS_FIELD = 'options'
-
-    @classmethod
-    def bean_name(cls) -> str:
-        return cls.__name__
-
-    @classmethod
-    def bean_schema(cls) -> dict:
-        return {}
-
-    @classmethod
-    def full_bean_schema(cls) -> Schema:
-        return Schema({
-            Bean.TYPE_FIELD: cls.bean_name(),
-            Bean.ARGS_FIELD: cls.bean_schema()
-        })
-
-    @classmethod
-    def from_dict(cls, d: dict):
-        return cls(**d)
-
-    def to_dict(self) -> dict:
-        return {}
-
-    def serialize(self, validate: bool = True) -> dict:
-        out = {
-            Bean.TYPE_FIELD: self.bean_name(),
-            Bean.ARGS_FIELD: self.to_dict()
-        }
-        if validate:
-            self.full_bean_schema().validate(out)
-        return out
-
-    @classmethod
-    def hydrate(cls, d: dict, validate: bool = True) -> any:
-        if validate:
-            cls.full_bean_schema().validate(d)
-        return cls.from_dict(d[Bean.ARGS_FIELD])
-
-
-class BeanFactory(object):
-
-    CLASSES_MAP: typing.Dict[str, Bean] = {}
-
-    @classmethod
-    def register_bean(cls, x: Bean):
-        cls.CLASSES_MAP[x.bean_name()] = x
-
-    @classmethod
-    def serialize(cls, x: Bean, validate: bool = True):
-        return x.serialize(validate=validate)
-
-    @classmethod
-    def create(cls, d: dict, validate: bool = True):
-        if d[Bean.TYPE_FIELD] in cls.CLASSES_MAP:
-            bt = cls.CLASSES_MAP[d[Bean.TYPE_FIELD]]
-            return bt.hydrate(d, validate=validate)
-        raise RuntimeError(f'Non serializable data: {d}')
-
-    # @staticmethod
-    # def make_serializable(x: type):
-    #     BeanFactory.register_bean(x)
-    #     return x
-
-    # @staticmethod
-    # def make_serializable(f):
-    #     BeanFactory.register_bean(f)
-
-    #     def _decorator():
-    #         return f()
-    #     _decorator.__doc__ = f.__doc__
-    #     return _decorator
-
-    # @staticmethod
-    # def make_serializable(x: type):
-    #     BeanFactory.register_bean(x)
-
-    #     @wraps(x)
-    #     class wrapper(object): *args, **kwargs):
-    #         return x
-    #         # _x = x(*args, **kwargs)
-    #         # return _x
-    #     return wrapper
-
-    @staticmethod
-    def make_serializable(x: type):
-        BeanFactory.register_bean(x)
-        @wraps(x, updated=())
-        class D(x):
-            decorated = 1
-        return D
diff --git a/pipelime/filesystem/toolkit.py b/pipelime/filesystem/toolkit.py
index 48220c9..8483eee 100644
--- a/pipelime/filesystem/toolkit.py
+++ b/pipelime/filesystem/toolkit.py
@@ -1,25 +1,22 @@
-from pathlib import Path
 import imghdr
+import json
 import pickle
-from pipelime.tools.bytes import DataCoding
+import warnings
+from collections import defaultdict
+from pathlib import Path
 from typing import Union
-import numpy as np
+
 import imageio
+import numpy as np
 import yaml
-import json
-from collections import defaultdict
+
+from pipelime.tools.bytes import DataCoding
 
 
 class FSToolkit(object):
 
-    INSTALLED_LIBRARIES = {
-        'exr': False,
-    }
- 
     # Default imageio options for each image format
-    OPTIONS = {
-        'png': {'compress_level': 4},
-    }
+    OPTIONS = {"png": {"compress_level": 4}}
 
     # Declare TREE structure
     @classmethod
@@ -42,7 +39,7 @@ def tree_from_underscore_notation_files(cls, folder: str):
 
         keys_tree = cls.tree()
         folder = Path(folder)
-        files = list(sorted(folder.glob('*')))
+        files = list(sorted(folder.glob("*")))
         for f in files:
             f: Path
 
@@ -51,10 +48,10 @@ def tree_from_underscore_notation_files(cls, folder: str):
 
             name = f.stem
 
-            if name.startswith('.'):
+            if name.startswith("."):
                 continue
 
-            chunks = name.split('_', maxsplit=1)
+            chunks = name.split("_", maxsplit=1)
             if len(chunks) == 1:
                 continue
 
@@ -77,22 +74,29 @@ def get_file_extension(cls, filename, with_dot=False):
     @classmethod
     def is_metadata_file(cls, filename: str) -> bool:
         ext = cls.get_file_extension(filename)
-        return ext in ['yml', 'json', 'toml', 'tml']
+        return ext in ["yml", "json", "toml", "tml"]
 
     @classmethod
     def is_image_file(cls, filename: str) -> bool:
         return imghdr.what(filename) is not None
 
+    @classmethod
+    def _numpy_load_txt(cls, filename: str) -> np.ndarray:
+        with warnings.catch_warnings():
+            warnings.filterwarnings("ignore", "loadtxt")
+            data = np.loadtxt(filename)
+        return data
+
     @classmethod
     def is_numpy_array_file(cls, filename: str) -> bool:
         ext = cls.get_file_extension(filename)
-        if ext in ['txt', 'data']:
+        if ext in ["txt", "data"]:
             try:
-                np.loadtxt(filename)
+                cls._numpy_load_txt(filename)
                 return True
             except Exception:
                 return False
-        if ext in ['npy', 'npz']:
+        if ext in ["npy", "npz"]:
             try:
                 np.load(filename)
                 return True
@@ -103,11 +107,11 @@ def is_numpy_array_file(cls, filename: str) -> bool:
     @classmethod
     def is_picke_file(cls, filename: str):
         ext = cls.get_file_extension(filename)
-        return ext == 'pkl'
+        return ext == "pkl"
 
     @classmethod
     def load_data(cls, filename: str) -> Union[None, np.ndarray, dict]:
-        """ Load data from file based on its extension
+        """Load data from file based on its extension
 
         :param filename: target filename
         :type filename: str
@@ -115,8 +119,6 @@ def load_data(cls, filename: str) -> Union[None, np.ndarray, dict]:
         :rtype: Union[None, np.ndarray, dict]
         """
 
-        cls._check_libraries()
-
         extension = cls.get_file_extension(filename)
         data = None
 
@@ -124,27 +126,26 @@ def load_data(cls, filename: str) -> Union[None, np.ndarray, dict]:
             data = np.array(imageio.imread(filename))
 
         elif cls.is_numpy_array_file(filename):
-            if extension in ['txt']:
-                data = np.loadtxt(filename)
-            elif extension in ['npy', 'npz']:
+            if extension in ["txt"]:
+                data = cls._numpy_load_txt(filename)
+            elif extension in ["npy", "npz"]:
                 data = np.load(filename)
             if data is not None:
                 data = np.atleast_2d(data)
 
         elif cls.is_metadata_file(filename):
-            if extension in ['yml']:
-                data = yaml.safe_load(open(filename, 'r'))
-            elif extension in ['json']:
+            if extension in ["yml"]:
+                data = yaml.safe_load(open(filename, "r"))
+            elif extension in ["json"]:
                 data = json.load(open(filename))
         elif cls.is_picke_file(filename):
-            data = pickle.load(open(filename, 'rb'))
+            data = pickle.load(open(filename, "rb"))
         else:
-            raise NotImplementedError(f'Unknown file extension: {filename}')
+            raise NotImplementedError(f"Unknown file extension: {filename}")
         return data
 
     @classmethod
     def store_data(cls, filename: str, data: any):
-        cls._check_libraries()
 
         extension = cls.get_file_extension(filename)
         if DataCoding.is_image_extension(extension):
@@ -155,34 +156,11 @@ def store_data(cls, filename: str, data: any):
         elif DataCoding.is_numpy_extension(extension):
             np.save(filename, data)
         elif DataCoding.is_metadata_extension(extension):
-            if extension in ['yml']:
-                yaml.safe_dump(data, open(filename, 'w'))
-            elif extension in ['json']:
-                json.dump(data, open(filename, 'w'))
+            if extension in ["yml"]:
+                yaml.safe_dump(data, open(filename, "w"))
+            elif extension in ["json"]:
+                json.dump(data, open(filename, "w"))
         elif DataCoding.is_pickle_extension(extension):
-            pickle.dump(data, open(filename, 'wb'))
+            pickle.dump(data, open(filename, "wb"))
         else:
-            raise NotImplementedError(f'Unknown file extension: {filename}')
-
-    @classmethod
-    def _check_libraries(cls):
-        """ Check if required libraries are installed,
-        if missing it install them
-        """
-
-        for k, v in cls.INSTALLED_LIBRARIES.items():
-            if not v:
-                cls._install_library(k)
-                cls.INSTALLED_LIBRARIES[k] = True
-
-    @classmethod
-    def _install_library(cls, lib: str):
-        """ Install a specified library
-
-        :param lib: library to install
-        :type lib: str
-        """
-
-        # Install EXR support for ImageIO
-        if lib == 'exr':
-            imageio.plugins.freeimage.download()
+            raise NotImplementedError(f"Unknown file extension: {filename}")
diff --git a/pipelime/h5/__init__.py b/pipelime/h5/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pipelime/h5/toolkit.py b/pipelime/h5/toolkit.py
new file mode 100644
index 0000000..884b918
--- /dev/null
+++ b/pipelime/h5/toolkit.py
@@ -0,0 +1,214 @@
+import pickle
+from io import BytesIO
+from typing import Optional
+
+import h5py
+import imageio
+import numpy as np
+
+from pipelime.tools.bytes import DataCoding
+
+
+class H5Database(object):
+    ITEMS_BRANCH_NAME = "items"
+    GLOBAL_BRANCH_NAME = "globals"
+
+    def __init__(self, filename, **kwargs):
+        """Generic H5Database
+        :param filename: database filename
+        :type filename: str
+        """
+        self._filename = filename
+        self._readonly = kwargs.get("readonly", True)
+        self._swmr = kwargs.get("swmr", True)
+        self._handle = None
+
+    def is_empty(self):
+        """Checks if H5Database is empty
+        :return: TRUE if is empty
+        :rtype: bool
+        """
+        if self.is_open():
+            return len(self.handle.keys()) == 0
+
+    def initialize(self):
+        """Initialize database"""
+        if self.is_open():
+            pass
+
+    @property
+    def readonly(self):
+        """Checks if database is in read only mode
+        :return: TRUE for readonly mode
+        :rtype: bool
+        """
+        return self._readonly
+
+    @property
+    def filename(self):
+        """Linked filename
+        :return: filename
+        :rtype: str
+        """
+        return self._filename
+
+    @property
+    def handle(self) -> h5py.File:
+        """Pointer to real h5py file
+        :return: h5py database
+        :rtype: h5py.File
+        """
+        return self._handle
+
+    def is_open(self):
+        """TRUE if file is already open"""
+
+        return self.handle is not None
+
+    def open(self):
+        """Opens related file"""
+        if not self.is_open():
+            self._handle = h5py.File(
+                self.filename,
+                "r" if self.readonly else "a",
+                swmr=(self._swmr and self.readonly),
+            )
+            if self.is_empty():
+                self.initialize()
+
+    def close(self):
+        """Closes related file"""
+        if self.is_open():
+            self._handle.close()
+            self._handle = None
+
+    def __enter__(self):
+        self.open()
+        return self
+
+    def __exit__(self, type, value, traceback):
+        self.close()
+
+    def get_group(self, key, force_create=True) -> h5py.Group:
+        """Fetches a Group by key if any
+        :param key: group key
+        :type key: str
+        :param force_create: TRUE to create Group if not present, defaults to True
+        :type force_create: bool, optional
+        :return: fetched Group if any
+        :rtype: h5py.Group
+        """
+        group = None
+        if self.is_open():
+            if key not in self.handle:
+                if force_create:
+                    group = self.handle.create_group(key)
+            else:
+                group = self.handle[key]
+        return group
+
+    def get_sample_group(self, key: str, force_create: bool = True) -> h5py.Group:
+        """Fetches a Sample Group by key. It pre-prend 'items' branch name to desired key
+        to compose the fulle key, like: /<ITEM_BRANCH_NAME>/key
+
+        :param key: sample key
+        :type key: str
+        :param force_create: True to create group if not present, defaults to True
+        :type force_create: bool, optional
+        :return: fetched Group if any
+        :rtype: h5py.Group
+        """
+
+        group_key = f"/{self.ITEMS_BRANCH_NAME}/{key}"
+        return self.get_group(group_key, force_create=force_create)
+
+    def get_sample_root(self, force_create: bool = True) -> h5py.Group:
+        """Fetches the Sample Group root
+
+        :param force_create: True to create group if not present, defaults to True
+        :type force_create: bool, optional
+        :return: fetched Group if any
+        :rtype: h5py.Group
+        """
+        return self.get_group(f"/{self.ITEMS_BRANCH_NAME}/", force_create=force_create)
+
+    def get_global_group_name(self, key: str) -> str:
+        return f"/{self.GLOBAL_BRANCH_NAME}/{key}"
+
+    def get_global_group(self, key: str, force_create: bool = True) -> h5py.Group:
+        """Fetches a Global Group by key. It pre-prend 'global' branch name to desired key
+        to compose the fulle key, like: /<GLOBAL_BRANCH_NAME>/key
+
+        :param key: sample key
+        :type key: str
+        :param force_create: True to create group if not present, defaults to True
+        :type force_create: bool, optional
+        :return: fetched Group if any
+        :rtype: h5py.Group
+        """
+
+        group_key = self.get_global_group_name(key=key)
+        return self.get_group(group_key, force_create=force_create)
+
+    def sample_keys(self) -> set:
+        group = self.get_sample_root(force_create=False)
+        if group is not None:
+            return set(group.keys())
+        else:
+            return set()
+
+
+class H5ToolKit:
+    ENCODING_STRING = "_encoding"
+    ENCODING_BINARY = "pkl"
+    OPTIONS = {"png": {"compress_level": 4}}
+    ALLOWED_ENCODINGS = DataCoding.IMAGE_CODECS + [ENCODING_BINARY]
+
+    @classmethod
+    def get_encoding(cls, dataset: h5py.Dataset) -> Optional[str]:
+        if cls.ENCODING_STRING in dataset.attrs:
+            return dataset.attrs[cls.ENCODING_STRING]
+        return None
+
+    @classmethod
+    def set_encoding(cls, dataset: h5py.Dataset, encoding: str):
+        dataset.attrs[cls.ENCODING_STRING] = encoding
+
+    @classmethod
+    def decode_data(cls, dataset: h5py.Dataset):
+        encoding = cls.get_encoding(dataset)
+        data = None
+        if encoding is not None:
+            if encoding in cls.ALLOWED_ENCODINGS:
+                data = DataCoding.bytes_to_data(dataset[...], encoding)
+                if data is None:
+                    data = pickle.loads(dataset[...])
+            else:
+                data = pickle.loads(dataset[...])
+        else:
+            data = dataset[...]
+        return data
+
+    @classmethod
+    def store_data(cls, group: h5py.Group, key: str, data: any, encoding: str = None):
+
+        if encoding is None:
+            if isinstance(data, np.ndarray):
+                group[key] = data
+            else:
+                buffer = BytesIO(bytes())
+                pickle.dump(data, buffer)
+                group[key] = buffer.getbuffer()
+                cls.set_encoding(group[key], cls.ENCODING_BINARY)
+
+        elif DataCoding.is_image_extension(encoding):
+            options = cls.OPTIONS.get(encoding, {})
+            buffer = BytesIO(bytes())
+            imageio.imwrite(buffer, data, format=encoding, **options)
+            group[key] = buffer.getbuffer()
+            group[key].attrs[cls.ENCODING_STRING] = encoding
+        else:
+            buffer = BytesIO(bytes())
+            pickle.dump(data, buffer)
+            group[key] = buffer.getbuffer()
+            cls.set_encoding(group[key], cls.ENCODING_BINARY)
diff --git a/pipelime/sequences/operations.py b/pipelime/sequences/operations.py
index dc3c8d9..c9551d0 100644
--- a/pipelime/sequences/operations.py
+++ b/pipelime/sequences/operations.py
@@ -1,79 +1,215 @@
-from loguru import logger
-import loguru
-from pipelime.sequences.stages import StageKeysFilter
-from pipelime.tools.idgenerators import IdGenerator, IdGeneratorUUID
-from pipelime.factories import Bean, BeanFactory
-import pydash as py_
-import dictquery as dq
-import numpy as np
+from __future__ import annotations
+
+import copy
+import multiprocessing
 import random
-from typing import Any, Callable, Dict, List, Optional, Sequence, Union
-from pipelime.sequences.samples import GroupedSample, Sample, SamplesSequence
 from abc import ABC, abstractmethod
-from schema import Or, Schema
+from typing import Any, Callable, Dict, Optional, Sequence, Union
+
+import dictquery as dq
+import numpy as np
+import pydash as py_
 import rich
+from choixe.spooks import Spook
+from loguru import logger
+from rich.progress import track
+from schema import Or, Schema
+
+from pipelime.sequences.samples import GroupedSample, Sample, SamplesSequence
+from pipelime.sequences.stages import SampleStage
+from pipelime.tools.idgenerators import IdGenerator, IdGeneratorInteger
 
 
 class OperationPort(object):
+    """Input/Output port of a pipelime `Operation`. Declares what an operation must receive
+    as input or what it returns as output and provides methods to:
+
+    - Validate - check if an object satisfies given requirements
+    - Check if two ports are compatible with each other
+    """
 
-    def __init__(self, schema: dict):
-        """ Creates an Operation Port with an associated schema (as dict)
+    def __init__(self, schema: dict) -> None:
+        """Creates an Operation Port with an associated schema (as dict)
 
         :param schema: port data schema
         :type schema: dict
         """
         self._schema = Schema(schema)
 
-    def match(self, o: 'OperationPort'):
+    def match(self, o: OperationPort) -> bool:
+        """Check if this port is compabile with another port.
+
+        :param o: the other port
+        :type o: OperationPort
+        :return: True if the ports match.
+        :rtype: bool
+        """
         return self._schema.json_schema(0) == o._schema.json_schema(0)
 
-    def is_valid_data(self, o: Any):
+    def is_valid_data(self, o: Any) -> bool:
+        """Check if a python object matches the schema of this port.
+
+        :param o: any python object
+        :type o: Any
+        :return: True if the object matches the schema
+        :rtype: bool
+        """
         return self._schema.validate(o)
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return str(self._schema)
 
 
 class SequenceOperation(ABC):
-    """Object representing a generic pipeline operation on a sequence
-    """
+    """Object representing a generic pipeline operation on a sequence"""
 
     def __init__(self) -> None:
         pass
 
     @abstractmethod
     def input_port(self) -> OperationPort:
+        """The input port of this `SequenceOperation`.
+
+        :raises NotImplementedError: if this method is not implemented
+        :return: The input port.
+        :rtype: OperationPort
+        """
         raise NotImplementedError()
 
     @abstractmethod
     def output_port(self) -> OperationPort:
+        """The output port of this `SequenceOperation`
+
+        :raises NotImplementedError: if this method is not implemented
+        :return: The output port.
+        :rtype: OperationPort
+        """
         raise NotImplementedError()
 
     @abstractmethod
     def __call__(self, x: Any) -> Any:
+        """Performs the operation on input data
+
+        :param x: An object matching this operation input port.
+        :type x: Any
+        :return: The operation result, matching this operation output port.
+        :rtype: Any
+        """
         p = self.input_port()
         assert p.is_valid_data(x)
 
-    def match(self, x: 'SequenceOperation'):
+    def match(self, x: SequenceOperation) -> bool:
+        """Checks if this operation can be composed with another operation -
+        i.e. if the output of this operation can be passed as input to the other operation.
+
+        :param x: Another operation to be composed with this.
+        :type x: SequenceOperation
+        :return: True if the operations are composable
+        :rtype: bool
+        """
         p0 = self.output_port()
         p1 = x.input_port()
         return p0.match(p1)
 
-    def print(self):
-        rich.print({
-            'name': self.__class__.__name__,
-            'input': self.input_port(),
-            'output': self.output_port()
-        })
+    def print(self) -> None:
+        """Rich-prints this operation details."""
+        rich.print(
+            {
+                "name": self.__class__.__name__,
+                "input": self.input_port(),
+                "output": self.output_port(),
+            }
+        )
 
 
-@BeanFactory.make_serializable
-class OperationSum(SequenceOperation, Bean):
+class MappableOperation(SequenceOperation):
+    """Abstract class for generic mappable operations. A mappable operation has the
+    following requirements:
 
-    def __init__(self) -> None:
-        """ Concatenatas multiple sequences
+    - Takes as input a single `SamplesSequence` `A`
+    - Returns a single `SamplesSequence` `B`
+    - len(`A`) >= len(`B`)
+    - Elements of `A` and `B` have the same order
+    - Each element of `B` is the result of a custom function applied on the
+      corresponding element in sequence `A`.
+
+    `MappableOperation` automatically takes care of progress bar and multiprocessing, so
+    you do not need to write custom multiprocessing logic every time you implement this
+    kind of operation.
+    """
+
+    def __init__(self, num_workers: int = 0, progress_bar: bool = False) -> None:
+        """Constructor for `MappableOperation` subclasses
+
+        :param num_workers: the number of multiprocessing workers, set to -1 to spawn as
+        many workers as possible, set to 0 to execute on current process, defaults to 0
+        :type num_workers: int, optional
+        :param progress_bar: Enable/disable rich progress bar printing on stdout, defaults to False
+        :type progress_bar: bool, optional
         """
         super().__init__()
+        self._num_workers = num_workers
+        self._progress_bar = progress_bar
+        self.pbar_desc = f"{self.__class__.__name__}..."
+
+    def input_port(self) -> OperationPort:
+        return OperationPort(SamplesSequence)
+
+    def output_port(self) -> OperationPort:
+        return OperationPort(SamplesSequence)
+
+    @abstractmethod
+    def apply_to_sample(self, sample: Sample) -> Optional[Sample]:
+        """Custom function that transforms a sample of sequence `A` to a sample of sequence `B`
+
+        :param sample: A sample of sequence `A`
+        :type sample: Sample
+        :return: The transformed sample of sequence `B`, None to discard the sample.
+        :rtype: Optional[Sample]
+        """
+        pass
+
+    def __call__(self, x: SamplesSequence) -> SamplesSequence:
+        super().__call__(x)
+        new_sequence = []
+        total = len(x)
+
+        if self._num_workers > 0 or self._num_workers == -1:
+            num_workers = None if self._num_workers == -1 else self._num_workers
+            pool = multiprocessing.Pool(processes=num_workers)
+            new_sequence = pool.imap(self.apply_to_sample, x)
+
+            if self._progress_bar:
+                new_sequence = track(new_sequence, total=total)
+
+        else:
+            if self._progress_bar:
+                x = track(x)
+
+            for sample in x:
+                new_sample = self.apply_to_sample(sample)
+                new_sequence.append(new_sample)
+
+        new_sequence = [x for x in new_sequence if x is not None]
+
+        return SamplesSequence(samples=new_sequence)
+
+    @classmethod
+    def spook_schema(cls) -> dict:
+        return {"num_workers": int, "progress_bar": bool}
+
+    def to_dict(self) -> dict:
+        return {
+            "num_workers": self._num_workers,
+            "progress_bar": self._progress_bar,
+        }
+
+
+class OperationSum(SequenceOperation, Spook):
+    """Concatenates multiple sequences."""
+
+    def __init__(self) -> None:
+        super().__init__()
 
     def input_port(self) -> OperationPort:
         return OperationPort([SamplesSequence])
@@ -88,24 +224,11 @@ def __call__(self, x: Sequence[SamplesSequence]) -> SamplesSequence:
             new_samples += s.samples
         return SamplesSequence(samples=new_samples)
 
-    @classmethod
-    def bean_schema(cls) -> dict:
-        return {}
-
-    @classmethod
-    def from_dict(cls, d: dict):
-        return OperationSum()
-
-    def to_dict(self):
-        return {}
-
 
-@BeanFactory.make_serializable
-class OperationIdentity(SequenceOperation, Bean):
+class OperationIdentity(SequenceOperation, Spook):
+    """An Identity operation that does nothing (NoOp)."""
 
     def __init__(self) -> None:
-        """ No Op
-        """
         super().__init__()
 
     def input_port(self) -> OperationPort:
@@ -118,26 +241,21 @@ def __call__(self, x: any) -> any:
         super().__call__(x)
         return x
 
-    @classmethod
-    def bean_schema(cls) -> dict:
-        return {}
 
-    @classmethod
-    def from_dict(cls, d: dict):
-        return OperationIdentity()
+class OperationResetIndices(SequenceOperation, Spook):  # TODO: unit test!
+    """Regenerates every sample id according to a custom id generation strategy."""
 
-    def to_dict(self):
-        return {}
+    def __init__(self, generator: Optional[IdGenerator] = None) -> None:
+        """Creates an `OperationResetIndices` with a custom id generator.
 
-
-@BeanFactory.make_serializable
-class OperationResetIndices(SequenceOperation, Bean):  # TODO: unit test!
-
-    def __init__(self, generator: Union[IdGenerator, None] = None) -> None:
-        """ Reset indices of sample
+        :param generator: The id generator to use, if set to `None` a simple progressive
+        integer number generator is used. Defaults to None
+        :type generator: Optional[IdGenerator], optional
         """
         super().__init__()
-        self._generator: Optional[IdGenerator] = generator if generator is not None else IdGeneratorUUID()
+        self._generator: Optional[IdGenerator] = (
+            generator if generator is not None else IdGeneratorInteger()
+        )
 
     def input_port(self) -> OperationPort:
         return OperationPort(SamplesSequence)
@@ -152,29 +270,28 @@ def __call__(self, x: SamplesSequence) -> SamplesSequence:
         return x
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'generator': dict
-        }
+    def spook_schema(cls) -> dict:
+        return {"generator": dict}
 
     @classmethod
     def from_dict(cls, d: dict):
-        return OperationResetIndices(
-            generator=BeanFactory.create(d['generator'])
-        )
+        return OperationResetIndices(generator=Spook.create(d["generator"]))
 
     def to_dict(self):
-        return {
-            'generator': self._generator.serialize()
-        }
+        return {"generator": self._generator.serialize()}
 
 
-@BeanFactory.make_serializable
-class OperationSubsample(SequenceOperation, Bean):
+class OperationSubsample(SequenceOperation, Spook):
+    """Subsamples an input sequence picking one element every K."""
+
     def __init__(self, factor: Union[int, float]) -> None:
-        """ Subsample an input sequence elements
+        """Instantiates an `OperationSubsample` with a given factor.
+
+        :param factor: Set to:
+
+        - an `int` to specify a subsampling factor (one element every `factor`)
+        - a `float` to specify a percentage of input elements to preserve
 
-        :param factor: if INT is the subsampling factor, if FLOAT is the percentage of input elements to preserve
         :type factor: Union[int, float]
         """
         super().__init__()
@@ -191,7 +308,9 @@ def __call__(self, x: SamplesSequence) -> SamplesSequence:
 
         new_samples = x.samples.copy()
         if isinstance(self._factor, int):
-            new_samples = new_samples[::self._factor]
+            new_samples = new_samples[
+                :: self._factor
+            ]  # Pick an element each `self._factor` elements
         elif isinstance(self._factor, float):
             new_size = int(len(new_samples) * min(max(self._factor, 0), 1.0))
             new_samples = new_samples[:new_size]
@@ -199,28 +318,18 @@ def __call__(self, x: SamplesSequence) -> SamplesSequence:
         return SamplesSequence(samples=new_samples)
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'factor': Or(float, int)
-        }
-
-    @classmethod
-    def from_dict(cls, d: dict):
-        return OperationSubsample(
-            factor=d['factor']
-        )
+    def spook_schema(cls) -> dict:
+        return {"factor": Or(float, int)}
 
     def to_dict(self):
-        return {
-            'factor': self._factor
-        }
+        return {"factor": self._factor}
 
 
-@BeanFactory.make_serializable
-class OperationShuffle(SequenceOperation, Bean):
+class OperationShuffle(SequenceOperation, Spook):
+    """Shuffle input sequence elements"""
 
     def __init__(self, seed=-1) -> None:
-        """ Shuffle input sequence elements
+        """Creates an `OperationShuffle` with an optional a random seed
 
         :param seed: random seed (-1 for current system time), defaults to -1
         :type seed: int, optional
@@ -242,28 +351,18 @@ def __call__(self, x: SamplesSequence) -> Any:
         return SamplesSequence(samples=new_data)
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'seed': int
-        }
-
-    @classmethod
-    def from_dict(cls, d: dict):
-        return OperationShuffle(
-            seed=d['seed']
-        )
+    def spook_schema(cls) -> dict:
+        return {"seed": int}
 
     def to_dict(self):
-        return {
-            'seed': self._seed
-        }
+        return {"seed": self._seed}
 
 
-@BeanFactory.make_serializable
-class OperationSplits(SequenceOperation, Bean):
+class OperationSplits(SequenceOperation, Spook):
+    """Splits an input sequence in multiple sub-sequences in a key/sequence map"""
 
     def __init__(self, split_map: Dict[str, float]) -> None:
-        """ Splits an input sequence in multiple sub-sequences in a key/sequence map
+        """Creates an `OperationSplits`.
 
         :param split_map: key/percengate map used as split map. Sum of percentages must be 1.0
         :type split_map: Dict[str, float]
@@ -285,6 +384,7 @@ def _splits(self, x: SamplesSequence, percentages: Sequence = [0.8, 0.1, 0.1]):
         :return: list of PandasDatabase
         :rtype: list
         """
+
         assert np.array(percentages).sum() <= 1.0, "Percentages sum must be <= 1.0"
         sizes = []
         for p in percentages:
@@ -295,12 +395,16 @@ def _splits(self, x: SamplesSequence, percentages: Sequence = [0.8, 0.1, 0.1]):
         chunks = []
         current_index = 0
         for s in sizes:
-            _samples = x.samples[current_index:current_index + s]
+            _samples = x.samples[current_index : current_index + s]
             chunks.append(SamplesSequence(samples=_samples))
             current_index += s
         return tuple(chunks)
 
-    def _splits_as_dict(self, x: SamplesSequence, percentages_dictionary: dict = {'train': 0.9, 'test': 0.1}):
+    def _splits_as_dict(
+        self,
+        x: SamplesSequence,
+        percentages_dictionary: dict = {"train": 0.9, "test": 0.1},
+    ):
         """Splits PandasDatabase in N objects based on a percentage dictionary name/percentage
 
         :param percentages_dictionary: percentages dictionary, defaults to {'train': 0.9, 'test': 0.1}
@@ -312,7 +416,9 @@ def _splits_as_dict(self, x: SamplesSequence, percentages_dictionary: dict = {'t
         percentages = list(percentages_dictionary.values())
         chunks = self._splits(x, percentages=percentages)
         output = {}
-        assert len(names) == len(chunks), "Len of chunks is different from percentage names number"
+        assert len(names) == len(
+            chunks
+        ), "Len of chunks is different from percentage names number"
         for idx in range(len(names)):
             output[names[idx]] = chunks[idx]
         return output
@@ -322,29 +428,17 @@ def __call__(self, x: SamplesSequence) -> Dict[str, SamplesSequence]:
         return self._splits_as_dict(x, self._split_map)
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'split_map': {str: float}
-        }
-
-    @classmethod
-    def from_dict(cls, d: dict):
-        return OperationSplits(
-            split_map=d['split_map']
-        )
+    def spook_schema(cls) -> dict:
+        return {"split_map": {str: float}}
 
     def to_dict(self):
-        return {
-            'split_map': self._split_map
-        }
+        return {"split_map": self._split_map}
 
 
-@BeanFactory.make_serializable
-class OperationDict2List(SequenceOperation, Bean):
+class OperationDict2List(SequenceOperation, Spook):
+    """Converts a Dict of sequences into a list of sequences"""
 
     def __init__(self) -> None:
-        """ Converts a Dict of sequences into a play list of sequences
-        """
         super().__init__()
 
     def input_port(self):
@@ -357,67 +451,55 @@ def __call__(self, x: Dict[str, SamplesSequence]) -> Sequence[SamplesSequence]:
         super().__call__(x)
         return list(x.values())
 
-    @classmethod
-    def bean_schema(cls) -> dict:
-        return {}
 
-    def to_dict(self):
-        return {}
+class OperationFilterByQuery(MappableOperation, Spook):
+    """Filter sequence elements based on query string.
 
+    If, for example, a sample contains a 'metadata' item
+    storing a dictionary like::
 
-@BeanFactory.make_serializable
-class OperationFilterByQuery(SequenceOperation, Bean):
+        {'metadata':{'num': 10}}
 
-    def __init__(self, query: str) -> None:
-        """ Filter sequence elements based on query string. If sample contains a 'metadata' item
-        storing a dict like {'metadata':{'num': 10}}, a query string could be something like
-        query = '`metadata.num` > 10' .
+    a query string to keep only samples with `num` greater than 10 could be::
 
-        :param query: query string (@see dictquery)
-        :type query: str
-        """
-        self._query = query
+        query = '`metadata.num` > 10'.
 
-    def input_port(self):
-        return OperationPort(SamplesSequence)
+    See dictquery docs for details.
+    """
 
-    def output_port(self):
-        return OperationPort(SamplesSequence)
+    def __init__(self, query: str, **kwargs) -> None:
+        """Creates an `OperationFilterByQuery` from custom query string.
 
-    def __call__(self, x: SamplesSequence) -> SamplesSequence:
-        super().__call__(x)
-        filtered_samples = []
-        for sample in x.samples:
-            if dq.match(sample, self._query):
-                filtered_samples.append(sample)
-        return SamplesSequence(samples=filtered_samples)
+        :param query: dictquery query string
+        :type query: str
+        """
+        super().__init__(**kwargs)
+        self._query = query
 
-    @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'query': str
-        }
+    def apply_to_sample(self, sample: Sample) -> Optional[Sample]:
+        """Applies the operation to a single sample"""
+        return sample if dq.match(sample, self._query) else None
 
     @classmethod
-    def from_dict(cls, d: dict):
-        return OperationFilterByQuery(
-            query=d['query']
-        )
+    def spook_schema(cls) -> dict:
+        return {"query": str, **super().spook_schema()}
 
     def to_dict(self):
-        return {
-            'query': self._query
-        }
+        return {"query": self._query, **super().to_dict()}
+
 
+# TODO: Replace dictquery with pydash?
+class OperationSplitByQuery(SequenceOperation, Spook):
+    """Splits an input sequence in two sub-sequences according to an input query. The first sequence
+    will contains elements with positve query matches, the second sequence the negative ones.
 
-@BeanFactory.make_serializable
-class OperationSplitByQuery(SequenceOperation, Bean):  # TODO: Replace dictquery with pydash?
+    See dictquery docs for details.
+    """
 
     def __init__(self, query: str) -> None:
-        """ Splits sequence elements in two sub-sequences based on an input query. The first list
-        will contains elements with positve query matches, the second list the negative ones.
+        """Creates am `OperationSplitByQuery` from a given query string
 
-        :param query: query string (@see dictquery)
+        :param query: dictquery query string
         :type query: str
         """
         self._query = query
@@ -438,34 +520,68 @@ def __call__(self, x: SamplesSequence) -> Sequence[SamplesSequence]:
             else:
                 b.append(sample)
 
-        return (
-            SamplesSequence(samples=a),
-            SamplesSequence(samples=b)
-        )
+        return (SamplesSequence(samples=a), SamplesSequence(samples=b))
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'query': str
-        }
+    def spook_schema(cls) -> dict:
+        return {"query": str}
+
+    def to_dict(self):
+        return {"query": self._query}
+
+
+class OperationSplitByValue(SequenceOperation, Spook):
+    """Similar to OperationGroupBy, but instead of using grouped samples, it simply
+    splits the input sequence into many sequences, one for each unique value of
+    the specified key
+    """
+
+    def __init__(self, key: str) -> None:
+        """Creates an `OperationSplitByValue`
+
+        :param key: key string on which to base the splitting operation. Can be use pydash dot notation.
+        :type key: str
+        """
+        super().__init__()
+        self._key = key
+
+    def input_port(self) -> OperationPort:
+        return OperationPort(SamplesSequence)
+
+    def output_port(self) -> OperationPort:
+        return OperationPort(Sequence[SamplesSequence])
+
+    def __call__(self, x: Any) -> Any:
+        super().__call__(x)
+
+        groups_map = {}
+        for sample in x:
+            value = py_.get(sample, self._key)
+            if value is not None:
+                if value not in groups_map:
+                    groups_map[value] = []
+                groups_map[value].append(sample)
+
+        for k, samples in groups_map.items():
+            groups_map[k] = SamplesSequence(samples)
+
+        keys = sorted(list(groups_map.keys()))
+        res = [groups_map[k] for k in keys]
+        return res
 
     @classmethod
-    def from_dict(cls, d: dict):
-        return OperationSplitByQuery(
-            query=d['query']
-        )
+    def spook_schema(cls) -> dict:
+        return {"key": str}
 
     def to_dict(self):
-        return {
-            'query': self._query
-        }
+        return {"key": self._key}
 
 
-@BeanFactory.make_serializable
-class OperationGroupBy(SequenceOperation, Bean):
+class OperationGroupBy(SequenceOperation, Spook):
+    """Groups sequence elements accoring to specific field"""
 
     def __init__(self, field: str, ungrouped: bool = False) -> None:
-        """ Groups sequence elements accoring to specific field
+        """Creates an `OperationGroupBy`
 
         :param query: field string (@see pydash deep path notation)
         :type query: str
@@ -474,7 +590,7 @@ def __init__(self, field: str, ungrouped: bool = False) -> None:
         they will be put in this special group.
         :type ungrouped: bool
         """
-        self._field = field.replace('`', '')
+        self._field = field.replace("`", "")
         self._ungrouped = ungrouped
 
     def input_port(self):
@@ -510,31 +626,17 @@ def __call__(self, x: SamplesSequence) -> Sequence[SamplesSequence]:
         return SamplesSequence(samples=out_samples)
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'field': str,
-            'ungrouped': bool
-        }
-
-    @classmethod
-    def from_dict(cls, d: dict):
-        return OperationGroupBy(
-            field=d['field'],
-            ungrouped=d['ungrouped']
-        )
+    def spook_schema(cls) -> dict:
+        return {"field": str, "ungrouped": bool}
 
     def to_dict(self):
-        return {
-            'field': self._field,
-            'ungrouped': self._ungrouped
-        }
+        return {"field": self._field, "ungrouped": self._ungrouped}
 
 
-@BeanFactory.make_serializable
-class OperationOrderBy(SequenceOperation, Bean):
+class OperationOrderBy(SequenceOperation, Spook):
+    """Performs multiple-key sorting of an input sequence."""
 
     class reversor:
-
         def __init__(self, obj):
             self.obj = obj
 
@@ -545,10 +647,15 @@ def __lt__(self, other):
             return other.obj < self.obj
 
     def __init__(self, order_keys: Sequence[str]) -> None:
-        """ Order sequence elements based on pydash dict key . like '`metadata.num`'.
+        """Creates an `OperationOrderBy` from list of sorting keys
 
-        :param order_keys: list of keys to order by
+        :param order_keys: list of keys to order by. Prepend a `+` or `-` sign to specify
+        if sorting order should be ascending or descending.
         :type order_keys: Sequence[str]
+
+        Example::
+
+            order_keys = ['+metadata.value_a', '-metadata.value_b'].
         """
         self._order_keys = order_keys
 
@@ -559,8 +666,8 @@ def output_port(self):
         return OperationPort(SamplesSequence)
 
     def _order_pair_from_string(self, v: str):
-        if v.startswith('+') or v.startswith('-'):
-            return True if v[0] == '-' else False, v[1:]
+        if v.startswith("+") or v.startswith("-"):
+            return True if v[0] == "-" else False, v[1:]
         else:
             return False, v
 
@@ -577,71 +684,56 @@ def __call__(self, x: SamplesSequence) -> SamplesSequence:
         return SamplesSequence(samples=sorted(x, key=self._order_by))
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'order_keys': [str]
-        }
-
-    @classmethod
-    def from_dict(cls, d: dict):
-        return OperationOrderBy(
-            order_keys=d['order_keys']
-        )
+    def spook_schema(cls) -> dict:
+        return {"order_keys": [str]}
 
     def to_dict(self):
-        return {
-            'order_keys': self._order_keys
-        }
+        return {"order_keys": self._order_keys}
 
 
-@BeanFactory.make_serializable
-class OperationFilterKeys(SequenceOperation, Bean):
+class OperationFilterKeys(MappableOperation, Spook):
+    """Filters items of every sample of a sequence"""
 
-    def __init__(self, keys: list, negate: bool = False) -> None:
-        """ Filter sequence elements by keys
+    def __init__(self, keys: list, negate: bool = False, **kwargs) -> None:
+        """Creates an `OperationFilterKeys`
 
         :param keys: list of keys to preserve
         :type keys: list
-        :param negate: TRUE to delete input keys, FALSE delete all but keys
+        :param negate: Set to:
+         - `True` to delete input keys.
+         - `False` to delete all but keys
         :type negate: bool
         """
+        super().__init__(**kwargs)
         self._keys = keys
         self._negate = negate
-        self._stage = StageKeysFilter(keys=keys, negate=negate)
-
-    def input_port(self):
-        return OperationPort(SamplesSequence)
-
-    def output_port(self):
-        return OperationPort(SamplesSequence)
 
-    def __call__(self, x: SamplesSequence) -> SamplesSequence:
-        super().__call__(x)
-        filtered_samples = []
-        for sample in x.samples:
-            filtered_samples.append(self._stage(sample))
-        return SamplesSequence(samples=filtered_samples)
+    def apply_to_sample(self, sample: Sample) -> Optional[Sample]:
+        """Applies operation to a single sample"""
+        sample = sample.copy()
+        if self._negate:
+            to_drop = set(sample.keys()).intersection(self._keys)
+        else:
+            to_drop = set(sample.keys()).difference(self._keys)
+        for k in to_drop:
+            del sample[k]
+        return sample
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'keys': list,
-            'negate': bool
-        }
+    def spook_schema(cls) -> dict:
+        return {"keys": list, "negate": bool, **super().spook_schema()}
 
     def to_dict(self):
-        return {
-            'keys': self._keys,
-            'negate': self._negate
-        }
+        return {"keys": self._keys, "negate": self._negate, **super().to_dict()}
 
 
-@BeanFactory.make_serializable
-class OperationFilterByScript(SequenceOperation, Bean):
+class OperationFilterByScript(SequenceOperation, Spook):
+    """Filter sequence elements based on custom python script (or callable). The script has to contain a function
+    named `check_sample` with signature `(sample: Sample, sequence: SampleSequence) -> bool`.
+    """
 
     def __init__(self, path_or_func: Union[str, Callable]) -> None:
-        """ Filter sequence elements based on custom python script (or callable). The script has to contain a function
-        named `check_sample` with signature `(sample: Sample, sequence: SampleSequence) -> bool` .
+        """Creates an `OperationFilterByScript` from a script file or callable function.
 
         :param path_or_func: python script path, or can be a callable function for On-The-Fly usage
         :type path_or_func: Union[str, Callable]
@@ -651,21 +743,26 @@ def __init__(self, path_or_func: Union[str, Callable]) -> None:
             self._check_sample = lambda x: True
             if len(self._path) > 0:
                 import importlib.util
-                spec = importlib.util.spec_from_file_location("check_sample", self._path)
+
+                spec = importlib.util.spec_from_file_location(
+                    "check_sample", self._path
+                )
                 module = importlib.util.module_from_spec(spec)
                 spec.loader.exec_module(module)
                 self._check_sample = module.check_sample
                 assert self._check_sample is not None
             else:
-                logger.warning(f'The input script function is empty! Operation performs no checks!')
+                logger.warning(
+                    f"The input script function is empty! Operation performs no checks!"
+                )
 
             self._serializable = True
         elif isinstance(path_or_func, Callable):
-            self._path = ''
+            self._path = ""
             self._check_sample = path_or_func
             self._serializable = False
         else:
-            raise NotImplementedError(f'Only str|Callable are allowed as input script ')
+            raise NotImplementedError(f"Only str|Callable are allowed as input script ")
 
     def input_port(self):
         return OperationPort(SamplesSequence)
@@ -682,19 +779,82 @@ def __call__(self, x: SamplesSequence) -> SamplesSequence:
         return SamplesSequence(samples=filtered_samples)
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'path_or_func': str
-        }
-
-    @classmethod
-    def from_dict(cls, d: dict):
-        return OperationFilterByScript(
-            path_or_func=d['path_or_func']
-        )
+    def spook_schema(cls) -> dict:
+        return {"path_or_func": str}
 
     def to_dict(self):
         logger.warning("Operation has on-the-fly mode! No script serialized!")
-        return {
-            'path_or_func': self._path
-        }
+        return {"path_or_func": self._path}
+
+
+class OperationMix(SequenceOperation, Spook):
+    """Mixes multiple sequences with same length and disjoint item sets"""
+
+    def __init__(self) -> None:
+        super().__init__()
+
+    def input_port(self) -> OperationPort:
+        return OperationPort([SamplesSequence])
+
+    def output_port(self) -> OperationPort:
+        return OperationPort(SamplesSequence)
+
+    def _check_length(sekf, x: Sequence[SamplesSequence]) -> None:
+        N = len(x[0])
+        for seq in x:
+            assert len(seq) == N, "Not all sample sequences have the same length"
+
+    def _check_keys(self, x: Sequence[SamplesSequence]) -> None:
+        keys_sets = []
+        for i in range(1, len(x)):
+            keys_sets.append(set(x[i][0].keys()))
+        keys_list = []
+        keys_set = set()
+        for k_set in keys_sets:
+            keys_list.extend(list(k_set))
+            keys_set = keys_set.union(set(k_set))
+        assert len(keys_set) == len(keys_list)
+
+    def __call__(self, x: Sequence[SamplesSequence]) -> SamplesSequence:
+        from rich.progress import track
+
+        super().__call__(x)
+        self._check_length(x)
+        self._check_keys(x)
+        N = len(x[0])
+        out = copy.deepcopy(x[0])
+        for i in track(range(N)):
+            for seq in x:
+                out[i].update(seq[i])
+        return out
+
+
+class OperationStage(MappableOperation, Spook):
+    """Transforms a `SampleStage` object into a multiprocessing-ready operation
+    that is applied on a whole dataset.
+    """
+
+    def __init__(self, stage: SampleStage, **kwargs) -> None:
+        """Creates an `OperationStage`
+
+        :param stage: the stage object to apply to every sample of a dataset
+        :type stage: SampleStage
+        """
+        super().__init__(**kwargs)
+        self._stage = stage
+
+    def apply_to_sample(self, sample: Sample) -> Optional[Sample]:
+        """Applies the stage to a single sample"""
+        return self._stage(sample)
+
+    @classmethod
+    def spook_schema(cls) -> dict:
+        return {"stage": dict, **super().spook_schema()}
+
+    def to_dict(self) -> dict:
+        return {"stage": self._stage.serialize(), **super().to_dict()}
+
+    @classmethod
+    def from_dict(cls, d: dict) -> OperationStage:
+        stage = Spook.create(d.pop("stage"))
+        return cls(stage, **d)
diff --git a/pipelime/sequences/pipes.py b/pipelime/sequences/pipes.py
index 3c8afd9..f877699 100644
--- a/pipelime/sequences/pipes.py
+++ b/pipelime/sequences/pipes.py
@@ -1,20 +1,18 @@
-
-
-from pipelime.sequences.readers.base import BaseReader
-from pipelime.sequences.writers.base import BaseWriter
-from pipelime.tools.idgenerators import IdGeneratorUUID
 import tempfile
+from typing import Hashable, Sequence, Union
 
-from pipelime.factories import Bean, BeanFactory
 import networkx as nx
+from choixe.spooks import Spook
 from schema import Or
-from typing import Hashable, Sequence, Union
+
 from pipelime.sequences.operations import SequenceOperation
+from pipelime.sequences.readers.base import BaseReader
 from pipelime.sequences.samples import SamplesSequence
+from pipelime.sequences.writers.base import BaseWriter
+from pipelime.tools.idgenerators import IdGeneratorUUID
 
 
 class PlaceholderDataNode(object):
-
     def __init__(self, name: str) -> None:
         self.name = name
 
@@ -29,12 +27,11 @@ def __eq__(self, o: object) -> bool:
 
 
 class PipeNode(object):
-
     def __init__(
-            self,
-            id: Hashable,
-            input_data: Union[str, list, dict],
-            output_data: Union[str, list, dict]
+        self,
+        id: Hashable,
+        input_data: Union[str, list, dict],
+        output_data: Union[str, list, dict],
     ) -> None:
 
         self._id = id
@@ -65,21 +62,19 @@ def _data_as_list(self, v: any):
         elif isinstance(v, dict):
             return list(v.values())
         else:
-            raise NotImplementedError(f'{type(v)}')
+            raise NotImplementedError(f"{type(v)}")
 
     def __repr__(self) -> str:
         return str(self.id)
 
 
-@BeanFactory.make_serializable
-class OperationNode(PipeNode, Bean):
-
+class OperationNode(PipeNode, Spook):
     def __init__(
-            self,
-            id: Hashable,
-            input_data: Union[str, list, dict],
-            output_data: Union[str, list, dict],
-            operation: SequenceOperation
+        self,
+        id: Hashable,
+        input_data: Union[str, list, dict],
+        output_data: Union[str, list, dict],
+        operation: SequenceOperation,
     ) -> None:
 
         super().__init__(id=id, input_data=input_data, output_data=output_data)
@@ -92,37 +87,30 @@ def operation(self):
     @classmethod
     def bean_schema(cls) -> dict:
         return {
-            'input_data': Or(str, list, dict),
-            'output_data': Or(str, list, dict),
-            'operation': dict
+            "input_data": Or(str, list, dict),
+            "output_data": Or(str, list, dict),
+            "operation": dict,
         }
 
     @classmethod
     def from_dict(cls, d: dict):
         return OperationNode(
             id=IdGeneratorUUID().generate(),
-            input_data=d['input_data'],
-            output_data=d['output_data'],
-            operation=BeanFactory.create(d['operation'])
+            input_data=d["input_data"],
+            output_data=d["output_data"],
+            operation=Spook.create(d["operation"]),
         )
 
     def to_dict(self):
         return {
-            'input_data': self._input_data,
-            'output_data': self._output_data,
-            'operation': self.operation.serialize()
+            "input_data": self._input_data,
+            "output_data": self._output_data,
+            "operation": self.operation.serialize(),
         }
 
 
-@BeanFactory.make_serializable
-class ReaderNode(PipeNode, Bean):
-
-    def __init__(
-            self,
-            id: Hashable,
-            output_data: str,
-            reader: BaseReader
-    ) -> None:
+class ReaderNode(PipeNode, Spook):
+    def __init__(self, id: Hashable, output_data: str, reader: BaseReader) -> None:
 
         super().__init__(id=id, input_data=None, output_data=output_data)
         self._reader = reader
@@ -133,35 +121,22 @@ def reader(self):
 
     @classmethod
     def bean_schema(cls) -> dict:
-        return {
-            'output_data': Or(None, str, list, dict),
-            'reader': dict
-        }
+        return {"output_data": Or(None, str, list, dict), "reader": dict}
 
     @classmethod
     def from_dict(cls, d: dict):
         return ReaderNode(
             id=IdGeneratorUUID().generate(),
-            output_data=d['output_data'],
-            reader=BeanFactory.create(d['reader'])
+            output_data=d["output_data"],
+            reader=Spook.create(d["reader"]),
         )
 
     def to_dict(self):
-        return {
-            'output_data': self._output_data,
-            'reader': self.reader.serialize()
-        }
+        return {"output_data": self._output_data, "reader": self.reader.serialize()}
 
 
-@BeanFactory.make_serializable
-class WriterNode(PipeNode, Bean):
-
-    def __init__(
-            self,
-            id: Hashable,
-            input_data: str,
-            writer: BaseWriter
-    ) -> None:
+class WriterNode(PipeNode, Spook):
+    def __init__(self, id: Hashable, input_data: str, writer: BaseWriter) -> None:
 
         super().__init__(id=id, output_data=None, input_data=input_data)
         self._writer = writer
@@ -172,29 +147,21 @@ def writer(self):
 
     @classmethod
     def bean_schema(cls) -> dict:
-        return {
-            'input_data': Or(None, str, list, dict),
-            'writer': dict
-        }
+        return {"input_data": Or(None, str, list, dict), "writer": dict}
 
     @classmethod
     def from_dict(cls, d: dict):
         return WriterNode(
             id=IdGeneratorUUID().generate(),
-            input_data=d['input_data'],
-            writer=BeanFactory.create(d['writer'])
+            input_data=d["input_data"],
+            writer=Spook.create(d["writer"]),
         )
 
     def to_dict(self):
-        return {
-            'input_data': self._input_data,
-            'writer': self.writer.serialize()
-        }
-
+        return {"input_data": self._input_data, "writer": self.writer.serialize()}
 
-@BeanFactory.make_serializable
-class NodeGraph(Bean):
 
+class NodeGraph(Spook):
     def __init__(self, nodes: Sequence[PipeNode]):
         self._nodes = nodes
         self._nodes_map = {}
@@ -202,8 +169,14 @@ def __init__(self, nodes: Sequence[PipeNode]):
 
         for node in self._nodes:
             node: PipeNode
-            [self._graph.add_edge(PlaceholderDataNode(x), node) for x in node.input_data(as_list=True)]
-            [self._graph.add_edge(node, PlaceholderDataNode(x)) for x in node.output_data(as_list=True)]
+            [
+                self._graph.add_edge(PlaceholderDataNode(x), node)
+                for x in node.input_data(as_list=True)
+            ]
+            [
+                self._graph.add_edge(node, PlaceholderDataNode(x))
+                for x in node.output_data(as_list=True)
+            ]
             self._nodes_map[node.id] = node
 
         self._data_cache = {}
@@ -228,7 +201,9 @@ def execute(self):
                 data = self._cache_to_node(node, self._data_cache)
                 node.writer(data)
 
-    def _cache_to_node(self, node: PipeNode, cache: dict) -> Union[SamplesSequence, list, dict]:
+    def _cache_to_node(
+        self, node: PipeNode, cache: dict
+    ) -> Union[SamplesSequence, list, dict]:
         v = node.input_data(as_list=False)
         if v is None:
             return None
@@ -239,9 +214,11 @@ def _cache_to_node(self, node: PipeNode, cache: dict) -> Union[SamplesSequence,
         elif isinstance(v, dict):
             return {k: cache[v] for k, v in v.items()}
         else:
-            raise NotImplementedError(f'{type(v)}')
+            raise NotImplementedError(f"{type(v)}")
 
-    def _node_to_cache(self, data: Union[SamplesSequence, list, dict], node: PipeNode, cache: dict):
+    def _node_to_cache(
+        self, data: Union[SamplesSequence, list, dict], node: PipeNode, cache: dict
+    ):
         v = node.output_data(as_list=False)
         if v is None:
             pass
@@ -254,54 +231,61 @@ def _node_to_cache(self, data: Union[SamplesSequence, list, dict], node: PipeNod
             for k, name in v.items():
                 cache[name] = data[k]
         else:
-            raise NotImplementedError(f'{type(v)}')
+            raise NotImplementedError(f"{type(v)}")
 
     @classmethod
     def bean_schema(cls) -> dict:
-        return {
-            'nodes': list
-        }
+        return {"nodes": list}
 
     @classmethod
     def from_dict(cls, d: dict):
-        nodes = [BeanFactory.create(c) for c in d['nodes']]
+        nodes = [Spook.create(c) for c in d["nodes"]]
         return NodeGraph(nodes=nodes)
 
     def to_dict(self):
-        return {
-            'nodes': [x.serialize() for x in self._nodes]
-        }
+        return {"nodes": [x.serialize() for x in self._nodes]}
 
     def draw_to_file(self, filename: str = None):
 
         try:
             from networkx.drawing.nx_agraph import to_agraph
+
             A = to_agraph(self._graph)
             for i, node in enumerate(A.iternodes()):
                 print(i, node)
                 if node in self._nodes_map:
                     ref = self._nodes_map[node]
                     if isinstance(ref, OperationNode):
-                        node.attr['label'] = self._nodes_map[node].operation.__class__.__name__
-                        node.attr['style'] = 'filled'
-                        node.attr['fillcolor'] = '#ffd54f'
-                        node.attr['shape'] = 'box'
+                        node.attr["label"] = self._nodes_map[
+                            node
+                        ].operation.__class__.__name__
+                        node.attr["style"] = "filled"
+                        node.attr["fillcolor"] = "#ffd54f"
+                        node.attr["shape"] = "box"
                     elif isinstance(ref, ReaderNode):
-                        node.attr['label'] = self._nodes_map[node].reader.__class__.__name__
-                        node.attr['style'] = 'filled'
-                        node.attr['fillcolor'] = '#009688'
-                        node.attr['shape'] = 'box'
+                        node.attr["label"] = self._nodes_map[
+                            node
+                        ].reader.__class__.__name__
+                        node.attr["style"] = "filled"
+                        node.attr["fillcolor"] = "#009688"
+                        node.attr["shape"] = "box"
                     elif isinstance(ref, WriterNode):
-                        node.attr['label'] = self._nodes_map[node].writer.__class__.__name__
-                        node.attr['style'] = 'filled'
-                        node.attr['fillcolor'] = '#9c27b0'
-                        node.attr['shape'] = 'box'
+                        node.attr["label"] = self._nodes_map[
+                            node
+                        ].writer.__class__.__name__
+                        node.attr["style"] = "filled"
+                        node.attr["fillcolor"] = "#9c27b0"
+                        node.attr["shape"] = "box"
 
                 else:
-                    node.attr['shape'] = 'circle'
-
-            A.layout('dot')
-            fname = f'{tempfile.NamedTemporaryFile().name}.png' if filename is None else filename
+                    node.attr["shape"] = "circle"
+
+            A.layout("dot")
+            fname = (
+                f"{tempfile.NamedTemporaryFile().name}.png"
+                if filename is None
+                else filename
+            )
             A.draw(fname)
             return A, fname
 
diff --git a/pipelime/sequences/readers/base.py b/pipelime/sequences/readers/base.py
index 6b39ab6..a02ccb3 100644
--- a/pipelime/sequences/readers/base.py
+++ b/pipelime/sequences/readers/base.py
@@ -1,7 +1,34 @@
-from pipelime.factories import Bean
+from typing import Dict, Sequence, Union
+
+from choixe.spooks import Spook
+from deepdiff import DeepDiff
+
 from pipelime.sequences.samples import SamplesSequence
 
 
-class BaseReader(SamplesSequence, Bean):
+class ReaderTemplate(object):
+    def __init__(
+        self,
+        extensions_map: Dict[str, any],
+        root_files_keys: Sequence[str],
+        idx_length: int = 5,
+    ):
+        self.extensions_map = extensions_map
+        self.root_files_keys = root_files_keys
+        self.idx_length = idx_length
+
+    def __eq__(self, o: object) -> bool:
+        equality = True
+        equality = equality and (not DeepDiff(o.extensions_map, self.extensions_map))
+        equality = equality and (set(self.root_files_keys) == set(o.root_files_keys))
+        equality = equality and (self.idx_length == o.idx_length)
+        return equality
+
+
+class BaseReader(SamplesSequence, Spook):
+    def get_reader_template(self) -> Union[ReaderTemplate, None]:
+        """Retrieves the template of the reader, i.e. a mapping
+        between sample_key/file_extension/encoding and a list of root files keys
 
-    pass
+        :rtype: Union[ReaderTemplate, None]
+        """
diff --git a/pipelime/sequences/readers/filesystem.py b/pipelime/sequences/readers/filesystem.py
index f6651f9..a0bfed3 100644
--- a/pipelime/sequences/readers/filesystem.py
+++ b/pipelime/sequences/readers/filesystem.py
@@ -1,108 +1,127 @@
+import multiprocessing
 from pathlib import Path
-from typing import Dict, Sequence, Union
-from pipelime.factories import Bean, BeanFactory
-from pipelime.sequences.readers.base import BaseReader
-from pipelime.sequences.samples import FileSystemSample
-from pipelime.filesystem.toolkit import FSToolkit
-from schema import Optional
+from typing import Union
 
+from loguru import logger
+from schema import Optional
 
-class UnderfolderReaderTemplate(object):
-
-    def __init__(
-        self,
-        extensions_map: Dict[str, any],
-        root_files_keys: Sequence[str],
-        idx_length: int = 5
-    ):
-        self.extensions_map = extensions_map
-        self.root_files_keys = root_files_keys
-        self.idx_length = idx_length
+from pipelime.filesystem.toolkit import FSToolkit
+from pipelime.sequences.readers.base import BaseReader, ReaderTemplate
+from pipelime.sequences.samples import FileSystemSample
 
 
 class UnderfolderReader(BaseReader):
-    DATA_SUBFOLDER = 'data'
+    DATA_SUBFOLDER = "data"
 
-    def __init__(self, folder: str, copy_root_files: bool = True, lazy_samples: bool = True) -> None:
+    def __init__(
+        self,
+        folder: str,
+        copy_root_files: bool = True,
+        lazy_samples: bool = True,
+        num_workers: int = 0,
+    ) -> None:
 
         self._folder = Path(folder)
         self._copy_root_files = copy_root_files
         self._datafolder = self._folder / self.DATA_SUBFOLDER
+        self._lazy_samples = lazy_samples
+        self._num_workers = num_workers
         assert self._datafolder.exists(), f'No data folder found: "{self._datafolder}"'
 
         # builds tree from subfolder with underscore notation
         self._tree = FSToolkit.tree_from_underscore_notation_files(self._datafolder)
         self._ids = list(sorted(self._tree.keys()))
-        self._root_files = [x for x in Path(self._folder).glob('*') if x.is_file()]
-        self._root_files = [x for x in self._root_files if not x.name.startswith('.')]
+        self._root_files = [x for x in Path(self._folder).glob("*") if x.is_file()]
+        self._root_files = [x for x in self._root_files if not x.name.startswith(".")]
         self._root_data = {}
         self._root_files_keys = set()
         for f in self._root_files:
             self._root_files_keys.add(f.stem)
             self._root_data[f.stem] = str(f)  # FSToolkit.load_data(f)
 
-        samples = []
-        for idx in range(len(self._ids)):
-            data = dict(self._tree[self._ids[idx]])
-            if self._copy_root_files:
-                data.update(self._root_data)
-
-            sample = FileSystemSample(data_map=data, lazy=lazy_samples, id=self._ids[idx])
-            samples.append(sample)
+        if self._num_workers == -1 or self._num_workers > 0:
+            if self._lazy_samples:
+                logger.warning(f"Multiprocessing with Lazy Samples are useless!")
+            pool = multiprocessing.Pool(
+                None if self._num_workers == -1 else self._num_workers
+            )
+            samples = list(pool.imap(self._read_sample, range(len(self._ids))))
+        else:
+            samples = []
+            for idx in range(len(self._ids)):
+                samples.append(self._read_sample(idx))
 
         super().__init__(samples=samples)
 
-    def get_filesystem_template(self) -> Union[UnderfolderReaderTemplate, None]:
-        """ Retrieves the template of the underfolder reader, i.e. a mapping
+    def _read_sample(self, idx: int):
+        data = dict(self._tree[self._ids[idx]])
+        if self._copy_root_files:
+            data.update(self._root_data)
+
+        return FileSystemSample(
+            data_map=data, lazy=self._lazy_samples, id=self._ids[idx]
+        )
+
+    def is_root_key(self, key: str):
+        return key in self._root_files_keys
+
+    def get_reader_template(self) -> Union[ReaderTemplate, None]:
+        """Retrieves the template of the underfolder reader, i.e. a mapping
         between sample_key/file_extension and a list of root files keys
 
         :raises TypeError: If first sample is not a FileSystemSample
-        :return: None if dataset is empty, otherwise an UnderfolderReaderTemplate
-        :rtype: Union[UnderfolderReaderTemplate, None]
+        :return: None if dataset is empty, otherwise an ReaderTemplate
+        :rtype: Union[ReaderTemplate, None]
         """
 
         if len(self) > 0:
             sample = self[0]
             if not isinstance(sample, FileSystemSample):
-                raise TypeError(f'Anomalous sample type found: {type(sample)}')
+                raise TypeError(f"Anomalous sample type found: {type(sample)}")
 
             extensions_map = {}
             idx_length = len(str(sample.id))
             for key, filename in sample.filesmap.items():
-                extensions_map[key] = Path(filename).suffix.replace('.', '')
+                extensions_map[key] = Path(filename).suffix.replace(".", "")
 
-            return UnderfolderReaderTemplate(
+            return ReaderTemplate(
                 extensions_map=extensions_map,
                 root_files_keys=list(self._root_files_keys),
-                idx_length=idx_length
+                idx_length=idx_length,
             )
         else:
             None
 
+    @classmethod
+    def get_reader_template_from_folder(
+        cls, folder: str
+    ) -> Union[ReaderTemplate, None]:
+        """Helper class function to retrieve a reader template directly from folder
+
+        :param folder: underfolder folder
+        :type folder: str
+        :return: ReaderTemplate of the loaded underfolder
+        :rtype: Union[ReaderTemplate, None]
+        """
+        reader = UnderfolderReader(
+            folder=folder, copy_root_files=True, lazy_samples=True
+        )
+        return reader.get_reader_template()
+
     def flush(self):
-        """ Clear cache for each internal FileSystemSample """
+        """Clear cache for each internal FileSystemSample"""
         for sample in self:
             sample.flush()
 
     @classmethod
     def bean_schema(cls) -> dict:
-        return {
-            'folder': str,
-            Optional('copy_root_files'): bool
-        }
+        return {"folder": str, Optional("copy_root_files"): bool}
 
     @classmethod
     def from_dict(cls, d: dict):
         return UnderfolderReader(
-            folder=d.get('folder'),
-            copy_root_files=d.get('copy_root_files', True)
+            folder=d.get("folder"), copy_root_files=d.get("copy_root_files", True)
         )
 
     def to_dict(self) -> dict:
-        return {
-            'folder': str(self._folder),
-            'copy_root_files': self._copy_root_files
-        }
-
-
-BeanFactory.register_bean(UnderfolderReader)
+        return {"folder": str(self._folder), "copy_root_files": self._copy_root_files}
diff --git a/pipelime/sequences/readers/h5.py b/pipelime/sequences/readers/h5.py
new file mode 100644
index 0000000..8b54630
--- /dev/null
+++ b/pipelime/sequences/readers/h5.py
@@ -0,0 +1,221 @@
+from pathlib import Path
+from typing import Dict, Hashable, Sequence, Union
+
+import h5py
+from schema import Optional
+
+from pipelime.h5.toolkit import H5Database, H5ToolKit
+from pipelime.sequences.readers.base import BaseReader
+from pipelime.sequences.samples import MemoryItem, MetaItem, Sample
+
+
+class H5Item(MetaItem):
+    def __init__(self, item: h5py.Dataset) -> None:
+        super().__init__()
+        self._item = item
+
+    def source(self) -> any:
+        return self._item
+
+
+class H5Sample(Sample):
+    def __init__(
+        self,
+        group: h5py.Group,
+        lazy: bool = True,
+        copy_global_items: bool = True,
+        id: Hashable = None,
+    ):
+        """Creates a H5Sample based on a key/filename map
+
+        :param group: h5py Group
+        :type group: h5py.Group
+        :param lazy: FALSE to preload data (slow), defaults to False
+        :type lazy: bool, optional
+        :param copy_global_items: TRUE to propagate global item to samples, defaults to True
+        :type copy_global_items: bool, optional
+        :param id: hashable value used as id
+        :type id: Hashable, optional
+        """
+        super().__init__(id=id)
+        self._group = group
+        self._copy_global_items = copy_global_items
+        self._keys = set()
+        for key in self._group.keys():
+            if self.is_link(key):
+                if self._copy_global_items:
+                    self._keys.add(key)
+            else:
+                self._keys.add(key)
+
+        self._cached = {}
+        if not lazy:
+            for k in self.keys():
+                d = self[k]
+
+    def is_cached(self, key) -> bool:
+        return key in self._cached
+
+    def __getitem__(self, key):
+        if not self.is_cached(key):
+            dataset = self._group[key]
+            data = H5ToolKit.decode_data(dataset)
+            self._cached[key] = data
+        return self._cached[key]
+
+    def get_encoding(self, key):
+        dataset = self._group[key]
+        return H5ToolKit.get_encoding(dataset)
+
+    def is_link(self, key):
+        return isinstance(self._group.get(key, getlink=True), h5py.SoftLink)
+
+    def __setitem__(self, key, value):
+        self._cached[key] = value
+
+    def __delitem__(self, key):
+        if key in self._cached:
+            del self._cached[key]
+
+    def __iter__(self):
+        return iter(
+            self._keys
+        )  # iter(set.union(set(self._group.keys()), set(self._cached.keys())))
+
+    def __len__(self):
+        return len(self._keys)
+
+    def copy(self):
+        newsample = H5Sample(self._group, id=self.id)
+        newsample._cached = self._cached.copy()
+        return newsample
+
+    def rename(self, old_key: str, new_key: str):
+        raise NotImplementedError
+
+    def metaitem(self, key: any):
+        if key in self._group:
+            return H5Item(self._group[key])
+        else:
+            return MemoryItem()
+
+    @property
+    def skeleton(self) -> dict:
+        return {x: None for x in self._group.keys()}
+
+    def flush(self):
+        keys = list(self._cached.keys())
+        for k in keys:
+            del self._cached[k]
+
+
+class H5ReaderTemplate(object):
+    def __init__(
+        self,
+        extensions_map: Dict[str, any],
+        root_files_keys: Sequence[str],
+        idx_length: int = 5,
+    ):
+        self.extensions_map = extensions_map
+        self.root_files_keys = root_files_keys
+        self.idx_length = idx_length
+
+
+class H5Reader(BaseReader):
+    DATA_SUBFOLDER = "data"
+
+    def __init__(
+        self, filename: str, copy_root_files: bool = True, lazy_samples: bool = True
+    ) -> None:
+
+        self._filename = Path(filename)
+        self._copy_root_files = copy_root_files
+        self._lazy_samples = lazy_samples
+
+        # builds tree from subfolder with underscore notation
+        self._h5database = H5Database(filename=self._filename, readonly=True)
+        self._h5database.open()
+
+        self._ids = list(sorted(self._h5database.sample_keys()))
+        self._root_files_keys = set()
+
+        samples = []
+        for idx in range(len(self._ids)):
+            group = self._h5database.get_sample_group(
+                self._ids[idx], force_create=False
+            )
+
+            sample = H5Sample(
+                group=group,
+                copy_global_items=self._copy_root_files,
+                lazy=self._lazy_samples,
+                id=idx,
+            )
+            samples.append(sample)
+
+        if len(samples) > 0:
+            sample: H5Sample = samples[0]
+            for key in sample:
+                if sample.is_link(key):
+                    self._root_files_keys.add(key)
+
+        super().__init__(samples=samples)
+
+    def is_root_key(self, key: str):
+        return key in self._root_files_keys
+
+    def get_reader_template(self) -> Union[H5ReaderTemplate, None]:
+        """Retrieves the template of the h5 reader, i.e. a mapping
+        between sample_key/file_extension and a list of root files keys
+
+        :raises TypeError: If first sample is not a H5Sample
+        :return: None if dataset is empty, otherwise an H5ReaderTemplate
+        :rtype: Union[H5ReaderTemplate, None]
+        """
+
+        if len(self) > 0:
+            sample = self[0]
+            if not isinstance(sample, H5Sample):
+                raise TypeError(f"Anomalous sample type found: {type(sample)}")
+
+            extensions_map = {}
+            idx_length = len(str(sample.id))
+            for key in sample:
+                if sample.get_encoding(key) is not None:
+                    extensions_map[key] = sample.get_encoding(key)
+
+            return H5ReaderTemplate(
+                extensions_map=extensions_map,
+                root_files_keys=list(self._root_files_keys),
+                idx_length=idx_length,
+            )
+        else:
+            None
+
+    def flush(self):
+        """Clear cache for each internal FileSystemSample"""
+        for sample in self:
+            sample.flush()
+
+    @classmethod
+    def bean_schema(cls) -> dict:
+        return {
+            "filename": str,
+            Optional("copy_root_files"): bool,
+            Optional("lazy_samples"): bool,
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict):
+        return H5Reader(
+            filename=d.get("filename"),
+            copy_root_files=d.get("copy_root_files", True),
+            lazy_samples=d.get("lazy_samples", True),
+        )
+
+    def to_dict(self) -> dict:
+        return {
+            "filename": str(self._filename),
+            "copy_root_files": self._copy_root_files,
+            "lazy_samples": self._lazy_samples,
+        }
diff --git a/pipelime/sequences/readers/summary.py b/pipelime/sequences/readers/summary.py
new file mode 100644
index 0000000..5d64a07
--- /dev/null
+++ b/pipelime/sequences/readers/summary.py
@@ -0,0 +1,202 @@
+from __future__ import annotations
+
+from typing import Any, Iterable, Sequence, Type
+
+import numpy as np
+
+from pipelime.sequences.readers.base import BaseReader
+from pipelime.sequences.samples import SamplesSequence
+
+
+class ItemInfo:
+    """Holds information on an Item"""
+
+    def __init__(
+        self,
+        name: str,
+        typeinfo: TypeInfo,
+        count: int,
+        root_item: bool,
+        encoding: str,
+    ) -> None:
+        self.name = name
+        self.typeinfo = typeinfo
+        self.count = count
+        self.root_item = root_item
+        self.encoding = encoding
+
+
+class TypeInfo:
+    """Wraps the type of an Item, and handles the common properties of
+    array-like types, such as shape and dtype.
+    """
+
+    def __init__(
+        self,
+        *types: Iterable[Type],
+        shape: Iterable[int] = None,
+        dtype: Any = None,
+    ) -> None:
+        self.types = set(types)
+        self.shape = shape
+        self.dtype = dtype
+
+    def __repr__(self) -> str:
+        return str((self.types, self.shape, self.dtype))
+
+    def _merge(self, other: TypeInfo) -> TypeInfo:
+        shape = self._merge_shapes(other)
+        dtype = None
+        if self.dtype == other.dtype:
+            dtype = self.dtype
+        return TypeInfo(*self.types, *other.types, shape=shape, dtype=dtype)
+
+    def _merge_shapes(self, other):
+        if self.shape is None or other.shape is None:
+            return None
+        if len(self.shape) != len(other.shape):
+            return None
+
+        a = np.array(self.shape)
+        b = np.array(other.shape)
+
+        where_eq = a == b
+        final_shape = a * where_eq - 1 * (1 - where_eq)
+        return final_shape.tolist()
+
+    def __add__(self, other: TypeInfo) -> TypeInfo:
+        """Creates a Union TypeInfo 'merging' the information of two
+        TypeInfo objects.
+
+        Output types is the set union of self types and other types
+
+        If both objects have a specified shape with the same length, the
+        output shape is the union shape, None otherwise.
+
+        If both objects have a specified dtype with the same value, the
+        output dtype is self.dtype, None otherwise.
+
+        :param other: another TypeInfo object to merge
+        :type other: TypeInfo
+        :return: the union TypeInfo object
+        :rtype: TypeInfo
+        """
+        return self._merge(other)
+
+
+class ItemInfoFactory:
+    """Provides an easy way to instantiate ItemInfo objects from a pipelime
+    BaseReader object
+    """
+
+    @classmethod
+    def _build_type_info(cls, obj: Any) -> TypeInfo:
+        the_type = type(obj)
+        shape = None
+        dtype = None
+        if hasattr(obj, "shape"):
+            shape = obj.shape
+        if hasattr(obj, "dtype"):
+            dtype = str(obj.dtype)
+        typeinfo = TypeInfo(the_type, shape=shape, dtype=dtype)
+        return typeinfo
+
+    @classmethod
+    def build_item_info(cls, reader: BaseReader, key: str, k: int) -> ItemInfo:
+        """Instantiates an ItemInfo object from the first k items whose key match the input key.
+
+        :param reader: Input pipelime reader
+        :type reader: BaseReader
+        :param key: The query key to inspect
+        :type key: str
+        :param k: The first k samples to inspect, this is done to avoid reading the entire dataset and speedup the summary creation. Set `k<1` to inspect every sample.
+        :type k: int
+        :return: An ItemInfo object
+        :rtype: ItemInfo
+        """
+        template = reader.get_reader_template()
+
+        # Count items
+        count = 0
+        for sample in reader:
+            if key in sample:
+                count += 1
+
+        # Get item types
+        if k < 1:
+            k = len(reader)
+        typeinfos = []
+        for i in range(k):
+            sample = reader[i]
+            if key in sample:
+                typeinfo = cls._build_type_info(sample[key])
+                typeinfos.append(typeinfo)
+            if hasattr(sample, "flush"):
+                sample.flush()
+        typeinfo = typeinfos[0]
+        for t in typeinfos:
+            typeinfo += t
+
+        # Is root item?
+        root_item = key in template.root_files_keys
+
+        # Get encoding type
+        encoding = template.extensions_map.get(key)
+
+        return ItemInfo(key, typeinfo, count, root_item, encoding)
+
+    @classmethod
+    def get_all_keys(cls, seq: SamplesSequence) -> Sequence[str]:
+        """Retrieves all items contained in a SamplesSequence.
+
+        :param seq: The sequence to inspect
+        :type seq: SamplesSequence
+        :return: The list of all item keys
+        :rtype: Sequence[str]
+        """
+        all_keys = []
+        for sample in seq:
+            all_keys += list(sample.keys())
+        all_keys = sorted(set(all_keys))
+        return all_keys
+
+    @classmethod
+    def build_all(cls, reader: BaseReader, k: int) -> Sequence[ItemInfo]:
+        """Creates all ItemInfo objects for every item key in an input reader
+
+        :param reader: Input pipelime BaseReader
+        :type reader: BaseReader
+        :param k: The first k samples to inspect, this is done to avoid reading the entire dataset and speedup the summary creation. Set to k < 1 to inspect every sample.
+        :type k: int
+        :return: The list of all ItemInfos
+        :rtype: Sequence[ItemInfo]
+        """
+        keys = cls.get_all_keys(reader)
+        return [cls.build_item_info(reader, key, k) for key in keys]
+
+
+class ReaderSummary:
+    """Wraps a reader and its corresponding list of iteminfos"""
+
+    def __init__(self, reader: BaseReader, k: int = 3) -> None:
+        self.k = min(k, len(reader))
+        self.reader = reader
+        self._iteminfos = ItemInfoFactory.build_all(self.reader, self.k)
+
+    def sort(self, key: str = "name", reverse: bool = False) -> None:
+        """Internally sorts the iteminfo list according to a specified key
+
+        :param key: the key along which to sort, defaults to "name"
+        :type key: str, optional
+        :param reverse: reverse sorting, defaults to False
+        :type reverse: bool, optional
+        """
+        self._iteminfos = sorted(
+            self._iteminfos, key=lambda x: str(getattr(x, key)), reverse=reverse
+        )
+
+    def __getitem__(self, k: int) -> ItemInfo:
+        return self._iteminfos[k]
+
+    def __len__(self) -> int:
+        return len(self._iteminfos)
diff --git a/pipelime/sequences/samples.py b/pipelime/sequences/samples.py
index 356385c..1a4a7d8 100644
--- a/pipelime/sequences/samples.py
+++ b/pipelime/sequences/samples.py
@@ -1,15 +1,15 @@
 import uuid
-from pipelime.filesystem.toolkit import FSToolkit
-from dataclasses import dataclass
-from collections.abc import MutableMapping
 from abc import abstractmethod
+from collections.abc import MutableMapping
+from dataclasses import dataclass
 from pathlib import Path
-from typing import Hashable, Sequence
+from typing import Any, Dict, Hashable, Sequence
+
+from pipelime.filesystem.toolkit import FSToolkit
 
 
 @dataclass
 class MetaItem(object):
-
     def __init__(self) -> None:
         pass
 
@@ -19,7 +19,6 @@ def source(self) -> any:
 
 
 class MemoryItem(MetaItem):
-
     def __init__(self) -> None:
         super().__init__()
 
@@ -28,7 +27,6 @@ def source(self) -> any:
 
 
 class FilesystemItem(MetaItem):
-
     def __init__(self, path: str) -> None:
         super().__init__()
         self._path = Path(path)
@@ -38,7 +36,6 @@ def source(self) -> any:
 
 
 class Sample(MutableMapping):
-
     def __init__(self, id: Hashable = None) -> None:
         self._id = id if id is not None else str(uuid.uuid1())
 
@@ -71,9 +68,8 @@ def flush(self):
 
 
 class GroupedSample(Sample):
-
     def __init__(self, samples: Sequence[Sample], id: Hashable = None) -> None:
-        """ Sample representing a group of basic samples
+        """Sample representing a group of basic samples
 
         :param samples: list of samples to group
         :type samples: Sequence[Sample]
@@ -133,9 +129,8 @@ def metaitem(self, key: any):
 
 
 class PlainSample(Sample):
-
     def __init__(self, data: dict = None, id: Hashable = None):
-        """ Plain sample (aka a dict wrapper)
+        """Plain sample (aka a dict wrapper)
 
         :param data: dictionary data, defaults to None
         :type data: dict, optional
@@ -176,9 +171,8 @@ def metaitem(self, key: any):
 
 
 class FileSystemSample(Sample):
-
     def __init__(self, data_map: dict, lazy: bool = True, id: Hashable = None):
-        """ Creates a FileSystemSample based on a key/filename map
+        """Creates a FileSystemSample based on a key/filename map
 
         :param data_map: key/filename map
         :type data_map: dict
@@ -201,6 +195,9 @@ def filesmap(self):
     def is_cached(self, key) -> bool:
         return key in self._cached
 
+    def __contains__(self, o: object) -> bool:
+        return o in self._cached or o in self._filesmap
+
     def __getitem__(self, key):
         if not self.is_cached(key):
             self._cached[key] = FSToolkit.load_data(self._filesmap[key])
@@ -242,14 +239,23 @@ def metaitem(self, key: any):
     def skeleton(self) -> dict:
         return {x: None for x in self._filesmap.keys()}
 
-    def flush(self):
-        keys = list(self._cached.keys())
+    def flush(self, keys: Sequence[str] = None):
+        if keys is None:
+            keys = list(self._cached.keys())
         for k in keys:
             del self._cached[k]
 
+    def update(self, other: Dict[str, Any]) -> None:
+        if isinstance(other, FileSystemSample):
+            self.filesmap.update(other.filesmap)
+            for k in other.keys():
+                if other.is_cached(k):
+                    self[k] = other[k]
+        else:
+            super().update(other)
 
-class SamplesSequence(Sequence):
 
+class SamplesSequence(Sequence):
     def __init__(self, samples: Sequence[Sample]):
         self._samples = samples
 
@@ -267,7 +273,7 @@ def __getitem__(self, idx: int):
         return self._samples[idx]
 
     def is_normalized(self) -> bool:
-        """ Checks for normalization i.e. each sample has to contain same keys.
+        """Checks for normalization i.e. each sample has to contain same keys.
         !!This method could be very slow if samples are lazy!!
 
         :return: TRUE if sequence is normalized
@@ -283,3 +289,11 @@ def is_normalized(self) -> bool:
                 if new_keys_set != keys_set:
                     return False
         return True
+
+    def best_zfill(self) -> int:
+        """Computes the best zfill for integer indexing
+
+        :return: zfill values (maximum number of digits based on current size)
+        :rtype: int
+        """
+        return len(str(len(self)))
diff --git a/pipelime/sequences/stages.py b/pipelime/sequences/stages.py
index b372f7b..a0c6c3e 100644
--- a/pipelime/sequences/stages.py
+++ b/pipelime/sequences/stages.py
@@ -1,32 +1,22 @@
-import albumentations as A
 from abc import ABC, abstractmethod
-from typing import Dict, Hashable, Sequence, Union
-from pipelime.factories import Bean, BeanFactory
-from pipelime.sequences.samples import Sample
+from typing import Sequence
 
+import albumentations as A
+from choixe.spooks import Spook
+
+from pipelime.sequences.samples import Sample
 
-class SampleStage(ABC):
 
+class SampleStage(ABC, Spook):
     def __init__(self):
         pass
 
     @abstractmethod
-    def __call__(self, x:
-                 Union[
-                     Sample,
-                     Sequence[Sample],
-                     Dict[Hashable, Sample]
-                 ]) -> Union[
-                     Sample,
-                     Sequence[Sample],
-                     Dict[Hashable, Sample]
-    ]:
+    def __call__(self, x: Sample) -> Sample:
         pass
 
 
-@BeanFactory.make_serializable
-class StageCompose(SampleStage, Bean):
-
+class StageCompose(SampleStage):
     def __init__(self, stages: Sequence[SampleStage]):
         super().__init__()
         self._stages = stages
@@ -38,48 +28,29 @@ def __call__(self, x: Sample) -> Sample:
         return out
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'stages': list
-        }
+    def spook_schema(cls) -> dict:
+        return {"stages": list}
 
     @classmethod
     def from_dict(cls, d: dict):
-        stages = [BeanFactory.create(s) for s in d['stages']]
+        stages = [Spook.create(s) for s in d["stages"]]
         return StageCompose(stages=stages)
 
     def to_dict(self):
-        return {
-            'stages': [s.serialize() for s in self._stages]
-        }
-
+        return {"stages": [s.serialize() for s in self._stages]}
 
-@BeanFactory.make_serializable
-class StageIdentity(SampleStage, Bean):
 
+class StageIdentity(SampleStage):
     def __init__(self):
         super().__init__()
 
     def __call__(self, x: any) -> any:
         return x
 
-    @classmethod
-    def bean_schema(cls) -> dict:
-        return {}
-
-    @classmethod
-    def from_dict(cls, d: dict):
-        return StageIdentity()
-
-    def to_dict(self):
-        return {}
-
-
-@BeanFactory.make_serializable
-class StageRemap(SampleStage, Bean):
 
+class StageRemap(SampleStage):
     def __init__(self, remap: dict, remove_missing: bool = True):
-        """ Remaps keys in sample
+        """Remaps keys in sample
 
         :param remap: old_key:new_key dictionary remap
         :type remap: dict
@@ -91,10 +62,8 @@ def __init__(self, remap: dict, remove_missing: bool = True):
         self._remove_missing = remove_missing
 
     def __call__(self, x: Sample) -> Sample:
-
         out: Sample = x.copy()
         for k in x.keys():
-            # for k, v in x.items():
             if k in self._remap:
                 out.rename(k, self._remap[k])
             else:
@@ -103,31 +72,20 @@ def __call__(self, x: Sample) -> Sample:
         return out
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'remap': dict,
-            'remove_missing': bool
-        }
+    def spook_schema(cls) -> dict:
+        return {"remap": dict, "remove_missing": bool}
 
     @classmethod
     def from_dict(cls, d: dict):
-        return StageRemap(
-            remap=d['remap'],
-            remove_missing=d['remove_missing']
-        )
+        return StageRemap(remap=d["remap"], remove_missing=d["remove_missing"])
 
     def to_dict(self):
-        return {
-            'remap': self._remap,
-            'remove_missing': self._remove_missing
-        }
+        return {"remap": self._remap, "remove_missing": self._remove_missing}
 
 
-@BeanFactory.make_serializable
-class StageKeysFilter(SampleStage, Bean):
-
+class StageKeysFilter(SampleStage):
     def __init__(self, keys: list, negate: bool = False):
-        """ Filter sample keys
+        """Filter sample keys
 
         :param keys: list of keys to preserve
         :type keys: list
@@ -148,22 +106,14 @@ def __call__(self, x: Sample) -> Sample:
         return out
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'keys': list,
-            'negate': bool
-        }
+    def spook_schema(cls) -> dict:
+        return {"keys": list, "negate": bool}
 
     def to_dict(self):
-        return {
-            'keys': self._keys,
-            'negate': self._negate
-        }
-
+        return {"keys": self._keys, "negate": self._negate}
 
-@BeanFactory.make_serializable
-class StageAugmentations(SampleStage, Bean):
 
+class StageAugmentations(SampleStage):
     def __init__(self, transform_cfg: dict, targets: dict):
         super().__init__()
         self._transform_cfg = transform_cfg
@@ -190,24 +140,17 @@ def __call__(self, x: Sample) -> Sample:
 
             return out
         except Exception as e:
-            raise Exception(f'Stage[{self.__class__.__name__}] -> {e}')
+            raise Exception(f"Stage[{self.__class__.__name__}] -> {e}")
 
     @classmethod
-    def bean_schema(cls) -> dict:
-        return {
-            'transform_cfg': dict,
-            'targets': dict
-        }
+    def spook_schema(cls) -> dict:
+        return {"transform_cfg": dict, "targets": dict}
 
     @classmethod
     def from_dict(cls, d: dict):
         return StageAugmentations(
-            transform_cfg=d['transform_cfg'],
-            targets=d['targets']
+            transform_cfg=d["transform_cfg"], targets=d["targets"]
         )
 
     def to_dict(self):
-        return {
-            'transform_cfg': self._transform_cfg,
-            'targets': self._targets
-        }
+        return {"transform_cfg": self._transform_cfg, "targets": self._targets}
diff --git a/pipelime/sequences/validation.py b/pipelime/sequences/validation.py
index d4ab5f7..285acbe 100644
--- a/pipelime/sequences/validation.py
+++ b/pipelime/sequences/validation.py
@@ -1,12 +1,13 @@
-from pipelime.sequences.samples import Sample
-from schema import Schema
-import uuid
-import types
 import importlib.machinery
+import types
+import uuid
 
+from schema import Schema
 
-class SampleSchema:
+from pipelime.sequences.samples import Sample
 
+
+class SampleSchema:
     def __init__(self, schema: Schema, deep: bool = True):
         self._schema = schema
         self._deep = deep
@@ -27,15 +28,17 @@ def validate(self, sample: Sample):
 
 
 class SchemaLoader:
-    DEFAULT_SCHEMA_VARIABLE_NAME = 'schema'
-    DEFAULT_DEEP_VARIABLE_NAME = 'deep'
+    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}')
+            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)
diff --git a/pipelime/sequences/writers/base.py b/pipelime/sequences/writers/base.py
index 291111a..8c87515 100644
--- a/pipelime/sequences/writers/base.py
+++ b/pipelime/sequences/writers/base.py
@@ -1,10 +1,11 @@
-from pipelime.factories import Bean
-from pipelime.sequences.samples import SamplesSequence
 from abc import abstractmethod
 
+from choixe.spooks import Spook
+
+from pipelime.sequences.samples import SamplesSequence
 
-class BaseWriter(Bean):
 
+class BaseWriter(Spook):
     @abstractmethod
     def __call__(self, x: SamplesSequence) -> None:
         pass
diff --git a/pipelime/sequences/writers/filesystem.py b/pipelime/sequences/writers/filesystem.py
index 2fef225..bbb2596 100644
--- a/pipelime/sequences/writers/filesystem.py
+++ b/pipelime/sequences/writers/filesystem.py
@@ -1,19 +1,27 @@
-from pipelime.sequences.readers.filesystem import UnderfolderReader
-from rich.progress import track
-from pipelime.filesystem.toolkit import FSToolkit
+import multiprocessing
+import os
 import re
+import shutil
 from pathlib import Path
-from pipelime.factories import Bean, BeanFactory
-from pipelime.sequences.writers.base import BaseWriter
-from pipelime.sequences.samples import FileSystemSample, FilesystemItem, Sample, SamplesSequence
+
+from choixe.spooks import Spook
+from rich.progress import track
 from schema import Optional, Or
-import shutil
-import os
+
+from pipelime.filesystem.toolkit import FSToolkit
+from pipelime.sequences.readers.base import BaseReader
+from pipelime.sequences.samples import (
+    FilesystemItem,
+    FileSystemSample,
+    Sample,
+    SamplesSequence,
+)
+from pipelime.sequences.writers.base import BaseWriter
 
 
 class UnderfolderWriter(BaseWriter):
-    DATA_SUBFOLDER = 'data'
-    DEFAULT_EXTENSION = 'pkl'
+    DATA_SUBFOLDER = "data"
+    DEFAULT_EXTENSION = "pkl"
 
     def __init__(
         self,
@@ -22,9 +30,11 @@ def __init__(
         extensions_map: dict = None,
         zfill: int = 5,
         copy_files: bool = True,
-        use_symlinks: bool = False
+        use_symlinks: bool = False,
+        force_copy_keys: list = None,
+        num_workers: int = 0,  # typing is here because 'schema.Optional' conflicts
     ) -> None:
-        """ UnderfolderWriter for an input SamplesSequence
+        """UnderfolderWriter for an input SamplesSequence
 
         :param folder: destiantion folder
         :type folder: str
@@ -40,6 +50,12 @@ def __init__(
         :type copy_files: bool, optional
         :param use_symlinks: if TRUE (and copy_files == TRUE) the copy will be replaced with a symlink, defaults to False
         :type use_symlinks: bool, optional
+        :param force_copy_keys: A list of string keys that will be copied/symlinked regardless of their
+        cached/modified state (all changes will be lost), if they contain file system items.
+        This is useful in case you want to quickly write an item that you know for certain it was not modified, even if it was cached.
+        :type force_copy_keys: list, optional
+        :param num_workers: if 0 disable multiprocessing, if -1 use Multiprocessing pool with all available processors, if > 0 use Multiprocessing using as many processes
+        :type num_workers: int, optional
         """
         self._folder = Path(folder)
 
@@ -49,10 +65,19 @@ def __init__(
         self._zfill = zfill
         self._copy_files = copy_files
         self._use_symlinks = use_symlinks
+        self._num_workers = num_workers
+
+        if force_copy_keys is None:
+            force_copy_keys = []
+
+        self._force_copy_keys = force_copy_keys
         self._datafolder = self._folder / self.DATA_SUBFOLDER
         if not self._datafolder.exists():
             self._datafolder.mkdir(parents=True, exist_ok=True)
 
+        # multiprocessing attrs
+        self._saved_root_keys = None
+
     def _build_sample_basename(self, sample: Sample):
         if isinstance(sample.id, int):
             return str(sample.id).zfill(self._zfill)
@@ -73,74 +98,93 @@ def _is_root_key(self, key: str):
                 return True
         return False
 
+    def _process_sample(self, sample: Sample):
+        basename = self._build_sample_basename(sample)
+
+        for key in sample.keys():
+            if self._is_root_key(key):
+                # Write root keys only once
+                if key not in self._saved_root_keys:
+                    self._saved_root_keys[key] = True
+                    itemname = f"{key}.{self._build_item_extension(key)}"
+                    output_file = Path(self._folder) / itemname
+                    self._write_sample_item(output_file, sample, key)
+            else:
+                itemname = f"{basename}_{key}.{self._build_item_extension(key)}"
+                output_file = Path(self._datafolder) / itemname
+
+                self._write_sample_item(output_file, sample, key)
+
     def __call__(self, x: SamplesSequence) -> None:
 
-        if isinstance(x, UnderfolderReader) and self._empty_template:
-            template = x.get_filesystem_template()
+        if isinstance(x, BaseReader) and self._empty_template:
+            template = x.get_reader_template()
             if template is not None:
                 self._extensions_map = template.extensions_map
                 self._root_files_keys = list(template.root_files_keys)
                 self._zfill = template.idx_length
 
-        saved_root_keys = set()
-        for sample in track(x):
-            basename = self._build_sample_basename(sample)
-
-            for key in sample.keys():
-                if self._is_root_key(key):
-                    if key not in saved_root_keys:
-                        saved_root_keys.add(key)
-                        itemname = f'{key}.{self._build_item_extension(key)}'
-                        output_file = Path(self._folder) / itemname
-                        self._write_sample_item(output_file, sample, key)
-                else:
-                    itemname = f'{basename}_{key}.{self._build_item_extension(key)}'
-                    output_file = Path(self._datafolder) / itemname
+        self._zfill = max(self._zfill, x.best_zfill())
 
-                    self._write_sample_item(output_file, sample, key)
+        if self._num_workers > 0 or self._num_workers == -1:
+            manager = multiprocessing.Manager()
+            self._saved_root_keys = manager.dict()
+            pool = multiprocessing.Pool(
+                processes=None if self._num_workers == -1 else self._num_workers
+            )
+            list(track(pool.imap_unordered(self._process_sample, x), total=len(x)))
+        else:
+            self._saved_root_keys = {}
+            for sample in track(x):
+                self._process_sample(sample)
+
+    def _copy_filesystem_item(self, output_file: Path, item: FilesystemItem) -> None:
+        path = item.source()
+        if not self._use_symlinks:
+            if path != output_file:
+                shutil.copy(path, output_file)
+        else:
+            os.symlink(path, output_file)
 
     def _write_sample_item(self, output_file: Path, sample: Sample, key: str):
-
-        if self._copy_files:
-            if isinstance(sample, FileSystemSample):
-                item = sample.metaitem(key)
-                if not sample.is_cached(key) and isinstance(item, FilesystemItem):
-                    path = item.source()
-                    if path.suffix == output_file.suffix:
-                        if not self._use_symlinks:
-                            shutil.copy(path, output_file)
-                        else:
-                            os.symlink(path, output_file)
-                        return
-
-        # Default action
-        FSToolkit.store_data(output_file, sample[key])
+        copy_item = False
+        if self._copy_files and isinstance(sample, FileSystemSample):
+            item = sample.metaitem(key)
+            if (
+                not sample.is_cached(key) or key in self._force_copy_keys
+            ) and isinstance(item, FilesystemItem):
+                path = item.source()
+                if path.suffix == output_file.suffix:
+                    copy_item = True
+
+        if copy_item:
+            self._copy_filesystem_item(output_file, item)
+        else:
+            item = sample[key]
+            FSToolkit.store_data(output_file, item)
 
     @classmethod
     def bean_schema(cls) -> dict:
         return {
-            'folder': str,
-            Optional('root_files_keys'): Or(None, list),
-            Optional('extensions_map'): Or(None, dict),
-            Optional('zfill'): int,
+            "folder": str,
+            Optional("root_files_keys"): Or(None, list),
+            Optional("extensions_map"): Or(None, dict),
+            Optional("zfill"): int,
         }
 
     @classmethod
     def from_dict(cls, d: dict):
         return UnderfolderWriter(
-            folder=d.get('folder'),
-            root_files_keys=d.get('root_files_keys', None),
-            extensions_map=d.get('extensions_map', None),
-            zfill=d.get('zfill', 5),
+            folder=d.get("folder"),
+            root_files_keys=d.get("root_files_keys", None),
+            extensions_map=d.get("extensions_map", None),
+            zfill=d.get("zfill", 5),
         )
 
     def to_dict(self) -> dict:
         return {
-            'folder': str(self._folder),
-            'root_files_keys': self._root_files_keys,
-            'extensions_map': self._extensions_map,
-            'zfill': self._zfill
+            "folder": str(self._folder),
+            "root_files_keys": self._root_files_keys,
+            "extensions_map": self._extensions_map,
+            "zfill": self._zfill,
         }
-
-
-BeanFactory.register_bean(UnderfolderWriter)
diff --git a/pipelime/sequences/writers/h5.py b/pipelime/sequences/writers/h5.py
new file mode 100644
index 0000000..35310e1
--- /dev/null
+++ b/pipelime/sequences/writers/h5.py
@@ -0,0 +1,138 @@
+from pathlib import Path
+
+import h5py
+from choixe.spooks import Spook
+from rich.progress import track
+from schema import Optional, Or
+
+from pipelime.h5.toolkit import H5Database, H5ToolKit
+from pipelime.sequences.readers.base import BaseReader
+from pipelime.sequences.samples import Sample, SamplesSequence
+from pipelime.sequences.writers.base import BaseWriter
+
+
+class H5Writer(BaseWriter):
+    DATA_SUBFOLDER = "data"
+    DEFAULT_EXTENSION = None
+
+    def __init__(
+        self,
+        filename: str,
+        root_files_keys: list = None,
+        extensions_map: dict = None,
+        zfill: int = 5,
+    ) -> None:
+        """H5Writer for an input SamplesSequence
+
+        :param filename: destiantion hdf5 filename
+        :type filename: str
+        :param root_files_keys: list of keys to write as underfolder root files (only the first element
+        encountered will be stored as root file, the others will be discarded), defaults to None
+        :type root_files_keys: list, optional
+        :param extensions_map: dictionary of regex/extension to retrieve extension for each key matching
+        the corresponding regex. Unmatched keys will be stored as PICKLE object file, defaults to None
+        :type extensions_map: dict, optional
+        :param zfill: Length of zero padding in case of integer sample indices, defaults to 5
+        :type zfill: int, optional
+        """
+        self._filename = Path(filename)
+
+        self._empty_template = root_files_keys is None and extensions_map is None
+        self._root_files_keys = root_files_keys if root_files_keys is not None else []
+        self._extensions_map = extensions_map if extensions_map is not None else {}
+        self._zfill = zfill
+        self._h5database = H5Database(filename=self._filename, readonly=False)
+
+    def _build_sample_basename(self, sample: Sample):
+        if isinstance(sample.id, int):
+            return str(sample.id).zfill(self._zfill)
+        else:
+            return str(sample.id)
+
+    def _build_item_extension(self, key: str):
+        return self._extensions_map.get(key, H5Writer.DEFAULT_EXTENSION)
+
+    def _is_root_key(self, key: str):
+        return key in self._root_files_keys
+
+    def __call__(self, x: SamplesSequence) -> None:
+
+        if isinstance(x, BaseReader) and self._empty_template:
+            template = x.get_reader_template()
+            if template is not None:
+                self._extensions_map = template.extensions_map
+                self._root_files_keys = list(template.root_files_keys)
+                self._zfill = template.idx_length
+
+        self._zfill = max(self._zfill, x.best_zfill())
+
+        self._h5database.open()
+        saved_root_keys = set()
+
+        # Populate global links
+        global_links = {}
+        for sample in x:
+            basename = self._build_sample_basename(sample)
+            for key in sample.keys():
+                if self._is_root_key(key):
+                    if key not in saved_root_keys:
+                        group = self._h5database.get_global_group(
+                            key=basename, force_create=True
+                        )
+                        H5ToolKit.store_data(
+                            group,
+                            key=key,
+                            data=sample[key],
+                            encoding=self._build_item_extension(key),
+                        )
+                        # h5py.SoftLink(self._h5database.get_global_group_name(key=basename))
+                        global_links[key] = h5py.SoftLink(group[key].name)
+            break
+
+        for sample in track(x):
+            basename = self._build_sample_basename(sample)
+
+            for key in sample.keys():
+                if not self._is_root_key(key):
+                    group = self._h5database.get_sample_group(
+                        key=basename, force_create=True
+                    )
+                    H5ToolKit.store_data(
+                        group,
+                        key=key,
+                        data=sample[key],
+                        encoding=self._build_item_extension(key),
+                    )
+                else:
+                    group = self._h5database.get_sample_group(
+                        key=basename, force_create=True
+                    )
+                    group[key] = global_links[key]
+
+        self._h5database.close()
+
+    @classmethod
+    def bean_schema(cls) -> dict:
+        return {
+            "filename": str,
+            Optional("root_files_keys"): Or(None, list),
+            Optional("extensions_map"): Or(None, dict),
+            Optional("zfill"): int,
+        }
+
+    @classmethod
+    def from_dict(cls, d: dict):
+        return H5Writer(
+            filename=d.get("filename"),
+            root_files_keys=d.get("root_files_keys", None),
+            extensions_map=d.get("extensions_map", None),
+            zfill=d.get("zfill", 5),
+        )
+
+    def to_dict(self) -> dict:
+        return {
+            "filename": str(self._filename),
+            "root_files_keys": self._root_files_keys,
+            "extensions_map": self._extensions_map,
+            "zfill": self._zfill,
+        }
diff --git a/pipelime/tools/bytes.py b/pipelime/tools/bytes.py
index cd6d5dd..76ddf46 100644
--- a/pipelime/tools/bytes.py
+++ b/pipelime/tools/bytes.py
@@ -1,26 +1,21 @@
-
 from io import BytesIO
-import imageio
-import numpy as np
 from pathlib import Path
 from typing import Tuple
 
+import imageio
+import numpy as np
+
 
 class DataCoding(object):
 
-    IMAGE_CODECS = ['jpg', 'jpeg', 'png', 'tiff', 'bmp', 'exr']
-    IMAGE_CODECS_IS_LOSSY = {
-        'jpg': True,
-        'jpeg': True
-    }
-    IMAGE_CODECS_HAS_ALPHA = {
-        'png': True
-    }
+    IMAGE_CODECS = ["jpg", "jpeg", "png", "tiff", "bmp"]
+    IMAGE_CODECS_IS_LOSSY = {"jpg": True, "jpeg": True}
+    IMAGE_CODECS_HAS_ALPHA = {"png": True}
 
-    NUMPY_CODECS = ['npy']
-    TEXT_CODECS = ['txt']
-    METADATA_CODECS = ['json', 'yml', 'yaml']
-    PICKLE_CODECS = ['pkl']
+    NUMPY_CODECS = ["npy"]
+    TEXT_CODECS = ["txt"]
+    METADATA_CODECS = ["json", "yml", "yaml"]
+    PICKLE_CODECS = ["pkl"]
 
     @classmethod
     def is_image_extension(cls, extension: str):
@@ -44,7 +39,7 @@ def is_pickle_extension(cls, extension: str):
 
     @classmethod
     def bytes_to_data(cls, data: bytes, data_encoding: str) -> np.ndarray:
-        """ Converts bytes with corresponding encoding into numpy array
+        """Converts bytes with corresponding encoding into numpy array
 
         :param data: source bytes
         :type data: bytes
@@ -53,7 +48,7 @@ def bytes_to_data(cls, data: bytes, data_encoding: str) -> np.ndarray:
         :return: numpy array
         :rtype: np.ndarray
         """
-        data_encoding = data_encoding.replace('.', '')
+        data_encoding = data_encoding.replace(".", "")
 
         if data_encoding in cls.IMAGE_CODECS:
             buffer = BytesIO(data)
@@ -69,7 +64,7 @@ def bytes_to_data(cls, data: bytes, data_encoding: str) -> np.ndarray:
 
     @classmethod
     def file_to_bytes(cls, filename: str) -> Tuple[bytes, str]:
-        """ Converts image from file as (bytes, encoding)
+        """Converts image from file as (bytes, encoding)
 
         :param filename: source filename
         :type filename: str
@@ -77,12 +72,12 @@ def file_to_bytes(cls, filename: str) -> Tuple[bytes, str]:
         :rtype: Tuple[bytes, str]
         """
         filename = Path(filename)
-        extension = filename.suffix.replace('.', '')
-        return open(filename, 'rb').read(), extension
+        extension = filename.suffix.replace(".", "")
+        return open(filename, "rb").read(), extension
 
     @classmethod
     def numpy_image_to_bytes(cls, array: np.ndarray, data_encoding: str) -> bytes:
-        """ Converts image stored as numpy array into bytes with custom data encoding
+        """Converts image stored as numpy array into bytes with custom data encoding
 
         :param array: source image numpy array
         :type array: np.ndarray
@@ -91,7 +86,7 @@ def numpy_image_to_bytes(cls, array: np.ndarray, data_encoding: str) -> bytes:
         :return: bytes representation
         :rtype: bytes
         """
-        data_encoding = data_encoding.replace('.', '')
+        data_encoding = data_encoding.replace(".", "")
 
         data = bytes()
         if data_encoding in cls.IMAGE_CODECS:
@@ -102,8 +97,33 @@ def numpy_image_to_bytes(cls, array: np.ndarray, data_encoding: str) -> bytes:
             return None
 
     @classmethod
-    def numpy_array_to_bytes(cls, array: np.ndarray, data_encoding: str = 'npy') -> bytes:
-        """ Converts array data stored as numpy array into bytes with custom data encoding
+    def numpy_image_to_bytes_buffer(
+        cls, array: np.ndarray, data_encoding: str
+    ) -> bytes:
+        """Converts image stored as numpy array into bytes buffer with custom data encoding
+
+        :param array: source image numpy array
+        :type array: np.ndarray
+        :param data_encoding: codec string representation
+        :type data_encoding: str
+        :return: bytes representation
+        :rtype: bytes
+        """
+        data_encoding = data_encoding.replace(".", "")
+
+        data = bytes()
+        if data_encoding in cls.IMAGE_CODECS:
+            buffer = BytesIO(data)
+            imageio.imwrite(buffer, array, format=data_encoding)
+            return buffer.getbuffer()
+        else:
+            return None
+
+    @classmethod
+    def numpy_array_to_bytes(
+        cls, array: np.ndarray, data_encoding: str = "npy"
+    ) -> bytes:
+        """Converts array data stored as numpy array into bytes with custom data encoding
 
         :param array: source generic numpy array
         :type array: np.ndarray
@@ -112,7 +132,7 @@ def numpy_array_to_bytes(cls, array: np.ndarray, data_encoding: str = 'npy') ->
         :return: bytes representation
         :rtype: bytes
         """
-        data_encoding = data_encoding.replace('.', '')
+        data_encoding = data_encoding.replace(".", "")
 
         data = bytes()
         if data_encoding in cls.NUMPY_CODECS:
@@ -124,7 +144,7 @@ def numpy_array_to_bytes(cls, array: np.ndarray, data_encoding: str = 'npy') ->
 
     @classmethod
     def is_codec_lossy(cls, codec: str) -> bool:
-        """ Checks if codec should be lossy
+        """Checks if codec should be lossy
 
         :param codec: [description]
         :type codec: str
@@ -138,7 +158,7 @@ def is_codec_lossy(cls, codec: str) -> bool:
 
     @classmethod
     def has_codec_alpha(cls, codec: str) -> bool:
-        """ Checks if codec has alpha channel
+        """Checks if codec has alpha channel
 
         :param codec: [description]
         :type codec: str
diff --git a/pipelime/tools/idgenerators.py b/pipelime/tools/idgenerators.py
index bd28b9c..d9527eb 100644
--- a/pipelime/tools/idgenerators.py
+++ b/pipelime/tools/idgenerators.py
@@ -1,20 +1,17 @@
-
+import uuid
 from abc import ABC, abstractmethod
 from itertools import count
-from pipelime.factories import Bean, BeanFactory
-import uuid
 
+from choixe.spooks import Spook
 
-class IdGenerator(ABC):
 
+class IdGenerator(ABC):
     @abstractmethod
     def generate(self):
         pass
 
 
-@BeanFactory.make_serializable
-class IdGeneratorInteger(IdGenerator, Bean):
-
+class IdGeneratorInteger(IdGenerator, Spook):
     def __init__(self) -> None:
         super().__init__()
         self.COUNTER = count()
@@ -22,9 +19,10 @@ def __init__(self) -> None:
     def generate(self):
         return next(self.COUNTER)
 
+    def to_dict(self) -> dict:
+        return {}
 
-@BeanFactory.make_serializable
-class IdGeneratorUUID(IdGenerator, Bean):
 
+class IdGeneratorUUID(IdGenerator, Spook):
     def generate(self):
         return str(uuid.uuid1())
diff --git a/pipelime/tools/toydataset.py b/pipelime/tools/toydataset.py
index 003721f..9b3d795 100644
--- a/pipelime/tools/toydataset.py
+++ b/pipelime/tools/toydataset.py
@@ -1,14 +1,15 @@
 import json
+import uuid
 from pathlib import Path
 from typing import Sequence
+
 import numpy as np
 from PIL import Image, ImageDraw
-import uuid
+
 from pipelime.filesystem.toolkit import FSToolkit
 
 
 class ToyDatasetGenerator(object):
-
     def __init__(self, palette=None):
         pass
 
@@ -19,8 +20,12 @@ def generate_random_object_2D(self, size, max_label=5):
         size = np.array(size)
         center = np.random.uniform(0.25 * size[0], 0.75 * size[0], (2,))
         random_size = np.random.uniform(size * 0.05, size * 0.24, (2,))
-        top_left = np.array([center[0] - random_size[0] * 0.5, center[1] - random_size[1] * 0.5])
-        bottom_right = np.array([center[0] + random_size[0] * 0.5, center[1] + random_size[1] * 0.5])
+        top_left = np.array(
+            [center[0] - random_size[0] * 0.5, center[1] - random_size[1] * 0.5]
+        )
+        bottom_right = np.array(
+            [center[0] + random_size[0] * 0.5, center[1] + random_size[1] * 0.5]
+        )
         diag = bottom_right - top_left
 
         kp0 = np.array([center[0], center[1] - random_size[1] * 0.5])
@@ -31,38 +36,38 @@ def generate_random_object_2D(self, size, max_label=5):
         label = np.random.randint(0, max_label + 1)
 
         return {
-            'center': center,
-            'size': random_size,
-            'tl': top_left,
-            'br': bottom_right,
-            'diag': diag,
-            'kp0': kp0,
-            'kp1': kp1,
-            'w': width,
-            'h': height,
-            'label': label
+            "center": center,
+            "size": random_size,
+            "tl": top_left,
+            "br": bottom_right,
+            "diag": diag,
+            "kp0": kp0,
+            "kp1": kp1,
+            "w": width,
+            "h": height,
+            "label": label,
         }
 
     def generate_2d_object_bbox(self, size, obj):
-        center = obj['center']
+        center = obj["center"]
         w, h = size
         return (
-            center[0] - obj['w'] * 0.5,
-            center[1] - obj['h'] * 0.5,
-            center[0] + obj['w'] * 0.5,
-            center[1] + obj['h'] * 0.5,
-            obj['label']
+            center[0] - obj["w"] * 0.5,
+            center[1] - obj["h"] * 0.5,
+            center[0] + obj["w"] * 0.5,
+            center[1] + obj["h"] * 0.5,
+            obj["label"],
         )
 
     def generate_2d_object_keypoints(self, size, obj):
 
         # center = obj['center']
-        tl = obj['tl']
-        br = obj['br']
-        diag = obj['diag']
+        tl = obj["tl"]
+        br = obj["br"]
+        diag = obj["diag"]
         # obj_size = obj['size']
         w, h = size
-        label = obj['label']
+        label = obj["label"]
 
         orientation = np.arctan2(diag[1], diag[0])
         orientation2 = np.arctan2(-diag[1], -diag[0])
@@ -78,12 +83,12 @@ def generate_2d_object_keypoints(self, size, obj):
     def generate_2d_objects_images(self, size, objects):
 
         image = Image.fromarray(self.generate_random_background(size))
-        mask = Image.new('L', size)
-        instances = Image.new('L', size)
+        mask = Image.new("L", size)
+        instances = Image.new("L", size)
 
         for index, obj in enumerate(objects):
-            label = obj['label']
-            coords = tuple(obj['tl']), tuple(obj['br'])
+            label = obj["label"]
+            coords = tuple(obj["tl"]), tuple(obj["br"])
             color = tuple(np.random.randint(0, 255, (3,)))
 
             # ImageDraw.Draw(image).ellipse(coords, fill=color)
@@ -95,10 +100,9 @@ def generate_2d_objects_images(self, size, objects):
             ImageDraw.Draw(instances).rectangle(coords, fill=index + 1)
 
         return {
-            'rgb': np.array(image),
-            'mask': np.array(mask),
-            'instances': np.array(instances),
-            'invmask': np.logical_not(np.array(mask)).astype(np.float32)
+            "rgb": np.array(image),
+            "mask": np.array(mask),
+            "instances": np.array(instances),
         }
 
     def generate_image_sample(self, size, max_label=5, objects_number_range=[1, 5]):
@@ -106,7 +110,9 @@ def generate_image_sample(self, size, max_label=5, objects_number_range=[1, 5]):
         objects = []
         bboxes = []
         keypoints = []
-        objects_number = np.random.randint(objects_number_range[0], objects_number_range[1])
+        objects_number = np.random.randint(
+            objects_number_range[0], objects_number_range[1]
+        )
         for n in range(objects_number):
             obj = self.generate_random_object_2D(size, max_label=max_label)
             box = self.generate_2d_object_bbox(size, obj)
@@ -116,16 +122,20 @@ def generate_image_sample(self, size, max_label=5, objects_number_range=[1, 5]):
             keypoints.extend(kps)
 
         data = self.generate_2d_objects_images(size, objects)
-        data.update({
-            'bboxes': bboxes,
-            'keypoints': keypoints,
-            'label': np.random.randint(max_label + 1),
-            'id': str(uuid.uuid1())
-        })
+        data.update(
+            {
+                "bboxes": bboxes,
+                "keypoints": keypoints,
+                "label": np.random.randint(max_label + 1),
+                "id": str(uuid.uuid1()),
+            }
+        )
         return data
 
     @classmethod
-    def generate_toy_dataset(cls, output_folder: str, size: int, image_size: Sequence[int], zfill: int = 5):
+    def generate_toy_dataset(
+        cls, output_folder: str, size: int, image_size: Sequence[int], zfill: int = 5
+    ):
 
         output_folder = Path(output_folder)
         output_folder.mkdir(parents=True, exist_ok=True)
@@ -139,28 +149,30 @@ def generate_toy_dataset(cls, output_folder: str, size: int, image_size: Sequenc
 
             # Extracts metadata
             metadata = {
-                'bboxes': sample['bboxes'],
-                'keypoints': sample['keypoints'],
-                'label': sample['label'],
-                'id': sample['id']
+                "bboxes": sample["bboxes"],
+                "keypoints": sample["keypoints"],
+                "label": sample["label"],
+                "id": sample["id"],
             }
 
             metadata = json.loads(json.dumps(metadata))
 
             # Naming
             name = str(idx).zfill(zfill)
-            image_name = f'{name}_image.png'
-            mask_name = f'{name}_mask.png'
-            instances_name = f'{name}_inst.png'
-            inverse_mask_name = f'{name}_invmask.exr'
-            metadata_name = f'{name}_metadata.yml'
-            keypoints_name = f'{name}_keypoints.txt'
-            bboxes_name = f'{name}_bboxes.npy'
-
-            FSToolkit.store_data(str(output_folder / image_name), sample['rgb'])
-            FSToolkit.store_data(str(output_folder / mask_name), sample['mask'])
-            FSToolkit.store_data(str(output_folder / instances_name), sample['instances'])
-            FSToolkit.store_data(str(output_folder / inverse_mask_name), sample['invmask'])
-            FSToolkit.store_data(str(output_folder / keypoints_name), sample['keypoints'])
-            FSToolkit.store_data(str(output_folder / bboxes_name), sample['bboxes'])
+            image_name = f"{name}_image.png"
+            mask_name = f"{name}_mask.png"
+            instances_name = f"{name}_inst.png"
+            metadata_name = f"{name}_metadata.yml"
+            keypoints_name = f"{name}_keypoints.txt"
+            bboxes_name = f"{name}_bboxes.npy"
+
+            FSToolkit.store_data(str(output_folder / image_name), sample["rgb"])
+            FSToolkit.store_data(str(output_folder / mask_name), sample["mask"])
+            FSToolkit.store_data(
+                str(output_folder / instances_name), sample["instances"]
+            )
+            FSToolkit.store_data(
+                str(output_folder / keypoints_name), sample["keypoints"]
+            )
+            FSToolkit.store_data(str(output_folder / bboxes_name), sample["bboxes"])
             FSToolkit.store_data(str(output_folder / metadata_name), metadata)
diff --git a/pipelime/workflow/cwl.py b/pipelime/workflow/cwl.py
index a6815e0..130c4a9 100644
--- a/pipelime/workflow/cwl.py
+++ b/pipelime/workflow/cwl.py
@@ -1,24 +1,26 @@
-import typing
-import os
-from choixe.spooks import Spook
 import importlib.util
-from typing import Dict, Union, Sequence
+import os
+import typing
 from pathlib import Path
+from typing import Dict, Sequence, Union
+
 import click
-from schema import Or
 import yaml
-from pipelime import user_data_dir
 from choixe.configurations import XConfig
+from choixe.spooks import Spook
+from schema import Or
+
+from pipelime import user_data_dir
 
 
 class CwlNodesManager(object):
 
-    DEFAULT_CWL_EXTENSION = 'cwl'
-    DEFAULT_META_EXTENSION = 'yml'
+    DEFAULT_CWL_EXTENSION = "cwl"
+    DEFAULT_META_EXTENSION = "yml"
 
     @classmethod
     def nodes_folder(cls) -> Path:
-        """ Default nodes folder
+        """Default nodes folder
 
         :return: folder
         :rtype: Path
@@ -31,7 +33,7 @@ def nodes_folder(cls) -> Path:
 
     @classmethod
     def get_associated_meta_path(cls, p: Path) -> Path:
-        """ Gets cwl file associated metadata path
+        """Gets cwl file associated metadata path
 
         :param p: cwl file
         :type p: Path
@@ -39,11 +41,11 @@ def get_associated_meta_path(cls, p: Path) -> Path:
         :rtype: Path
         """
 
-        return p.parent / f'{p.stem}.{cls.DEFAULT_META_EXTENSION}'
+        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
+        """Checks for valid cwl file
 
         :param p: cwl file
         :type p: Path
@@ -54,8 +56,8 @@ def is_valid_cwl_file(cls, p: Path) -> 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
+    def load_cwl_node(cls, p: Path) -> "CwlNode":
+        """Loads cwl node from path
 
         :return: loaded CwlNode
         :rtype: CwlNode
@@ -69,23 +71,25 @@ def load_cwl_node(cls, p: Path) -> 'CwlNode':
         return None
 
     @classmethod
-    def available_nodes(cls, folder: Union[str, None] = None) -> Dict[str, 'CwlNode']:
-        """ Gets a map of stored CwlNodes
+    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 = 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
+    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
@@ -105,8 +109,10 @@ def get_node_by_name(cls, name: str, folder: Union[str, None] = None) -> Union['
         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
+    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
@@ -125,21 +131,17 @@ def create_node(cls, name: str, cwl_template: 'CwlTemplate', folder: Union[str,
 
         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_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
-        )
+        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
+        """Deletes a node given the name
 
         :param name: node to delete name
         :type name: str
@@ -153,15 +155,17 @@ def delete_node(cls, name: str, folder: Union[str, None] = None):
             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}'
+        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
+    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]
@@ -184,8 +188,10 @@ def initialize_workflow(cls, names: Sequence[str], folder: Union[str, None] = No
         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
+    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
@@ -195,18 +201,22 @@ def fill_workflow(cls, workflow: 'CwlWorkflowTemplate', folder: Union[str, None]
         :rtype: CwlWorkflowTemplate
         """
 
-        for _, opts in workflow.template['steps'].items():
-            node_name = opts['run']
+        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
+            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
+    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
@@ -229,23 +239,23 @@ def __init__(self, script: str = None, alias: Sequence[str] = None, forwards: Se
         if self._cmd is not None:
             self._fill()
 
-    @ classmethod
+    @classmethod
     def spook_schema(cls) -> typing.Union[None, dict]:
         return {
-            'script': Or(str, None),
-            'alias': Or([str], None),
-            'forwards': Or([str], None),
+            "script": Or(str, None),
+            "alias": Or([str], None),
+            "forwards": Or([str], None),
         }
 
-    @ classmethod
+    @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
+            "script": self._script,
+            "alias": self._alias,
+            "forwards": self._forwards,
         }
 
     @property
@@ -274,7 +284,7 @@ def forwards(self):
 
     @property
     def inputs(self):
-        return self._template.get('inputs', None)
+        return self._template.get("inputs", None)
 
     @property
     def inputs_keys(self):
@@ -284,7 +294,7 @@ def inputs_keys(self):
 
     @property
     def outputs(self):
-        return self._template.get('outputs', None)
+        return self._template.get("outputs", None)
 
     @property
     def outputs_keys(self):
@@ -293,8 +303,10 @@ def outputs_keys(self):
         return []
 
     @classmethod
-    def from_command(cls, command: click.Command, alias: list = None, forwards: list = None) -> 'CwlTemplate':
-        """ Creates a cwl template directly from click.Command
+    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
@@ -312,8 +324,7 @@ def from_command(cls, command: click.Command, alias: list = None, forwards: list
         return cwl_template
 
     def _fill(self):
-        """ Fills the template
-        """
+        """Fills the template"""
 
         self._init_template()
         if self._alias is not None:
@@ -323,7 +334,7 @@ def _fill(self):
             self._fill_outputs(self._forwards)
 
     def _load_click_command(self, script: str) -> Union[click.Command, None]:
-        """ Loads a click.Command from file, it assumes
+        """Loads a click.Command from file, it assumes
         that the script contains only one command,
         if there are no commands it will return None
 
@@ -348,43 +359,42 @@ def _load_click_command(self, script: str) -> Union[click.Command, None]:
         return cmd
 
     def _init_template(self):
-        """ Initializes the cwl template for a CommandLineTool
-        """
+        """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()
+        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
+        """Fills the cwl template with the commands informations
 
         :param commands: the commands needed to call the script
         :type commands: list
         """
 
-        self._template['baseCommand'] = commands
+        self._template["baseCommand"] = commands
 
     def _fill_inputs(self, cmd: click.Command):
-        """ Fills the cwl template with input informations
+        """Fills the cwl template with input informations
 
         :param cmd: the click command
         :type cmd: click.Command
         """
 
-        self._template['doc'] = cmd.help
+        self._template["doc"] = cmd.help
         for param in cmd.params:
             param: click.Option
             cwl_param = dict()
-            cwl_param['doc'] = param.help
+            cwl_param["doc"] = param.help
             cwl_type = self._convert_type(param)
             cwl_param.update(cwl_type)
-            self._template['inputs'][param.name] = cwl_param
+            self._template["inputs"][param.name] = cwl_param
 
     def _convert_type(self, param: click.Option) -> dict:
-        """ Converts a click type into a cwl type
+        """Converts a click type into a cwl type
 
         :param param: the click option
         :type param: click.Option
@@ -393,97 +403,111 @@ def _convert_type(self, param: click.Option) -> dict:
         """
 
         cwl_types = dict()
-        cwl_types['type'] = []
+        cwl_types["type"] = []
         if not param.required:
-            cwl_types['type'].append('null')
+            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'
+            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'
+                cwl_type = "int"
             elif isinstance(param.type, click.types.FloatParamType):
-                cwl_type = 'float'
+                cwl_type = "float"
             elif isinstance(param.type, click.types.BoolParamType):
-                cwl_type = 'boolean'
+                cwl_type = "boolean"
             elif isinstance(param.type, click.Tuple):
-                cwl_type = 'string'
+                cwl_type = "string"
 
-        elif (not param.multiple and param.nargs > 1) or (param.multiple and param.nargs == 1):
+        elif (not param.multiple and param.nargs > 1) or (
+            param.multiple and param.nargs == 1
+        ):
             cwl_type = dict()
-            cwl_type['type'] = 'array'
+            cwl_type["type"] = "array"
 
-            if isinstance(param.type, click.types.StringParamType) or isinstance(param.type, click.Choice):
-                cwl_type['items'] = 'string'
+            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'
+                cwl_type["items"] = "int"
             elif isinstance(param.type, click.types.FloatParamType):
-                cwl_type['items'] = 'float'
+                cwl_type["items"] = "float"
             elif isinstance(param.type, click.types.BoolParamType):
-                cwl_type['items'] = 'boolean'
+                cwl_type["items"] = "boolean"
             elif isinstance(param.type, click.Tuple):
-                cwl_type['items'] = 'string'
+                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'
+            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'
+                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'
+                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_type["items"] = dict()
+                cwl_type["items"]["type"] = "array"
+                cwl_type["items"]["items"] = "string"
 
-        cwl_types['type'].append(cwl_type)
+        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]
+                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]
+                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
+        """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_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
+                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})'
+                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
+        """Dumps the cwl template to target file
 
         :param path: the output complete filename
         :type path: str
@@ -493,18 +517,17 @@ class NoAliasDumper(yaml.SafeDumper):
             def ignore_aliases(self, data):
                 return True
 
-        with open(path, 'w') as f:
+        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:
+        with open(path, "r") as f:
             lines = f.readlines()
-        lines.insert(0, '#!/usr/bin/env cwl-runner\n')
-        with open(path, 'w') as f:
+        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
@@ -529,9 +552,8 @@ def is_valid(self):
 
 
 class CwlWorkflowTemplate(object):
-
     def __init__(self, nodes: Sequence[CwlNode] = None):
-        """ Creates a cwl workflow template from list of CwlNode
+        """Creates a cwl workflow template from list of CwlNode
 
         :param nodes: steps of the workflow
         :type nodes: Sequence[CwlNode]
@@ -548,8 +570,8 @@ def template(self):
         return self._template
 
     @classmethod
-    def from_file(cls, filename: str) -> 'CwlWorkflowTemplate':
-        """ Creates a cwl workflow template from a file
+    def from_file(cls, filename: str) -> "CwlWorkflowTemplate":
+        """Creates a cwl workflow template from a file
 
         :param filename: the file template
         :type filename: str
@@ -557,7 +579,7 @@ def from_file(cls, filename: str) -> 'CwlWorkflowTemplate':
         :rtype: CwlWorkflowTemplate
         """
 
-        with open(filename, 'r') as f:
+        with open(filename, "r") as f:
             template = yaml.safe_load(f)
 
         workflow_template = CwlWorkflowTemplate()
@@ -565,30 +587,28 @@ def from_file(cls, filename: str) -> 'CwlWorkflowTemplate':
         return workflow_template
 
     def _fill(self):
-        """ Fills the template
-        """
+        """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()
+        """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()
+        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
+        """Fills the cwl workflow template with steps informations
 
         :param steps: the steos of the workflow
         :type steps: Sequence[CwlNode]
@@ -599,19 +619,21 @@ def _fill_steps(self, steps: Sequence[CwlNode]):
 
         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
+            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']
+                self._template["inputs"][f"{step_name}_{input_name}"] = input_opt[
+                    "type"
+                ]
 
     def dumps(self, path: str):
-        """ Dumps the cwl workflow template to target file
+        """Dumps the cwl workflow template to target file
 
         :param path: the output complete filename
         :type path: str
@@ -621,11 +643,11 @@ class NoAliasDumper(yaml.SafeDumper):
             def ignore_aliases(self, data):
                 return True
 
-        with open(path, 'w') as f:
+        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:
+        with open(path, "r") as f:
             lines = f.readlines()
-        lines.insert(0, '#!/usr/bin/env cwl-runner\n')
-        with open(path, 'w') as f:
+        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 cbaf6fb..cbb1180 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,8 +5,9 @@ schema
 numpy
 albumentations>=0.5.2
 choixe
-imageio
+imageio<=2.10.5
 pydash
 Pillow>=8.0.0
 appdirs
-loguru
\ No newline at end of file
+loguru
+h5py
\ No newline at end of file
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 2408373..9f06e4f 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,11 +1,12 @@
-pip==19.2.3
+pip==21.3.1
 bump2version==0.5.11
 wheel==0.33.6
 watchdog==0.9.0
 flake8==3.7.8
 tox==3.14.0
 coverage==4.5.4
-Sphinx==1.8.5
+Sphinx==4.3.0
 twine==1.14.0
 pytest==4.6.5
-pytest-runner==5.1
\ No newline at end of file
+pytest-runner==5.1
+sphinx_rtd_theme==1.0.0
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index 6feb899..5a22364 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,15 +1,15 @@
 [bumpversion]
-current_version = 0.1.1
+current_version = 0.1.2
 commit = True
 tag = True
 
 [bumpversion:file:setup.py]
-search = version='{current_version}'
-replace = version='{new_version}'
+search = version="{current_version}"
+replace = version="{new_version}"
 
 [bumpversion:file:pipelime/__init__.py]
-search = __version__ = '{current_version}'
-replace = __version__ = '{new_version}'
+search = __version__ = "{current_version}"
+replace = __version__ = "{new_version}"
 
 [bdist_wheel]
 universal = 1
@@ -22,4 +22,3 @@ test = pytest
 
 [tool:pytest]
 collect_ignore = ['setup.py']
-
diff --git a/setup.py b/setup.py
index 45f0a27..00e079a 100644
--- a/setup.py
+++ b/setup.py
@@ -4,48 +4,52 @@
 
 from setuptools import setup, find_packages
 
-with open('README.rst') as readme_file:
+with open("README.rst") as readme_file:
     readme = readme_file.read()
 
-with open('requirements.txt') as requirements_file:
+with open("requirements.txt") as requirements_file:
     requirements = requirements_file.readlines()
 
-setup_requirements = ['pytest-runner', ]
+setup_requirements = [
+    "pytest-runner",
+]
 
-test_requirements = ['pytest>=3', ]
+test_requirements = [
+    "pytest>=3",
+]
 
 setup(
     author="daniele de gregorio",
-    author_email='daniele.degregorio@eyecan.ai',
-    python_requires='>=3.5',
+    author_email="daniele.degregorio@eyecan.ai",
+    python_requires=">=3.5",
     classifiers=[
-        'Development Status :: 2 - Pre-Alpha',
-        'Intended Audience :: Developers',
-        'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
-        'Natural Language :: English',
-        'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.5',
-        'Programming Language :: Python :: 3.6',
-        'Programming Language :: Python :: 3.7',
-        'Programming Language :: Python :: 3.8',
+        "Development Status :: 2 - Pre-Alpha",
+        "Intended Audience :: Developers",
+        "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+        "Natural Language :: English",
+        "Programming Language :: Python :: 3",
+        "Programming Language :: Python :: 3.5",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
     ],
     description="data pipeline 101",
     entry_points={
-        'console_scripts': [
-            'pipelime=pipelime.cli.main:pipelime',
+        "console_scripts": [
+            "pipelime=pipelime.cli.main:pipelime",
         ],
     },
     install_requires=requirements,
     license="GNU General Public License v3",
     long_description=readme,
     include_package_data=True,
-    keywords='pipelime',
-    name='pipelime',
-    packages=find_packages(include=['pipelime', 'pipelime.*']),
+    keywords="pipelime",
+    name="pipelime",
+    packages=find_packages(include=["pipelime", "pipelime.*"]),
     setup_requires=setup_requirements,
-    test_suite='tests',
+    test_suite="tests",
     tests_require=test_requirements,
-    url='https://github.com/eyecan-ai/pipelime',
-    version='0.1.1',
+    url="https://github.com/eyecan-ai/pipelime",
+    version="0.1.2",
     zip_safe=False,
 )


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 9faa588..c72ce98 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,78 +1,153 @@
-import yaml
-from pipelime.tools.toydataset import ToyDatasetGenerator
-import pytest
 import os
 from pathlib import Path
 
+import pytest
+import yaml
+
+from pipelime.tools.toydataset import ToyDatasetGenerator
+
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def data_folder():
     dirname = os.path.dirname(__file__)
-    return os.path.join(dirname, 'sample_data')
+    return os.path.join(dirname, "sample_data")
 
 
-@pytest.fixture(scope='function')
+@pytest.fixture(scope="function")
 def toy_dataset_small(tmpdir):
     folder = Path(tmpdir.mkdir("toy_dataset"))
-    datafolder = folder / 'data'
+    datafolder = folder / "data"
     size = 32
     image_size = 256
     zfill = 5
     ToyDatasetGenerator.generate_toy_dataset(
-        datafolder,
-        size=size,
-        image_size=image_size,
-        zfill=zfill
+        datafolder, size=size, image_size=image_size, zfill=zfill
     )
 
-    global_meta = {
-        'name': 'toy_dataset_small',
-        'numbers': [1, 2, 3, 4, 5, 6]
+    global_meta = {"name": "toy_dataset_small", "numbers": [1, 2, 3, 4, 5, 6]}
+
+    global_meta_name = folder / "global_meta.yml"
+    yaml.safe_dump(global_meta, open(global_meta_name, "w"))
+
+    return {
+        "folder": folder,
+        "data_folder": datafolder,
+        "size": size,
+        "image_size": image_size,
+        "zfill": zfill,
+        "keypoints_format": "xyas",
+        "bboxes_format": "pascal_voc",
+        "expected_keys": ["image", "mask", "inst", "keypoints", "metadata", "bboxes"],
+        "root_keys": ["global_meta"],
     }
 
-    global_meta_name = folder / 'global_meta.yml'
-    yaml.safe_dump(global_meta, open(global_meta_name, 'w'))
+
+@pytest.fixture(scope="function")
+def toy_h5dataset_small(tmpdir):
+    folder = Path(tmpdir.mkdir("toy_h5dataset"))
+    datafolder = folder / "data"
+    filename = folder / "h5dataset.h5"
+    size = 32
+    image_size = 256
+    zfill = 5
+    ToyDatasetGenerator.generate_toy_dataset(
+        datafolder, size=size, image_size=image_size, zfill=zfill
+    )
+
+    global_meta = {"name": "toy_dataset_small", "numbers": [1, 2, 3, 4, 5, 6]}
+
+    global_meta_name = folder / "global_meta.yml"
+    yaml.safe_dump(global_meta, open(global_meta_name, "w"))
 
     return {
-        'folder': folder,
-        'data_folder': datafolder,
-        'size': size,
-        'image_size': image_size,
-        'zfill': zfill,
-        'keypoints_format': 'xyas',
-        'bboxes_format': 'pascal_voc',
-        'expected_keys': ['image', 'mask', 'inst', 'invmask', 'keypoints', 'metadata', 'bboxes'],
-        'root_keys': ['global_meta']
+        "folder": folder,
+        "data_folder": datafolder,
+        "size": size,
+        "image_size": image_size,
+        "zfill": zfill,
+        "keypoints_format": "xyas",
+        "bboxes_format": "pascal_voc",
+        "expected_keys": ["image", "mask", "inst", "keypoints", "metadata", "bboxes"],
+        "root_keys": ["global_meta"],
     }
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def filesystem_datasets(data_folder):
     return {
-        'minimnist_underfolder': {
-            'folder': Path(data_folder) / 'datasets' / 'underfolder_minimnist',
-            'type': 'Undefolder',
-            'schemas': {
-                'simple': {
-                    'filename': Path(data_folder) / 'datasets' / 'underfolder_minimnist_schemas' / 'simple.schema',
-                    'valid': True,
-                    'should_pass': True
+        "minimnist_underfolder": {
+            "folder": Path(data_folder) / "datasets" / "underfolder_minimnist",
+            "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,
                 },
-                '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,
                 },
-                '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,
                 },
-                'bad_file': {
-                    'filename': Path(data_folder) / 'datasets' / 'underfolder_minimnist_schemas' / 'bad.schema',
-                    'valid': False,
-                    'should_pass': False
-                }
-            }
+            },
+        }
+    }
+
+
+@pytest.fixture(scope="session")
+def sample_underfolder_minimnist(filesystem_datasets):
+    return filesystem_datasets["minimnist_underfolder"]
+
+
+@pytest.fixture(scope="session")
+def h5_datasets(data_folder):
+    return {
+        "minimnist_h5": {
+            "filename": Path(data_folder) / "datasets" / "underfolder_minimnist.h5",
+            "type": "H5",
+            # '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/augmentations/conftest.py b/tests/pipelime/augmentations/conftest.py
index bdf039f..bcd149b 100644
--- a/tests/pipelime/augmentations/conftest.py
+++ b/tests/pipelime/augmentations/conftest.py
@@ -1,12 +1,11 @@
-import pytest
 from pathlib import Path
 
+import pytest
+
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def augmentations_test_configurations(data_folder):
-    configurations_folder = Path(data_folder) / 'augmentations'
+    configurations_folder = Path(data_folder) / "augmentations"
     return [
-        {
-            'filename': configurations_folder / 'full_custom_augmentations.yml'
-        },
+        {"filename": configurations_folder / "full_custom_augmentations.yml"},
     ]
diff --git a/tests/pipelime/augmentations/test_factory.py b/tests/pipelime/augmentations/test_factory.py
index 8b8d6fa..5502cb8 100644
--- a/tests/pipelime/augmentations/test_factory.py
+++ b/tests/pipelime/augmentations/test_factory.py
@@ -1,22 +1,21 @@
-
-from pipelime.augmentations.factory import AugmentationsFactory
 import albumentations as A
 import numpy as np
 from choixe.configurations import XConfig
 
+from pipelime.augmentations.factory import AugmentationsFactory
 
-class TestAugmentationsFactory(object):
 
+class TestAugmentationsFactory(object):
     def _transform_test_image(self, transform):
 
-        img = np.random.uniform(0., 1., (256, 256, 3))
+        img = np.random.uniform(0.0, 1.0, (256, 256, 3))
         img = transform(image=img)
 
     def test_creation_from_file(self, augmentations_test_configurations):
 
         for cfg in augmentations_test_configurations:
 
-            transform = AugmentationsFactory.build_from_file(cfg['filename'])
+            transform = AugmentationsFactory.build_from_file(cfg["filename"])
             assert transform is not None
 
             self._transform_test_image(transform)
@@ -25,7 +24,7 @@ def test_creation_from_cfg(self, augmentations_test_configurations):
 
         for cfg in augmentations_test_configurations:
 
-            xc = XConfig(filename=cfg['filename']).to_dict()
+            xc = XConfig(filename=cfg["filename"]).to_dict()
             transform = AugmentationsFactory.build_from_dict(xc)
             assert transform is not None
 
diff --git a/tests/pipelime/cli/__init__.py b/tests/pipelime/cli/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/pipelime/cli/test_underfolder.py b/tests/pipelime/cli/test_underfolder.py
new file mode 100644
index 0000000..3ef57f3
--- /dev/null
+++ b/tests/pipelime/cli/test_underfolder.py
@@ -0,0 +1,427 @@
+from click.testing import CliRunner
+
+from pipelime.cli.underfolder.operations import operation_split
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+
+
+class TestCLIUnderfolderOperationSum:
+    def test_sum(self, tmpdir, sample_underfolder_minimnist):
+
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import operation_sum
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+        output_folder = Path(tmpdir.mkdir("test_sum"))
+        N = 5
+
+        options = []
+        for _ in range(N):
+            options.extend(["-i", str(input_folder)])
+        options.extend(["-o", f"{str(output_folder)}"])
+
+        runner = CliRunner()
+        result = runner.invoke(operation_sum, options)
+        print(result)
+        assert result.exit_code == 0
+
+        output_reader = UnderfolderReader(folder=output_folder, lazy_samples=True)
+        print("OUTPUT", output_folder)
+        assert len(output_reader) == len(input_dataset) * N
+
+
+class TestCLIUnderfolderOperationSubsample:
+    def test_subsample(self, tmpdir, sample_underfolder_minimnist):
+
+        import uuid
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import operation_subsample
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+
+        N = 5
+        perc = 1 / N
+        expected_length = int(len(input_dataset) / N)
+        # expected_length_sub = len(input_dataset) // N
+
+        for factor in [perc, N]:
+            output_folder = Path(tmpdir.mkdir(str(uuid.uuid1())))
+
+            options = []
+            options.extend(["-i", str(input_folder)])
+            options.extend(["-o", f"{str(output_folder)}"])
+            options.extend(["-f", f"{factor}"])
+
+            runner = CliRunner()
+            result = runner.invoke(operation_subsample, options)
+            print(result)
+
+            assert result.exit_code == 0
+            output_reader = UnderfolderReader(folder=output_folder, lazy_samples=True)
+            print("OUTPUT", factor, output_folder)
+
+            assert len(output_reader) == expected_length
+
+
+class TestCLIUnderfolderOperationShuffle:
+    def test_shuffle(self, tmpdir, sample_underfolder_minimnist):
+
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import operation_shuffle
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+        output_folder = Path(tmpdir.mkdir("test_shuffle"))
+
+        options = []
+        options.extend(["-i", str(input_folder)])
+        options.extend(["-o", f"{str(output_folder)}"])
+        options.extend(
+            ["-s", f"{str(1024)}"]
+        )  # This should shuffle dataset such as `7` will be the new first sample! CHeck it!!
+
+        runner = CliRunner()
+        result = runner.invoke(operation_shuffle, options)
+        print(result)
+        assert result.exit_code == 0
+
+        output_reader = UnderfolderReader(folder=output_folder, lazy_samples=True)
+        print("OUTPUT", output_folder)
+        assert len(output_reader) == len(input_dataset)
+        assert output_reader[0]["label"] != input_dataset[0]["label"]
+
+
+class TestCLIUnderfolderOperationSplit:
+    def test_split(self, tmpdir, sample_underfolder_minimnist):
+
+        import uuid
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import operation_split
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+
+        percs = [0.1, 0.2, 0.3, 0.4]
+        output_folders = [
+            str(Path(tmpdir.mkdir(str(uuid.uuid1())))) for _ in range(len(percs))
+        ]
+        splits_map = {k: v for k, v in zip(output_folders, percs)}
+
+        options = []
+        options.extend(["-i", str(input_folder)])
+        for k, v in splits_map.items():
+            options.extend(["-s", f"{str(k)}", f"{str(v)}"])
+
+        runner = CliRunner()
+        result = runner.invoke(operation_split, options)
+        print(result)
+
+        assert result.exit_code == 0
+
+        cumulative = 0
+        for output_folder in output_folders:
+            output_reader = UnderfolderReader(folder=output_folder, lazy_samples=True)
+            cumulative += len(output_reader)
+        assert len(input_dataset) == cumulative
+
+
+class TestCLIUnderfolderOperationFilterByQuery:
+    def test_filterbyquery(self, tmpdir, sample_underfolder_minimnist):
+
+        import uuid
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import operation_filterbyquery
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+
+        query = "`metadata.double` <= 10.0 AND `metadata.half` <= 2.5"
+        # Samples whose double is <= 10 adn half <= 2.5 are  6! Check it!
+        expected_size = 6
+
+        output_folder = str(Path(tmpdir.mkdir(str(uuid.uuid1()))))
+
+        options = []
+        options.extend(["-i", str(input_folder)])
+        options.extend(["-o", f"{str(output_folder)}"])
+        options.extend(["-q", f"{str(query)}"])
+
+        runner = CliRunner()
+        result = runner.invoke(operation_filterbyquery, options)
+        print(result)
+
+        assert result.exit_code == 0
+
+        output_reader = UnderfolderReader(folder=output_folder, lazy_samples=True)
+        assert len(output_reader) == expected_size
+
+
+class TestCLIUnderfolderOperationSplitByQuery:
+    def test_splitbyquery(self, tmpdir, sample_underfolder_minimnist):
+
+        import uuid
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import operation_splitbyquery
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+
+        query = "`metadata.double` <= 10.0 AND `metadata.half` <= 2.5"
+
+        output_folders = [str(Path(tmpdir.mkdir(str(uuid.uuid1())))) for _ in range(2)]
+
+        options = []
+        options.extend(["-i", str(input_folder)])
+        options.extend(["-o1", f"{str(output_folders[0])}"])
+        options.extend(["-o2", f"{str(output_folders[1])}"])
+        options.extend(["-q", f"{str(query)}"])
+
+        runner = CliRunner()
+        result = runner.invoke(operation_splitbyquery, options)
+        print(result)
+
+        assert result.exit_code == 0
+
+        cumulative = 0
+        for output_folder in output_folders:
+            output_reader = UnderfolderReader(folder=output_folder, lazy_samples=True)
+            cumulative += len(output_reader)
+        assert cumulative == len(input_dataset)
+
+
+class TestCLIUnderfolderOperationFilterByScript:
+    def test_filterbyscript(self, tmpdir, sample_underfolder_minimnist):
+
+        import uuid
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import operation_filterbyscript
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+
+        # script
+        func = ""
+        func += "import numpy as np\n"
+        func += "def check_sample(sample, sequence):\n"
+        func += " return np.array(sample['metadata']['double']) > 4\n"
+        script_path = tmpdir.join("custom_script.py")
+        with open(script_path, "w") as f:
+            f.write(func)
+
+        # Samples whose double is > 4 17! Check it!
+        expected_size = 17
+
+        output_folder = str(Path(tmpdir.mkdir(str(uuid.uuid1()))))
+
+        options = []
+        options.extend(["-i", str(input_folder)])
+        options.extend(["-o", f"{str(output_folder)}"])
+        options.extend(["-s", f"{str(script_path)}"])
+
+        runner = CliRunner()
+        result = runner.invoke(operation_filterbyscript, options)
+        print(result)
+
+        assert result.exit_code == 0
+
+        output_reader = UnderfolderReader(folder=output_folder, lazy_samples=True)
+        assert len(output_reader) == expected_size
+
+
+class TestCLIUnderfolderOperationFilterKeys:
+    def test_filterkeys(self, tmpdir, sample_underfolder_minimnist):
+
+        import uuid
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import operation_filterkeys
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+
+        # script
+        filtering_keys = ["image", "label", "metadata"]
+
+        for negate in [False, True]:
+            output_folder = str(Path(tmpdir.mkdir(str(uuid.uuid1()))))
+
+            options = []
+            options.extend(["-i", str(input_folder)])
+            for k in filtering_keys:
+                options.extend(["-k", f"{str(k)}"])
+            options.extend(["-o", f"{str(output_folder)}"])
+            if negate:
+                options.extend(["--negate"])
+
+            runner = CliRunner()
+            result = runner.invoke(operation_filterkeys, options)
+            print(result)
+
+            assert result.exit_code == 0
+
+            output_reader = UnderfolderReader(folder=output_folder, lazy_samples=True)
+            assert len(output_reader) == len(input_dataset)
+
+            for sample in output_reader:
+                for key in sample.keys():
+                    if negate:
+                        assert key not in filtering_keys
+                    else:
+                        assert key in filtering_keys
+                break
+
+
+class TestCLIUnderfolderOperationOrderKeys:
+    def test_orderkeys(self, tmpdir, sample_underfolder_minimnist):
+
+        import uuid
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import operation_orderby
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+
+        # script
+        filtering_keys = ["-metadata.sample_id"]
+
+        output_folder = str(Path(tmpdir.mkdir(str(uuid.uuid1()))))
+        print("OUTPUT", output_folder)
+
+        options = []
+        options.extend(["-i", str(input_folder)])
+        for k in filtering_keys:
+            options.extend(["-k", f"{str(k)}"])
+        options.extend(["-o", f"{str(output_folder)}"])
+
+        runner = CliRunner()
+        result = runner.invoke(operation_orderby, options)
+        print(result)
+
+        assert result.exit_code == 0
+
+        output_reader = UnderfolderReader(folder=output_folder, lazy_samples=True)
+        assert len(output_reader) == len(input_dataset)
+
+        assert (
+            output_reader[0]["label"] == 9
+        )  # The first sample should be `9`! CHeck it!!
+
+
+class TestCLIUnderfolderOperationSplitByValue:
+    def test_split_by_value(self, tmpdir, sample_underfolder_minimnist):
+
+        import uuid
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import operation_split_by_value
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+
+        # script
+        split_key = "metadata.sample_id"
+
+        output_folder = Path(tmpdir.mkdir(str(uuid.uuid1())))
+        print("OUTPUT", output_folder)
+
+        options = []
+        options.extend(["-i", str(input_folder)])
+        options.extend(["-k", f"{str(split_key)}"])
+        options.extend(["-o", f"{str(output_folder)}"])
+
+        runner = CliRunner()
+        result = runner.invoke(operation_split_by_value, options)
+        print(result)
+
+        assert result.exit_code == 0
+
+        total = 0
+        for subfolder in output_folder.iterdir():
+            print(subfolder)
+            output_reader = UnderfolderReader(folder=subfolder)
+            total += len(output_reader)
+        assert total == len(input_dataset)
+
+
+class TestCLIUnderfolderOperationGroupBy:
+    def test_groupby(self, tmpdir, sample_underfolder_minimnist):
+
+        import uuid
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import operation_groupby
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+
+        # script
+        group_key = "metadata.sample_id"
+
+        output_folder = str(Path(tmpdir.mkdir(str(uuid.uuid1()))))
+        print("OUTPUT", output_folder)
+
+        options = []
+        options.extend(["-i", str(input_folder)])
+        options.extend(["-k", f"{str(group_key)}"])
+        options.extend(["-o", f"{str(output_folder)}"])
+
+        runner = CliRunner()
+        result = runner.invoke(operation_groupby, options)
+        print(result)
+
+        assert result.exit_code == 0
+
+        output_reader = UnderfolderReader(folder=output_folder, lazy_samples=True)
+        assert len(output_reader) == len(
+            input_dataset
+        )  # TODO: this test is pretty useless! check content!
+
+
+class TestCLIUnderfolderOperationMix:
+    def test_mix(self, tmpdir, sample_underfolder_minimnist):
+
+        import uuid
+        from pathlib import Path
+
+        from pipelime.cli.underfolder.operations import (
+            operation_filterkeys,
+            operation_mix,
+        )
+
+        input_folder = sample_underfolder_minimnist["folder"]
+        input_dataset = UnderfolderReader(folder=input_folder)
+        input_folder_a = str(Path(tmpdir.mkdir(str(uuid.uuid1()))))
+        input_folder_b = str(Path(tmpdir.mkdir(str(uuid.uuid1()))))
+        output_folder = str(Path(tmpdir.mkdir(str(uuid.uuid1()))))
+        print(set(input_dataset[0].keys()))
+        runner = CliRunner()
+        runner.invoke(
+            operation_filterkeys,
+            f"-i {input_folder} -o {input_folder_a} -k pose -k label",
+        )
+        runner.invoke(
+            operation_filterkeys,
+            f"-i {input_folder} -o {input_folder_b} -k image -k points",
+        )
+
+        options = []
+        options.extend(["-i", str(input_folder_a)])
+        options.extend(["-i", str(input_folder_b)])
+        options.extend(["-o", f"{str(output_folder)}"])
+
+        res = runner.invoke(operation_mix, options)
+        assert res.exit_code == 0
+
+        output_reader = UnderfolderReader(folder=output_folder)
+        assert len(output_reader) == len(input_dataset)
+        keys = set(output_reader[0].keys())
+        assert keys == {"pose", "label", "image", "points"}
diff --git a/tests/pipelime/sequences/conftest.py b/tests/pipelime/sequences/conftest.py
index 90b0f98..a163aa2 100644
--- a/tests/pipelime/sequences/conftest.py
+++ b/tests/pipelime/sequences/conftest.py
@@ -1,53 +1,50 @@
+import numpy as np
+import pytest
 
-from pathlib import Path
-from pipelime.tools.toydataset import ToyDatasetGenerator
 from pipelime.sequences.samples import PlainSample, SamplesSequence
-import pytest
-import numpy as np
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def plain_samples_generator():
     def generate_plain_samples(
-            namespace: str,
-            N: int,
-            group_each: int = 5,
-            heavy_data: bool = True
+        namespace: str, N: int, group_each: int = 5, heavy_data: bool = True
     ):
 
         samples = []
         for i in range(N):
             data = {
-                'idx': f'{namespace}{i}',
-                'number': i,
-                'reverse_number': N - i,
-                'fraction': i / 1000.,
-                'odd': i % 2 == 1,
-                'data0': np.random.uniform(0.0, 1.0, (64, 64, 3)) if heavy_data else 1.64643,
-                'data1': np.random.uniform(0, 255, (8, 8)).astype(np.uint8) if heavy_data else 25588,
-                'metadata': {
-                    'even': i % 2 == 0,
-                    'name': f'{namespace}{i}',
-                    'N': i,
-                    'deep': {
-                        'super_deep': 0,
-                        'groupby_field': int(i / group_each)
-                    }
-                }
+                "idx": f"{namespace}{i}",
+                "number": i,
+                "reverse_number": N - i,
+                "fraction": i / 1000.0,
+                "odd": i % 2 == 1,
+                "data0": np.random.uniform(0.0, 1.0, (64, 64, 3))
+                if heavy_data
+                else 1.64643,
+                "data1": np.random.uniform(0, 255, (8, 8)).astype(np.uint8)
+                if heavy_data
+                else 25588,
+                "metadata": {
+                    "even": i % 2 == 0,
+                    "name": f"{namespace}{i}",
+                    "N": i,
+                    "deep": {"super_deep": 0, "groupby_field": int(i / group_each)},
+                },
             }
             samples.append(PlainSample(data=data))
 
         return samples
+
     return generate_plain_samples
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def plain_samples_sequence_generator(plain_samples_generator):
     def generate_plain_samples_sequence(
-        namespace: str,
-        N: int,
-        group_each: int = 5,
-        heavy_data: bool = True
+        namespace: str, N: int, group_each: int = 5, heavy_data: bool = True
     ):
-        return SamplesSequence(samples=plain_samples_generator(namespace, N, group_each, heavy_data))
+        return SamplesSequence(
+            samples=plain_samples_generator(namespace, N, group_each, heavy_data)
+        )
+
     return generate_plain_samples_sequence
diff --git a/tests/pipelime/sequences/io/test_filesystem.py b/tests/pipelime/sequences/io/test_filesystem.py
index cc0cf58..4a4d921 100644
--- a/tests/pipelime/sequences/io/test_filesystem.py
+++ b/tests/pipelime/sequences/io/test_filesystem.py
@@ -1,20 +1,19 @@
-
-
-from itertools import product
 import uuid
-import numpy as np
+from itertools import product
 from pathlib import Path
+
+import numpy as np
+from choixe.spooks import Spook
+
 from pipelime.sequences.operations import OperationFilterKeys
+from pipelime.sequences.readers.base import BaseReader, ReaderTemplate
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+from pipelime.sequences.samples import FilesystemItem, FileSystemSample, Sample
 from pipelime.sequences.writers.filesystem import UnderfolderWriter
-from pipelime.factories import BeanFactory
-from pipelime.sequences.readers.base import BaseReader
-from pipelime.sequences.samples import FileSystemSample, FilesystemItem, Sample
-from pipelime.sequences.readers.filesystem import UnderfolderReader, UnderfolderReaderTemplate
-from schema import Schema
 
 
 def _plug_test(reader: BaseReader):
-    """ Test what a generic BaseReader should do
+    """Test what a generic BaseReader should do
 
     :param reader: input BaseReader
     :type reader: BaseReader
@@ -26,18 +25,17 @@ def _plug_test(reader: BaseReader):
     rereader = reader.hydrate(reader.serialize())
     assert isinstance(rereader, BaseReader)
 
-    factored = BeanFactory.create(reader.serialize())
+    factored = Spook.create(reader.serialize())
     assert isinstance(factored, BaseReader)
 
 
 class TestUnderfolder(object):
-
     def test_reader(self, toy_dataset_small):
 
-        size = toy_dataset_small['size']
-        folder = toy_dataset_small['folder']
-        keys = toy_dataset_small['expected_keys']
-        root_keys = toy_dataset_small['root_keys']
+        size = toy_dataset_small["size"]
+        folder = toy_dataset_small["folder"]
+        keys = toy_dataset_small["expected_keys"]
+        root_keys = toy_dataset_small["root_keys"]
 
         copies = [True, False]
         for copy in copies:
@@ -60,17 +58,21 @@ def test_reader(self, toy_dataset_small):
 
 
 class TestUnderfolderReaderWriterTemplating(object):
-
     def test_reader_writer(self, toy_dataset_small, tmpdir_factory):
 
-        folder = toy_dataset_small['folder']
-        keys = toy_dataset_small['expected_keys']
-        root_keys = toy_dataset_small['root_keys']
+        folder = toy_dataset_small["folder"]
+        keys = toy_dataset_small["expected_keys"]
+        root_keys = toy_dataset_small["root_keys"]
 
         reader = UnderfolderReader(folder=folder, copy_root_files=True)
-        template = reader.get_filesystem_template()
+        template = reader.get_reader_template()
+        _helper_template = UnderfolderReader.get_reader_template_from_folder(
+            folder=folder
+        )
+        assert _helper_template == template
+
         assert template is not None
-        assert isinstance(template, UnderfolderReaderTemplate)
+        assert isinstance(template, ReaderTemplate)
 
         assert set(template.extensions_map.keys()) == set(keys + root_keys)
         assert set(template.root_files_keys) == set(root_keys)
@@ -80,28 +82,32 @@ def test_reader_writer(self, toy_dataset_small, tmpdir_factory):
             folder=writer_folder,
             root_files_keys=template.root_files_keys,
             extensions_map=template.extensions_map,
-            zfill=template.idx_length
+            zfill=template.idx_length,
         )
         writer(reader)
         print("Writer", writer_folder)
 
         re_reader = UnderfolderReader(folder=writer_folder, copy_root_files=True)
-        re_template = re_reader.get_filesystem_template()
+        re_template = re_reader.get_reader_template()
 
-        assert set(template.extensions_map.keys()) == set(re_template.extensions_map.keys())
+        assert set(template.extensions_map.keys()) == set(
+            re_template.extensions_map.keys()
+        )
         assert set(template.root_files_keys) == set(re_template.root_files_keys)
         assert template.idx_length == re_template.idx_length
 
-    def test_reader_writer_without_explicit_template(self, toy_dataset_small, tmpdir_factory):
+    def test_reader_writer_without_explicit_template(
+        self, toy_dataset_small, tmpdir_factory
+    ):
 
-        folder = toy_dataset_small['folder']
-        keys = toy_dataset_small['expected_keys']
-        root_keys = toy_dataset_small['root_keys']
+        folder = toy_dataset_small["folder"]
+        keys = toy_dataset_small["expected_keys"]
+        root_keys = toy_dataset_small["root_keys"]
 
         reader = UnderfolderReader(folder=folder, copy_root_files=True)
-        template = reader.get_filesystem_template()
+        template = reader.get_reader_template()
         assert template is not None
-        assert isinstance(template, UnderfolderReaderTemplate)
+        assert isinstance(template, ReaderTemplate)
         assert set(template.extensions_map.keys()) == set(keys + root_keys)
         assert set(template.root_files_keys) == set(root_keys)
 
@@ -111,17 +117,21 @@ def test_reader_writer_without_explicit_template(self, toy_dataset_small, tmpdir
         print("Writer", writer_folder)
 
         re_reader = UnderfolderReader(folder=writer_folder, copy_root_files=True)
-        re_template = re_reader.get_filesystem_template()
+        re_template = re_reader.get_reader_template()
 
-        assert set(template.extensions_map.keys()) == set(re_template.extensions_map.keys())
+        assert set(template.extensions_map.keys()) == set(
+            re_template.extensions_map.keys()
+        )
         assert set(template.root_files_keys) == set(re_template.root_files_keys)
         assert template.idx_length == re_template.idx_length
 
-    def test_filtered_reader_writer_without_explicit_template(self, toy_dataset_small, tmpdir_factory):
+    def test_filtered_reader_writer_without_explicit_template(
+        self, toy_dataset_small, tmpdir_factory
+    ):
 
-        folder = toy_dataset_small['folder']
-        keys = toy_dataset_small['expected_keys']
-        root_keys = toy_dataset_small['root_keys']
+        folder = toy_dataset_small["folder"]
+        keys = toy_dataset_small["expected_keys"]
+        root_keys = toy_dataset_small["root_keys"]
 
         reader = UnderfolderReader(folder=folder, copy_root_files=True)
         op = OperationFilterKeys(keys=keys[0], negate=False)
@@ -132,36 +142,36 @@ def test_filtered_reader_writer_without_explicit_template(self, toy_dataset_smal
         print("Filtered writer", writer_folder)
         writer = UnderfolderWriter(
             folder=writer_folder,
-            extensions_map=reader.get_filesystem_template().extensions_map,
-            root_files_keys=reader.get_filesystem_template().root_files_keys,
-            zfill=reader.get_filesystem_template().idx_length
+            extensions_map=reader.get_reader_template().extensions_map,
+            root_files_keys=reader.get_reader_template().root_files_keys,
+            zfill=reader.get_reader_template().idx_length,
         )
         writer(filtered_reader)
 
     def test_writer_copy_correct_extension(self, toy_dataset_small, tmpdir_factory):
-        
-        folder = toy_dataset_small['folder']
-        keys = toy_dataset_small['expected_keys']
+
+        folder = toy_dataset_small["folder"]
+        keys = toy_dataset_small["expected_keys"]
 
         for lazy_samples, copy_files, use_symlinks in product([True, False], repeat=3):
             reader = UnderfolderReader(folder=folder, lazy_samples=lazy_samples)
-            extensions_map = reader.get_filesystem_template().extensions_map
+            extensions_map = reader.get_reader_template().extensions_map
             changed_keys = []
-            old_ext = 'png'
-            new_ext = 'jpg'
+            old_ext = "png"
+            new_ext = "jpg"
             for k, ext in extensions_map.items():
                 if ext == old_ext:
                     extensions_map[k] = new_ext
                     changed_keys.append(k)
 
-            writer_folder = Path(tmpdir_factory.mktemp('writer_folder'))
+            writer_folder = Path(tmpdir_factory.mktemp("writer_folder"))
             writer = UnderfolderWriter(
                 folder=writer_folder,
                 extensions_map=extensions_map,
-                root_files_keys=reader.get_filesystem_template().root_files_keys,
-                zfill=reader.get_filesystem_template().idx_length,
+                root_files_keys=reader.get_reader_template().root_files_keys,
+                zfill=reader.get_reader_template().idx_length,
                 copy_files=copy_files,
-                use_symlinks=use_symlinks
+                use_symlinks=use_symlinks,
             )
             writer(reader)
 
@@ -173,7 +183,6 @@ def test_writer_copy_correct_extension(self, toy_dataset_small, tmpdir_factory):
 
 
 class TestUnderfolderReaderWriterConsistency(object):
-
     def test_reader_writer_content(self, toy_dataset_small, tmpdir_factory):
 
         # ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
@@ -182,36 +191,122 @@ def test_reader_writer_content(self, toy_dataset_small, tmpdir_factory):
         # when being written and therefore change their numeric content.
         # ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
 
-        folder = toy_dataset_small['folder']
-        keys = toy_dataset_small['expected_keys']
-        root_keys = toy_dataset_small['root_keys']
+        folder = toy_dataset_small["folder"]
+        keys = toy_dataset_small["expected_keys"]
+        root_keys = toy_dataset_small["root_keys"]
 
         combo_items = [
-            {'copy_files': True, 'use_symlinks': False},
-            {'copy_files': False, 'use_symlinks': False},
-            {'copy_files': False, 'use_symlinks': True},
-            {'copy_files': True, 'use_symlinks': True},
+            {"copy_files": True, "use_symlinks": False},
+            {"copy_files": False, "use_symlinks": False},
+            {"copy_files": False, "use_symlinks": True},
+            {"copy_files": True, "use_symlinks": True},
         ]
 
         for combo in combo_items:
 
             reader = UnderfolderReader(folder=folder, copy_root_files=True)
-            template = reader.get_filesystem_template()
+            template = reader.get_reader_template()
 
             print("\nCombo", combo)
             writer_folder = Path(tmpdir_factory.mktemp(str(uuid.uuid1())))
             print(writer_folder)
             writer = UnderfolderWriter(
                 folder=writer_folder,
-                copy_files=combo['copy_files'],
-                use_symlinks=combo['use_symlinks']
+                copy_files=combo["copy_files"],
+                use_symlinks=combo["use_symlinks"],
             )
             writer(reader)
 
             re_reader = UnderfolderReader(folder=writer_folder, copy_root_files=True)
-            re_template = re_reader.get_filesystem_template()
+            re_template = re_reader.get_reader_template()
 
             for idx in range(len(re_reader)):
-                data = reader[idx]['image']
-                re_data = re_reader[idx]['image']
+                data = reader[idx]["image"]
+                re_data = re_reader[idx]["image"]
                 assert np.array_equal(data, re_data)
+
+
+class TestUnderfolderWriterForceCopy(object):
+    def test_reader_writer_force_copy(self, toy_dataset_small, tmpdir_factory):
+        folder = toy_dataset_small["folder"]
+        image_key = "image"
+
+        reader = UnderfolderReader(folder=folder, copy_root_files=True)
+        sample: FileSystemSample = reader[0]
+        original_image = sample[image_key].copy()
+        sample[image_key] = 255 - original_image
+        assert sample.is_cached(image_key)
+        assert np.any(sample[image_key] != original_image)
+
+        writer_folder = Path(tmpdir_factory.mktemp(str(uuid.uuid1())))
+        print(writer_folder)
+        writer = UnderfolderWriter(
+            folder=writer_folder,
+            copy_files=True,
+            use_symlinks=False,
+            force_copy_keys=[image_key],
+        )
+        writer(reader)
+
+        re_reader = UnderfolderReader(folder=writer_folder, copy_root_files=True)
+        re_sample = re_reader[0]
+        assert np.all(re_sample[image_key] == original_image)
+
+
+class TestUnderfolderWriterMultiprocessing(object):
+    def test_reader_writer_force_copy(self, toy_dataset_small, tmpdir_factory):
+        folder = toy_dataset_small["folder"]
+        image_key = "image"
+
+        reader = UnderfolderReader(folder=folder, copy_root_files=True)
+
+        workers_options = [-1, 0, 1, 2, 3, 4]
+
+        for num_workers in workers_options:
+            writer_folder = Path(tmpdir_factory.mktemp(str(uuid.uuid1())))
+            print(writer_folder)
+            writer = UnderfolderWriter(
+                folder=writer_folder,
+                copy_files=True,
+                use_symlinks=False,
+                num_workers=num_workers,
+            )
+            writer(reader)
+
+            re_reader = UnderfolderReader(folder=writer_folder, copy_root_files=True)
+            re_sample = re_reader[0]
+            assert len(reader) == len(re_reader)
+            for sample_id in range(len(re_reader)):
+                sample = reader[sample_id]
+                resample = re_reader[sample_id]
+                assert sample["metadata"]["id"] == resample["metadata"]["id"]
+
+
+class TestUnderfolderReaderMultiprocessing(object):
+    def test_reader_writer_force_copy(self, toy_dataset_small, tmpdir_factory):
+        folder = toy_dataset_small["folder"]
+        image_key = "image"
+
+        workers_options = [1, 0, 1, 2, 3, 4]
+
+        for num_workers in workers_options:
+            reader = UnderfolderReader(
+                folder=folder, copy_root_files=True, num_workers=num_workers
+            )
+            writer_folder = Path(tmpdir_factory.mktemp(str(uuid.uuid1())))
+            print(writer_folder)
+            writer = UnderfolderWriter(
+                folder=writer_folder,
+                copy_files=True,
+                use_symlinks=False,
+                num_workers=num_workers,
+            )
+            writer(reader)
+
+            re_reader = UnderfolderReader(folder=writer_folder, copy_root_files=True)
+            re_sample = re_reader[0]
+            assert len(reader) == len(re_reader)
+            for sample_id in range(len(re_reader)):
+                sample = reader[sample_id]
+                resample = re_reader[sample_id]
+                assert sample["metadata"]["id"] == resample["metadata"]["id"]
diff --git a/tests/pipelime/sequences/io/test_h5.py b/tests/pipelime/sequences/io/test_h5.py
new file mode 100644
index 0000000..e35a645
--- /dev/null
+++ b/tests/pipelime/sequences/io/test_h5.py
@@ -0,0 +1,182 @@
+from pathlib import Path
+
+import h5py
+import numpy as np
+import pytest
+from choixe.spooks import Spook
+
+from pipelime.sequences.readers.base import BaseReader
+from pipelime.sequences.readers.h5 import H5Item, H5Reader, H5Sample
+from pipelime.sequences.samples import Sample
+from pipelime.sequences.writers.base import BaseWriter
+from pipelime.sequences.writers.h5 import H5Writer
+
+
+def _plug_test(reader: BaseReader):
+    """Test what a generic BaseReader should do
+
+    :param reader: input BaseReader
+    :type reader: BaseReader
+    """
+
+    assert isinstance(reader, BaseReader)
+
+    print(reader.serialize())
+    rereader = reader.hydrate(reader.serialize())
+    assert isinstance(rereader, BaseReader)
+
+    factored = Spook.create(reader.serialize())
+    assert isinstance(factored, BaseReader)
+
+
+def _plug_test_writer(writer: BaseWriter):
+    """Test what a generic BaseWriter should do
+
+    :param writer: input BaseWriter
+    :type writer: BaseWriter
+    """
+
+    assert isinstance(writer, BaseWriter)
+
+    print(writer.serialize())
+    rewriter = writer.hydrate(writer.serialize())
+    assert isinstance(rewriter, BaseWriter)
+
+    factored = Spook.create(writer.serialize())
+    assert isinstance(factored, BaseWriter)
+
+
+class TestH5(object):
+    def test_reader(self, h5_datasets: dict, tmpdir):
+
+        output_folder = Path(tmpdir.mkdir("test_h5reader_writer"))
+
+        for dataset_name, cfg in h5_datasets.items():
+            filename = cfg["filename"]
+            reader = H5Reader(filename=filename, copy_root_files=True)
+
+            _plug_test(reader)
+            assert len(reader) > 0
+            for sample in reader:
+                assert isinstance(sample, Sample)
+                assert isinstance(sample, H5Sample)
+                assert len(sample.keys()) > 0
+
+            out_filename = output_folder / "output_dataset.h5"
+            writer = H5Writer(filename=out_filename)
+            _plug_test_writer(writer)
+            writer(reader)
+
+            second_reader = H5Reader(filename=out_filename, copy_root_files=True)
+            assert len(second_reader) == len(reader)
+            assert len(second_reader) > 0
+
+            proto_sample = second_reader[0]
+            assert len(proto_sample.keys()) > 0
+            for sample in second_reader:
+                for key in sample:
+                    assert key in proto_sample
+
+            out_filename.unlink()
+
+    def test_reader_copy_root(self, h5_datasets: dict, tmpdir):
+
+        for dataset_name, cfg in h5_datasets.items():
+            filename = cfg["filename"]
+            reader_copy = H5Reader(filename=filename, copy_root_files=True)
+            reader_nocopy = H5Reader(filename=filename, copy_root_files=False)
+
+            assert len(reader_copy) == len(reader_nocopy)
+
+            for sample_copy, sample_nocopy in zip(reader_copy, reader_nocopy):
+                assert len(sample_copy.keys()) > len(sample_nocopy.keys())
+
+
+class TestH5Sample(object):
+    def test_h5_sample(self, h5_datasets):
+
+        for dataset_name, cfg in h5_datasets.items():
+            filename = cfg["filename"]
+            reader = H5Reader(filename=filename, copy_root_files=True)
+
+            sample = reader[0]
+
+            for key in sample.keys():
+                assert isinstance(sample.metaitem(key), H5Item)
+                item: H5Item = sample.metaitem(key)
+                assert isinstance(item.source(), h5py.Dataset)
+
+            assert len(sample) > 0
+            assert isinstance(sample["image"], np.ndarray)
+            assert isinstance(sample, H5Sample)
+
+            with pytest.raises(NotImplementedError):
+                sample.rename("image", "_NEW_IMAGE_")
+
+            del sample["image"]
+
+    def test_flush(self, h5_datasets):
+
+        for dataset_name, cfg in h5_datasets.items():
+            filename = cfg["filename"]
+            reader = H5Reader(filename=filename, copy_root_files=True)
+
+            temp = None
+            for sample in reader:
+                sample: H5Sample
+                for key in sample.keys():
+                    temp = sample[key]
+                    assert sample.is_cached(key)
+
+            reader.flush()
+
+            for sample in reader:
+                sample: H5Sample
+                for key in sample.keys():
+                    assert not sample.is_cached(key)
+
+    def test_copy(self, h5_datasets):
+
+        for dataset_name, cfg in h5_datasets.items():
+            filename = cfg["filename"]
+            reader = H5Reader(filename=filename, copy_root_files=True)
+
+            for sample in reader:
+                sample: H5Sample
+
+                keys = list(sample.keys())[:2]
+                for key in keys:
+                    temp = sample[key]
+                    assert sample.is_cached(key)
+
+                sample_copy = sample.copy()
+                assert isinstance(sample_copy, type(sample))
+
+                for key in sample.keys():
+                    assert sample.is_cached(key) == sample_copy.is_cached(key)
+
+    def test_skeleton(self, h5_datasets):
+
+        for dataset_name, cfg in h5_datasets.items():
+            filename = cfg["filename"]
+            reader = H5Reader(filename=filename, copy_root_files=True)
+
+            for sample in reader:
+                sample: H5Sample
+                assert sample.skeleton.keys() == sample.keys()
+                for key in sample.skeleton.keys():
+                    assert sample.skeleton[key] is None
+
+    def test_nonlazy(self, h5_datasets: dict, tmpdir):
+
+        for dataset_name, cfg in h5_datasets.items():
+            filename = cfg["filename"]
+            reader = H5Reader(
+                filename=filename, lazy_samples=False, copy_root_files=True
+            )
+
+            for sample in reader:
+                assert isinstance(sample, H5Sample)
+                for key in sample.keys():
+                    assert sample.is_cached(key)
+                    assert isinstance(sample.metaitem(key), H5Item)
diff --git a/tests/pipelime/sequences/io/test_mixed.py b/tests/pipelime/sequences/io/test_mixed.py
new file mode 100644
index 0000000..d128cf8
--- /dev/null
+++ b/tests/pipelime/sequences/io/test_mixed.py
@@ -0,0 +1,79 @@
+
+
+from pathlib import Path
+from pipelime.sequences.writers.h5 import H5Writer
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+from pipelime.sequences.readers.h5 import H5Reader
+from pipelime.sequences.writers.filesystem import UnderfolderWriter
+
+
+class TestH5Underfolder:
+
+    def test_cascade_huh(self, h5_datasets: dict, tmpdir):
+
+        output_folder = Path(tmpdir.mkdir("test_h5reader_huh"))
+
+        for dataset_name, cfg in h5_datasets.items():
+            filename = cfg["filename"]
+            h5_reader = H5Reader(filename=filename, copy_root_files=True)
+
+            for sample in h5_reader:
+                for key in sample.keys():
+                    assert sample[key] is not None
+
+            underfolder_0 = output_folder / '0'
+
+            underfolder_writer = UnderfolderWriter(folder=underfolder_0)
+            underfolder_writer(h5_reader)
+            underfolder_reader = UnderfolderReader(folder=underfolder_0, copy_root_files=True)
+
+            assert len(underfolder_reader) == len(h5_reader)
+            assert len(underfolder_reader) > 0
+
+            for idx in range(len(underfolder_reader)):
+                sample_h5 = h5_reader[idx]
+                sample_underfolder = underfolder_reader[idx]
+                assert len(sample_h5.keys()) > 0
+                assert len(sample_h5.keys()) == len(sample_underfolder.keys())
+                for key in sample_h5.keys():
+                    assert key in sample_underfolder
+                for key in sample_underfolder.keys():
+                    assert key in sample_h5
+
+                for key in sample_h5.keys():
+                    assert h5_reader.is_root_key(key) == underfolder_reader.is_root_key(key)
+
+            h5filename_0 = output_folder / 'h5.py'
+            h5_writer = H5Writer(filename=h5filename_0)
+
+            h5_writer(underfolder_reader)
+
+            last_reader_h5 = H5Reader(filename=h5filename_0)
+
+            assert len(last_reader_h5) > 0
+            assert len(last_reader_h5) == len(h5_reader)
+
+            for sample in last_reader_h5:
+                for key in sample.keys():
+                    assert sample[key] is not None
+
+    def test_cascade_uhu(self, toy_dataset_small: dict, tmpdir):
+
+        output_folder = Path(tmpdir.mkdir("test_h5reader_uhu"))
+
+        folder = toy_dataset_small['folder']
+        keys = toy_dataset_small['expected_keys']
+        root_keys = toy_dataset_small['root_keys']
+
+        underfolder_reader = UnderfolderReader(folder=folder, copy_root_files=True)
+
+        h5_0 = output_folder / 'h5py'
+
+        h5_writer = H5Writer(filename=h5_0)
+        h5_writer(underfolder_reader)
+
+        h5_reader = H5Reader(filename=h5_0)
+
+        underfolder_0 = output_folder / '0'
+        underfolder_writer = UnderfolderWriter(folder=underfolder_0)
+        underfolder_writer(h5_reader)
diff --git a/tests/pipelime/sequences/io/test_summary.py b/tests/pipelime/sequences/io/test_summary.py
new file mode 100644
index 0000000..fec1f58
--- /dev/null
+++ b/tests/pipelime/sequences/io/test_summary.py
@@ -0,0 +1,65 @@
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+from pipelime.sequences.readers.summary import ReaderSummary, TypeInfo
+import numpy as np
+
+class TestSummary:
+    def test_summary(self, toy_dataset_small):
+        path = toy_dataset_small["folder"]
+        non_root = toy_dataset_small["expected_keys"]
+        root_keys = toy_dataset_small["root_keys"]
+        all_keys = [*non_root, *root_keys]
+        reader = UnderfolderReader(path)
+        summary = ReaderSummary(reader, k=-1)
+        summary.sort()
+        assert len(summary) == len(all_keys)
+
+        # Check iteminfo 0
+        iteminfo = summary[0]
+        key = sorted(all_keys)[0]
+        assert iteminfo.name == key
+        assert iteminfo.count == len(reader)
+        assert np.ndarray in iteminfo.typeinfo.types 
+        assert iteminfo.root_item == (key in root_keys)
+        assert iteminfo.encoding == "npy"
+
+class TestTypeInfo:
+    def test_typeinfo(self):
+        # Compatible but different shape
+        a = TypeInfo(np.ndarray, shape=[100, 100, 100], dtype="uint8")
+        b = TypeInfo(np.ndarray, shape=[200, 100, 100], dtype="uint8")
+        c = a + b
+        assert c.types == {np.ndarray}
+        assert c.shape == [-1, 100, 100]
+        assert c.dtype == "uint8"
+
+        # Incompatible shape
+        a = TypeInfo(np.ndarray, shape=[100, 100, 100], dtype="uint8")
+        b = TypeInfo(np.ndarray, shape=[100, 100], dtype="uint8")
+        c = a + b
+        assert c.types == {np.ndarray}
+        assert c.shape == None
+        assert c.dtype == "uint8"
+
+        # None shape
+        a = TypeInfo(np.ndarray, shape=None, dtype="uint8")
+        b = TypeInfo(np.ndarray, shape=[200, 100, 100], dtype="uint8")
+        c = a + b
+        assert c.types == {np.ndarray}
+        assert c.shape == None
+        assert c.dtype == "uint8"
+
+        # Incompatible dtypes
+        a = TypeInfo(np.ndarray, shape=None, dtype="uint16")
+        b = TypeInfo(np.ndarray, shape=None, dtype="uint8")
+        c = a + b
+        assert c.types == {np.ndarray}
+        assert c.shape == None
+        assert c.dtype == None
+
+        # Incompatible types
+        a = TypeInfo(list, shape=None, dtype="uint8")
+        b = TypeInfo(np.ndarray, shape=[200, 100, 100], dtype="uint8")
+        c = a + b
+        assert c.types == {np.ndarray, list}
+        assert c.shape == None
+        assert c.dtype == "uint8"
\ No newline at end of file
diff --git a/tests/pipelime/sequences/test_operations.py b/tests/pipelime/sequences/test_operations.py
index 3ca4baf..3cd9315 100644
--- a/tests/pipelime/sequences/test_operations.py
+++ b/tests/pipelime/sequences/test_operations.py
@@ -1,26 +1,45 @@
-import pydash
-from pipelime.tools.idgenerators import IdGeneratorInteger, IdGeneratorUUID
 import functools
 import hashlib
-from typing import Dict, Sequence
+from typing import Dict, Optional, Sequence
+
+import pydash
 import pytest
 import rich
-from pipelime.sequences.samples import SamplesSequence
+from choixe.spooks import Spook
+
 from pipelime.sequences.operations import (
-    OperationDict2List, OperationFilterByQuery, OperationFilterByScript, OperationFilterKeys, OperationGroupBy,
-    OperationIdentity, OperationOrderBy, OperationPort, OperationResetIndices, OperationShuffle,
-    OperationSplitByQuery, OperationSplits, OperationSubsample,
-    OperationSum, SequenceOperation
+    MappableOperation,
+    OperationDict2List,
+    OperationFilterByQuery,
+    OperationFilterByScript,
+    OperationFilterKeys,
+    OperationGroupBy,
+    OperationIdentity,
+    OperationMix,
+    OperationOrderBy,
+    OperationPort,
+    OperationResetIndices,
+    OperationShuffle,
+    OperationSplitByQuery,
+    OperationSplitByValue,
+    OperationSplits,
+    OperationStage,
+    OperationSubsample,
+    OperationSum,
+    SequenceOperation,
 )
-
-from pipelime.factories import BeanFactory
+from pipelime.sequences.samples import Sample, SamplesSequence
+from pipelime.sequences.stages import StageRemap
+from pipelime.tools.idgenerators import IdGeneratorInteger, IdGeneratorUUID
 
 
-def _plug_test(op: SequenceOperation):
-    """ Test what a generic SequenceOperation should do
+def _plug_test(op: SequenceOperation, check_serialization: bool = True):
+    """Test what a generic SequenceOperation should do
 
     :param op: input SequenceOperation
     :type op: SequenceOperation
+    :param check_serialization: True to also check serialization/deserialization
+    :type check_serialization: bool
     """
 
     assert isinstance(op, SequenceOperation)
@@ -29,32 +48,26 @@ def _plug_test(op: SequenceOperation):
     assert op.input_port().match(op.input_port())
     assert op.output_port().match(op.output_port())
 
-    reop = op.from_dict(op.to_dict())
-    assert isinstance(reop, SequenceOperation)
-    assert isinstance(op.bean_schema(), dict)
-    op.print()
+    if check_serialization:
+        reop = op.from_dict(op.to_dict())
+        assert isinstance(reop, SequenceOperation)
+        schema = op.spook_schema()
+        assert isinstance(schema, dict) or schema is None
+        op.print()
 
-    factored = BeanFactory.create(op.serialize())
-    assert isinstance(factored, SequenceOperation)
+        factored = Spook.create(op.serialize())
+        assert isinstance(factored, SequenceOperation)
 
 
 class TestOperationSum(object):
-
     def test_sum(self, plain_samples_sequence_generator):
 
-        pairs = [
-            (32, 5),
-            (32, 1),
-            (8, 32),
-            (1, 24),
-            (0, 64),
-            (48, 0)
-        ]
+        pairs = [(32, 5), (32, 1), (8, 32), (1, 24), (0, 64), (48, 0)]
 
         for N, D in pairs:
             datasets = []
             for idx in range(D):
-                datasets.append(plain_samples_sequence_generator('d{idx}_', N))
+                datasets.append(plain_samples_sequence_generator("d{idx}_", N))
 
             op = OperationSum()
             _plug_test(op)
@@ -70,7 +83,6 @@ def test_sum(self, plain_samples_sequence_generator):
 
 
 class TestOperationSubsample(object):
-
     def test_subsample(self, plain_samples_sequence_generator):
 
         sizes = [32, 10, 128, 16, 1]
@@ -78,7 +90,7 @@ def test_subsample(self, plain_samples_sequence_generator):
 
         for F in factors:
             for N in sizes:
-                dataset = plain_samples_sequence_generator('d0_', N)
+                dataset = plain_samples_sequence_generator("d0_", N)
 
                 op = OperationSubsample(factor=F)
                 _plug_test(op)
@@ -103,13 +115,12 @@ def test_subsample(self, plain_samples_sequence_generator):
 
 
 class TestOperationIdentity(object):
-
     def test_subsample(self, plain_samples_sequence_generator):
 
         sizes = [32, 10, 128, 16, 1]
 
         for N in sizes:
-            dataset = plain_samples_sequence_generator('d0_', N)
+            dataset = plain_samples_sequence_generator("d0_", N)
 
             op = OperationIdentity()
             _plug_test(op)
@@ -120,17 +131,16 @@ def test_subsample(self, plain_samples_sequence_generator):
 
 
 class TestOperationShuffle(object):
-
     def _sign(self, dataset: SamplesSequence):
-        names = [x['idx'] for x in dataset.samples]
-        return hashlib.md5(bytes('_'.join(names), encoding='utf-8')).hexdigest()
+        names = [x["idx"] for x in dataset.samples]
+        return hashlib.md5(bytes("_".join(names), encoding="utf-8")).hexdigest()
 
     def test_shuffle(self, plain_samples_sequence_generator):
 
         sizes = [100, 10, 128, 20]
 
         for N in sizes:
-            dataset = plain_samples_sequence_generator('d0_', N)
+            dataset = plain_samples_sequence_generator("d0_", N)
 
             sign = self._sign(dataset)
 
@@ -140,7 +150,9 @@ def test_shuffle(self, plain_samples_sequence_generator):
             out = op(dataset)
             out_sign = self._sign(out)
 
-            assert sign != out_sign, 'for a series of unfortunate events did the two hashes collide?'
+            assert (
+                sign != out_sign
+            ), "for a series of unfortunate events did the two hashes collide?"
 
             rich.print(sign, "!=", out_sign)
 
@@ -148,17 +160,13 @@ def test_shuffle(self, plain_samples_sequence_generator):
 
 
 class TestOperationResetIndices(object):
-
     def test_reset(self, plain_samples_sequence_generator):
 
-        generators = [
-            IdGeneratorUUID(),
-            IdGeneratorInteger()
-        ]
+        generators = [IdGeneratorUUID(), IdGeneratorInteger()]
         N = 32
         for generator in generators:
-            dataset = plain_samples_sequence_generator('d0_', N)
-            dataset_clone = plain_samples_sequence_generator('d0_', N)
+            dataset = plain_samples_sequence_generator("d0_", N)
+            dataset_clone = plain_samples_sequence_generator("d0_", N)
 
             op = OperationResetIndices(generator=generator)
             _plug_test(op)
@@ -171,29 +179,37 @@ def test_reset(self, plain_samples_sequence_generator):
 
 
 class TestOperationOrderBy(object):
-
     def _sign(self, dataset: SamplesSequence):
-        names = [x['idx'] for x in dataset.samples]
-        return hashlib.md5(bytes('_'.join(names), encoding='utf-8')).hexdigest()
+        names = [x["idx"] for x in dataset.samples]
+        return hashlib.md5(bytes("_".join(names), encoding="utf-8")).hexdigest()
 
     def test_orderby(self, plain_samples_sequence_generator):
 
         N = 32
-        dataset = plain_samples_sequence_generator('d0_', N)
+        dataset = plain_samples_sequence_generator("d0_", N)
 
         order_items = [
-            {'keys': ['reverse_number'], 'different': True},
-            {'keys': ['+reverse_number'], 'different': True},
-            {'keys': ['-reverse_number'], 'different': False},
-            {'keys': ['-reverse_number', 'metadata.deep.groupby_field'], 'different': False},
-            {'keys': ['metadata.deep.groupby_field', '-reverse_number'], 'different': False},
-            {'keys': ['metadata.deep.groupby_field', 'reverse_number'], 'different': True},
-            {'keys': ['-metadata.deep.groupby_field'], 'different': True},
+            {"keys": ["reverse_number"], "different": True},
+            {"keys": ["+reverse_number"], "different": True},
+            {"keys": ["-reverse_number"], "different": False},
+            {
+                "keys": ["-reverse_number", "metadata.deep.groupby_field"],
+                "different": False,
+            },
+            {
+                "keys": ["metadata.deep.groupby_field", "-reverse_number"],
+                "different": False,
+            },
+            {
+                "keys": ["metadata.deep.groupby_field", "reverse_number"],
+                "different": True,
+            },
+            {"keys": ["-metadata.deep.groupby_field"], "different": True},
         ]
 
         for order_item in order_items:
-            order_keys = order_item['keys']
-            should_be_different = order_item['different']
+            order_keys = order_item["keys"]
+            should_be_different = order_item["different"]
 
             op = OperationOrderBy(order_keys=order_keys)
             _plug_test(op)
@@ -217,26 +233,25 @@ def test_orderby(self, plain_samples_sequence_generator):
 
 
 class TestOperationSplits(object):
-
     def test_splits(self, plain_samples_sequence_generator):
 
         N = 128
         splits_cfgs = [
-            {'split_map': {'train': 0.5, 'test': 0.2, 'val': 0.3}, 'good': True},
-            {'split_map': {'a': 0.4, 'b': 0.2, 'c': 0.2, 'd': 0.2}, 'good': True},
-            {'split_map': {'a': 0.8, 'b': 0.}, 'good': True},
-            {'split_map': {'a': 1.0}, 'good': True},
-            {'split_map': {'a': 0.7}, 'good': True},
-            {'split_map': {'a': 1.7}, 'good': False},
-            {'split_map': {}, 'good': False},
+            {"split_map": {"train": 0.5, "test": 0.2, "val": 0.3}, "good": True},
+            {"split_map": {"a": 0.4, "b": 0.2, "c": 0.2, "d": 0.2}, "good": True},
+            {"split_map": {"a": 0.8, "b": 0.0}, "good": True},
+            {"split_map": {"a": 1.0}, "good": True},
+            {"split_map": {"a": 0.7}, "good": True},
+            {"split_map": {"a": 1.7}, "good": False},
+            {"split_map": {}, "good": False},
         ]
 
         for cfg in splits_cfgs:
 
-            good = cfg['good']
-            split_map = cfg['split_map']
+            good = cfg["good"]
+            split_map = cfg["split_map"]
             expected_splits = len(split_map)
-            dataset = plain_samples_sequence_generator('d0_', N)
+            dataset = plain_samples_sequence_generator("d0_", N)
 
             if good:
                 op = OperationSplits(split_map=split_map)
@@ -255,7 +270,7 @@ def test_splits(self, plain_samples_sequence_generator):
 
                 idx_maps: Dict[str, set] = {}
                 for k, d in out_dict.items():
-                    idxs = set([x['idx'] for x in d])
+                    idxs = set([x["idx"] for x in d])
                     idx_maps[k] = idxs
 
                 for k0, _ in idx_maps.items():
@@ -271,7 +286,6 @@ def test_splits(self, plain_samples_sequence_generator):
 
 
 class TestOperationDict2List(object):
-
     def test_shuffle(self, plain_samples_sequence_generator):
 
         pairs = [
@@ -286,7 +300,7 @@ def test_shuffle(self, plain_samples_sequence_generator):
             datasets = []
             datasets_map = {}
             for idx in range(D):
-                d = plain_samples_sequence_generator('d{idx}_', N)
+                d = plain_samples_sequence_generator("d{idx}_", N)
                 datasets.append(d)
                 datasets_map[str(idx)] = d
 
@@ -303,24 +317,25 @@ def test_shuffle(self, plain_samples_sequence_generator):
 
 
 class TestOperationFilterByQuery(object):
-
     def test_filter_by_query(self, plain_samples_sequence_generator):
 
         N = 128
-        dataset = plain_samples_sequence_generator('d0_', N)
+        dataset = plain_samples_sequence_generator("d0_", N)
 
         queries_items = [
-            {'query': '`metadata.name` CONTAINS "d0_"', 'expected': N},
-            {'query': '`metadata.name` CONTAINS "@IMPOSSIBLE@!S_TRING12"', 'expected': 0},
-            {'query': f'`metadata.N` < {N/2}', 'expected': N / 2},
-            {'query': '`metadata.name` like "d?_*"', 'expected': N},
-
+            {"query": '`metadata.name` CONTAINS "d0_"', "expected": N},
+            {
+                "query": '`metadata.name` CONTAINS "@IMPOSSIBLE@!S_TRING12"',
+                "expected": 0,
+            },
+            {"query": f"`metadata.N` < {N/2}", "expected": N / 2},
+            {"query": '`metadata.name` like "d?_*"', "expected": N},
         ]
 
         for item in queries_items:
-            query = item['query']
-            expected = item['expected']
-            op = OperationFilterByQuery(query=query)
+            query = item["query"]
+            expected = item["expected"]
+            op = OperationFilterByQuery(query=query, num_workers=-1)
             _plug_test(op)
 
             out = op(dataset)
@@ -329,23 +344,24 @@ def test_filter_by_query(self, plain_samples_sequence_generator):
 
 
 class TestOperationSplitByQuery(object):
-
     def test_split_by_query(self, plain_samples_sequence_generator):
 
         N = 128
-        dataset = plain_samples_sequence_generator('d0_', N)
+        dataset = plain_samples_sequence_generator("d0_", N)
 
         queries_items = [
-            {'query': '`metadata.name` CONTAINS "d0_"', 'expected': [N, 0]},
-            {'query': '`metadata.name` CONTAINS "@IMPOSSIBLE@!S_TRING12"', 'expected': [0, N]},
-            {'query': f'`metadata.N` < {N/2}', 'expected': [N / 2, N / 2]},
-            {'query': '`metadata.name` like "d?_*"', 'expected': [N, 0]},
-
+            {"query": '`metadata.name` CONTAINS "d0_"', "expected": [N, 0]},
+            {
+                "query": '`metadata.name` CONTAINS "@IMPOSSIBLE@!S_TRING12"',
+                "expected": [0, N],
+            },
+            {"query": f"`metadata.N` < {N/2}", "expected": [N / 2, N / 2]},
+            {"query": '`metadata.name` like "d?_*"', "expected": [N, 0]},
         ]
 
         for item in queries_items:
-            query = item['query']
-            expecteds = item['expected']
+            query = item["query"]
+            expecteds = item["expected"]
             op = OperationSplitByQuery(query=query)
             _plug_test(op)
 
@@ -361,25 +377,44 @@ def test_split_by_query(self, plain_samples_sequence_generator):
             assert sumup == sumup_exp
 
 
-class TestOperationFilterKeys(object):
+class TestOperationSplitByValue:
+    def test_split_by_value(self, plain_samples_sequence_generator):
+        N = 20
+        G = 5
+        key = "metadata.deep.groupby_field"
+        dataset = plain_samples_sequence_generator("d0_", N, group_each=G)
+        op = OperationSplitByValue(key)
+        _plug_test(op)
+        out = op(dataset)
+
+        assert len(out) == N // G
+        total = 0
+        for split in out:
+            total += len(split)
+            target = pydash.get(split[0], key)
+            for sample in split:
+                assert pydash.get(sample, key) == target
+        assert total == len(dataset)
 
+
+class TestOperationFilterKeys(object):
     def test_filter_keys(self, plain_samples_sequence_generator):
 
         N = 128
-        dataset = plain_samples_sequence_generator('d0_', N)
+        dataset = plain_samples_sequence_generator("d0_", N)
 
         keys_to_filter = [
-            {'keys': ['idx'], 'negate': False},
-            {'keys': ['idx'], 'negate': True},
-            {'keys': ['number', 'data0'], 'negate': False},
-            {'keys': ['number', 'data0'], 'negate': True},
-            {'keys': [], 'negate': False},
+            {"keys": ["idx"], "negate": False},
+            {"keys": ["idx"], "negate": True},
+            {"keys": ["number", "data0"], "negate": False},
+            {"keys": ["number", "data0"], "negate": True},
+            {"keys": [], "negate": False},
         ]
 
         for item in keys_to_filter:
-            keys = item['keys']
-            negate = item['negate']
-            op = OperationFilterKeys(keys=keys, negate=negate)
+            keys = item["keys"]
+            negate = item["negate"]
+            op = OperationFilterKeys(keys=keys, negate=negate, num_workers=-1)
             _plug_test(op)
             out = op(dataset)
 
@@ -394,22 +429,21 @@ def test_filter_keys(self, plain_samples_sequence_generator):
 
 
 class TestOperationGroupBy(object):
-
     def test_group_by(self, plain_samples_sequence_generator):
 
         N = 128
-        dataset = plain_samples_sequence_generator('d0_', N, heavy_data=False)
+        dataset = plain_samples_sequence_generator("d0_", N, heavy_data=False)
 
         fields_items = [
-            {'field': '`metadata.deep.groupby_field`', 'valid': True},
-            {'field': '`metadata.even`', 'valid': True},
-            {'field': '`odd`', 'valid': True},
-            {'field': '`z`', 'valid': False},
+            {"field": "`metadata.deep.groupby_field`", "valid": True},
+            {"field": "`metadata.even`", "valid": True},
+            {"field": "`odd`", "valid": True},
+            {"field": "`z`", "valid": False},
         ]
 
         for item in fields_items:
-            field = item['field']
-            valid = item['valid']
+            field = item["field"]
+            valid = item["valid"]
 
             for ungrouped in [True, False]:
                 op = OperationGroupBy(field=field, ungrouped=ungrouped)
@@ -440,23 +474,22 @@ def test_group_by(self, plain_samples_sequence_generator):
 
 
 class TestOperationFilterByScript(object):
-
     def test_filter_by_script(self, plain_samples_sequence_generator, tmpdir):
 
         N = 128
-        dataset = plain_samples_sequence_generator('d0_', N)
+        dataset = plain_samples_sequence_generator("d0_", N)
 
         # Bad filename
         with pytest.raises(Exception):
-            op = OperationFilterByScript(path='/tmp/script_IMPOSSIBLE_NAME@@!!')
+            op = OperationFilterByScript(path="/tmp/script_IMPOSSIBLE_NAME@@!!")
 
-        func = ''
+        func = ""
         func += "import numpy as np\n"
         func += "def check_sample(sample, sequence):\n"
         func += " return np.array(sample['number']) % 2 == 0\n"
 
-        script_path = tmpdir.join('custom_script.py')
-        with open(script_path, 'w') as f:
+        script_path = tmpdir.join("custom_script.py")
+        with open(script_path, "w") as f:
             f.write(func)
 
         op = OperationFilterByScript(path_or_func=str(script_path))
@@ -468,14 +501,84 @@ def test_filter_by_script(self, plain_samples_sequence_generator, tmpdir):
     def test_filter_by_script_onthefly(self, plain_samples_sequence_generator, tmpdir):
 
         N = 128
-        dataset = plain_samples_sequence_generator('d0_', N)
+        dataset = plain_samples_sequence_generator("d0_", N)
 
         def check_sample_onthefly(sample, sequence):
             import numpy as np
-            return np.array(sample['number']) % 2 == 0
+
+            return np.array(sample["number"]) % 2 == 0
 
         op = OperationFilterByScript(path_or_func=check_sample_onthefly)
         _plug_test(op)
         out = op(dataset)
 
         assert len(out) < len(dataset)
+
+
+class TestMappableOperation(object):
+    class OperationPlusOne(MappableOperation):
+        def __init__(self, key: str, **kwargs) -> None:
+            super().__init__(**kwargs)
+            self._key = key
+
+        def apply_to_sample(self, sample: Sample) -> Optional[Sample]:
+            sample = sample.copy()
+            sample[self._key] += 1
+            return sample
+
+    class OperationLessThan10(MappableOperation):
+        def __init__(self, key: str, **kwargs) -> None:
+            super().__init__(**kwargs)
+            self._key = key
+
+        def apply_to_sample(self, sample: Sample) -> Optional[Sample]:
+            cond = sample[self._key] < 10
+            return sample if cond else None
+
+    @pytest.mark.parametrize(
+        ("pb", "workers"), ((False, 0), (True, 0), (False, 5), (True, 5), (True, -1))
+    )
+    def test_mappable_operation(self, plain_samples_sequence_generator, pb, workers):
+        N = 32
+        key = "number"
+
+        dataset = plain_samples_sequence_generator("d0_", N)
+        op = self.OperationPlusOne(key, progress_bar=pb, num_workers=workers)
+
+        out = op(dataset)
+        _plug_test(op, check_serialization=False)
+
+        expected_numbers = [x[key] + 1 for x in dataset]
+        out_numbers = [x[key] for x in out]
+
+        assert isinstance(out, SamplesSequence)
+        assert N == len(out)
+        for x, y in zip(expected_numbers, out_numbers):
+            assert x == y, (expected_numbers, out_numbers)
+
+        op = self.OperationLessThan10(key)
+        out = op(dataset)
+
+        assert len(out) == 10
+
+
+class TestOperationStage(object):
+    @pytest.mark.parametrize(
+        ("pb", "workers"), ((False, 0), (True, 0), (False, 5), (True, 5), (True, -1))
+    )
+    def test_operation_stage(self, plain_samples_sequence_generator, pb, workers):
+        N = 32
+        key = "number"
+        key_2 = "number_2"
+
+        dataset = plain_samples_sequence_generator("d0_", N)
+        stage = StageRemap({key: key_2})
+        op = OperationStage(stage, progress_bar=pb, num_workers=workers)
+        _plug_test(op, check_serialization=False)
+        out = op(dataset)
+
+        assert isinstance(out, SamplesSequence)
+        assert N == len(out)
+        for x in out:
+            assert key_2 in x
+            assert key not in x
diff --git a/tests/pipelime/sequences/test_pipes.py b/tests/pipelime/sequences/test_pipes.py
index 84c9f5f..52e9066 100644
--- a/tests/pipelime/sequences/test_pipes.py
+++ b/tests/pipelime/sequences/test_pipes.py
@@ -1,24 +1,30 @@
-from pipelime.sequences.operations import OperationFilterByQuery, OperationResetIndices, OperationSplitByQuery, OperationSplits, OperationSubsample, OperationSum
-from pipelime.tools.idgenerators import IdGeneratorUUID
-from pipelime.sequences.readers.filesystem import UnderfolderReader
-from pipelime.sequences.writers.filesystem import UnderfolderWriter
-from pipelime.factories import BeanFactory
-from pipelime.sequences.pipes import NodeGraph, ReaderNode, OperationNode, WriterNode
 import pytest
+from choixe.spooks import Spook
 
+from pipelime.sequences.operations import (
+    OperationFilterByQuery,
+    OperationResetIndices,
+    OperationSplitByQuery,
+    OperationSplits,
+    OperationSum,
+)
+from pipelime.sequences.pipes import NodeGraph, OperationNode, ReaderNode, WriterNode
+from pipelime.sequences.readers.filesystem import UnderfolderReader
+from pipelime.sequences.writers.filesystem import UnderfolderWriter
+from pipelime.tools.idgenerators import IdGeneratorUUID
 
 # @pytest.fixture
 # def simple_graph():
 #     return {
-#         '__type__': 'NodeGraph',
+#         '__spook__': 'NodeGraph',
 #         'options': {
 #             'nodes': [
 #                 {
-#                     '__type__': 'ReaderNode',
+#                     '__spook__': 'ReaderNode',
 #                     'options': {
 #                         'output_data': 'A',
 #                         'reader': {
-#                             '__type__': 'UnderfolderReader',
+#                             '__spook__': 'UnderfolderReader',
 #                             'options': {
 #                                 'folder': '/Users/daniele/Downloads/lego_dataset/lego_00',
 #                             }
@@ -26,11 +32,11 @@
 #                     }
 #                 },
 #                 {
-#                     '__type__': 'ReaderNode',
+#                     '__spook__': 'ReaderNode',
 #                     'options': {
 #                         'output_data': 'B',
 #                         'reader': {
-#                             '__type__': 'UnderfolderReader',
+#                             '__spook__': 'UnderfolderReader',
 #                             'options': {
 #                                 'folder': '/Users/daniele/Downloads/lego_dataset/lego_01'
 #                             }
@@ -38,12 +44,12 @@
 #                     }
 #                 },
 #                 {
-#                     '__type__': 'OperationNode',
+#                     '__spook__': 'OperationNode',
 #                     'options': {
 #                         'input_data': 'A',
 #                         'output_data': 'A_filtered',
 #                         'operation': {
-#                             '__type__': 'OperationFilterByQuery',
+#                             '__spook__': 'OperationFilterByQuery',
 #                             'options': {
 #                                 'query': '`metadata.tag` == "image"'
 #                             }
@@ -51,23 +57,23 @@
 #                     }
 #                 },
 #                 {
-#                     '__type__': 'OperationNode',
+#                     '__spook__': 'OperationNode',
 #                     'options': {
 #                         'input_data': ['A_filtered', 'B'],
 #                         'output_data': 'C',
 #                         'operation': {
-#                             '__type__': 'OperationSum',
+#                             '__spook__': 'OperationSum',
 #                             'options': {}
 #                         }
 #                     }
 #                 },
 #                 {
-#                     '__type__': 'OperationNode',
+#                     '__spook__': 'OperationNode',
 #                     'options': {
 #                         'input_data': 'C',
 #                         'output_data': 'D',
 #                         'operation': {
-#                             '__type__': 'OperationSplits',
+#                             '__spook__': 'OperationSplits',
 #                             'options': {
 #                                 'split_map': {
 #                                     'train': 0.45,
@@ -85,86 +91,81 @@
 
 
 class TestPipes(object):
-
     def test_simple_graph(self, filesystem_datasets, tmp_path_factory):
 
         print(filesystem_datasets)
-        dataset_folder = filesystem_datasets['minimnist_underfolder']['folder']
+        dataset_folder = filesystem_datasets["minimnist_underfolder"]["folder"]
 
-        out_folders = {k: tmp_path_factory.mktemp(k) for k in ['D0', 'D1', 'D2']}
+        out_folders = {k: tmp_path_factory.mktemp(k) for k in ["D0", "D1", "D2"]}
         r = UnderfolderReader(folder=dataset_folder)
         N = len(r)
-        EXPECTED = N * 3 - (2 * 3)  # '`metadatay.half` >= 1.0' query removes 2 samples per input dataset (3)
+        EXPECTED = N * 3 - (
+            2 * 3
+        )  # '`metadatay.half` >= 1.0' query removes 2 samples per input dataset (3)
         generator = IdGeneratorUUID()
 
         nodes = [
             ReaderNode(
                 id=generator.generate(),
-                output_data='A',
-                reader=UnderfolderReader(folder=dataset_folder)
+                output_data="A",
+                reader=UnderfolderReader(folder=dataset_folder),
             ),
             ReaderNode(
                 id=generator.generate(),
-                output_data='B',
-                reader=UnderfolderReader(folder=dataset_folder)
+                output_data="B",
+                reader=UnderfolderReader(folder=dataset_folder),
             ),
             ReaderNode(
                 id=generator.generate(),
-                output_data='C',
-                reader=UnderfolderReader(folder=dataset_folder)
+                output_data="C",
+                reader=UnderfolderReader(folder=dataset_folder),
             ),
             OperationNode(
                 id=generator.generate(),
-                input_data=['A', 'B', 'C'],
-                output_data='X',
-                operation=OperationSum()
+                input_data=["A", "B", "C"],
+                output_data="X",
+                operation=OperationSum(),
             ),
             OperationNode(
                 id=generator.generate(),
-                input_data='X',
-                output_data=['X0', 'X1'],
-                operation=OperationSplitByQuery(query='`metadatay.sample_id` <= 5')
+                input_data="X",
+                output_data=["X0", "X1"],
+                operation=OperationSplitByQuery(query="`metadatay.sample_id` <= 5"),
             ),
             OperationNode(
                 id=generator.generate(),
-                input_data='X0',
-                output_data='X0_f',
-                operation=OperationFilterByQuery(query='`metadatay.half` >= 1.0')
+                input_data="X0",
+                output_data="X0_f",
+                operation=OperationFilterByQuery(query="`metadatay.half` >= 1.0"),
             ),
             OperationNode(
                 id=generator.generate(),
-                input_data='X1',
-                output_data='X1_f',
-                operation=OperationFilterByQuery(query='`metadatay.half` >= 1.0')
+                input_data="X1",
+                output_data="X1_f",
+                operation=OperationFilterByQuery(query="`metadatay.half` >= 1.0"),
             ),
             OperationNode(
                 id=generator.generate(),
-                input_data=['X0_f', 'X1_f'],
-                output_data='Y',
-                operation=OperationSum()
+                input_data=["X0_f", "X1_f"],
+                output_data="Y",
+                operation=OperationSum(),
             ),
             OperationNode(
                 id=generator.generate(),
-                input_data='Y',
-                output_data='OUT',
-                operation=OperationResetIndices(
-                    generator=IdGeneratorUUID()
-                )
+                input_data="Y",
+                output_data="OUT",
+                operation=OperationResetIndices(generator=IdGeneratorUUID()),
             ),
             OperationNode(
                 id=generator.generate(),
-                input_data='OUT',
+                input_data="OUT",
                 output_data={
-                    'a': 'D0',
-                    'b': 'D1',
-                    'c': 'D2',
+                    "a": "D0",
+                    "b": "D1",
+                    "c": "D2",
                 },
-                operation=OperationSplits(split_map={
-                    'a': 0.25,
-                    'b': 0.25,
-                    'c': 0.50
-                })
-            )
+                operation=OperationSplits(split_map={"a": 0.25, "b": 0.25, "c": 0.50}),
+            ),
         ]
 
         for k, v in out_folders.items():
@@ -174,22 +175,23 @@ def test_simple_graph(self, filesystem_datasets, tmp_path_factory):
                     input_data=k,
                     writer=UnderfolderWriter(
                         folder=out_folders[k],
-                        root_files_keys=['cfg', 'numbers', 'pose']
-                    )
+                        root_files_keys=["cfg", "numbers", "pose"],
+                    ),
                 )
             )
 
         # print(n)
         graph = NodeGraph(nodes=nodes)
         import rich
+
         rich.print(graph.serialize())
-        graph = BeanFactory.create(graph.serialize())
+        graph = Spook.create(graph.serialize())
 
         try:
             f, a = graph.draw_to_file()
             print(f, a)
         except ImportError:
-            pytest.skip(msg='No pygraphviz module found!')
+            pytest.skip(msg="No pygraphviz module found!")
 
         graph.execute()
 
@@ -200,15 +202,13 @@ def test_simple_graph(self, filesystem_datasets, tmp_path_factory):
             print("OUT", k, v)
 
         assert counter == EXPECTED
-        print("COUNTER", N, '/', counter)
-        print(len(graph._data_cache['X']))
-        print(len(graph._data_cache['X0']))
-        print(len(graph._data_cache['X1']))
-        print(len(graph._data_cache['X0_f']))
-        print(len(graph._data_cache['X1_f']))
-        print(len(graph._data_cache['Y']))
-        print(len(graph._data_cache['D0']))
-        print(len(graph._data_cache['D1']))
-        print(len(graph._data_cache['D2']))
-        # g = BeanFactory.create(simple_graph)
-        # assert isinstance(g, NodeGraph)
+        print("COUNTER", N, "/", counter)
+        print(len(graph._data_cache["X"]))
+        print(len(graph._data_cache["X0"]))
+        print(len(graph._data_cache["X1"]))
+        print(len(graph._data_cache["X0_f"]))
+        print(len(graph._data_cache["X1_f"]))
+        print(len(graph._data_cache["Y"]))
+        print(len(graph._data_cache["D0"]))
+        print(len(graph._data_cache["D1"]))
+        print(len(graph._data_cache["D2"]))
diff --git a/tests/pipelime/sequences/test_samples.py b/tests/pipelime/sequences/test_samples.py
index f70f669..8d09286 100644
--- a/tests/pipelime/sequences/test_samples.py
+++ b/tests/pipelime/sequences/test_samples.py
@@ -1,14 +1,18 @@
 import numpy as np
+
 from pipelime.sequences.readers.filesystem import UnderfolderReader
-import rich
-from pipelime.sequences.samples import FileSystemSample, FilesystemItem, GroupedSample, MemoryItem, PlainSample, SamplesSequence
+from pipelime.sequences.samples import (
+    FilesystemItem,
+    FileSystemSample,
+    GroupedSample,
+    MemoryItem,
+)
 
 
 class TestPlainSamples(object):
-
     def test_plain_sample(self, plain_samples_generator):
 
-        dataset = plain_samples_generator('d0_', 10)
+        dataset = plain_samples_generator("d0_", 10)
         for sample in dataset:
             assert set(sample.keys()) == set(sample.skeleton.keys())
             for key in sample.keys():
@@ -16,13 +20,12 @@ def test_plain_sample(self, plain_samples_generator):
 
 
 class TestGroupedSamples(object):
-
     def _empty(*args):
         pass
 
     def test_groupby_samples(self, plain_samples_generator):
 
-        dataset = plain_samples_generator('d0_', 10)
+        dataset = plain_samples_generator("d0_", 10)
 
         samples = dataset[::2]
         g = GroupedSample(samples=samples)
@@ -32,8 +35,8 @@ def test_groupby_samples(self, plain_samples_generator):
             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
+        g.rename("image", "NEW_IMAGE")
+        assert "image" not in g
 
         for key in list(g.keys()):
             del g[key]
@@ -41,7 +44,7 @@ def test_groupby_samples(self, plain_samples_generator):
         assert len(g.keys()) == 0
         assert len(g_copy.keys()) == 0
 
-        g['my_new_key'] = 112.3
+        g["my_new_key"] = 112.3
         assert len(g.keys()) == 1
 
         assert len(GroupedSample(samples=[])) == 0
@@ -49,10 +52,9 @@ def test_groupby_samples(self, plain_samples_generator):
 
 
 class TestFilesystemSample(object):
-
     def test_filesystem_sample(self, filesystem_datasets, tmp_path_factory):
 
-        dataset_folder = filesystem_datasets['minimnist_underfolder']['folder']
+        dataset_folder = filesystem_datasets["minimnist_underfolder"]["folder"]
 
         reader = UnderfolderReader(folder=dataset_folder)
 
@@ -62,17 +64,17 @@ def test_filesystem_sample(self, filesystem_datasets, tmp_path_factory):
             assert isinstance(sample.metaitem(key), FilesystemItem)
 
         assert len(sample) > 0
-        assert isinstance(sample['image'], np.ndarray)
+        assert isinstance(sample["image"], np.ndarray)
         assert isinstance(sample, FileSystemSample)
 
-        sample.rename('image', '_NEW_IMAGE_')
-        assert 'image' not in sample
+        sample.rename("image", "_NEW_IMAGE_")
+        assert "image" not in sample
 
-        del sample['_NEW_IMAGE_']
+        del sample["_NEW_IMAGE_"]
 
     def test_flush(self, filesystem_datasets, tmp_path_factory):
 
-        dataset_folder = filesystem_datasets['minimnist_underfolder']['folder']
+        dataset_folder = filesystem_datasets["minimnist_underfolder"]["folder"]
 
         reader = UnderfolderReader(folder=dataset_folder)
 
@@ -92,5 +94,25 @@ def test_flush(self, filesystem_datasets, tmp_path_factory):
 
     def test_filesystem_sample_nonlazy(self, filesystem_datasets, tmp_path_factory):
 
-        dataset_folder = filesystem_datasets['minimnist_underfolder']['folder']
+        dataset_folder = filesystem_datasets["minimnist_underfolder"]["folder"]
         reader = UnderfolderReader(folder=dataset_folder, lazy_samples=False)
+
+    def test_update(self, filesystem_datasets, tmp_path_factory):
+        dataset_folder = filesystem_datasets["minimnist_underfolder"]["folder"]
+        reader = UnderfolderReader(folder=dataset_folder)
+        sample: FileSystemSample = reader[0]
+        other = {"a": 10, "b": 20}
+        sample.update(other)
+        for k, v in other.items():
+            assert k in sample
+            assert sample.is_cached(k)
+            assert k not in sample.filesmap
+            assert sample[k] == v
+
+        other = FileSystemSample({"c": "fake_path", "d": "fake_path"})
+        sample.update(other)
+        for k, v in other.filesmap.items():
+            assert k in sample
+            assert not sample.is_cached(k)
+            assert k in sample.filesmap
+            assert sample.filesmap[k] == v
diff --git a/tests/pipelime/sequences/test_sequence.py b/tests/pipelime/sequences/test_sequence.py
index 067f895..532fcbd 100644
--- a/tests/pipelime/sequences/test_sequence.py
+++ b/tests/pipelime/sequences/test_sequence.py
@@ -2,33 +2,32 @@
 
 
 class TestSequenceNormalized(object):
-
     def test_normalized(self, plain_samples_sequence_generator):
 
         N = 100
-        dataset: SamplesSequence = plain_samples_sequence_generator('d{idx}_', N)
+        dataset: SamplesSequence = plain_samples_sequence_generator("d{idx}_", N)
         assert dataset.is_normalized()
 
         # Changes all keys change should be normalized as well
-        dataset: SamplesSequence = plain_samples_sequence_generator('d{idx}_', N)
+        dataset: SamplesSequence = plain_samples_sequence_generator("d{idx}_", N)
         for i in range(N):
-            dataset[i]['#IPOSSsibl3!KEY_@0123'] = {'fake_data': f'd_{i}'}
+            dataset[i]["#IPOSSsibl3!KEY_@0123"] = {"fake_data": f"d_{i}"}
         assert dataset.is_normalized()
 
         # Changes All keys but last should be not normalized
-        dataset: SamplesSequence = plain_samples_sequence_generator('d{idx}_', N)
+        dataset: SamplesSequence = plain_samples_sequence_generator("d{idx}_", N)
         for i in range(N - 2):
-            dataset[i]['#IPOSSsibl3!KEY_@0123'] = {'fake_data': f'd_{i}'}
+            dataset[i]["#IPOSSsibl3!KEY_@0123"] = {"fake_data": f"d_{i}"}
         assert not dataset.is_normalized()
 
         # Changes All keys but first should be not normalized
-        dataset: SamplesSequence = plain_samples_sequence_generator('d{idx}_', N)
+        dataset: SamplesSequence = plain_samples_sequence_generator("d{idx}_", N)
         for i in range(1, N):
-            dataset[i]['#IPOSSsibl3!KEY_@0123'] = {'fake_data': f'd_{0}'}
+            dataset[i]["#IPOSSsibl3!KEY_@0123"] = {"fake_data": f"d_{0}"}
         assert not dataset.is_normalized()
 
         # Changes first key should be not normalized
-        dataset: SamplesSequence = plain_samples_sequence_generator('d{idx}_', N)
+        dataset: SamplesSequence = plain_samples_sequence_generator("d{idx}_", N)
         for i in range(1):
-            dataset[i]['#IPOSSsibl3!KEY_@0123'] = {'fake_data': f'd_{0}'}
+            dataset[i]["#IPOSSsibl3!KEY_@0123"] = {"fake_data": f"d_{0}"}
         assert not dataset.is_normalized()
diff --git a/tests/pipelime/sequences/test_stages.py b/tests/pipelime/sequences/test_stages.py
index 487a0bc..405bf38 100644
--- a/tests/pipelime/sequences/test_stages.py
+++ b/tests/pipelime/sequences/test_stages.py
@@ -1,15 +1,19 @@
+from choixe.spooks import Spook
+
 from pipelime.sequences.readers.filesystem import UnderfolderReader
 from pipelime.sequences.samples import PlainSample
-from schema import Schema
 from pipelime.sequences.stages import (
-    SampleStage, StageAugmentations,
-    StageCompose, StageIdentity, StageKeysFilter, StageRemap
+    SampleStage,
+    StageAugmentations,
+    StageCompose,
+    StageIdentity,
+    StageKeysFilter,
+    StageRemap,
 )
-from pipelime.factories import BeanFactory
 
 
 def _plug_test(stage: SampleStage):
-    """ Test what a generic SampleStage should do
+    """Test what a generic SampleStage should do
 
     :param stage: input SampleStage
     :type stage: SampleStage
@@ -19,17 +23,17 @@ def _plug_test(stage: SampleStage):
 
     restage = stage.from_dict(stage.to_dict())
     assert isinstance(restage, SampleStage)
-    assert isinstance(restage.bean_schema(), dict)
+    schema = restage.spook_schema()
+    assert isinstance(schema, dict) or schema is None
 
-    factored = BeanFactory.create(stage.serialize())
+    factored = Spook.create(stage.serialize())
     assert isinstance(factored, SampleStage)
 
 
 class TestStageIdentity(object):
-
     def test_identity(self):
 
-        s = PlainSample(data={'name': 'sample', 'idx': 111})
+        s = PlainSample(data={"name": "sample", "idx": 111})
 
         stage = StageIdentity()
         _plug_test(stage)
@@ -40,84 +44,86 @@ def test_identity(self):
 
 
 class TestStageRemap(object):
-
     def test_remap(self):
 
-        s = PlainSample(data={'name': 'sample', 'idx': 111})
+        s = PlainSample(data={"name": "sample", "idx": 111})
 
-        stage = StageRemap(remap={'name': 'a', 'idx': 'b', 'not_present': 'x'})
+        stage = StageRemap(remap={"name": "a", "idx": "b", "not_present": "x"})
         _plug_test(stage)
 
         out = stage(s)
 
-        assert 'a' in out
-        assert 'b' in out
-        assert 'name' not in out
-        assert 'idx' not in out
-        assert 'x' not in out
-        assert 'not_present' not in out
+        assert "a" in out
+        assert "b" in out
+        assert "name" not in out
+        assert "idx" not in out
+        assert "x" not in out
+        assert "not_present" not in out
 
     def test_remap_remove(self):
 
-        s = PlainSample(data={'name': 'sample', 'idx': 111})
+        s = PlainSample(data={"name": "sample", "idx": 111})
 
-        stage = StageRemap(remap={'name': 'a'}, remove_missing=True)
+        stage = StageRemap(remap={"name": "a"}, remove_missing=True)
         _plug_test(stage)
 
         out = stage(s)
 
-        assert 'a' in out
-        assert 'idx' not in out
+        assert "a" in out
+        assert "idx" not in out
 
 
 class TestStageKeysFilter(object):
-
     def test_filter(self):
 
-        s = PlainSample(data={'name': 'sample', 'idx': 111, 'tail': 2.2})
+        s = PlainSample(data={"name": "sample", "idx": 111, "tail": 2.2})
 
         negates = [True, False]
         for negate in negates:
-            stage = StageKeysFilter(keys=['name', 'idx'], negate=negate)
+            stage = StageKeysFilter(keys=["name", "idx"], negate=negate)
             _plug_test(stage)
 
             out = stage(s)
 
-            assert ('name' in out) if not negate else ('name' not in out)
-            assert ('idx' in out) if not negate else ('idx' not in out)
-            assert ('tail' in out) if negate else ('tail' not in out)
+            assert ("name" in out) if not negate else ("name" not in out)
+            assert ("idx" in out) if not negate else ("idx" not in out)
+            assert ("tail" in out) if negate else ("tail" not in out)
 
 
 class TestStageAugmentations(object):
-
     def test_augmentations(self, toy_dataset_small):
 
         import albumentations as A
 
-        folder = toy_dataset_small['folder']
-        keypoints_format = toy_dataset_small['keypoints_format']
-        bboxes_format = toy_dataset_small['bboxes_format']
+        folder = toy_dataset_small["folder"]
+        keypoints_format = toy_dataset_small["keypoints_format"]
+        bboxes_format = toy_dataset_small["bboxes_format"]
 
         reader = UnderfolderReader(folder=folder)
 
-        transform = A.Compose([
-            A.HorizontalFlip(p=1),
-            A.ShiftScaleRotate(shift_limit=0.2, scale_limit=0.6, rotate_limit=180, p=1.0),
-            A.RandomBrightnessContrast(p=1),
-        ],
-            keypoint_params=A.KeypointParams(format=keypoints_format, remove_invisible=False, angle_in_degrees=False),
-            bbox_params=A.BboxParams(format=bboxes_format)
+        transform = A.Compose(
+            [
+                A.HorizontalFlip(p=1),
+                A.ShiftScaleRotate(
+                    shift_limit=0.2, scale_limit=0.6, rotate_limit=180, p=1.0
+                ),
+                A.RandomBrightnessContrast(p=1),
+            ],
+            keypoint_params=A.KeypointParams(
+                format=keypoints_format, remove_invisible=False, angle_in_degrees=False
+            ),
+            bbox_params=A.BboxParams(format=bboxes_format),
         )
 
         stage = StageAugmentations(
             transform_cfg=A.to_dict(transform),
             targets={
-                'image': 'image',
-                'mask': 'mask',
-                'inst': 'inst',
-                'keypoints': 'keypoints',
-                'bboxes': 'bboxes'
-            }
+                "image": "image",
+                "mask": "mask",
+                "inst": "inst",
+                "keypoints": "keypoints",
+                "bboxes": "bboxes",
+            },
         )
         _plug_test(stage)
 
@@ -129,17 +135,16 @@ def test_augmentations(self, toy_dataset_small):
 
 
 class TestStageCompose(object):
-
     def test_compose(self):
 
-        s = PlainSample(data={'name': 'sample', 'idx': 111, 'float': 2.3, 'tail': True})
+        s = PlainSample(data={"name": "sample", "idx": 111, "float": 2.3, "tail": True})
 
         stages = [
             StageIdentity(),
-            StageRemap(remap={'name': 'a'}, remove_missing=False),
-            StageRemap(remap={'idx': 'b'}, remove_missing=False),
-            StageRemap(remap={'float': 'c'}, remove_missing=False),
-            StageKeysFilter(keys=['a', 'b', 'c'], negate=False),
+            StageRemap(remap={"name": "a"}, remove_missing=False),
+            StageRemap(remap={"idx": "b"}, remove_missing=False),
+            StageRemap(remap={"float": "c"}, remove_missing=False),
+            StageKeysFilter(keys=["a", "b", "c"], negate=False),
             StageIdentity(),
         ]
 
@@ -148,7 +153,7 @@ def test_compose(self):
 
         out = stage(s)
 
-        assert 'a' in out
-        assert 'b' in out
-        assert 'c' in out
-        assert 'tail' not in out
+        assert "a" in out
+        assert "b" in out
+        assert "c" in out
+        assert "tail" not in out
diff --git a/tests/pipelime/sequences/test_validation.py b/tests/pipelime/sequences/test_validation.py
index eaede92..4d61a94 100644
--- a/tests/pipelime/sequences/test_validation.py
+++ b/tests/pipelime/sequences/test_validation.py
@@ -1,24 +1,22 @@
-
-
+import pytest
 from schema import SchemaError
-from pipelime.sequences.validation import SchemaLoader
+
 from pipelime.sequences.readers.filesystem import UnderfolderReader
-import pytest
+from pipelime.sequences.validation import SchemaLoader
 
 
 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']
+        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']
+            filename = schema["filename"]
+            valid = schema["valid"]
+            should_pass = schema["should_pass"]
 
             if valid:
                 schema = SchemaLoader.load(filename)
diff --git a/tests/pipelime/workflow/tools/test_cwl_template.py b/tests/pipelime/workflow/tools/test_cwl_template.py
index 3363ab2..b5a05e0 100644
--- a/tests/pipelime/workflow/tools/test_cwl_template.py
+++ b/tests/pipelime/workflow/tools/test_cwl_template.py
@@ -1,368 +1,494 @@
 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
 
+from pipelime.cli.workflow.workflow import workflow
+from pipelime.workflow.cwl import (
+    CwlNode,
+    CwlNodesManager,
+    CwlTemplate,
+    CwlWorkflowTemplate,
+)
 
-class TestClick2Cwl(object):
 
+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')
+        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')
+        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')
+        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')
+        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')
+        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']
+        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']
+        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'
+        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'}
+            "doc": "help opt01",
+            "type": ["int"],
+            "inputBinding": {"prefix": "--opt01"},
         }
-        assert 'opt01' in cwl_template.template['inputs'].keys()
-        assert cwl_template.template['inputs']['opt01'] == expected_cwl
+        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'}
+            "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
+        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'}
+            "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
+        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'}
-            }]
+            "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
+        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'}
-            }]
+            "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
+        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'}
+            "doc": "help opt11",
+            "type": ["float"],
+            "inputBinding": {"prefix": "--opt11"},
         }
-        assert 'opt11' in cwl_template.template['inputs'].keys()
-        assert cwl_template.template['inputs']['opt11'] == expected_cwl
+        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'}
+            "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
+        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'}
-            }]
+            "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
+        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'}
-            }]
+            "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
+        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'}
+            "doc": "help opt21",
+            "type": ["string"],
+            "inputBinding": {"prefix": "--opt21"},
         }
-        assert 'opt21' in cwl_template.template['inputs'].keys()
-        assert cwl_template.template['inputs']['opt21'] == expected_cwl
+        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'}
+            "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
+        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'}
-            }]
+            "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
+        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'}
-            }]
+            "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
+        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'}
+            "doc": "help opt31",
+            "type": ["boolean"],
+            "inputBinding": {"prefix": "--opt31"},
         }
-        assert 'opt31' in cwl_template.template['inputs'].keys()
-        assert cwl_template.template['inputs']['opt31'] == expected_cwl
+        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'}
-            }]
+            "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
+        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'}
+            "doc": "help opt41",
+            "type": ["string"],
+            "inputBinding": {"prefix": "--opt41"},
         }
-        assert 'opt41' in cwl_template.template['inputs'].keys()
-        assert cwl_template.template['inputs']['opt41'] == expected_cwl
+        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'}
+            "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
+        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'}
-            }]
+            "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
+        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'}
-            }]
+            "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
+        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'}
+            "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
+        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'}
+            "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
+        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'}
-            }]
+            "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
+        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'}
-            }]
+            "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)'
+        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'
+        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:
+        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 lines[0] == "#!/usr/bin/env cwl-runner\n"
+            loaded_cwl = yaml.safe_load("".join(lines))
             assert cwl_template.template == loaded_cwl
 
         click_command = f"""
@@ -381,90 +507,100 @@ 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:
+        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']
+        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
+        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)
+        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']
+        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'
+        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'))
+        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)])
+        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)
+        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)
+        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)
+        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
 
@@ -475,11 +611,13 @@ def test_cwl_nodes_manager(self, tmpdir_factory):
 def command1(opt0):
     pass
         """
-        output_click_file1 = Path(tmpdir_factory.mktemp('click')) / f'command1.py'
-        with open(output_click_file1, 'w') as f:
+        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)
+        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
@@ -488,20 +626,37 @@ def command1(opt0):
 def command2(opt0):
     pass
         """
-        output_click_file2 = Path(tmpdir_factory.mktemp('click')) / f'command2.py'
-        with open(output_click_file2, 'w') as f:
+        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
+        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.h5 b/tests/sample_data/datasets/underfolder_minimnist.h5
new file mode 100644
index 0000000..cf915d5
Binary files /dev/null and b/tests/sample_data/datasets/underfolder_minimnist.h5 differ
diff --git a/tests/test_pipelime.py b/tests/test_pipelime.py
index 5ffbb5a..9e7ac6e 100644
--- a/tests/test_pipelime.py
+++ b/tests/test_pipelime.py
@@ -3,11 +3,9 @@
 """Tests for `pipelime` package."""
 
 import pytest
-
 from click.testing import CliRunner
 
-from pipelime import pipelime
-from pipelime import cli
+from pipelime import cli, pipelime
 
 
 @pytest.fixture
@@ -39,5 +37,13 @@ def test_content(response):
 
 def test_toy_dataset_small(toy_dataset_small):
 
-    for x in ['folder', 'data_folder', 'size', 'image_size', 'zfill', 'expected_keys', 'keypoints_format']:
+    for x in [
+        "folder",
+        "data_folder",
+        "size",
+        "image_size",
+        "zfill",
+        "expected_keys",
+        "keypoints_format",
+    ]:
         assert x in toy_dataset_small