Repository: bigdatabr/deirokay (language: python)

Issue:
docs: Improve docstrings + typing for the entire repository



Discussion / hints:

Fix patch:
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 0a51390..7882547 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -3,7 +3,13 @@ version: 2
 build:
   os: ubuntu-20.04
   tools:
-    python: "3.9"
+    python: "3.10"
 
 sphinx:
-   configuration: docs/source/conf.py
+    configuration: docs/source/conf.py
+
+python:
+  install:
+    - requirements: requirements.txt
+    - requirements: requirements-dev.txt
+    - requirements: docs/requirements.txt
diff --git a/README.md b/README.md
index 8b0285b..bf31cef 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,7 @@
 [![docstr_coverage](https://img.shields.io/endpoint?url=https://jsonbin.org/bressanmarcos/deirokay/badges/docstr-cov)](https://github.com/HunterMcGushion/docstr_coverage)
 [![license: MIT](https://img.shields.io/github/license/bigdatabr/deirokay)](https://github.com/bigdatabr/deirokay/blob/master/LICENSE)
 [![code style: flake8](https://img.shields.io/badge/code%20style-flake8-000000.svg)](https://flake8.pycqa.org/en/latest/internal/writing-code.html)
+[![docstring: numpy](https://img.shields.io/badge/docstring-numpy-008080.svg)](https://numpydoc.readthedocs.io/en/latest/format.html)
 [![imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
 [![semantic-release: conventionalcommits](https://img.shields.io/badge/semantic--release-conventionalcommits-e10079?logo=semantic-release)](https://www.conventionalcommits.org/)
 
diff --git a/deirokay/_typing.py b/deirokay/_typing.py
new file mode 100644
index 0000000..ad73340
--- /dev/null
+++ b/deirokay/_typing.py
@@ -0,0 +1,9 @@
+from typing import Any, Dict, List, Union
+
+DeirokayOption = Dict[str, Any]
+DeirokayOptionsDocument = Dict[str, Union[str, Dict[str, DeirokayOption]]]
+
+DeirokayStatement = Dict[str, Any]
+DeirokayValidationItem = Dict[str, Union[str, List[DeirokayStatement]]]
+DeirokayValidationDocument = Dict[str,
+                                  Union[str, List[DeirokayValidationItem]]]
diff --git a/deirokay/airflow/deirokay_operator.py b/deirokay/airflow/deirokay_operator.py
index 43f05cf..bdb89ff 100644
--- a/deirokay/airflow/deirokay_operator.py
+++ b/deirokay/airflow/deirokay_operator.py
@@ -23,32 +23,37 @@ class DeirokayOperator(BaseOperator):
 
     Parameters
     ----------
-    data / path_to_file (deprecated) : str
+    data : Optional[str]
         File to be parsed into Deirokay.
+    path_to_file : Optional[str]
+        (Deprecated) File to be parsed into Deirokay.
+        Use `data` instead.
     options : Union[dict, str]
         A dict or a local/S3 path to a YAML/JSON options file.
     against : Union[dict, str]
         A dict or a local/S3 path to a YAML/JSON validation
         document file.
-    template : dict, optional
+    template : Optional[dict]
         Map of templates to be passed to Deirokay validation.
-    save_to : str, optional
+    save_to : Optional[str], optional
         Where validation logs will be saved to.
         If None, no log is saved. By default None.
-    soft_fail_level : int, optional
+    soft_fail_level : Union[SeverityLevel, int]
         Minimum Deirokay severity level to trigger a
         "soft failure".
         Any statement with lower severity level will only raise a
         warning. Set to `None` to never trigger.
         By default SeverityLevel.MINIMAL (1).
-    hard_fail_level : int, optional
+    hard_fail_level : Union[SeverityLevel, int]
         Minimum Deirokay severity level to trigger a task failure.
         Set to `None` to never trigger.
         By default SeverityLevel.CRITICAL (5).
-    reader_kwargs : dict, optional
+    reader_kwargs : Optional[dict]
         Additional keyword arguments for `Deirokay.data_reader` method.
-    validator_kwargs : dict, optional
+    validator_kwargs : Optional[dict]
         Additional keyword arguments for `Deirokay.validate` method.
+    **kwargs : Optional[dict]
+        Additional keyword arguments for `BaseOperator`.
     """
 
     template_fields = [
@@ -65,14 +70,15 @@ class DeirokayOperator(BaseOperator):
 
     def __init__(
         self,
-        data: str = None,
-        path_to_file: str = None,
-        options: Union[dict, str] = None,
-        against: Union[dict, str] = None,
+        *,
+        data: Optional[str] = None,
+        path_to_file: Optional[str] = None,  # Deprecated
+        options: Union[dict, str],
+        against: Union[dict, str],
         template: Optional[dict] = None,
         save_to: Optional[str] = None,
-        soft_fail_level: int = SeverityLevel.MINIMAL,
-        hard_fail_level: int = SeverityLevel.CRITICAL,
+        soft_fail_level: Union[SeverityLevel, int] = SeverityLevel.MINIMAL,
+        hard_fail_level: Union[SeverityLevel, int] = SeverityLevel.CRITICAL,
         reader_kwargs: Optional[dict] = None,
         validator_kwargs: Optional[dict] = None,
         **kwargs
diff --git a/deirokay/enums.py b/deirokay/enums.py
index 27fc7e6..68d8764 100644
--- a/deirokay/enums.py
+++ b/deirokay/enums.py
@@ -7,23 +7,49 @@
 
 class DTypes(str, Enum):
     """Deirokay data types."""
+
     INT64 = 'integer'
+    """Integer values, treated by
+    :ref:`IntegerTreater<deirokay.parser.treaters.IntegerTreater>`."""
     INTEGER = INT64
+    """Alias for INT64."""
     FLOAT64 = 'float'
+    """Float values, treated by
+    :ref:`FloatTreater<deirokay.parser.treaters.FloatTreater>`."""
     FLOAT = FLOAT64
+    """Alias for FLOAT64."""
     STRING = 'string'
+    """Text values, treated by
+    :ref:`StringTreater<deirokay.parser.treaters.StringTreater>`."""
     STR = STRING
+    """Alias for STRING."""
     DATETIME = 'datetime'
+    """Datetime values, treated by
+    :ref:`DateTime64Treater<deirokay.parser.treaters.DateTime64Treater>`."""
     DT = DATETIME
+    """Alias for DATETIME."""
     DATE = 'date'
+    """Date values, treated by
+    :ref:`DateTreater<deirokay.parser.treaters.DateTreater>`."""
     TIME = 'time'
+    """Time values, treated by
+    :ref:`TimeTreater<deirokay.parser.treaters.TimeTreater>`."""
     BOOLEAN = 'boolean'
+    """Boolean values, treated by
+    :ref:`BooleanTreater<deirokay.parser.treaters.BooleanTreater>`."""
     BOOL = BOOLEAN
+    """Alias for BOOLEAN."""
     DECIMAL = 'decimal'
+    """Decimal values, treated by
+    :ref:`DecimalTreater<deirokay.parser.treaters.DecimalTreater>`."""
 
 
 class SeverityLevel(int, Enum):
     """Deirokay named Severity levels."""
+
     MINIMAL = 1
+    """Minimal severity validation error."""
     WARNING = 3
+    """Validation warning."""
     CRITICAL = 5
+    """Critical validation error."""
diff --git a/deirokay/fs.py b/deirokay/fs.py
index b09226c..9c8396d 100644
--- a/deirokay/fs.py
+++ b/deirokay/fs.py
@@ -3,15 +3,17 @@
 paths.
 """
 
+import contextlib
 import importlib
 import json
 import os
 import re
+import sys
 from os.path import splitext
 from pathlib import Path
 from tempfile import NamedTemporaryFile
 from types import ModuleType
-from typing import List, Optional
+from typing import IO, Optional, Sequence
 
 import yaml
 
@@ -23,7 +25,7 @@
     boto3_import_error = e
 
 
-BUCKET_KEY_REGEX = re.compile(r's3:\/\/([\w\-]+)\/([\w\-\/.]+)')
+BUCKET_KEY_REGEX = re.compile(r's3:\/\/([\w\-]+)\/([\w\-\/.]*)')
 
 
 def split_s3_path(s3_path: str) -> tuple:
@@ -70,9 +72,9 @@ def _import_file_as_python_module(path_to_file: str) -> ModuleType:
         raise ValueError('You should pass a valid Python file')
 
     module_dir = os.path.dirname(path_to_file)
-    os.sys.path.insert(0, module_dir)
+    sys.path.insert(0, module_dir)
     module = importlib.import_module(module_name)
-    os.sys.path.pop(0)
+    sys.path.pop(0)
 
     return module
 
@@ -90,7 +92,8 @@ class FileSystem():
     def __init__(self, path: str):
         self.path = path
 
-    def ls(self, recursive=False, files_only=False) -> List['FileSystem']:
+    def ls(self, recursive: bool = False,
+           files_only: bool = False) -> Sequence['FileSystem']:
         """List files in a prefix or folder.
 
         Parameters
@@ -120,7 +123,7 @@ def read_dict(self, *args, **kwargs) -> dict:
             return self.read_yaml(*args, **kwargs)
         raise NotImplementedError(f'No parser for file type: {extension}')
 
-    def write_dict(self, *args, **kwargs):
+    def write_dict(self, *args, **kwargs) -> None:
         """Serialize and write a Python `dict` to either YAML or JSON
         file."""
         extension = splitext(self.path)[1].lower()
@@ -138,9 +141,10 @@ def read_yaml(self) -> dict:
         dict
             Python dictionary with the file content.
         """
-        raise NotImplementedError
+        with self.open('r') as fp:
+            return yaml.safe_load(fp)
 
-    def write_yaml(self, doc: dict, **kwargs):
+    def write_yaml(self, doc: dict, **kwargs) -> None:
         """Serialize and write a Python `dict` to a YAML file.
 
         Parameters
@@ -149,7 +153,8 @@ def write_yaml(self, doc: dict, **kwargs):
             The Python `dict`.
 
         """
-        raise NotImplementedError
+        with self.open('w') as fp:
+            yaml.dump(doc, fp, sort_keys=False, **kwargs)
 
     def read_json(self) -> dict:
         """Read and parse a JSON file as a Python `dict`.
@@ -159,9 +164,10 @@ def read_json(self) -> dict:
         dict
             Python dictionary with the file content.
         """
-        raise NotImplementedError
+        with self.open('r') as fp:
+            return json.load(fp)
 
-    def write_json(self, doc: dict, **kwargs):
+    def write_json(self, doc: dict, **kwargs) -> None:
         """Serialize and write a Python `dict` to a JSON file.
 
         Parameters
@@ -169,7 +175,8 @@ def write_json(self, doc: dict, **kwargs):
         doc : dict
             The Python `dict`.
         """
-        raise NotImplementedError
+        with self.open('w') as fp:
+            json.dump(doc, fp, **kwargs)
 
     def isdir(self) -> bool:
         """Return True if the path is a directory.
@@ -201,13 +208,14 @@ def import_as_python_module(self) -> ModuleType:
         """Import file as a Python module."""
         raise NotImplementedError
 
-    def open(self, *args, **kwargs):
+    def open(self, *args, **kwargs) -> IO:
         """Open file."""
         raise NotImplementedError
 
     def read(self, *args, **kwargs) -> str:
         """Read a file as text."""
-        return self.open(*args, **kwargs).read()
+        with self.open(*args, **kwargs) as fp:
+            return fp.read()
 
     def __truediv__(self, rest: str) -> 'FileSystem':
         """Create another FileSystem object by '/'-joining a FileSystem
@@ -233,11 +241,11 @@ def __truediv__(self, rest: str) -> 'FileSystem':
             return cls(os.path.join(self.path, rest))
         raise TypeError()
 
-    def __lt__(self, other: 'FileSystem'):
+    def __lt__(self, other: 'FileSystem') -> bool:
         assert isinstance(self, type(other))
         return self.path.__lt__(other.path)
 
-    def __str__(self):
+    def __str__(self) -> str:
         return self.path
 
 
@@ -245,7 +253,8 @@ class LocalFileSystem(FileSystem):
     """FileSystem wrapper for local files and folders."""
 
     # docstr-coverage:inherited
-    def ls(self, recursive=False, files_only=False) -> List['LocalFileSystem']:
+    def ls(self, recursive: bool = False,
+           files_only: bool = False) -> Sequence['LocalFileSystem']:
         if recursive is False:
             raise NotImplementedError
 
@@ -257,40 +266,20 @@ def ls(self, recursive=False, files_only=False) -> List['LocalFileSystem']:
         return [LocalFileSystem(path) for path in acc]
 
     # docstr-coverage:inherited
-    def read_yaml(self) -> dict:
-        with self.open() as fp:
-            return yaml.safe_load(fp)
-
-    # docstr-coverage:inherited
-    def write_yaml(self, doc: dict, **kwargs) -> None:
-        with self.open('w') as fp:
-            yaml.dump(doc, fp, sort_keys=False, **kwargs)
-
-    # docstr-coverage:inherited
-    def read_json(self) -> dict:
-        with self.open() as fp:
-            return json.load(fp)
-
-    # docstr-coverage:inherited
-    def write_json(self, doc: dict, **kwargs) -> None:
-        with self.open('w') as fp:
-            json.dump(doc, fp, **kwargs)
-
-    # docstr-coverage:inherited
-    def import_as_python_module(self):
+    def import_as_python_module(self) -> ModuleType:
         module = _import_file_as_python_module(self.path)
         return module
 
     # docstr-coverage:inherited
-    def isdir(self):
+    def isdir(self) -> bool:
         return os.path.isdir(self.path)
 
     # docstr-coverage:inherited
-    def mkdir(self, *args, **kwargs):
+    def mkdir(self, *args, **kwargs) -> None:
         return Path(self.path).mkdir(*args, **kwargs)
 
     # docstr-coverage:inherited
-    def open(self, *args, **kwargs):
+    def open(self, *args, **kwargs) -> IO:
         return open(self.path, *args, **kwargs)
 
 
@@ -300,7 +289,7 @@ class S3FileSystem(FileSystem):
     def __init__(self, path: Optional[str] = None,
                  bucket: Optional[str] = None,
                  prefix_or_key: Optional[str] = None,
-                 client=None):
+                 client: Optional['boto3.client'] = None):
 
         if boto3 is None:
             raise ImportError('S3-backend requires `boto3` module to be'
@@ -312,18 +301,21 @@ def __init__(self, path: Optional[str] = None,
                 ' passed (but not both).'
             )
 
-        if not path:
-            path = os.path.join(bucket, prefix_or_key)
-        else:
-            bucket, prefix_or_key = split_s3_path(path)
+        if bucket and prefix_or_key:
+            final_bucket, final_prefix_or_key = bucket, prefix_or_key
+            final_path = os.path.join(bucket, prefix_or_key)
+        elif path:
+            final_bucket, final_prefix_or_key = split_s3_path(path)
+            final_path = path
 
-        super().__init__(path)
+        super().__init__(final_path)
         self.client = client or boto3.client('s3')
-        self.bucket = bucket
-        self.prefix_or_key = prefix_or_key
+        self.bucket = final_bucket
+        self.prefix_or_key = final_prefix_or_key
 
     # docstr-coverage:inherited
-    def ls(self, recursive=False, files_only=False) -> List['S3FileSystem']:
+    def ls(self, recursive: bool = False,
+           files_only: bool = False) -> Sequence['S3FileSystem']:
         if recursive is False:
             raise NotImplementedError
 
@@ -338,32 +330,8 @@ def ls(self, recursive=False, files_only=False) -> List['S3FileSystem']:
         ]
 
     # docstr-coverage:inherited
-    def read_yaml(self) -> dict:
-        return yaml.safe_load(self.open())
-
-    # docstr-coverage:inherited
-    def write_yaml(self, doc: dict, **kwargs) -> None:
-        return self.client.put_object(
-            Body=yaml.dump(doc, sort_keys=False, **kwargs),
-            Bucket=self.bucket,
-            Key=self.prefix_or_key
-        )
-
-    # docstr-coverage:inherited
-    def read_json(self) -> dict:
-        return json.load(self.open())
-
-    # docstr-coverage:inherited
-    def write_json(self, doc: dict, **kwargs):
-        return self.client.put_object(
-            Body=json.dumps(doc, **kwargs),
-            Bucket=self.bucket,
-            Key=self.prefix_or_key
-        )
-
-    # docstr-coverage:inherited
-    def import_as_python_module(self):
-        extension = os.path.splitext(self.path)[1]
+    def import_as_python_module(self) -> ModuleType:
+        _, extension = os.path.splitext(self.path)
         with NamedTemporaryFile(suffix=extension) as tmp_fp:
             self.client.download_file(self.bucket,
                                       self.prefix_or_key,
@@ -372,16 +340,26 @@ def import_as_python_module(self):
             return module
 
     # docstr-coverage:inherited
-    def open(self):
-        o = self.client.get_object(Bucket=self.bucket, Key=self.prefix_or_key)
-        return o['Body']
-
-    # docstr-coverage:inherited
-    def read(self, *args, **kwargs):
-        return super().read(*args, **kwargs).decode('utf-8')
-
-
-def fs_factory(path: str):
+    def open(self, mode, *args, **kwargs) -> IO:
+        if mode == 'r':
+            body = self.client.get_object(Bucket=self.bucket,
+                                          Key=self.prefix_or_key)['Body']
+            return contextlib.closing(body)
+        elif mode == 'w':
+            def _writable():
+                with NamedTemporaryFile(mode, *args, **kwargs) as tmp_fp:
+                    yield tmp_fp
+                    tmp_fp.flush()
+                    self.client.upload_file(
+                        tmp_fp.name,
+                        self.bucket,
+                        self.prefix_or_key
+                    )
+            return contextlib.contextmanager(_writable)()
+        raise NotImplementedError(f"Mode '{mode}' not supported.")
+
+
+def fs_factory(path: str) -> FileSystem:
     """Factory for FileSystem objects.
 
     Parameters
diff --git a/deirokay/history_template.py b/deirokay/history_template.py
index 82e6e57..113a9e2 100644
--- a/deirokay/history_template.py
+++ b/deirokay/history_template.py
@@ -4,6 +4,7 @@
 """
 
 import warnings
+from typing import Callable, List
 
 import jq
 from pandas import Series
@@ -11,7 +12,8 @@
 from .fs import FileSystem
 
 
-def series_from_fs(series_name: str, lookback: int, folder: FileSystem):
+def series_from_fs(series_name: str, lookback: int,
+                   folder: FileSystem) -> List[dict]:
     """List log files as FileSystem objects for a given Validation
     Document.
 
@@ -30,7 +32,7 @@ def series_from_fs(series_name: str, lookback: int, folder: FileSystem):
         List of logs to be queried.
     """
 
-    acc = (folder/series_name).ls(recursive=True, files_only=True)
+    acc = list((folder/series_name).ls(recursive=True, files_only=True))
 
     acc.sort(reverse=True)
     acc = acc[:min(lookback, len(acc))]
@@ -41,7 +43,7 @@ def series_from_fs(series_name: str, lookback: int, folder: FileSystem):
 class NullCallableNode():
     """Dummy node which returns nothing."""
 
-    def __getattr__(self, name):
+    def __getattr__(self, name: str) -> Callable[[], None]:
         return lambda: None
 
 
@@ -49,7 +51,7 @@ class StatementNode():
     """Node at Statement level."""
     detail_keys = jq.compile('.[].report.detail | keys')
 
-    def __init__(self, statements):
+    def __init__(self, statements) -> None:
         attributes = StatementNode.detail_keys.input(statements).all()
         attributes = set([key for sub in attributes for key in sub])
 
@@ -60,7 +62,7 @@ def __init__(self, statements):
             )
             setattr(self, att, Series(child))
 
-    def __getattr__(self, name):
+    def __getattr__(self, name: str) -> NullCallableNode:
         return NullCallableNode()
 
 
@@ -70,7 +72,7 @@ class ItemNode():
         '.[].statements[] | if .alias != null then .alias else .type end'
     )
 
-    def __init__(self, items):
+    def __init__(self, items: List[str]):
         attributes = set(ItemNode.attribute_keys.input(items).all())
 
         for att in attributes:
@@ -80,7 +82,7 @@ def __init__(self, items):
             ).input(items).all()
             setattr(self, att, StatementNode(child))
 
-    def __getattr__(self, name):
+    def __getattr__(self, name: str) -> StatementNode:
         return StatementNode([])
 
 
@@ -90,7 +92,7 @@ class DocumentNode():
         '.[].items[] | if .alias != null then .alias else .scope end'
     )
 
-    def __init__(self, docs):
+    def __init__(self, docs: List[dict]):
         try:
             attributes = set(DocumentNode.attribute_keys.input(docs).all())
         except TypeError:
@@ -106,7 +108,7 @@ def __init__(self, docs):
             ).input(docs).all()
             setattr(self, att, ItemNode(child))
 
-    def __getattr__(self, name):
+    def __getattr__(self, name: str):
         return ItemNode([])
 
 
diff --git a/deirokay/parser/parser.py b/deirokay/parser/parser.py
index 9f7ef52..5819d6c 100644
--- a/deirokay/parser/parser.py
+++ b/deirokay/parser/parser.py
@@ -5,12 +5,13 @@
 import datetime
 import decimal
 from os.path import splitext
-from typing import Union
+from typing import Any, Dict, List, Type, Union
 
 import pandas
 from pandas import (DataFrame, Timestamp, read_csv, read_excel, read_parquet,
                     read_sql)
 
+from deirokay._typing import DeirokayOption, DeirokayOptionsDocument
 from deirokay.enums import DTypes
 from deirokay.fs import fs_factory
 from deirokay.utils import _check_columns_in_df_columns
@@ -19,7 +20,7 @@
 
 
 def data_reader(data: Union[str, DataFrame],
-                options: Union[dict, str],
+                options: Union[str, DeirokayOptionsDocument],
                 **kwargs) -> DataFrame:
     """Create a DataFrame from a file or an existing DataFrame and
     apply Deirokay treatments to correctly parse it and pre-validate
@@ -38,13 +39,16 @@ def data_reader(data: Union[str, DataFrame],
         A pandas DataFrame treated by Deirokay.
     """
     if isinstance(options, str):
-        options = fs_factory(options).read_dict()
-    options.update(kwargs)
+        options_dict = fs_factory(options).read_dict()
+    else:
+        options_dict = options
+
+    options_dict.update(kwargs)
 
-    columns = options.pop('columns')
+    columns = options_dict.pop('columns')
 
     if isinstance(data, str):
-        df = pandas_read(data, columns=list(columns), **options)
+        df = pandas_read(data, columns=list(columns), **options_dict)
     else:
         df = data.copy()[list(columns)]
     data_treater(df, columns)
@@ -52,7 +56,7 @@ def data_reader(data: Union[str, DataFrame],
     return df
 
 
-def pandas_read(data: str, columns: list, sql: bool = False,
+def pandas_read(data: str, columns: List[str], sql: bool = False,
                 **kwargs) -> DataFrame:
     """Infer the file type by its extension and call the proper
     `pandas` method to parse it.
@@ -61,7 +65,7 @@ def pandas_read(data: str, columns: list, sql: bool = False,
     ----------
     data : str
         Path to file or SQL query.
-    columns : list
+    columns : List[str]
         List of columns to be parsed.
     sql : bool, optional
         Whether or not `data` should be interpreted as a path to a file
@@ -73,6 +77,7 @@ def pandas_read(data: str, columns: list, sql: bool = False,
     DataFrame
         The pandas DataFrame.
     """
+    default_kwargs: Dict[str, Any]
     if sql:
         default_kwargs = {
             'columns': columns
@@ -118,53 +123,64 @@ def pandas_read(data: str, columns: list, sql: bool = False,
         return read_(data, **kwargs)[columns]
 
 
-def get_dtype_treater(dtype: Union[DTypes, str]) -> treaters.Validator:
+def get_dtype_treater(dtype: Any) -> Type[treaters.Validator]:
     """Map a dtype to its Treater class."""
+
     treat_dtypes = {
         DTypes.INT64: treaters.IntegerTreater,
-        int: treaters.IntegerTreater,
         DTypes.FLOAT64: treaters.FloatTreater,
-        float: treaters.FloatTreater,
         DTypes.STRING: treaters.StringTreater,
-        str: treaters.StringTreater,
         DTypes.DATETIME: treaters.DateTime64Treater,
-        Timestamp: treaters.DateTime64Treater,
         DTypes.DATE: treaters.DateTreater,
-        datetime.date: treaters.DateTreater,
         DTypes.TIME: treaters.TimeTreater,
-        datetime.time: treaters.TimeTreater,
         DTypes.BOOLEAN: treaters.BooleanTreater,
-        bool: treaters.BooleanTreater,
         DTypes.DECIMAL: treaters.DecimalTreater,
+    }
+    treat_primitives = {
+        int: treaters.IntegerTreater,
+        float: treaters.FloatTreater,
+        str: treaters.StringTreater,
+        Timestamp: treaters.DateTime64Treater,
+        datetime.date: treaters.DateTreater,
+        datetime.time: treaters.TimeTreater,
+        bool: treaters.BooleanTreater,
         decimal.Decimal: treaters.DecimalTreater,
     }
-    if isinstance(dtype, str):
-        dtype = DTypes(dtype)
-    return treat_dtypes.get(dtype)
+
+    try:
+        if isinstance(dtype, DTypes):
+            return treat_dtypes[dtype]
+        elif isinstance(dtype, str):
+            return treat_dtypes[DTypes(dtype)]
+        else:
+            return treat_primitives[dtype]
+
+    except KeyError as e:
+        raise NotImplementedError(f"Handler for '{dtype}' hasn't been"
+                                  " implemented yet") from e
 
 
-def get_treater_instance(option: dict):
+def get_treater_instance(option: DeirokayOption) -> treaters.Validator:
     """Create a treater instance from a Deirokay-style option.
 
     Example
     -------
 
-    option = {
-        'dtype': 'integer',
-        'thousand_sep': ','
-    }
+    .. code-block:: python
+
+        option = {
+            'dtype': 'integer',
+            'thousand_sep': ','
+        }
     """
     option = option.copy()
     dtype = option.pop('dtype')
 
     cls = get_dtype_treater(dtype)
-    if not cls:
-        raise NotImplementedError(f"Handler for '{dtype}' hasn't been"
-                                  " implemented yet")
     return cls(**option)
 
 
-def data_treater(df: DataFrame, options: dict):
+def data_treater(df: DataFrame, options: dict) -> None:
     """Receive options dict and call the proper treater class for each
     Deirokay data type.
 
@@ -182,8 +198,8 @@ def data_treater(df: DataFrame, options: dict):
     """
     _check_columns_in_df_columns(options.keys(), df.columns)
 
-    for col, option in options.items():
-        option: dict = option.copy()
+    for col, opt in options.items():
+        option: dict = opt.copy()
 
         dtype = option.get('dtype', None)
         rename_to = option.pop('rename', None)
diff --git a/deirokay/parser/treaters.py b/deirokay/parser/treaters.py
index d2099c0..f2b8bd8 100644
--- a/deirokay/parser/treaters.py
+++ b/deirokay/parser/treaters.py
@@ -4,23 +4,24 @@
 """
 
 from decimal import Decimal
+from typing import Iterable, List, Optional, Union
 
 from numpy import nan
 from pandas import NA, NaT, Series, to_datetime
 
 
-class Validator:
+class Validator():
     """Base validation class for column data type validation.
     """
 
-    def __init__(self, *, unique=False, nullable=True):
+    def __init__(self, *, unique: bool = False, nullable: bool = True):
         self.unique = unique
         self.nullable = nullable
 
-    def __call__(self, listlike):
+    def __call__(self, listlike: Iterable) -> Series:
         return self.treat(Series(listlike))
 
-    def treat(self, series: Series):
+    def treat(self, series: Series) -> Series:
         """Treat a raw Series to match data expectations for parsing
         and formatting.
 
@@ -56,6 +57,8 @@ def treat(self, series: Series):
                              " and here are some of them:\n"
                              f"{duplicated_limit}...")
 
+        return series
+
     @staticmethod
     def serialize(series: Series) -> dict:
         """Create a Deirokay-compatible serializable object that can
@@ -84,19 +87,19 @@ def serialize(series: Series) -> dict:
 class NumericTreater(Validator):
     """Base class for numeric treaters"""
 
-    def __init__(self, thousand_sep=None, **kwargs):
+    def __init__(self, thousand_sep: Optional[str] = None, **kwargs):
         super().__init__(**kwargs)
 
         self.thousand_sep = thousand_sep
 
     # docstr-coverage:inherited
-    def treat(self, series):
-        super().treat(series)
+    def treat(self, series: Series) -> Series:
+        series = super().treat(series)
         series = self._treat_thousand_sep(series)
 
         return series
 
-    def _treat_thousand_sep(self, series):
+    def _treat_thousand_sep(self, series: Series) -> Series:
         if self.thousand_sep is not None:
             try:
                 series = series.str.replace(self.thousand_sep, '', regex=False)
@@ -114,10 +117,10 @@ class BooleanTreater(Validator):
     """Treater for boolean-like variables"""
 
     def __init__(self,
-                 truthies=['true', 'True'],
-                 falsies=['false', 'False'],
-                 ignore_case=False,
-                 default_value=None,
+                 truthies: List[str] = ['true', 'True'],
+                 falsies: List[str] = ['false', 'False'],
+                 ignore_case: bool = False,
+                 default_value: Optional[bool] = None,
                  **kwargs):
         super().__init__(**kwargs)
 
@@ -137,7 +140,7 @@ def __init__(self,
             raise ValueError('Truthies and Falsies sets should be'
                              ' disjoint sets.')
 
-    def _evaluate(self, value):
+    def _evaluate(self, value: Union[bool, str, None]) -> Union[bool, None]:
         if value is True:
             return True
         if value is False:
@@ -155,8 +158,8 @@ def _evaluate(self, value):
                          f'Expected values: {self.truthies | self.falsies}')
 
     # docstr-coverage:inherited
-    def treat(self, series):
-        super().treat(series)
+    def treat(self, series: Series) -> Series:
+        series = super().treat(series)
         series = series.apply(self._evaluate).astype('boolean')
         # Validate again
         super().treat(series)
@@ -165,7 +168,7 @@ def treat(self, series):
 
     # docstr-coverage:inherited
     @staticmethod
-    def serialize(series):
+    def serialize(series: Series) -> dict:
         def _convert(item):
             if item is NA:
                 return None
@@ -181,19 +184,19 @@ def _convert(item):
 class FloatTreater(NumericTreater):
     """Treater for float variables"""
 
-    def __init__(self, decimal_sep=None, **kwargs):
+    def __init__(self, decimal_sep: Optional[str] = None, **kwargs):
         super().__init__(**kwargs)
 
         self.decimal_sep = decimal_sep
 
     # docstr-coverage:inherited
-    def treat(self, series):
+    def treat(self, series: Series) -> Series:
         series = super().treat(series)
         series = self._treat_decimal_sep(series)
 
         return series.astype(float).astype('Float64')
 
-    def _treat_decimal_sep(self, series):
+    def _treat_decimal_sep(self, series: Series) -> Series:
         if self.decimal_sep is not None and self.decimal_sep != '.':
             try:
                 series = series.str.replace(self.decimal_sep, '.', regex=False)
@@ -209,7 +212,7 @@ def _treat_decimal_sep(self, series):
 
     # docstr-coverage:inherited
     @staticmethod
-    def serialize(series):
+    def serialize(series: Series) -> dict:
         def _convert(item):
             if item is NA:
                 return None
@@ -225,13 +228,13 @@ def _convert(item):
 class DecimalTreater(FloatTreater):
     """Treater for decimal variables"""
 
-    def __init__(self, decimal_places=None, **kwargs):
+    def __init__(self, decimal_places: Optional[int] = None, **kwargs):
         super().__init__(**kwargs)
 
         self.decimal_places = decimal_places
 
     # docstr-coverage:inherited
-    def treat(self, series):
+    def treat(self, series: Series) -> Series:
         series = NumericTreater.treat(self, series)
         series = self._treat_decimal_sep(series)
         series = series.map(lambda x: Decimal(x) if x is not None else None)
@@ -239,7 +242,7 @@ def treat(self, series):
 
         return series
 
-    def _treat_decimal_places(self, series):
+    def _treat_decimal_places(self, series: Series) -> Series:
         if self.decimal_places is not None:
             q = Decimal(10) ** -self.decimal_places
             series = series.apply(
@@ -249,7 +252,7 @@ def _treat_decimal_places(self, series):
 
     # docstr-coverage:inherited
     @staticmethod
-    def serialize(series):
+    def serialize(series: Series) -> dict:
         def _convert(item):
             if item is None or item is NA:
                 return None
@@ -266,12 +269,12 @@ class IntegerTreater(NumericTreater):
     """Treater for integer variables"""
 
     # docstr-coverage:inherited
-    def treat(self, series):
+    def treat(self, series: Series) -> Series:
         return super().treat(series).astype(float).astype('Int64')
 
     # docstr-coverage:inherited
     @staticmethod
-    def serialize(series):
+    def serialize(series: Series) -> dict:
         def _convert(item):
             if item is NA:
                 return None
@@ -287,20 +290,20 @@ def _convert(item):
 class DateTime64Treater(Validator):
     """Treater for datetime variables"""
 
-    def __init__(self, format='%Y-%m-%d %H:%M:%S', **kwargs):
+    def __init__(self, format: str = '%Y-%m-%d %H:%M:%S', **kwargs):
         super().__init__(**kwargs)
 
         self.format = format
 
     # docstr-coverage:inherited
-    def treat(self, series):
-        super().treat(series)
+    def treat(self, series: Series) -> Series:
+        series = super().treat(series)
 
         return to_datetime(series, format=self.format)
 
     # docstr-coverage:inherited
     @staticmethod
-    def serialize(series):
+    def serialize(series: Series) -> dict:
         def _convert(item):
             if item is None or item is NaT:
                 return None
@@ -316,16 +319,16 @@ def _convert(item):
 class DateTreater(DateTime64Treater):
     """Treater for date-only variables"""
 
-    def __init__(self, format='%Y-%m-%d', **kwargs):
+    def __init__(self, format: str = '%Y-%m-%d', **kwargs):
         super().__init__(format, **kwargs)
 
     # docstr-coverage:inherited
-    def treat(self, series):
+    def treat(self, series: Series) -> Series:
         return super().treat(series).dt.date
 
     # docstr-coverage:inherited
     @staticmethod
-    def serialize(series):
+    def serialize(series: Series) -> dict:
         def _convert(item):
             if item is None or item is NaT:
                 return None
@@ -341,16 +344,16 @@ def _convert(item):
 class TimeTreater(DateTime64Treater):
     """Treater for time-only variables"""
 
-    def __init__(self, format='%H:%M:%S', **kwargs):
+    def __init__(self, format: str = '%H:%M:%S', **kwargs):
         super().__init__(format, **kwargs)
 
     # docstr-coverage:inherited
-    def treat(self, series):
+    def treat(self, series: Series) -> Series:
         return super().treat(series).dt.time
 
     # docstr-coverage:inherited
     @staticmethod
-    def serialize(series):
+    def serialize(series: Series) -> dict:
         def _convert(item):
             if item is None or item is NaT:
                 return None
@@ -366,14 +369,14 @@ def _convert(item):
 class StringTreater(Validator):
     """Treater for string variables"""
 
-    def __init__(self, treat_null_as=None, **kwargs):
+    def __init__(self, treat_null_as: Optional[str] = None, **kwargs):
         super().__init__(**kwargs)
 
         self.treat_null_as = treat_null_as
 
     # docstr-coverage:inherited
-    def treat(self, series):
-        super().treat(series)
+    def treat(self, series: Series) -> Series:
+        series = super().treat(series)
 
         if self.treat_null_as is not None:
             series = series.fillna(self.treat_null_as)
@@ -382,7 +385,7 @@ def treat(self, series):
 
     # docstr-coverage:inherited
     @staticmethod
-    def serialize(series):
+    def serialize(series: Series) -> dict:
         def _convert(item):
             if item is None:
                 return None
diff --git a/deirokay/profiler.py b/deirokay/profiler.py
index 09a4b64..2a1cafd 100644
--- a/deirokay/profiler.py
+++ b/deirokay/profiler.py
@@ -4,20 +4,43 @@
 """
 
 import warnings
-from typing import Optional
+from typing import List, Optional
+
 from pandas import DataFrame
 
 from .__version__ import __version__
+from ._typing import (DeirokayStatement, DeirokayValidationDocument,
+                      DeirokayValidationItem)
 from .fs import fs_factory
-from .statements import Contain, NotNull, RowCount, Unique
+from .statements import STATEMENTS_MAP
+
+
+def _generate_statements(df_scope: DataFrame,
+                         table_only: bool) -> List[DeirokayStatement]:
 
-profiling_statement_classes = [
-    NotNull, RowCount, Unique, Contain
-]
+    statements: List[DeirokayStatement] = []
 
+    for stmt_cls in STATEMENTS_MAP.values():
+        if table_only and stmt_cls.table_only or not stmt_cls.table_only:
+            try:
+                statement = stmt_cls.profile(df_scope)
+                statements.append(statement)
+            except NotImplementedError:
+                pass
+            except Exception as e:
+                columns = list(df_scope.columns)
+                warnings.warn(
+                    f'Unexpected error when profiling scope {columns}'
+                    f' using {stmt_cls.__name__} statement: {e}\n\n'
+                    'Please, consider reporting this issue to the '
+                    'developers.',
+                    RuntimeWarning
+                )
+    return statements
 
-def _generate_items(df):
-    items = []
+
+def _generate_items(df: DataFrame) -> List[DeirokayValidationItem]:
+    items: List[DeirokayValidationItem] = []
 
     df_columns = list(df.columns)
     scope__table_stmt = zip([df_columns] + df_columns,
@@ -25,33 +48,19 @@ def _generate_items(df):
     for scope, table_only in scope__table_stmt:
         df_scope = df[scope] if isinstance(scope, list) else df[[scope]]
         item = {
-            'scope': scope
-        }
-        items.append(item)
-        statements = item['statements'] = []
-
-        for stmt_cls in profiling_statement_classes:
-            if table_only and stmt_cls.table_only or not stmt_cls.table_only:
-                try:
-                    statement = stmt_cls.profile(df_scope)
-                    statements.append(statement)
-                except NotImplementedError:
-                    pass
-                except Exception as e:
-                    warnings.warn(
-                        f'Unexpected error when profiling scope {scope}'
-                        f' using {stmt_cls.__name__} statement: {e}\n\n'
-                        'Please, consider reporting this issue to the '
-                        'developers.',
-                        RuntimeWarning
-                    )
+            'scope': scope,
+            'statements': _generate_statements(df_scope, table_only=table_only)
+        }  # type: DeirokayValidationItem
+
+        if item['statements']:
+            items.append(item)
 
     return items
 
 
 def profile(df: DataFrame,
             document_name: str,
-            save_to: Optional[str] = None) -> dict:
+            save_to: Optional[str] = None) -> DeirokayValidationDocument:
     """Generate a validation document from a given template DataFrame
     using profiling methods for builtin Deirokay statements.
     By default, statement objects are generated for the entire template
@@ -83,8 +92,8 @@ def profile(df: DataFrame,
     validation_document = {
         'name': document_name,
         'description': f'Auto generated using Deirokay {__version__}',
-    }
-    validation_document['items'] = _generate_items(df)
+        'items': _generate_items(df)
+    }  # type: DeirokayValidationDocument
 
     if save_to:
         fs_factory(save_to).write_dict(validation_document, indent=2)
diff --git a/deirokay/statements/__init__.py b/deirokay/statements/__init__.py
index 4ba7582..6318849 100644
--- a/deirokay/statements/__init__.py
+++ b/deirokay/statements/__init__.py
@@ -2,18 +2,21 @@
 Module for BaseStatement and builtin Deirokay statements.
 """
 
+import inspect
+
 from .base_statement import BaseStatement
-from .column_expression import ColumnExpression
-from .contain import Contain
-from .not_null import NotNull
-from .row_count import RowCount
-from .unique import Unique
+from .column_expression import ColumnExpression  # noqa F401
+from .contain import Contain  # noqa F401
+from .not_null import NotNull  # noqa F401
+from .row_count import RowCount  # noqa F401
+from .unique import Unique  # noqa F401
+
+STATEMENTS_MAP = {
+    cls.name: cls
+    for _, cls in locals().items()
+    if inspect.isclass(cls) and issubclass(cls, BaseStatement)
+}
 
-__all__ = (
-    'BaseStatement',
-    'ColumnExpression',
-    'Contain',
-    'NotNull',
-    'RowCount',
-    'Unique',
+__all__ = tuple(
+    cls.__name__ for cls in STATEMENTS_MAP.values()
 )
diff --git a/deirokay/statements/base_statement.py b/deirokay/statements/base_statement.py
index 604c977..add8fe0 100644
--- a/deirokay/statements/base_statement.py
+++ b/deirokay/statements/base_statement.py
@@ -1,36 +1,37 @@
+"""
+The base statement that all other statements inherit from.
+"""
+from abc import ABC, abstractmethod
+
 from pandas import DataFrame
 
+from .._typing import DeirokayStatement
+
 
-class BaseStatement:
+class BaseStatement(ABC):
     """Base abstract statement class for all Deirokay statements.
 
-    Parameters
+    Attributes
     ----------
     options : dict
         Statement parameters provided by user.
-
-    Attributes
-    ----------
-    name : str
-        Statement name when referred in Validation Documents (only
-        valid for non-custom statements).
-    expected_parameters : List[str]
-        Parameters expected for this statement.
-    table_only : bool
-        Whether or not this statement in applicable only to the entire
-        table, instead of scoped columns.
     """
 
     name = 'base_statement'
+    """str: Statement name when referred in Validation Documents
+    (only valid for Deirokay built-in statements)."""
     expected_parameters = ['type', 'severity', 'location']
+    """List[str]: Parameters expected for this statement."""
     table_only = False
+    """bool: Whether or not this statement in applicable only to the
+    entire table, instead of scoped columns."""
 
-    def __init__(self, options: dict):
+    def __init__(self, options: dict) -> None:
         self._validate_options(options)
         self.options = options
 
-    def _validate_options(self, options: dict):
-        """Make sure all providded statement parameters are expected
+    def _validate_options(self, options: dict) -> None:
+        """Make sure all provided statement parameters are expected
         by statement classes"""
         cls = type(self)
         unexpected_parameters = [
@@ -45,7 +46,7 @@ def _validate_options(self, options: dict):
                 f'The valid parameters are: {cls.expected_parameters}'
             )
 
-    def __call__(self, df: DataFrame):
+    def __call__(self, df: DataFrame) -> dict:
         """Run statement instance."""
         internal_report = self.report(df)
         result = self.result(internal_report)
@@ -56,6 +57,7 @@ def __call__(self, df: DataFrame):
         }
         return final_report
 
+    @abstractmethod
     def report(self, df: DataFrame) -> dict:
         """Receive a DataFrame containing only columns on the scope of
         validation and returns a report of related metrics that can
@@ -73,8 +75,8 @@ def report(self, df: DataFrame) -> dict:
         dict
             A dictionary of useful statistics about the target columns.
         """
-        return {}
 
+    @abstractmethod
     def result(self, report: dict) -> bool:
         """Receive the report previously generated and declare this
         statement as either fulfilled (True) or failed (False).
@@ -91,10 +93,9 @@ def result(self, report: dict) -> bool:
         bool
             Whether or not this statement passed.
         """
-        return True
 
     @staticmethod
-    def profile(df: DataFrame) -> dict:
+    def profile(df: DataFrame) -> DeirokayStatement:
         """Given a template data table, generate a statement dict
         from it.
 
@@ -107,5 +108,12 @@ def profile(df: DataFrame) -> dict:
         -------
         dict
             Statement dict.
+
+        Raises
+        ------
+        NotImplementedError
+            If this method is not implemented by the subclass or the
+            profile generation for this statement was intentionally
+            skipped.
         """
         raise NotImplementedError
diff --git a/deirokay/statements/column_expression.py b/deirokay/statements/column_expression.py
index 6785d01..ed78ee5 100644
--- a/deirokay/statements/column_expression.py
+++ b/deirokay/statements/column_expression.py
@@ -1,21 +1,79 @@
 """
-Module for BaseStatement and builtin Deirokay statements.
+Statement to evaluate mathematical expressions againt a set of
+columns from a given scope.
 """
-
 import re
 from decimal import Decimal
 
 import numpy
-import pandas
-from pandas import Float64Dtype, Int64Dtype, Series
+from pandas import (DataFrame, Float32Dtype, Float64Dtype, Int32Dtype,
+                    Int64Dtype, Series)
 
 from .base_statement import BaseStatement
 
 
 class ColumnExpression(BaseStatement):
     """
-    Evaluates an expression involving the scope columns, using
-    `numpy.eval()`
+    Evaluates an expression (or a list of expressions) involving
+    the scope columns, using `numpy.eval()`.
+    The statement passes only if all expressions evaluate to `True`.
+
+    The columns in the scope must be ideally of the same dtype.
+    This statement supports the following dtypes:
+    `string`, `integer`, `float` and `decimal`.
+
+    The available parameters for this statement are:
+
+    * `expressions` (required): an expression (or a `list` of
+      expressions) to be evaluated.
+      The valid operators are: `==`, `!=`, `=~`, `>=`, `<=`, `>` and
+      `<`.
+    * `at_least_%`: the minimum percentage of valid rows. Default: 100.
+    * `at_most_%`: the maximum percentage of valid rows. Default: 100.
+    * `rtol`: the relative tolerance for float evaluations (when
+      using the `=~` operator). Default: 1e-5.
+    * `atol`: the absolute tolerance for float evaluations (when
+      using the `=~` operator). Default: 1e-8.
+
+    Examples
+    --------
+    In the example below, in JSON format, we test whether or not the
+    values of the `a` column are equal to the values of the `b` column.
+    Similarly, we test whether or not the values of the `b` column are
+    greater than the values of the `c` column:
+
+    .. code-block:: json
+
+        {
+            "scope": ["a", "b", "c"],
+            "statements": [
+                {
+                    "type": "column_expression",
+                    "expressions": ["a == b", "a < c"],
+                    "at_least_%": 50.0
+                }
+            ]
+        }
+
+    For float comparisons, you may prefer using the `rtol` or `atol`
+    parameters, in addition to the `=~` operator. For example, if you
+    want to test whether or not the values of the `a` column are equal
+    to the values of the `b` column with a relative tolerance of 1e-3,
+    you can use the following JSON:
+
+    .. code-block:: json
+
+        {
+            "scope": ["a", "b"],
+            "statements": [
+                {
+                    "type": "column_expression",
+                    "expressions": "a =~ b",
+                    "rtol": 1e-3
+                }
+            ]
+        }
+
     """
 
     name = 'column_expression'
@@ -24,7 +82,7 @@ class ColumnExpression(BaseStatement):
     ]
     table_only = False
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
 
         self.expressions = self.options['expressions']
@@ -42,7 +100,7 @@ def __init__(self, *args, **kwargs):
                 raise SyntaxError('Expression comparison operand not found')
 
     # docstr-coverage:inherited
-    def report(self, df):
+    def report(self, df: DataFrame) -> dict:
         report = {}
         df = df.copy()
         df = self._fix_df_dtypes(df)
@@ -60,7 +118,7 @@ def report(self, df):
             }
         return report
 
-    def _fix_df_dtypes(self, df):
+    def _fix_df_dtypes(self, df: DataFrame) -> DataFrame:
         """
         Fixes DataFrame dtypes. If Int64Dtype() or Float64Dtype(),
         converts to traditional int64 and float64 dtypes. If object
@@ -70,8 +128,8 @@ def _fix_df_dtypes(self, df):
         When a pandas version corrects this bug, we can delete this
         method.
         """
-        pandas_dtypes_int = [Int64Dtype(), pandas.Int32Dtype()]
-        pandas_dtypes_float = [Float64Dtype(), pandas.Float32Dtype()]
+        pandas_dtypes_int = [Int64Dtype(), Int32Dtype()]
+        pandas_dtypes_float = [Float64Dtype(), Float32Dtype()]
         pandas_dtypes_decimal = [Decimal]
         for col in df.columns:
             if df[[col]].dtypes.isin(pandas_dtypes_int)[0]:
@@ -90,17 +148,17 @@ def _fix_df_dtypes(self, df):
                     df[[col]] = df[[col]].astype(float)
         return df
 
-    def _eval(self, df, expr):
+    def _eval(self, df: DataFrame, expr: str) -> Series:
         if '=~' not in expr:
             row_count = df.eval(expr)
         else:
             row_count = self._isclose_eval(df, expr)
         return row_count
 
-    def _isclose_eval(self, df, expr):
+    def _isclose_eval(self, df: DataFrame, expr: str) -> Series:
         """
         Accomplishes the paper of `pandas.eval` when we have the
-        =~ comparison to evaluate. That implementation is done by
+        `=~` comparison to evaluate. That implementation is done by
         using `numpy.eval`.
         """
         expr_calculus = re.split('|'.join(self.valid_expressions), expr)
@@ -134,7 +192,7 @@ def _isclose_eval(self, df, expr):
         return eval_bool
 
     # docstr-coverage:inherited
-    def result(self, report):
+    def result(self, report: dict) -> bool:
         for expr in report.keys():
             if not report[expr].get('valid_rows_%') >= self.at_least_perc:
                 return False
diff --git a/deirokay/statements/contain.py b/deirokay/statements/contain.py
index 177107c..665c749 100644
--- a/deirokay/statements/contain.py
+++ b/deirokay/statements/contain.py
@@ -1,29 +1,202 @@
+"""
+Statement to check the presence (or absence) of values in a scope.
+"""
 from numpy import inf
-from pandas import concat
+from pandas import DataFrame, concat
 
+from .._typing import DeirokayStatement
 from ..parser import get_dtype_treater, get_treater_instance
 from .base_statement import BaseStatement
 
 
 class Contain(BaseStatement):
     """
-    Checks if a given column contains specific values. We can also
-    check the number of their occurrences, specifying a minimum and
+    Checks if the given scope contains specific values. We can also
+    check the number of their occurrences by specifying a minimum and
     maximum value of frequency.
+
+    The available parameters for this statement are:
+
+    * `rule` (required): One of `all`, `only` or `all_and_only`.
+    * `values` (required): A list of values to which the rule applies.
+    * `parser` (required): The parser to be used to parse the `values`.
+      Correspond to the `parser` parameter of the `treater` function
+      (see `deirokay.data_reader` method).
+    * `min_occurrences`: a global minimum number of occurrences for
+      each of the `values`. Default: 1 for `all` and `all_and_only`
+      rules, 0 for `only`.
+    * `max_occurrences`: a global maximum number of occurrences for
+      each of the `values`. Default: `inf` (unbounded).
+    * `occurrences_per_value`: a list of dictionaries overriding the
+      global boundaries. Each dictionary may have the following keys:
+
+        * `values` (required): a value (or a list) to which the
+          occurrence bounds below must apply to.
+        * `min_occurrences`: a minimum number of occurrences for these
+          values. Default: global `min_occurrences` parameter.
+        * `max_occurrences`: a maximum number of occurrences for these
+          values. Default: global `max_occurrences` parameter.
+
+      Global parameters apply to all values not present in any of the
+      dictionaries in `occurrences_per_value` (but yet present on the
+      main `values` list).
+
+    * `verbose`: if `True`, the report will include the percentage of
+      occurrences for each value. Default: `True`.
+
+    The `all` rule checks if all the `values` declared are present in
+    the scope (possibly tolerating other values not declared in
+    `values`).
+    Use it when you want to be sure that your data contains at least
+    all the values you declare, also setting `min_occurrences` and
+    `max_occurrences` when necessary.
+    You may also check for "zero" occurrences of a set of values by
+    setting `max_occurrences` to 0.
+
+    The `only` rule ensures that the `values` are the only possible
+    values in the scope (possibly not containing them at all).
+    Use it when you want to enumerate the admitted values for the
+    scope, as in an enumeration.
+
+    The `all_and_only` rule checks both if all the `values` declared
+    are present in the scope and if only they are present (not
+    tolerating values not declared).
+    Use it when you know all the possible values for the scope and you
+    are sure that they will be always present.
+
+    The `min_occurrences` and `max_occurrences` parameters are applied
+    applied to all the `values` declared, and only these. It means you
+    cannot (yet) specify boundaries for values you did't declare.
+
+    You may also notice that, by tweaking the expected number of
+    occurrences, you may end up having the very same behaviour
+    regardless the `rule` you choose.
+    In this case, you should go for the rule that semantically matches
+    best your intents, so that your final validation document looks
+    more readable and easy to understand.
+
+    Examples
+    --------
+    * You have a table of users containg a column `handedness`
+      only admitting the values:
+      `right-handed`, `left-handed` and `ambidextrous`.
+      You know that some of these values may not appear in the data,
+      but you don't want other values to be present.
+
+    .. code-block:: json
+
+        {
+            "scope": "handedness",
+            "statements": [
+                {
+                    "type": "contain",
+                    "rule": "only",
+                    "values": ["right-handed", "left-handed", "ambidextrous"],
+                    "parser": {"dtype": "string"}
+                }
+            ]
+        }
+
+    * You have a table of servers containg a column `role` which may
+      contain the values `master` and `slave`.
+      You want to be sure that there is always one and only one master
+      server in the data.
+
+    .. code-block:: json
+
+        {
+            "scope": "role",
+            "statements": [
+                {
+                    "type": "contain",
+                    "rule": "all",
+                    "values": ["master"],
+                    "parser": {"dtype": "string"},
+                    "min_occurrences": 1,
+                    "max_occurrences": 1
+                }
+            ]
+        }
+
+    You may also extend the previous example by making some adjustments
+    to ensure that there is no other value than `master` and `role`
+    in the data. Make notice that although the `rule` below is changed
+    to `only`, the statement above is still contemplated by the
+    `occurrences_per_value` parameter in the following validation item:
+
+    .. code-block:: json
+
+        {
+            "scope": "role",
+            "statements": [
+                {
+                    "type": "contain",
+                    "rule": "only",
+                    "values": ["master", "slave"],
+                    "parser": {"dtype": "string"},
+                    "occurrences_per_value": [
+                        {
+                            "values": ["master"],
+                            "min_occurrences": 1,
+                            "max_occurrences": 1
+                        }
+                    ]
+                }
+            ]
+        }
+
+    * You have a table of transactions containing details about
+      transactions in all the branches of a company. You expect that
+      there should always be at least one transaction per branch.
+
+    .. code-block:: json
+
+        {
+            "scope": ["branch_name"],
+            "statements": [
+                {
+                    "type": "contain",
+                    "rule": "all_and_only",
+                    "values": [
+                        "Albany", "Utica", "Scranton", "Akron",
+                        "Nashua", "Buffalo", "Rochester"
+                    ],
+                    "parser": {"dtype": "string"}
+                }
+            ]
+        }
+
+    * You have a table for the logs of user accesses to a website which
+      contains an `IP` column. You want to be sure that blacklisted
+      IPs are not present in the data. The following validation item in
+      YAML format checks for the absense of blacklisted IPs:
+
+    .. code-block:: yaml
+
+        scope: IP
+        statements:
+        - type: contain
+          rule: all
+          max_occurrences: 0
+          values: # blacklisted IPs
+          - 3.48.48.135
+          - 3.48.48.136
+          parser: {dtype: string}
+
     """
     name = 'contain'
     expected_parameters = [
         'rule',
         'values',
         'parser',
-        'occurrences_per_value',
         'min_occurrences',
         'max_occurrences',
+        'occurrences_per_value',
         'verbose'
     ]
     table_only = False
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
 
         self.rule = self.options['rule']
@@ -40,7 +213,7 @@ def __init__(self, *args, **kwargs):
         self._set_default_minmax_occurrences()
         self._assert_parameters()
 
-    def _set_default_minmax_occurrences(self):
+    def _set_default_minmax_occurrences(self) -> None:
         min_occurrences_rule_default = {
             'all': 1,
             'only': 0,
@@ -57,13 +230,13 @@ def _set_default_minmax_occurrences(self):
         if self.max_occurrences is None:
             self.max_occurrences = max_occurrences_rule_default[self.rule]
 
-    def _assert_parameters(self):
+    def _assert_parameters(self) -> None:
         assert self.rule in ('all', 'only', 'all_and_only')
         assert self.min_occurrences >= 0
         assert self.max_occurrences >= 0
 
     # docstr-coverage:inherited
-    def report(self, df):
+    def report(self, df: DataFrame) -> dict:
         # Concat all columns
         count_isin = (
             concat(df[col] for col in df.columns).value_counts()
@@ -92,8 +265,8 @@ def report(self, df):
         }
 
     # docstr-coverage:inherited
-    def result(self, report):
-        self._set_min_max_boundaries(self.value_count)
+    def result(self, report: dict) -> bool:
+        self._set_min_max_boundaries()
         self._set_values_scope()
 
         if not self._check_interval(self.value_count):
@@ -102,7 +275,7 @@ def result(self, report):
             return False
         return True
 
-    def _set_min_max_boundaries(self, value_count):
+    def _set_min_max_boundaries(self) -> None:
         # Global boundaries
         min_max_boundaries = {}
         for value in self.values:
@@ -149,7 +322,7 @@ def _set_values_scope(self):
         ]
         self.values_scope_filter = values_col
 
-    def _check_interval(self, value_count):
+    def _check_interval(self, value_count: dict) -> bool:
         """
         Check if each value is inside an interval of min and max
         number of occurrencies. These values are set globally in
@@ -174,7 +347,7 @@ def _check_interval(self, value_count):
                     return False
         return True
 
-    def _check_rule(self, value_count):
+    def _check_rule(self, value_count: dict) -> bool:
         """
         Checks if given columns attend the given requirements
         of presence or absence of values, according to a criteria
@@ -204,12 +377,11 @@ def _check_rule(self, value_count):
             return self._check_all(value_count)
         elif self.rule == 'only':
             return self._check_only(value_count)
-        elif self.rule == 'all_and_only':
-            is_check_all = self._check_all(value_count)
-            is_check_only = self._check_only(value_count)
-            return is_check_all and is_check_only
+        else:
+            return (self._check_all(value_count) and
+                    self._check_only(value_count))
 
-    def _check_all(self, value_count):
+    def _check_all(self, value_count: dict) -> bool:
         """
         Checks if values in df contains all the expected values
         """
@@ -219,7 +391,7 @@ def _check_all(self, value_count):
             return False
         return True
 
-    def _check_only(self, value_count):
+    def _check_only(self, value_count: dict) -> bool:
         """
         Checks if all values in df are inside the expected values
         """
@@ -231,7 +403,7 @@ def _check_only(self, value_count):
 
     # docstr-coverage:inherited
     @staticmethod
-    def profile(df):
+    def profile(df: DataFrame) -> DeirokayStatement:
         if any(dtype != df.dtypes for dtype in df.dtypes):
             raise NotImplementedError(
                 "Refusing to mix up different types of columns"
@@ -249,7 +421,7 @@ def profile(df):
         statement_template = {
             'type': 'contain',
             'rule': 'all'
-        }
+        }  # type: DeirokayStatement
         # Get most common type to infer treater
         try:
             statement_template.update(
diff --git a/deirokay/statements/not_null.py b/deirokay/statements/not_null.py
index 6b8f3e5..f52ae25 100644
--- a/deirokay/statements/not_null.py
+++ b/deirokay/statements/not_null.py
@@ -1,13 +1,99 @@
+"""
+Statement to check the number of not-null rows in a scope.
+"""
+from pandas import DataFrame
+
+from .._typing import DeirokayStatement
 from .base_statement import BaseStatement
 
 
 class NotNull(BaseStatement):
-    """Check if the rows of a scoped DataFrame are not null."""
+    """Check if the rows of a scoped DataFrame are not null, possibly
+    setting boundaries for the minimum and maximum percentage of
+    not-null rows.
+
+    The available options are:
+
+    * `at_least_%`: The minimum percentage of not-null rows.
+      Default: 100.0.
+    * `at_most_%`: The maximum percentage of not-null rows.
+      Default: 100.0.
+    * `multicolumn_logic`: The logic to use when checking for not-null
+      values in multicolumn scopes (either 'any' or 'all').
+      Default: 'any'.
+
+    Be careful When using multicolumn scopes: the `any` logic considers
+    a row as null only if all columns are null.
+    The `all` logic considers a row as null when any of its columns is
+    null.
+
+    Examples
+    --------
+    * You want to ensure that less than 1% of the values in a column
+      `foo` are null. You can declare the following validation item:
+
+    .. code-block:: json
+
+        {
+            "scope": "foo",
+            "statements": [
+                {
+                    "name": "not_null",
+                    "at_least_%": 99.0
+                }
+            ]
+        }
+
+    You noticed that you imposed a unrealistic value for `at_least_%`,
+    and maybe less than 10% should be a reasonable percentage of null
+    values.
+    Still, you don't want to lose track of that ideal <= 1% checks,
+    since you intend to improve your data quality in the near future.
+    You may take advantage of `severity` to set different exception
+    levels for different values of `at_least_%`:
+
+    .. code-block:: json
+
+        {
+            "scope": "foo",
+            "statements": [
+                {
+                    "name": "not_null",
+                    "at_least_%": 99.0,
+                    "severity": 3
+                },
+                {
+                    "name": "not_null",
+                    "at_least_%": 90.0,
+                    "severity": 5
+                }
+            ]
+        }
+
+    This way, values between 90% and 99% will only raise a warning,
+    while values below 90% will raise a validation exception (by
+    default).
+
+    * You don't tolerate any null values in a list of columns:
+
+    .. code-block:: json
+
+        {
+            "scope": ["foo", "bar", "baz", "qux"],
+            "statements": [
+                {
+                    "name": "not_null",
+                    "multicolumn_logic": "all"
+                }
+            ]
+        }
+
+    """
 
     name = 'not_null'
     expected_parameters = ['at_least_%', 'at_most_%', 'multicolumn_logic']
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
 
         self.at_least_perc = self.options.get('at_least_%', 100.0)
@@ -17,7 +103,7 @@ def __init__(self, *args, **kwargs):
         assert self.multicolumn_logic in ('any', 'all')
 
     # docstr-coverage:inherited
-    def report(self, df):
+    def report(self, df: DataFrame) -> dict:
         if self.multicolumn_logic == 'all':
             #  REMINDER: ~all == any
             not_nulls = ~df.isnull().any(axis=1)
@@ -33,7 +119,7 @@ def report(self, df):
         return report
 
     # docstr-coverage:inherited
-    def result(self, report):
+    def result(self, report: dict) -> bool:
         if not report.get('not_null_rows_%') >= self.at_least_perc:
             return False
         if not report.get('not_null_rows_%') <= self.at_most_perc:
@@ -42,14 +128,20 @@ def result(self, report):
 
     # docstr-coverage:inherited
     @staticmethod
-    def profile(df):
+    def profile(df: DataFrame) -> DeirokayStatement:
         not_nulls = ~df.isnull().all(axis=1)
 
         statement = {
             'type': 'not_null'
-        }
+        }  # type: DeirokayStatement
 
         at_least_perc = float(100.0*not_nulls.sum()/len(not_nulls))
+
+        if at_least_perc == 0.0:
+            raise NotImplementedError(
+                'Statement is useless when all rows are null.'
+            )
+
         if at_least_perc != 100.0:
             statement['at_least_%'] = at_least_perc
 
diff --git a/deirokay/statements/row_count.py b/deirokay/statements/row_count.py
index f78db21..8c5e019 100644
--- a/deirokay/statements/row_count.py
+++ b/deirokay/statements/row_count.py
@@ -1,14 +1,119 @@
+"""
+Statement to check the number of rows in a scope.
+"""
+from pandas import DataFrame
+
+from .._typing import DeirokayStatement
 from .base_statement import BaseStatement
 
 
 class RowCount(BaseStatement):
-    """Check if the number of rows in a DataFrame is within a
-    range."""
+    """Check if the number of rows (or the number of of distinct rows)
+    in a scope is between a minimum and maximum value.
+
+    The available options are:
+
+    * `min`: The minimum number of rows. If None, no minimum is
+      enforced. Default: None.
+    * `max`: The maximum number of rows. If None, no maximum is
+      enforced. Default: None.
+    * `distinct`: If True, check the number of distinct rows instead of
+      the total number of rows. Default: False.
+
+    Providing no `min` or `max` parameters, the statement will act only
+    as a logger for its statistics.
+
+    When counting the total number of rows (`distinct=False`), this
+    statement may be applied to any scope of your DataFrame, since
+    every column would have the same number of rows.
+    By convention, you should apply it to a scope containing all the
+    columns of your DataFrame.
+
+    To count the number of (not-)null rows, you should use the `not_null`
+    statement instead.
+    To count the number of unique rows, use the `unique` statement.
+
+    Examples
+    --------
+    * After some historial analysis of your data, you found that the
+      number of rows is always greater or equal to than 42.
+      You can declare the following validation item to represent this
+      rule:
+
+    .. code-block:: json
+
+        {
+            "scope": ["foo", "bar"],
+            "statements": [
+                {
+                    "name": "row_count",
+                    "min": 42
+                }
+            ]
+        }
+
+    * You have a table of daily transactions from all branches of a
+      company. Not all branches have transactions for every day, and
+      new branches may be added at any time. You want to ensure that
+      the number of branches that appears in your data does not vary
+      sharply downwards (below 5% of its 7-day historical average),
+      which could be a sign of failure to receive transactions from
+      some branches.
+      You can declare the following validation item (in YAML format) to
+      check this rule:
+
+    .. code-block:: yaml
+
+        scope: branch_name
+        statements:
+        - name: row_count
+          distinct: True
+          min: >
+            {{ 0.95 * (
+              series("transactions", 7).branch_name.row_count.distinct_rows.mean()  # noqa E501
+              | default(19, true))
+              | float
+            ) }}
+
+    There are many things going on here:
+
+    * In YAML, the ">" operator is used to collapse a multi-line string
+      into a single line. In JSON you would have to put everything in
+      the same line;
+    * The "{{}}" braces are used to indicate that the following
+      expression is a Jinja2 template.
+    * The `series` function is a built-in Deirokay method
+      used to get the 7-day historical validations.
+      Further down, the `mean` function is used to
+      compute the 7-day average of the `distinct_rows` metric returned
+      by the `row_count` statement in the `branch_name` scope.
+    * The "|" operator inside the Jinja2 template is used to apply
+      a function to the result of the previous expression, such that in
+      the end we obtain a float.
+    * The `default` function is used to set a default value if the
+      previous expression is `None`.
+    * The `float` function is used to convert the result of the
+      previous expression, which is a `numpy.float64`, to a float,
+      which can be properly serialized in JSON or YAML format when the
+      validation logs are generated.
+
+    For this example to work, you will need to declare in your 
+    `deirokay.validate` call the `save_to` parameters, so that the
+    validation logs can be saved and later used to provide historical
+    analysis.
+
+    .. code-block:: python
+
+        from deirokay import validate
+
+        validate(df, against=assertions, save_to='logs')
+
+    """
 
     name = 'row_count'
     expected_parameters = ['min', 'max', 'distinct']
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
 
         self.min = self.options.get('min', None)
@@ -16,7 +121,7 @@ def __init__(self, *args, **kwargs):
         self.distinct = self.options.get('distinct', False)
 
     # docstr-coverage:inherited
-    def report(self, df):
+    def report(self, df: DataFrame) -> dict:
         row_count = len(df)
         distinct_count = len(df.drop_duplicates())
 
@@ -27,7 +132,7 @@ def report(self, df):
         return report
 
     # docstr-coverage:inherited
-    def result(self, report):
+    def result(self, report: dict) -> bool:
         if self.distinct:
             count = report['distinct_rows']
         else:
@@ -43,19 +148,22 @@ def result(self, report):
 
     # docstr-coverage:inherited
     @staticmethod
-    def profile(df):
+    def profile(df: DataFrame) -> DeirokayStatement:
+        statement: DeirokayStatement
+
         if len(df.columns) > 1:
             count = len(df)
-            return {
+            statement = {
                 'type': 'row_count',
                 'min': count,
                 'max': count
             }
         else:
             count = len(df.drop_duplicates())
-            return {
+            statement = {
                 'type': 'row_count',
                 'distinct': True,
                 'min': count,
                 'max': count
             }
+        return statement
diff --git a/deirokay/statements/unique.py b/deirokay/statements/unique.py
index 0b33b4e..f1b2dc6 100644
--- a/deirokay/statements/unique.py
+++ b/deirokay/statements/unique.py
@@ -1,19 +1,51 @@
+"""
+Statement to check the number unique rows in a scope.
+"""
+from pandas import DataFrame
+
+from .._typing import DeirokayStatement
 from .base_statement import BaseStatement
 
 
 class Unique(BaseStatement):
-    """Check if the rows of a scoped DataFrame are unique."""
+    """Checks for the unicity of rows in a scope.
+
+    The only available option is:
+
+    * `at_least_%`: The minimum percentage of unique rows.
+
+    Examples
+    --------
+    In a table containing information about cities of your country,
+    you expect the pair of columns `state` and `city` to be unique
+    across all rows. It means that, although some values of `state`
+    can be repeated, as well as `city` names, the combination of
+    both columns should be unique.
+    You can declare the following validation item to represent this
+    rule:
+
+    .. code-block:: json
+
+        {
+            "scope": ["state", "city"],
+            "statements": [
+                {
+                    "name": "unique"
+                }
+            ]
+        }
+    """
 
     name = 'unique'
     expected_parameters = ['at_least_%']
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
 
         self.at_least_perc = self.options.get('at_least_%', 100.0)
 
     # docstr-coverage:inherited
-    def report(self, df):
+    def report(self, df: DataFrame) -> dict:
         unique = ~df.duplicated(keep=False)
 
         report = {
@@ -23,20 +55,26 @@ def report(self, df):
         return report
 
     # docstr-coverage:inherited
-    def result(self, report):
+    def result(self, report: dict) -> bool:
         return report.get('unique_rows_%') >= self.at_least_perc
 
     # docstr-coverage:inherited
     @staticmethod
-    def profile(df):
+    def profile(df: DataFrame) -> DeirokayStatement:
         unique = ~df.duplicated(keep=False)
 
         statement = {
             'type': 'unique',
-        }
+        }  # type: DeirokayStatement
+
+        at_least_perc = float(100.0*unique.sum()/len(unique))
+
+        if at_least_perc == 0.0:
+            raise NotImplementedError(
+                'Statement is useless when all rows are not unique.'
+            )
 
-        at_leat_perc = float(100.0*unique.sum()/len(unique))
-        if at_leat_perc != 100.0:
-            statement['at_least_%'] = at_leat_perc
+        if at_least_perc != 100.0:
+            statement['at_least_%'] = at_least_perc
 
         return statement
diff --git a/deirokay/validator.py b/deirokay/validator.py
index c206696..2c18b10 100644
--- a/deirokay/validator.py
+++ b/deirokay/validator.py
@@ -2,7 +2,6 @@
 Set of functions related to Deirokay validation.
 """
 
-import inspect
 import json
 import warnings
 from copy import deepcopy
@@ -15,23 +14,13 @@
 from jinja2 import StrictUndefined as strict
 from jinja2.nativetypes import NativeEnvironment
 
-import deirokay.statements
 from deirokay.enums import SeverityLevel
 from deirokay.exceptions import ValidationError
 from deirokay.fs import FileSystem, LocalFileSystem, fs_factory
 from deirokay.history_template import get_series
-from deirokay.statements import BaseStatement
+from deirokay.statements import STATEMENTS_MAP, BaseStatement
 from deirokay.utils import _check_columns_in_df_columns, _render_dict
 
-# List all Core statement classes automatically
-core_statement_classes = {
-    cls.name: cls
-    for _, cls in inspect.getmembers(deirokay.statements)
-    if isinstance(cls, type) and
-    issubclass(cls, BaseStatement) and
-    cls is not BaseStatement
-}
-
 
 def _load_custom_statement(location: str):
     """Load a custom statement from a .py file"""
@@ -84,12 +73,12 @@ def _process_stmt(statement: dict) -> BaseStatement:
             raise KeyError('A custom statement must define a `location`'
                            ' parameter.')
         cls = _load_custom_statement(location)
-    elif stmt_type in core_statement_classes:
-        cls = core_statement_classes[stmt_type]
+    elif stmt_type in STATEMENTS_MAP:
+        cls = STATEMENTS_MAP[stmt_type]
     else:
         raise NotImplementedError(
             f'Statement type "{stmt_type}" not implemented.\n'
-            f'The available types are {list(core_statement_classes)}'
+            f'The available types are {list(STATEMENTS_MAP)}'
             ' or `custom` for your own statements.'
         )
 
@@ -170,8 +159,8 @@ def validate(df: pandas.DataFrame, *,
     """
 
     if save_to:
-        save_to = fs_factory(save_to)
-        if isinstance(save_to, LocalFileSystem) and not save_to.isdir():
+        save_to_fs = fs_factory(save_to)
+        if isinstance(save_to_fs, LocalFileSystem) and not save_to_fs.isdir():
             raise ValueError('The `save_to` parameter must be an existing'
                              ' directory or an S3 path.')
 
@@ -187,14 +176,14 @@ def validate(df: pandas.DataFrame, *,
 
     # Render templates
     template = dict(
-        series=lambda x, y: get_series(x, y, read_from=save_to),
+        series=lambda x, y: get_series(x, y, read_from=save_to_fs),
         **(template or {})
     )
     _render_dict(NativeEnvironment(loader=BaseLoader(), undefined=strict),
                  dict_=validation_document,
                  template=template)
 
-    for item in validation_document.get('items'):
+    for item in validation_document['items']:
         scope = item.get('scope')
         scope = [scope] if not isinstance(scope, list) else scope
         _check_columns_in_df_columns(scope, df.columns)
@@ -210,7 +199,7 @@ def validate(df: pandas.DataFrame, *,
             stmt['report'] = report
 
     if save_to:
-        _save_validation_document(validation_document, save_to,
+        _save_validation_document(validation_document, save_to_fs,
                                   save_format, current_date)
 
     if raise_exception:
@@ -220,7 +209,7 @@ def validate(df: pandas.DataFrame, *,
 
 
 def raise_validation(validation_result_document: dict,
-                     exception_level: SeverityLevel):
+                     exception_level: SeverityLevel) -> None:
     """Check for a validation result `dict` and raise a
     `ValidationError` exception whenever a statement whose severity
     level is greater or equal to `exception_level` fails.
@@ -239,7 +228,7 @@ def raise_validation(validation_result_document: dict,
         greater or equal to `exception_level`.
     """
     highest_level = None
-    for item in validation_result_document.get('items'):
+    for item in validation_result_document['items']:
         scope = item['scope']
         for stmt in item.get('statements'):
             severity = stmt.get('severity', SeverityLevel.CRITICAL)
@@ -263,7 +252,7 @@ def raise_validation(validation_result_document: dict,
 def _save_validation_document(document: dict,
                               save_to: FileSystem,
                               save_format: Optional[str] = None,
-                              current_date: Optional[datetime] = None):
+                              current_date: Optional[datetime] = None) -> None:
     if current_date is None:
         warnings.warn(
             'Document is being saved using the current date returned by the'
@@ -271,7 +260,7 @@ def _save_validation_document(document: dict,
             ' `current_date` argument to `validate`.', Warning
         )
         current_date = datetime.utcnow()
-    current_date = current_date.strftime('%Y%m%dT%H%M%S')
+    current_date = current_date.strftime('%Y%m%dT%H%M%S')  # type: ignore
 
     document_name = document['name']
     folder_path = save_to / document_name
diff --git a/docs/.gitignore b/docs/.gitignore
index 2b7c4eb..378eac2 100644
--- a/docs/.gitignore
+++ b/docs/.gitignore
@@ -1,2 +1,1 @@
 build
-**/_*
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..9518256
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,1 @@
+nbsphinx
diff --git a/docs/source/_templates/custom-class-template.rst b/docs/source/_templates/custom-class-template.rst
index f73eda5..1b0b9d9 100644
--- a/docs/source/_templates/custom-class-template.rst
+++ b/docs/source/_templates/custom-class-template.rst
@@ -6,6 +6,7 @@
    :members:
    :show-inheritance:
    :inherited-members:
+   :undoc-members:
    :special-members: __call__, __add__, __mul__
 
    {% block methods %}
diff --git a/docs/source/api-reference/.gitignore b/docs/source/api-reference/.gitignore
new file mode 100644
index 0000000..64ca098
--- /dev/null
+++ b/docs/source/api-reference/.gitignore
@@ -0,0 +1,1 @@
+_autosummary
diff --git a/docs/source/api-reference/index.rst b/docs/source/api-reference/index.rst
index 83c5315..4763a63 100644
--- a/docs/source/api-reference/index.rst
+++ b/docs/source/api-reference/index.rst
@@ -7,4 +7,4 @@ API Reference
       :template: custom-module-template.rst
       :recursive:
 
-    deirokay
\ No newline at end of file
+      deirokay
\ No newline at end of file
diff --git a/docs/source/assets/getting_started/assertions.json b/docs/source/assets/getting_started/assertions.json
new file mode 100644
index 0000000..550370e
--- /dev/null
+++ b/docs/source/assets/getting_started/assertions.json
@@ -0,0 +1,38 @@
+{
+    "name": "example",
+    "description": "Getting started with Deirokay",
+    "items": [
+        {
+            "scope": "name",
+            "statements": [
+                {
+                    "type": "unique"
+                }
+            ]
+        },
+        {
+            "scope": "is_married",
+            "statements": [
+                {
+                    "type": "contain",
+                    "rule": "all",
+                    "values": [
+                        true
+                    ],
+                    "parser": {
+                        "dtype": "boolean"
+                    },
+                    "min_occurrences": 3
+                }
+            ]
+        },
+        {
+            "scope": "age",
+            "statements": [
+                {
+                    "type": "not_null"
+                }
+            ]
+        }
+    ]
+}
\ No newline at end of file
diff --git a/docs/source/assets/getting_started/example.csv b/docs/source/assets/getting_started/example.csv
new file mode 100644
index 0000000..1ad6973
--- /dev/null
+++ b/docs/source/assets/getting_started/example.csv
@@ -0,0 +1,7 @@
+name,age,is_married
+John,55,True
+Mariah,44,yes
+Carl,,false
+Josh,12,yes
+Anna,32,no
+Josh,12,true
\ No newline at end of file
diff --git a/docs/source/assets/getting_started/options.json b/docs/source/assets/getting_started/options.json
new file mode 100644
index 0000000..5b94490
--- /dev/null
+++ b/docs/source/assets/getting_started/options.json
@@ -0,0 +1,23 @@
+{
+    "columns": {
+        "name": {
+            "dtype": "string"
+        },
+        "age": {
+            "dtype": "integer"
+        },
+        "is_married": {
+            "dtype": "boolean",
+            "truthies": [
+                "yes",
+                "true",
+                "True"
+            ],
+            "falsies": [
+                "no",
+                "false",
+                "False"
+            ]
+        }
+    }
+}
\ No newline at end of file
diff --git a/docs/source/concepts/definitions.rst b/docs/source/concepts/definitions.rst
new file mode 100644
index 0000000..024512e
--- /dev/null
+++ b/docs/source/concepts/definitions.rst
@@ -0,0 +1,179 @@
+===========
+Definitions
+===========
+
+Here you can find the description about key concepts that you will see when using Deirokay.
+
+Deirokay Options Document
+=========================
+
+In order to be able to parse a file, the `deirokay.data_reader` method should receive specifications about how to interpret each column of your data, as well as source-wise parameters, such as file encoding or column separator (in case of `.csv` files). 
+Those specifications, called *Deirokay Options document*, can be expressed in form of a JSON/YAML file or a Python object.
+To specify each of your columns, you must declare which Deirokay DType should be used, including additional parameters regarding the datatype you chose.
+
+The block of code below depicts an example of options document in JSON format.
+The only required parameter for this document is the `columns` key, which contains the specifications for each column.
+In current version of Deirokay, all other parameters besides `columns` are transparently passed to the respective Pandas reader, based on the data file extension or source.
+
+.. code-block:: json
+
+    {
+        "sep": ",",
+        "encoding": "utf-8",
+        "columns": {
+            "id": {
+                "dtype": "integer",
+                "thousand_sep": ","
+            },
+            "customer_name": {
+                "dtype": "string"
+            },
+            "age": {
+                "dtype": "integer"
+            },
+            "revenue": {
+                "dtype": "decimal",
+                "decimal_places": 2,
+                "decimal_sep": ".",
+                "thousand_sep": ","
+            }
+        }
+    }
+
+Validation Document
+===================
+
+The *Validation Document* gathers all constraints, expectations and business rules that you want to test and validate against your data.
+
+.. code-block:: json
+
+  {
+    "name": "CUSTOMERS",
+    "description": "Client's data",
+    "items": [
+      {
+        "scope": "customer_name",
+        "statements": [
+          {
+            "type": "row_count",
+            "distinct": true,
+            "min": 1000
+          },
+          {
+            "type": "unique"
+          }
+        ]
+      },
+      {
+        "scope": [
+          "age",
+          "id"
+        ],
+        "statements": [
+          {
+            "type": "not_null"
+          }
+        ]
+      }
+    ]
+  }
+
+The fields that compose such a document are presented below:
+
+Name & Description
+------------------
+
+Every validation document starts with the `name` of the dataset that will work on followed by an optional `description`.
+Be sure the `name` field does not contain reserved characters if you want your validation logs to be saved to a local folder or S3 bucket.
+
+Validation Items
+----------------
+
+Another field in the Validation Document is `items`, which contains a list of `Validation Items`.
+A validation item is composed by a `scope` and a `statements` list.
+The `statements` are the actual validation rules, which will be applied to the given `scope`.
+The `scope` defines a column or a list of columns to be validated.
+
+To get dive into *Deirokay Statements*, you can find more at 
+:doc:`Statements <statements>`.
+
+
+Validation Result
+=================
+
+At the end of the data validation, Deirokay create meaningful logs that reflect your validation document. A validation report is attached to each statement, containing its validation result (either `pass` or `fail`) and useful statistics about the analysed scope. 
+
+In the code below, we can see an example of a validation document:
+
+.. code-block:: json
+
+    {
+        "name": "CUSTOMERS",
+        "description": "Client's data",
+        "items": [
+            {
+                "scope": "transaction_id",
+                "statements": [
+                    {
+                        "type": "not_null",
+                        "at_least_%": 100.0,
+                        "severity": 1
+                    }
+                ]
+            }
+        ]
+    }
+
+which generates the following validation report:
+
+.. code-block:: json
+
+    {
+        "name": "CUSTOMERS",
+        "description": "Client's data",
+        "items": [
+            {
+                "scope": "transaction_id",
+                "statements": [
+                    {
+                        "type": "not_null",
+                        "at_least_%": 100.0,
+                        "severity": 1,
+                        "report": {
+                            "detail": {
+                                "null_rows": 0,
+                                "null_rows_%": 0,
+                                "not_null_rows": 830400,
+                                "not_null_rows_%": 100
+                            },
+                            "result": "pass"
+                        }
+
+                    }
+                ]
+            }
+        ]
+    }
+
+Profiling
+=========
+A native feature of Deirokay is the ability to generate a validation document from a given template DataFrame.
+Once you have correctly use `deirokay.data_reader` to parse your data into a DataFrame, you may use `deirokay.profile` to quickly create a first version of your validation document. 
+
+.. code-block:: python
+
+    from deirokay import data_reader, profile
+
+
+    df = data_reader('file.csv', options='options.json')
+
+    profile(df, 'CUSTOMERS', save_to='validation_doc.json')
+
+
+You should get a `validation_doc.json` file at the end of the process, containing a bunch of valid statements about your data.
+The results of this function should be used only as a draft for a validation document 
+or as a means to quickly launch a first version with minimum efforts. 
+It is up to you to modify it and enrich it with your own rules.
+
+By default, this function receives an DataFrame previously parsed using `data_reader`, a  name for the validation document and optionally a path where to save the document to. 
+The `profile` method also returns the same document as a Python *dict* object.
diff --git a/docs/source/statements/index.rst b/docs/source/concepts/statements.rst
similarity index 59%
rename from docs/source/statements/index.rst
rename to docs/source/concepts/statements.rst
index 7448d05..31ddde9 100644
--- a/docs/source/statements/index.rst
+++ b/docs/source/concepts/statements.rst
@@ -2,9 +2,13 @@
 Statements
 ==========
 
-A Statement is one of the main entities in Deirokay. In short, a Statement is a special rule that is executed against your data in form of test to determine whether it passes or fails.
+A Statement is one of the main entities in Deirokay. In short, a 
+Statement is a special rule that is executed against your data in form 
+of test to determine whether it passes or fails.
 
-In the context of validation, it is represented by an object of key-value pairs (also called *associative array* or *dictionary*). See the example of Validation Document below, in JSON format:
+In the context of validation, it is represented by an object of 
+key-value pairs (also called *associative array* or *dictionary*). See 
+the example of Validation Document below, in JSON format:
 
 .. code-block:: json
 
@@ -36,7 +40,8 @@ In the context of validation, it is represented by an object of key-value pairs
         ]
     }
 
-The same structure can be represented in a YAML, which is preferred for readability reasons:
+The same structure can be represented in a YAML, which is preferred for 
+readability reasons:
 
 .. code-block:: yaml
 
@@ -55,27 +60,41 @@ The same structure can be represented in a YAML, which is preferred for readabil
               min: 18
               max: 22
 
-In the example above, we have three statements applied in the same scope of columns. It means that each of these statements will generate tests only over these two columns: *WERKS01* and *PROD_VENDA*.
+In the example above, we have three statements applied in the same 
+scope of columns. It means that each of these statements will generate 
+tests only over these two columns: *WERKS01* and *PROD_VENDA*.
 
-Make notice every statement declaration identifies its *type*, which is the rule to be tested. In function of its type, a statement may have several additional parameters, and they can be optional or not.
+Make notice every statement declaration identifies its *type*, which is 
+the rule to be tested. In function of its type, a statement may have 
+several additional parameters, and they can be optional or not.
 
-There are special parameter names, which are actually directives to pass attributes regarding the statement itself, and not the statement type. They are:
+There are special parameter names, which are actually directives to 
+pass attributes regarding the statement itself, and not the statement 
+type. They are:
 
-- **type**: It defines the statement type itself, so it cannot be used as a parameter name. Should be set to *custom* in case of custom statements, and the statement class location should be declared in **location**.
+- **type**: It defines the statement type itself, so it cannot be used 
+  as a parameter name. Should be set to *custom* in case of custom 
+  statements, and the statement class location should be declared in 
+  **location**.
 
-- **severity**: Set a severity level for a statement in case of failure. It must be an integer value, conventionally from 1 (minimal) to 5 (critical). In one use case, you could create several similar statements setting different threshold levels for a statement parameter, each one with a different severity level.
+- **severity**: Set a severity level for a statement in case of failure.
+  It must be an integer value, conventionally from 1 (minimal) to 5 
+  (critical). In one use case, you could create several similar 
+  statements setting different threshold levels for a statement parameter,
+  each one with a different severity level.
 
-- **location**: When declaring a custom statement, this key specifies where the statement class is stored, and what is the class name.
+- **location**: When declaring a custom statement, this key specifies 
+  where the statement class is stored, and what is the class name.
 
 
 How Statements are processed
 ============================
 
-For a statement to be processed, its type, as declared in the validation document, must match a statement class name (the *name* attribute of a statement class).
+For a statement to be processed, its type, as declared in the 
+validation document, must match a statement class name (the *name* 
+attribute of a statement class).
 
-.. _deirokay/statements.py: http://gitlab.bigdata/data-engineers/deirokay/-/blob/master/deirokay/statements.py
-
-The native statement classes are all declared in `deirokay/statements.py`_. See an example:
+The native statement classes are all declared in :ref:`deirokay.statements`. See an example:
 
 .. code-block:: python
 
@@ -127,14 +146,36 @@ The native statement classes are all declared in `deirokay/statements.py`_. See
             }
             return statement
 
-When processing statements, Deirokay will list all class names in this module and look for a class whose name matches the *type* key from the validation document. It means it is enough to create a class in this module to make it available to use.
-
-The *expected_parameters* is a mandatory argument to identify all valid parameters for the current class. Any parameter that is neither special nor listed as expected will raise an exception.
-
-Ideally, the statement class could validate the parameters it accepts in the *__init__* method.
-
-The *report* method is intended to report statistics that may be useful for the current statement. Thinking of the validation process also as a form of logging (when the validation result is saved), the metrics reported by the statement could be useful in a numerous use cases. Ideally, the *report* method should also summarize all calculations that will be logically evaluated by the next method.
-
-The *result* method has only one purpose: return either True (for a successful test) or False (for a failed test). A failure can be a consequence of several reasons, since a statement is able to evaluate a series of parameters passed by the user and a set of metrics reported by the *report* method.
-
-The *profile* is a static method used to generate a default statement object for the current class. It is not called during the validation process, but when profiling the data. When the user calls the *deirokay.profile* function, all native statement classes having a *profile* method are iterated to generate a default statement. By default, statement objects are generated for the entire template DataFrame (the entire set of columns), and then for each of its columns individually.
+When processing statements, Deirokay will list all class names in 
+this module and look for a class whose name matches the *type* key from 
+the validation document. It means it is enough to create a class in 
+this module to make it available to use.
+
+The *expected_parameters* is a mandatory argument to identify all valid 
+parameters for the current class. Any parameter that is neither special 
+nor listed as expected will raise an exception.
+
+Ideally, the statement class could validate the parameters it accepts 
+in the *__init__* method.
+
+The *report* method is intended to report statistics that may be useful 
+for the current statement. Thinking of the validation process also as a 
+form of logging (when the validation result is saved), the metrics 
+reported by the statement could be useful in a numerous use cases. 
+Ideally, the *report* method should also summarize all calculations 
+that will be logically evaluated by the next method.
+
+The *result* method has only one purpose: return either True (for a 
+successful test) or False (for a failed test). A failure can be a 
+consequence of several reasons, since a statement is able to evaluate a 
+series of parameters passed by the user and a set of metrics reported 
+by the *report* method.
+
+The *profile* is a static method used to generate a default statement 
+object for the current class. It is not called during the validation 
+process, but when profiling the data. When the user calls the 
+*deirokay.profile* function, all native statement classes having a 
+*profile* method are iterated to generate a default statement. By 
+default, statement objects are generated for the entire template 
+DataFrame (the entire set of columns), and then for each of its columns 
+individually.
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 4c3929e..e0bccb7 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -6,17 +6,17 @@
 
 # -- Path setup --------------------------------------------------------------
 
+import os
+import sys
 from datetime import datetime
+
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
 #
-from os.path import dirname, join
-
-with open(join(dirname(__file__), '../../deirokay/__version__.py')) as v:
-    __version__ = None
-    exec(v.read().strip())
+sys.path.insert(0, os.path.abspath('../../'))
 
+from deirokay.__version__ import __version__  # noqa E501
 
 # -- Project information -----------------------------------------------------
 
@@ -43,11 +43,16 @@
     # Add a link to the Python source code for classes, functions etc.
     'sphinx.ext.viewcode',
     # Add a link to another page of the documentation
-    'sphinx.ext.autosectionlabel'
+    'sphinx.ext.autosectionlabel',
+    # Theme
+    'sphinx_rtd_theme',
+    # Notebook support
+    'nbsphinx'
 ]
 
 # napoleon configuration for numpy docstring
 todo_include_todos = True
+todo_link_only = True
 napoleon_use_ivar = True
 napoleon_use_google_string = False
 napoleon_include_special_with_doc = True
diff --git a/docs/source/contents.rst b/docs/source/contents.rst
index 31fbdb9..f349f8a 100644
--- a/docs/source/contents.rst
+++ b/docs/source/contents.rst
@@ -1,8 +1,38 @@
 .. toctree::
    :hidden:
    :maxdepth: 2
-   :caption: Project Documentation
+   :caption: User Guide
 
-   statements/index
-   api-reference/index
+   self
+   installation
+   getting_started
+   tutorial
+
+.. toctree::
+   :hidden:
+   :maxdepth: 1
+   :caption: Concepts
 
+   concepts/definitions
+   concepts/statements
+
+.. toctree::
+   :hidden:
+   :maxdepth: 2
+   :caption: Main Features
+
+   features/data_reader
+   features/validate
+   features/profile
+   features/template
+   features/deirokay_operator
+
+.. toctree::
+   :hidden:
+   :maxdepth: 2
+   :caption: References
+
+   reference/dtypes
+   reference/statements
+   reference/templates
+   api-reference/index
diff --git a/docs/source/features/data_reader.rst b/docs/source/features/data_reader.rst
new file mode 100644
index 0000000..c9f273f
--- /dev/null
+++ b/docs/source/features/data_reader.rst
@@ -0,0 +1,8 @@
+Data Reader
+===========
+
+The API for `data_reader` can be imported from :ref:`deirokay.data_reader<deirokay.parser.parser.data\\_reader>`.
+
+.. todo::
+
+    Details about Data Reader
diff --git a/docs/source/features/deirokay_operator.rst b/docs/source/features/deirokay_operator.rst
new file mode 100644
index 0000000..d20d13c
--- /dev/null
+++ b/docs/source/features/deirokay_operator.rst
@@ -0,0 +1,8 @@
+Deirokay Airflow Operator
+=========================
+
+The `DeirokayOperator` class can be imported from :ref:`deirokay.airflow.DeirokayOperator<deirokay.airflow.deirokay\\_operator.DeirokayOperator>`.
+
+.. todo::
+
+    Details about DeirokayOperator
\ No newline at end of file
diff --git a/docs/source/features/profile.rst b/docs/source/features/profile.rst
new file mode 100644
index 0000000..22e6bf4
--- /dev/null
+++ b/docs/source/features/profile.rst
@@ -0,0 +1,10 @@
+Profile
+=======
+
+
+The `profile` method can be imported from :ref:`deirokay.profile<deirokay.profiler.profile>`.
+
+
+.. todo::
+
+    Add details about the API for `profile`.
diff --git a/docs/source/features/template.rst b/docs/source/features/template.rst
new file mode 100644
index 0000000..cf8e2bf
--- /dev/null
+++ b/docs/source/features/template.rst
@@ -0,0 +1,81 @@
+Jinja Templating and Statements based on Past Validation Data
+=============================================================
+
+Some validation statements may present dynamic behaviour, maybe
+folowing a natural uptrend or downtrend movement of your data. Suppose
+you expect the number of rows of your data file to possibly fluctuate
++/- 3% around a 7-day moving average. Deirokay allows you to refer to
+past validation data by means of a special function called `series`.
+
+To use it, replace a static value for your statement parameter by a
+templated argument, such as the following:
+
+.. code-block:: yaml
+
+    name: VENDAS
+    items:
+    - scope:
+      - WERKS01
+      - PROD_VENDA
+      #  When the scope has more than one column or has special characters,
+      #  you should provide an `alias` string to refer to this item.
+      alias: werks_prod
+      statements:
+      - type: row_count
+        min: > 
+          '{{ 0.97 * (series("VENDAS", 7).werks_prod.row_count.rows.mean()
+          |default(19, true)) | float }}'
+        max: >
+          '{{ 1.03 * (series("VENDAS", 7).werks_prod.row_count.rows.mean()
+          |default(21, true)) | float }}'
+
+
+The YAML validation document above presents some new
+features. A templated Jinja argument is identified by a pair of double curly
+braces (`{{ }}`) surrounding its content. Deirokay has a special
+method named `series` that you can use to retrieve past data as a
+`pandas.Series` object.
+When declared, `series` receives a validation document name and a
+number of logs to look behind. Next, you should provide a path to a
+statement report value, following the sequence:
+scope (possibly aliased) => statement type name => statement result
+metric name. This returns a `pandas.Series` object you can take any
+calculation from (`mean`, `min`, `max`, etc.).
+
+To illustrate this path, take the templated argument from the YAML
+document above as an example:
+
+.. code-block:: python
+
+    series("VENDAS", 7).werks_prod.row_count.rows.mean()
+
+
+- `series("VENDAS", 7)`: Retrieve the 7 last validation logs for "VENDAS";
+- `werks_prod`: Consider the `werks_prod`-aliased item (could be the scope value itself if it is a string for a single column name);
+- `row_count`: Use statistics from `row_count` statement;
+- `rows`: Use the `rows` metric reported by the selected statement (you should know the statistics reported by your statement).
+- `mean()`: Since the previous object is already a pandas Series, this call gets its mean value.
+
+When your validation results have no history, this call returns `None`.
+In Jinja, you may provide a default value to replace a null variable
+by using `|default(<default value>, true)`.
+
+Finally, here is an example of validation result log. Notice that the
+templates are already rendered before being saved:
+
+.. code-block:: yaml
+
+    name: VENDAS
+    items:
+    - scope:
+      - WERKS01
+      - PROD_VENDA
+      alias: werks_prod
+      statements:
+      - type: row_count
+        min: 1490.0
+        max: 1510.0
+        report:
+          detail:
+            rows: 1500
+          result: pass
diff --git a/docs/source/features/validate.rst b/docs/source/features/validate.rst
new file mode 100644
index 0000000..2e230f2
--- /dev/null
+++ b/docs/source/features/validate.rst
@@ -0,0 +1,9 @@
+=========
+Validator
+=========
+
+The `validate` method can be imported from :ref:`deirokay.validate<deirokay.validator.validate>`.
+
+.. todo::
+
+    Details about validator usage.
diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst
new file mode 100644
index 0000000..52f09e1
--- /dev/null
+++ b/docs/source/getting_started.rst
@@ -0,0 +1,305 @@
+===============
+Getting started
+===============
+
+This page will guide you through some of the basic use cases where
+Deirokay can make your life easier when you are looking for a data quality tool.
+At the end you will be familiar with the most features that comes with Deirokay.
+
+.. note::
+
+    Here are the artifacts if you wish to follow this guide while practicing:
+
+    * :download:`example.csv<assets/getting_started/example.csv>`
+    * :download:`options.json (options document)<assets/getting_started/options.json>`
+    * :download:`assertions.json (validation document)<assets/getting_started/assertions.json>`
+    
+
+Facing data quality issues
+==========================
+
+In any data-driven company, there comes a time when data processing and 
+validation becomes a bottleneck for data products. It is usual for any company 
+to have a lot of data sources such as databases, file systems, object storage systems,
+data generated by certain processes, and so on.
+
+Let's start simple, with a single CSV file, which will show how Deirokay can help you.
+
+Suppose it has the following content:
+
++--------+-----+------------+
+| name   | age | is_married |
++========+=====+============+
+| John   | 55  | True       |
++--------+-----+------------+
+| Mariah | 44  | yes        |
++--------+-----+------------+
+| Carl   |     | false      |
++--------+-----+------------+
+| Josh   | 12  | yes        |
++--------+-----+------------+
+| Anna   | 32  | no         |
++--------+-----+------------+
+| Josh   | 12  | true       |
++--------+-----+------------+
+
+Maybe some of the rules that you want your data to follow are:
+
+* there can't be any duplicate `name` in the table;
+* a minimum of 3 `is_married` values should be True (or truthy);
+* there can't be any null `age` value;
+
+Even before we can apply these rules, we need to be sure that our data is being correctly parsed and converted to the right data types.
+Take the `is_married` column for example: although it semantically represents a boolean value, its values are not consistent, since we have `True`, `yes` and `true` values to identify the same piece of information.
+
+Thus, we need to guarantee that all data is correct and free of possible inconsistences, which would lead to errors in next steps of your data pipeline.
+
+Let's consider another problem when you are working specifically with `pandas` for your data analysis. A known issue when dealing with integer values is that they are parsed as `float` type when there is a null value in the same column (which is converted to `NaN`), preserving this data type when you try to save or export the data:
+
+
+.. code-block:: python
+
+    >>> import pandas
+    >>> pandas.read_csv('example.csv')
+        name    age     is_married
+    0   John    55.0    True
+    1   Mariah  44.0    yes
+    2   Carl    NaN     false
+    3   Josh    12.0    yes
+    4   Anna    32.0    no
+    5   Josh    12.0    true
+    >>> pandas.read_csv('example.csv').dtypes
+    name           object
+    age           float64
+    is_married     object
+    dtype: object
+
+Even though strings are correctly parsed, the integer column became float.
+
+Depending on the dataset you are working with, you will always have to remember to manually fix all sorts of issues caused by the Pandas' builtin datatype inference. If you just decide to read the file forcing the data type as string, you may get rid of part of the problem, but you still need to convert your columns manually to the right data type.
+
+As a solution, Deirokay comes with the `data_reader` feature, which makes it easier for you to customize how your data must be parsed.
+
+
+Parsing data with Deirokay Data Reader
+======================================
+
+For the Data Reader to work, you will need to pass as argument a :ref:`Deirokay Options Document`, which contains your instructions for how the data should be parsed. An example of such a document is shown below:
+
+.. code-block:: json
+
+    {
+        "columns": {
+            "name": {
+                "dtype": "string"
+            },
+            "age": {
+                "dtype": "integer"
+            },
+            "is_married": {
+                "dtype": "boolean",
+                "truthies": ["yes", "true", "True"],
+                "falsies": ["no", "false", "False"]
+            }
+        }
+    }
+
+To be able to use this option document you just need to import from Deirokay the DataReader, and will get a
+pandas dataframe that doesn't have the initial problems:
+
+.. code-block:: python
+
+    >>> from deirokay import data_reader
+    >>> data_reader('example.csv', options='options.json')
+         name   age  is_married
+    0    John    55        True
+    1  Mariah    44        True
+    2    Carl  <NA>       False
+    3    Josh    12        True
+    4    Anna    32       False
+    5    Josh    12        True
+
+The `options` argument also accepts YAML files or `dict` objects directly.
+When passing Deirokay file options as `dict`, you may optionally import the 
+available data types from the `deirokay.enums.DTypes` enumeration class to prevent typos.
+
+Validating data with Deirokay Validator
+=======================================
+
+The next step is to translate the rules we want into a Deirokay :ref:`Validation Item<Validation Items>`:
+
+* there can't be any duplicate `name` in the table;
+
+.. code-block:: json
+
+  {
+    "scope": "name",
+    "statements": [
+      {
+        "type": "unique"
+      }
+    ]
+  }
+
+* a minimum of 3 `is_married` values should be True (or truthy);
+
+.. code-block:: json
+
+  {
+    "scope": "is_married",
+    "statements": [
+      {
+        "type": "contain",
+        "rule": "all",
+        "values": [ true ],
+        "parser": { "dtype": "boolean" },
+        "min_occurrences": 3
+      }
+    ]
+  }
+
+* there can't be any null `age` value;
+
+.. code-block:: json
+
+  {
+    "scope": "age",
+    "statements": [
+      {
+        "type": "not_null"
+      }
+    ]
+  }
+
+Below you can find our final :ref:`Validation Document`:
+
+.. code-block:: json
+
+  {
+      "name": "example",
+      "description": "Getting started with Deirokay",
+      "items": [
+          {
+            "scope": "name",
+            "statements": [
+              {
+                "type": "unique"
+              }
+            ]
+          },
+          {
+            "scope": "is_married",
+            "statements": [
+              {
+                "type": "contain",
+                "rule": "all",
+                "values": [ true ],
+                "parser": { "dtype": "boolean" },
+                "min_occurrences": 3
+              }
+            ]
+          },
+          {
+            "scope": "age",
+            "statements": [
+              {
+                "type": "not_null"
+              }
+            ]
+          }
+      ]
+  }
+
+Finally, to test your dataset against the validation document:
+
+.. code-block:: python
+
+    >>> from deirokay import data_reader
+    >>> df = data_reader('example.csv', options='options.json')
+    >>> validation_result_document = validate(df,
+    ...                                       against='assertions.json',
+    ...                                       raise_exception=False)
+
+The resulting validation document will present the reports for each
+statement, as well as its final result: `pass` or `fail`. You may
+probably want to save your validation result document by passing a path
+to a folder (local or in S3) as `save_to` argument to `validate`. 
+By default, the validation result document will be saved in the same file
+format as the original validation document (you may specify another
+format -- either `json` or `yaml` -- in the `save_format` argument).
+
+Here is the resulting document in JSON format:
+
+.. code-block:: json
+
+  {
+    "name": "example",
+    "description": "Getting started with Deirokay",
+    "items": [
+      {
+        "scope": "name",
+        "statements": [
+          {
+            "type": "unique",
+            "report": {
+              "detail": {
+                "unique_rows": 4,
+                "unique_rows_%": 66.66666666666667
+              },
+              "result": "fail"
+            }
+          }
+        ]
+      },
+      {
+        "scope": "is_married",
+        "statements": [
+          {
+            "type": "contain",
+            "rule": "all",
+            "values": [
+              true
+            ],
+            "parser": {
+              "dtype": "boolean"
+            },
+            "min_occurrences": 3,
+            "report": {
+              "detail": {
+                "values": [
+                  {
+                    "value": true,
+                    "count": 4,
+                    "perc": 66.66666666666667
+                  },
+                  {
+                    "value": false,
+                    "count": 2,
+                    "perc": 33.333333333333336
+                  }
+                ]
+              },
+              "result": "pass"
+            }
+          }
+        ]
+      },
+      {
+        "scope": "age",
+        "statements": [
+          {
+            "type": "not_null",
+            "report": {
+              "detail": {
+                "null_rows": 1,
+                "null_rows_%": 16.666666666666668,
+                "not_null_rows": 5,
+                "not_null_rows_%": 83.33333333333333
+              },
+              "result": "fail"
+            }
+          }
+        ]
+      }
+    ]
+  }
\ No newline at end of file
diff --git a/docs/source/index.rst b/docs/source/index.rst
index ab020b2..0d2198f 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -1,6 +1,61 @@
-========
-Deirokay
-========
+===============
+Deirokay - Home
+===============
 
+Deirokay (*dejɾo'kaj*) is a Python tool for data parsing and data validation.
+Deirokay separates document parsing from validation 
+logic, so that you can create your own statements about your data
+being sure that your source data has been properly parsed.
+
+Why should I use Deirokay?
+===========================
+
+Here are some of the benefits:
+
+* **Simple to use**: instead of writing your data validation in pure Python, you can create your rules in a simple and intuitive language with just a few lines of configuration. This leads to a more human-readable format to express your validations, making it possible for people with different levels of expertize to engage and contribute with it.
+* **Centralized and versionable**: all validations for a same data source are present in a single document, so that you can easily keep track of the rules that have been added, removed or modified with the help of your preferred versioning system;
+* **Shareable configuration**: by having a simple language, you can easily replicate your statements in different validation documents or in different projects, so that you can share the same validation rules with your colleagues;
+* **Logging**: meaningful logs containing relevant statistics that you may use to understand your data, find sources of errors and even reuse in your next validations;
+* **Customizable**: you may create and reuse your own statement classes to express rules tailored to your needs, instead of being limited to the standard library.
+* **Multiple sources**: you can validate data from multiple sources, including files, databases or existing Pandas DataFrames.
+
+Main Features
+=============
+Deirokay comes with several features to help you in your daily 
+data quality problems. The main tools that facilitate your job
+are *Data Reader*, *Data Validation* and *Data Profiling*.
+
+The :ref:`deirokay.data_reader<deirokay.parser.parser.data\\_reader>` method is responsible for parsing and transforming your source data into a trustable DataFrame before it gets validated. You may get a DataFrame from a file, a database or an existing Pandas DataFrame that has not been treated yet, all you need to do is to provide a Deirokay Options Document specifying the parsing options.
+Deirokay Data Reader can also be imported and used in your project to improve your analysis and data manipulation when using Pandas.
+
+
+Data Validation is performed by the :ref:`deirokay.validate<deirokay.validator.validate>` method and works with the help of Deirokay Validation Documents. The latter must be created for each data file and they gather all the validation (using native or custom statements) to be executed against the data. 
+Every validation results in a *Validation Result Document*, which you may choose to log for reference or later use.
+
+Data Profiling is provided by the :ref:`deirokay.profile<deirokay.profiler.profile>` method and is here to help you with automatically generating Validation Documents based on template data. 
+The user is encouraged to modify and supplement the generated document to better meet their requirements.
+
+
+Why use Deirokay instead of other data quality tools?
+======================================================
+
+Although Deirokay is still in its early stages, it is already
+a powerful tool that can be used to validate data from any
+source. It is also a tool that can be used to generate
+validation documents based on a template.
+
+In comparison to the most popular open-source alternative, `Great Expectations <https://docs.greatexpectations.io/docs/>`_, Deirokay was made to allow a more human-readable and more flexible configuration of data validation with its *Validation Document*. Besides that, Great Expectations requires you to open a Jupyter notebook in order to generate a valid Expectations file, and although such a notebook comes already pre-populated, their API is not as easy to understand as Deirokay's. The resulting Expectations file is too verbose and not suitable for people with lower levels of expertise.
+
+Deirokay also provides a better logging system that allows you to reuse past validation results in your next validation process, making it possible to test your data using dynamic parameters such as moving averages.
+
+Deirokay also integrates easily with `Apache Airflow <https://airflow.apache.org/docs/>`_ with its built-in :ref:`DeirokayOperator<deirokay.airflow.deirokay\\_operator.DeirokayOperator>`.
+
+On the other hand, Great Expectations is a well consolidated tool with great community support and already used in a lot of projects. It already has a vast set of builtin expectations (analogous to Deirokay Statements), a cli tool to manage your configurations and also generates *Data Docs* for visualization of your validations (which unfortunately still lacks of offline support, requiring you to be online to download external `.js` and `.css` and be able to view the docs).
+
+
+Documentation TODOs
+===================
+
+.. todolist::
 
 .. include:: contents.rst
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
new file mode 100644
index 0000000..7554a3d
--- /dev/null
+++ b/docs/source/installation.rst
@@ -0,0 +1,25 @@
+Installation
+============
+
+To start using Deirokay, install its package by following the instructions below.
+
+Install Deirokay using the pip command:
+
+.. code-block:: bash
+
+    pip install Deirokay
+
+To include optional dependencies for AWS S3, install:
+
+.. code-block:: bash
+
+    pip install Deirokay[s3]
+
+
+If you wish to contribute for Deirokay development, maybe
+you will want to install a more complete set of packages for
+testing to help you in your development.
+
+.. code-block:: bash
+
+    pip install Deirokay[dev]
diff --git a/docs/source/reference/dtypes.rst b/docs/source/reference/dtypes.rst
new file mode 100644
index 0000000..5211370
--- /dev/null
+++ b/docs/source/reference/dtypes.rst
@@ -0,0 +1,49 @@
+DType Reference
+===============
+
+For Deirokay, as in any other application, data types are an important 
+concept. So, when you are parsing your data with Deirokay, there are 
+a few datatypes that you might want to make use of.
+
+All available datatypes are defined in the :ref:`deirokay.enums.DTypes` enumeration class.
+Each Deirokay Dtype is associated to a Treater class from :ref:`deirokay.parser.treaters`, which is responsible for parsing and converting the data to the specified datatype.
+
+Below you will find a list of supported datatypes and available parameters:
+
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| DTypes     | String-like alias | Supported Arguments  | Default             | Argument Description                   |
++============+===================+======================+=====================+========================================+
+| All DTypes |                   | nullable             | True                | Values can be null                     |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| All DTypes |                   | unique               | False               | Values should be unique                |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| All DTypes |                   | rename               | None                | Rename column                          |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| INTEGER    | 'integer'         | thousand_sep         | None                | Thousand separator (e.g., "1,988")     |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| FLOAT      | 'float'           | thousand_sep         | None                | Thousand separator (e.g., "1,988")     |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| FLOAT      | 'float'           | decimal_sep          | '.'                 | Decimal separator (e.g., "3.14")       |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| DECIMAL    | 'decimal'         | decimal_sep          | '.'                 | Decimal separator (e.g., "3.14")       |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| DECIMAL    | 'decimal'         | thousand_sep         | None                | Thousand separator (e.g., "1,988")     |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| DECIMAL    | 'decimal'         | decimal_places       | None                | Decimal places (e.g., 2 for "1.25")    |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| BOOLEAN    | 'boolean'         | truthies             | ['true','True']     | Values taken as True                   |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| BOOLEAN    | 'boolean'         | falsies              | ['false', 'False']  | Values taken as False                  |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| BOOLEAN    | 'boolean'         | ignore_case          | False               | Ignore case when evaluating True/False |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| BOOLEAN    | 'boolean'         | default_value        | None                | Value to use if not truthy nor falsy   |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| DATETIME   | 'datetime'        | format               | '%Y-%m-%d %H:%M:%S' | Date Time format                       |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| TIME       | 'time'            | format               | '%H:%M:%S'          | Time format                            |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| DATE       | 'date'            | format               | '%Y-%m-%d'          | Date format                            |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
+| STRING     | 'string'          | treat_null_as        | None                | Value to replace when null (e.g., "")  |
++------------+-------------------+----------------------+---------------------+----------------------------------------+
diff --git a/docs/source/reference/statements.rst b/docs/source/reference/statements.rst
new file mode 100644
index 0000000..47302ea
--- /dev/null
+++ b/docs/source/reference/statements.rst
@@ -0,0 +1,8 @@
+Statement Reference
+===================
+
+Deirokay statements can all be found in :ref:`deirokay.statements` page.
+
+.. todo::
+
+    Add more information about the statements.
diff --git a/docs/source/reference/templates.rst b/docs/source/reference/templates.rst
new file mode 100644
index 0000000..18e553c
--- /dev/null
+++ b/docs/source/reference/templates.rst
@@ -0,0 +1,6 @@
+Template Reference
+==================
+
+.. todo::
+
+    - Add a template reference
diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst
new file mode 100644
index 0000000..49b5b40
--- /dev/null
+++ b/docs/source/tutorial.rst
@@ -0,0 +1,7 @@
+========
+Tutorial
+========
+
+.. todo::
+
+    * Add a tutorial section.
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 62decdc..2f29052 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
 pandas~=1.3
 jq~=1.2
 Jinja2~=3.0
+PyYaml
diff --git a/sdf.json b/sdf.json
new file mode 100644
index 0000000..653ba3c
--- /dev/null
+++ b/sdf.json
@@ -0,0 +1,27 @@
+{
+    "name": "example",
+    "descripiton": "just a statement test",
+    "items": [
+        {
+            "scope": "name",
+            "statements": [
+                {
+                    "type": "row_count",
+                    "distinct": true,
+                    "min": 2
+                },
+                {
+                    "type": "unique"
+                }
+            ]
+        },
+        {
+            "scope": "age",
+            "statements": [
+                {
+                    "type": "not_null"
+                }
+            ]
+        }
+    ]
+}
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..976ba02
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,2 @@
+[mypy]
+ignore_missing_imports = True


Test patch:
diff --git a/tests/statements/test_not_null.py b/tests/statements/test_not_null.py
index c288a3d..dc1a5d1 100644
--- a/tests/statements/test_not_null.py
+++ b/tests/statements/test_not_null.py
@@ -1,4 +1,6 @@
-from deirokay import data_reader, validate
+from pprint import pprint
+
+from deirokay import data_reader, profile, validate
 
 
 def test_not_null_statement():
@@ -32,3 +34,23 @@ def test_not_null_statement():
     }
 
     validate(df, against=assertions)
+
+
+def test_profile_wont_generate_useless_statement():
+    """Prevent statement generation when `at_least_%` is 0."""
+    df = data_reader(
+        'tests/transactions_sample.csv',
+        options='tests/options.json'
+    )
+
+    validation_doc = profile(df, 'sample')
+    pprint(validation_doc)
+
+    # Check that the statement was not generated for "EMPTY" column
+    scope_empty = next(filter(
+        lambda x: x['scope'] == 'EMPTY', validation_doc['items']
+    ))
+    assert len([
+        stmt for stmt in scope_empty['statements']
+        if stmt['type'] == 'not_null'
+    ]) == 0
diff --git a/tests/statements/test_unique.py b/tests/statements/test_unique.py
index e69de29..9d19a36 100644
--- a/tests/statements/test_unique.py
+++ b/tests/statements/test_unique.py
@@ -0,0 +1,25 @@
+from pprint import pprint
+
+from deirokay import data_reader, profile
+
+
+def test_profile_wont_generate_useless_statement():
+    """Prevent statement generation when `at_least_%` is 0."""
+    df = data_reader(
+        'tests/transactions_sample.csv',
+        options='tests/options.json'
+    )
+
+    validation_doc = profile(df, 'sample')
+    pprint(validation_doc)
+
+    # Check that the statement was not generated for
+    # "DT_OPERACAO01" column
+    scope_empty = next(filter(
+        lambda x: x['scope'] == 'DT_OPERACAO01',
+        validation_doc['items']
+    ))
+    assert len([
+        stmt for stmt in scope_empty['statements']
+        if stmt['type'] == 'unique'
+    ]) == 0
diff --git a/tests/test_data_profiling.py b/tests/test_data_profiling.py
index 3326c1a..92df290 100644
--- a/tests/test_data_profiling.py
+++ b/tests/test_data_profiling.py
@@ -1,3 +1,5 @@
+from pprint import pprint
+
 from deirokay import data_reader, profile, validate
 
 
@@ -9,4 +11,6 @@ def test_profiling():
     )
 
     validation_document = profile(df, 'transactions_sample')
+    pprint(validation_document)
+
     validate(df, against=validation_document)
diff --git a/tests/test_data_validation.py b/tests/test_data_validation.py
index 0bfb03a..c2be63d 100644
--- a/tests/test_data_validation.py
+++ b/tests/test_data_validation.py
@@ -81,7 +81,7 @@ def test_custom_statement():
 @pytest.fixture
 def prepare_s3_custom_statement():
     local_path = 'tests/custom_statement.py'
-    s3_path = 's3://bigdata-momo/temp/custom_statement.py'
+    s3_path = 's3://deirokay/custom_statement.py'
     bucket, key = split_s3_path(s3_path)
 
     import boto3
diff --git a/tests/test_fs.py b/tests/test_fs.py
new file mode 100644
index 0000000..e77907f
--- /dev/null
+++ b/tests/test_fs.py
@@ -0,0 +1,25 @@
+import pytest
+
+from deirokay.fs import fs_factory
+
+
+@pytest.mark.skip(reason='Need AWS credentials')
+@pytest.mark.parametrize('src_path, dst_path', [
+    ['tests/options.json', 's3://deirokay/options.yaml'],
+    ['tests/options.json', 's3://deirokay/options.json'],
+    ['tests/options.yaml', 's3://deirokay/options.yaml'],
+    ['tests/options.yaml', 's3://deirokay/options.json'],
+    ['s3://deirokay/options.json', '/tmp/options.yaml'],
+    ['s3://deirokay/options.json', '/tmp/options.json'],
+    ['s3://deirokay/options.yaml', '/tmp/options.yaml'],
+    ['s3://deirokay/options.yaml', '/tmp/options.json'],
+])
+def test_fs_local(src_path, dst_path):
+    src = fs_factory(src_path)
+    dst = fs_factory(dst_path)
+
+    doc = src.read_dict()
+    print(doc)
+    dst.write_dict(doc)
+
+    assert src.read_dict() == dst.read_dict()
diff --git a/tests/test_history_validation.py b/tests/test_history_validation.py
index a3e5874..f9c33f0 100644
--- a/tests/test_history_validation.py
+++ b/tests/test_history_validation.py
@@ -57,7 +57,7 @@ def delete_s3_prefix(bucket, prefix):
                     .get('Contents', [])):
             s3.delete_object(Bucket=bucket, Key=obj['Key'])
 
-    s3_path = 's3://bigdata-momo/temp/deirokay/'
+    s3_path = 's3://deirokay/'
     bucket, prefix = split_s3_path(s3_path)
     delete_s3_prefix(bucket, prefix)
     yield s3_path