diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f1ee3c9541269e62b5d186825d155ee2810e8413
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,62 @@
+workflow:
+  rules:
+    - if: $CI_MERGE_REQUEST_ID            # Execute jobs in merge request context
+    - if: $CI_COMMIT_BRANCH == 'develop'  # Execute jobs when a new commit is pushed to develop branch
+
+image: $CI_REGISTRY/orfeotoolbox/otb-build-env/otb-ubuntu-native-develop:20.04
+
+stages:
+  - Static Analysis
+  - Test
+
+# --------------------------------- Static analysis ---------------------------------
+
+.static_analysis_base:
+  stage: Static Analysis
+  tags:
+    - light
+  allow_failure: true
+
+flake8:
+  extends: .static_analysis_base
+  script:
+    - pip install flake8 && python3 -m flake8 --max-line-length=120 $PWD/pyotb --ignore=F403,E402,F401,W503,W504
+
+pylint:
+  extends: .static_analysis_base
+  script:
+    - pip install pylint && pylint --max-line-length=120 $PWD/pyotb --disable=too-many-nested-blocks,too-many-locals,too-many-statements,too-few-public-methods,too-many-instance-attributes,too-many-arguments,invalid-name,fixme,too-many-return-statements,too-many-lines,too-many-branches,import-outside-toplevel,wrong-import-position,wrong-import-order,import-error
+
+codespell:
+  extends: .static_analysis_base
+  script:
+    - pip install codespell && codespell --skip="*.png,*.jpg,*git/lfs*"
+
+pydocstyle:
+  extends: .static_analysis_base
+  script:
+    - pip install pydocstyle && pydocstyle $PWD/pyotb --ignore=D400,D403,D213,D212,D202,D203,D200,D210,D205,D401,D404,D204,D415
+
+# --------------------------------- Test ---------------------------------
+
+.test_base:
+  stage: Test
+  tags:
+    - light
+  allow_failure: false
+  before_script:
+    - pip install .
+    - export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/opt/otb/lib/
+    - ls /opt/otb/lib/
+    - export OTB_APPLICATION_PATH=/opt/otb/lib/otb/applications/
+
+import_pyotb:
+  extends: .test_base
+  script:
+    - python3 -c "import pyotb"
+
+compute_ndvi:
+  extends: .test_base
+  script:
+    - cd tests
+    - python3 ndvi_test.py
diff --git a/doc/MISC.md b/doc/MISC.md
index 4cdf0f56899b9a9b62157fabbc4e97306634e192..8da0eeace3eeb71cd0c20e92a88758fa5c072da2 100644
--- a/doc/MISC.md
+++ b/doc/MISC.md
@@ -1,4 +1,4 @@
-## Miscellaneous: Work with images with differents footprints / resolutions
+## Miscellaneous: Work with images with different footprints / resolutions
 OrfeoToolBox provides a handy `Superimpose` application that enables the projection of an image into the geometry of another one.
 
 In pyotb, a function has been created to handle more than 2 images.
@@ -35,4 +35,4 @@ print(s2_image.shape)  # (657, 520, 4)
 print(vhr_image.shape)  # (657, 520, 3)
 print(labels.shape)  # (657, 520, 1)
 # Then we can do whichever computations with s2_image, vhr_image, labels
-```
\ No newline at end of file
+```
diff --git a/pyotb/__init__.py b/pyotb/__init__.py
index 8a2994661fa57dc2b62d3f8728fd53fe18c828ae..8ae8d7d7260fd6eec03c07bfbdb0e8c000799c5c 100644
--- a/pyotb/__init__.py
+++ b/pyotb/__init__.py
@@ -1,7 +1,10 @@
 # -*- coding: utf-8 -*-
+"""
+This module provides convenient python wrapping of otbApplications
+"""
 __version__ = "1.3"
 
 from .apps import *
 from .core import App, Output, Input, get_nbchannels, get_pixel_type
-from .functions import *
+from .functions import *  # pylint: disable=redefined-builtin
 from .tools import logger
diff --git a/pyotb/apps.py b/pyotb/apps.py
index 70b924782e422f29acecc9ea32b57bc6d2c5e613..243131e1a02e3a5eafd978fee53284e45a4e0670 100644
--- a/pyotb/apps.py
+++ b/pyotb/apps.py
@@ -12,7 +12,7 @@ from .tools import logger, find_otb, set_gdal_vars
 OTB_ROOT, OTB_APPLICATION_PATH = find_otb()
 
 if not OTB_ROOT:
-    sys.exit("Can't run without OTB. Exiting.")
+    raise EnvironmentError("Can't run without OTB installed.")
 
 set_gdal_vars(OTB_ROOT)
 
@@ -28,7 +28,7 @@ def get_available_applications(as_subprocess=False):
     """
     app_list = ()
     if as_subprocess and sys.executable and hasattr(sys, 'ps1'):
-        # Currently there is an incompatibility between OTBTF and Tensorflow that causes segfault
+        # Currently, there is an incompatibility between OTBTF and Tensorflow that causes segfault
         # when OTBTF apps are used in a script where tensorflow has already been imported.
         # See https://github.com/remicres/otbtf/issues/28
         # Thus, we run this piece of code in a clean independent `subprocess` that doesn't interact with Tensorflow
@@ -36,14 +36,14 @@ def get_available_applications(as_subprocess=False):
         if "PYTHONPATH" not in env:
             env["PYTHONPATH"] = ""
         env["PYTHONPATH"] = ":" + str(Path(otb.__file__).parent)
-        env["OTB_LOGGER_LEVEL"] = "CRITICAL"  # in order to supress warnings while listing applications
+        env["OTB_LOGGER_LEVEL"] = "CRITICAL"  # in order to suppress warnings while listing applications
         pycmd = "import otbApplication; print(otbApplication.Registry.GetAvailableApplications())"
         cmd_args = [sys.executable, "-c", pycmd]
         try:
             import subprocess
             params = {"env": env, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
             with subprocess.Popen(cmd_args, **params) as p:
-                logger.debug(f"{' '.join(cmd_args[:-1])} '{pycmd}'")
+                logger.debug("%s %s", ' '.join(cmd_args[:-1]), pycmd)
                 stdout, stderr = p.communicate()
                 stdout, stderr = stdout.decode(), stderr.decode()
                 # ast.literal_eval is secure and will raise more handy Exceptions than eval
@@ -54,7 +54,7 @@ def get_available_applications(as_subprocess=False):
         except subprocess.SubprocessError:
             logger.debug("Failed to call subprocess")
         except (ValueError, SyntaxError, AssertionError):
-            logger.debug("Failed to decode output or convert to tuple :" + f"\nstdout={stdout}\nstderr={stderr}")
+            logger.debug("Failed to decode output or convert to tuple:\nstdout=%s\nstderr=%s", stdout, stderr)
         if not app_list:
             logger.info("Failed to list applications in an independent process. Falling back to local otb import")
 
@@ -64,7 +64,7 @@ def get_available_applications(as_subprocess=False):
         logger.warning("Unable to load applications. Set env variable OTB_APPLICATION_PATH then try again")
         return ()
 
-    logger.info(f"Successfully loaded {len(app_list)} OTB applications")
+    logger.info("Successfully loaded %s OTB applications", len(app_list))
     return app_list
 
 
@@ -85,15 +85,22 @@ class {name}(App):
 """
 for _app in AVAILABLE_APPLICATIONS:
     # Default behavior for any OTB application
-    exec(_code_template.format(name=_app))
+    exec(_code_template.format(name=_app))  # pylint: disable=exec-used
 
     # Customize the behavior for TensorflowModelServe application. The user doesn't need to set the env variable
     # `OTB_TF_NSOURCES`, it is handled in pyotb
     if _app == 'TensorflowModelServe':
         class TensorflowModelServe(App):
-            def set_nb_sources(self, *args, n_sources=None):
+            """
+            Helper for OTBTF
+            """
+            @staticmethod
+            def set_nb_sources(*args, n_sources=None):
                 """
                 Set the number of sources of TensorflowModelServe. Can be either user-defined or deduced from the args
+                :param args: arguments
+                :param n_sources: number of sources. Default is None (resolves the number of sources based on the
+                content of the dict passed in args, where some 'source' str is found)
                 """
                 if n_sources:
                     os.environ['OTB_TF_NSOURCES'] = str(int(n_sources))
@@ -104,5 +111,11 @@ for _app in AVAILABLE_APPLICATIONS:
                     os.environ['OTB_TF_NSOURCES'] = str(n_sources)
 
             def __init__(self, *args, n_sources=None, **kwargs):
+                """
+                :param args: args
+                :param n_sources: number of sources. Default is None (resolves the number of sources based on the
+                content of the dict passed in args, where some 'source' str is found)
+                :param kwargs: kwargs
+                """
                 self.set_nb_sources(*args, n_sources=n_sources)
                 super().__init__('TensorflowModelServe', *args, **kwargs)
diff --git a/pyotb/core.py b/pyotb/core.py
index 39c12cb3ad17a58e4497353dc666917dd1c11637..f5b9d341634a4dee5c9b0df7bd874acc5151526f 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -1,4 +1,7 @@
 # -*- coding: utf-8 -*-
+"""
+This module is the core of pyotb
+"""
 import logging
 from abc import ABC
 from pathlib import Path
@@ -14,6 +17,7 @@ class otbObject(ABC):
     Abstract class that gathers common operations for any OTB in-memory raster.
     All child of this class must have an `app` attribute that is an OTB application.
     """
+
     @property
     def shape(self):
         """
@@ -39,7 +43,7 @@ class otbObject(ABC):
                                    Will be used for all outputs
         :param pixel_type: Can be : - dictionary {output_parameter_key: pixeltype} when specifying for several outputs
                                     - str (e.g. 'uint16') or otbApplication.ImagePixelType_... When there are several
-                                      outputs, all outputs are writen with this unique type
+                                      outputs, all outputs are written with this unique type
                            Valid pixel types are double, float, uint8, uint16, uint32, int16, int32, float, double,
                            cint16, cint32, cfloat, cdouble.
         :param is_intermediate: WARNING: not fully tested. whether the raster we want to write is intermediate,
@@ -52,7 +56,7 @@ class otbObject(ABC):
             if isinstance(arg, dict):
                 kwargs.update(arg)
             elif isinstance(arg, str) and kwargs:
-                logger.warning(f'{self.name}: Keyword arguments specified, ignoring argument "{arg}"')
+                logger.warning('%s: Keyword arguments specified, ignoring argument "%s"', self.name, arg)
             elif isinstance(arg, str):
                 if hasattr(self, 'output_parameter_key'):  # this is for Output, Operation...
                     output_parameter_key = self.output_parameter_key
@@ -85,8 +89,8 @@ class otbObject(ABC):
                     if not out_fn.endswith('?'):
                         out_fn += "?"
                     out_fn += filename_extension
-                    logger.info(f'{self.name}: Using extended filename for output.')
-                logger.info(f'{self.name}: write output file "{output_parameter_key}" to {out_fn}')
+                    logger.info('%s: Using extended filename for output.', self.name)
+                logger.info('%s: write output file "%s" to %s', self.name, output_parameter_key, out_fn)
                 self.app.SetParameterString(output_parameter_key, out_fn)
 
                 if output_parameter_key in pixel_types:
@@ -103,124 +107,206 @@ class otbObject(ABC):
         - slicing, i.e. selecting ROI/bands. For example, selecting first 3 bands: object[:, :, :3]
                                                           selecting bands 1, 2 & 5 : object[:, :, [0, 1, 4]]
                                                           selecting 1000x1000 subset : object[:1000, :1000]
+        :param key: attribute key
+        :return: attribute or Slicer
         """
         # Accessing string attributes
         if isinstance(key, str):
             return self.__dict__.get(key)
 
         # Slicing
-        elif not isinstance(key, tuple) or (isinstance(key, tuple) and len(key) < 2):
-            raise ValueError('`{}`cannot be interpreted as valid slicing. Slicing should be 2D or 3D.'.format(key))
-        elif isinstance(key, tuple) and len(key) == 2:
-            # adding a 3rd dimension
+        if not isinstance(key, tuple) or (isinstance(key, tuple) and (len(key) < 2 or len(key) > 3)):
+            raise ValueError(f'"{key}"cannot be interpreted as valid slicing. Slicing should be 2D or 3D.')
+        if isinstance(key, tuple) and len(key) == 2:
+            # Adding a 3rd dimension
             key = key + (slice(None, None, None),)
         (cols, rows, channels) = key
         return Slicer(self, rows, cols, channels)
 
     def __getattr__(self, name):
         """
-        This method is called when the default attribute access fails. We choose to try to access the attribute of
-        self.app. Thus, any method of otbApplication can be used transparently on otbObject objects,
-        e.g. SetParameterOutputImagePixelType() or ExportImage() work
+        This method is called when the default attribute access fails. We try 2 things:
+        - 1) access the attribute `name` of self.app. Thus, any method of otbApplication can be used transparently on
+             otbObject objects, e.g. SetParameterOutputImagePixelType() or ExportImage() work
+        - 2) if the previous failed, we try to access the ParameterValue of `name`. It is useful for applications such
+             as PixelValue or CompareImages to access scalar "output" parameters
+        :param name: attribute name
+        :return: attribute
         """
-        return getattr(self.app, name)
+        try:
+            res = getattr(self.app, name)
+        except AttributeError:
+            try:
+                # TODO: how to access scalar output parameter whose key contains a `.` ?
+                res = self.app.GetParameterValue(name)
+            except RuntimeError as e:
+                raise AttributeError(f'{self.name}: Could not find attribute `{name}`') from e
+
+        return res
 
     def __add__(self, other):
-        """Overrides the default addition and flavours it with BandMathX"""
+        """
+        Overrides the default addition and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self + other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return Operation('+', self, other)
 
     def __sub__(self, other):
-        """Overrides the default subtraction and flavours it with BandMathX"""
+        """
+        Overrides the default subtraction and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self - other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return Operation('-', self, other)
 
     def __mul__(self, other):
-        """Overrides the default subtraction and flavours it with BandMathX"""
+        """
+        Overrides the default subtraction and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self * other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return Operation('*', self, other)
 
     def __truediv__(self, other):
-        """Overrides the default subtraction and flavours it with BandMathX"""
+        """
+        Overrides the default subtraction and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self / other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return Operation('/', self, other)
 
     def __radd__(self, other):
-        """Overrides the default reverse addition and flavours it with BandMathX"""
+        """
+        Overrides the default reverse addition and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: other + self
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return Operation('+', other, self)
 
     def __rsub__(self, other):
-        """Overrides the default subtraction and flavours it with BandMathX"""
+        """
+        Overrides the default subtraction and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: other - self
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return Operation('-', other, self)
 
     def __rmul__(self, other):
-        """Overrides the default multiplication and flavours it with BandMathX"""
+        """
+        Overrides the default multiplication and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: other * self
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return Operation('*', other, self)
 
     def __rtruediv__(self, other):
-        """Overrides the default division and flavours it with BandMathX"""
+        """
+        Overrides the default division and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: other / self
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return Operation('/', other, self)
 
     def __abs__(self):
-        """Overrides the default abs operator and flavours it with BandMathX"""
+        """
+        Overrides the default abs operator and flavours it with BandMathX
+        :return: abs(self)
+        """
         return Operation('abs', self)
 
     def __ge__(self, other):
-        """Overrides the default greater or equal and flavours it with BandMathX"""
+        """
+        Overrides the default greater or equal and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self >= other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return logicalOperation('>=', self, other)
 
     def __le__(self, other):
-        """Overrides the default less or equal and flavours it with BandMathX"""
+        """
+        Overrides the default less or equal and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self <= other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return logicalOperation('<=', self, other)
 
     def __gt__(self, other):
-        """Overrides the default greater operator and flavours it with BandMathX"""
+        """
+        Overrides the default greater operator and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self > other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return logicalOperation('>', self, other)
 
     def __lt__(self, other):
-        """Overrides the default less operator and flavours it with BandMathX"""
+        """
+        Overrides the default less operator and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self < other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return logicalOperation('<', self, other)
 
     def __eq__(self, other):
-        """Overrides the default eq operator and flavours it with BandMathX"""
+        """
+        Overrides the default eq operator and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self == other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return logicalOperation('==', self, other)
 
     def __ne__(self, other):
-        """Overrides the default different operator and flavours it with BandMathX"""
+        """
+        Overrides the default different operator and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self != other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return logicalOperation('!=', self, other)
 
     def __or__(self, other):
-        """Overrides the default or operator and flavours it with BandMathX"""
+        """
+        Overrides the default or operator and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self || other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return logicalOperation('||', self, other)
 
     def __and__(self, other):
-        """Overrides the default and operator and flavours it with BandMathX"""
+        """
+        Overrides the default and operator and flavours it with BandMathX
+        :param other: Another otbObject
+        :return: self && other
+        """
         if isinstance(other, (np.ndarray, np.generic)):
             return NotImplemented  # this enables to fallback on numpy emulation thanks to __array_ufunc__
         return logicalOperation('&&', self, other)
@@ -229,11 +315,16 @@ class otbObject(ABC):
     #  e.g. __pow__... cf https://docs.python.org/3/reference/datamodel.html#emulating-numeric-types
 
     def __hash__(self):
+        """
+        :return: self hash
+        """
         return id(self)
 
     def to_numpy(self, propagate_pixel_type=False):
         """
         Export a pyotb object to numpy array
+        :param propagate_pixel_type: when set to True, the numpy array is created with the same pixel type as
+        the otbObject first output. Default is False.
         :return: a numpy array
         """
         if hasattr(self, 'output_parameter_key'):  # this is for Input, Output, Operation, Slicer
@@ -272,15 +363,15 @@ class otbObject(ABC):
             # Converting potential pyotb inputs to arrays
             arrays = []
             image_dic = None
-            for input in inputs:
-                if isinstance(input, (float, int, np.ndarray, np.generic)):
-                    arrays.append(input)
-                elif isinstance(input, (App, Input, Output, Operation, Slicer)):
+            for inp in inputs:
+                if isinstance(inp, (float, int, np.ndarray, np.generic)):
+                    arrays.append(inp)
+                elif isinstance(inp, (App, Input, Output, Operation, Slicer)):
                     if hasattr(self, 'output_parameter_key'):  # this is for Input, Output, Operation, Slicer
                         output_parameter_key = self.output_parameter_key
                     else:  # this is for App
                         output_parameter_key = self.output_parameters_keys[0]
-                    image_dic = input.app.ExportImage(output_parameter_key)
+                    image_dic = inp.app.ExportImage(output_parameter_key)
                     array = image_dic['array']
                     arrays.append(array)
                 else:
@@ -301,30 +392,31 @@ class otbObject(ABC):
             app.Execute()
             return app
 
-        else:
-            return NotImplemented
+        return NotImplemented
 
 
 class Slicer(otbObject):
-    """Slicer objects i.e. when we call something like raster[:, :, 2] from Python"""
+    """
+    Slicer objects i.e. when we call something like raster[:, :, 2] from Python
+    """
 
-    def __init__(self, input, rows, cols, channels):
+    def __init__(self, x, rows, cols, channels):
         """
         Create a slicer object, that can be used directly for writing or inside a BandMath :
         - an ExtractROI app that handles extracting bands and ROI and can be written to disk or used in pipelines
         - in case the user only wants to extract one band, an expression such as "im1b#"
-        :param input:
-        :param rows:
-        :param cols:
-        :param channels:
+        :param x: input
+        :param rows: rows slicing (e.g. 100:2000)
+        :param cols: columns slicing (e.g. 100:2000)
+        :param channels: channels, can be slicing, list or int
         """
         # Initialize the app that will be used for writing the slicer
-        app = App('ExtractROI', {"in": input, 'mode': 'extent'}, propagate_pixel_type=True)
+        app = App('ExtractROI', {"in": x, 'mode': 'extent'}, propagate_pixel_type=True)
         self.output_parameter_key = 'out'
         self.name = 'Slicer'
 
         # Channel slicing
-        nb_channels = get_nbchannels(input)
+        nb_channels = get_nbchannels(x)
         if channels != slice(None, None, None):
             # if needed, converting int to list
             if isinstance(channels, int):
@@ -371,35 +463,49 @@ class Slicer(otbObject):
         # These are some attributes when the user simply wants to extract *one* band to be used in an Operation
         if not spatial_slicing and isinstance(channels, list) and len(channels) == 1:
             self.one_band_sliced = channels[0] + 1  # OTB convention: channels start at 1
-            self.input = input
+            self.input = x
 
 
 class Input(otbObject):
     """
     Class for transforming a filepath to pyOTB object
     """
+
     def __init__(self, filepath):
+        """
+        :param filepath: raster file path
+        """
         self.app = App('ExtractROI', filepath, propagate_pixel_type=True).app
         self.output_parameter_key = 'out'
         self.filepath = filepath
         self.name = f'Input from {filepath}'
 
     def __str__(self):
-        return '<pyotb.Input object from {}>'.format(self.filepath)
+        """
+        :return: a str
+        """
+        return f'<pyotb.Input object from {self.filepath}>'
 
 
 class Output(otbObject):
     """
     Class for output of an app
     """
+
     def __init__(self, app, output_parameter_key):
+        """
+        :param app: The OTB application
+        :param output_parameter_key: Output parameter key
+        """
         self.app = app  # keeping a reference of the OTB app
         self.output_parameter_key = output_parameter_key
         self.name = f'Output {output_parameter_key} from {self.app.GetName()}'
 
     def __str__(self):
-        return '<pyotb.Output {} object, id {}>'.format(self.app.GetName(), id(self))
-
+        """
+        :return: a str
+        """
+        return f'<pyotb.Output {self.app.GetName()} object, id {id(self)}>'
 
 
 class App(otbObject):
@@ -407,28 +513,42 @@ class App(otbObject):
     Class of an OTB app
     """
     _name = ""
+
     @property
     def name(self):
-        """Property to store a custom app name that will be displayed in logs"""
+        """
+        :return: name or appname
+        """
         return self._name or self.appname
 
     @name.setter
     def name(self, val):
+        """
+        set name
+        :param val: new name
+        """
         self._name = val
 
     @property
     def finished(self):
-        """Property to store whether or not App has been executed"""
+        """
+        Property to store whether App has been executed
+        :return: True or False
+        """
         if self._finished and self.find_output():
             return True
         return False
 
     @finished.setter
     def finished(self, val):
-        # This will only store if app has been excuted, then find_output() is called when accessing the property
+        """
+        This will only store if app has been executed, then find_output() is called when accessing the property
+        :param val: new status
+        """
         self._finished = val
 
-    def __init__(self, appname, *args, execute=True, image_dic=None, otb_stdout=True, propagate_pixel_type=False, **kwargs):
+    def __init__(self, appname, *args, execute=True, image_dic=None, otb_stdout=True, propagate_pixel_type=False,
+                 **kwargs):
         """
         Enables to run an otb app as a oneliner. Handles in-memory connection between apps
         :param appname: name of the app, e.g. 'Smoothing'
@@ -460,7 +580,7 @@ class App(otbObject):
         if args or kwargs:
             self.set_parameters(*args, **kwargs)
         else:
-            logger.warning(f"{self.name}: No parameters where provided. Use App.set_parameters() then App.execute()")
+            logger.warning("%s: No parameters where provided. Use App.set_parameters() then App.execute()", self.name)
             execute = False
         # Run app, write output if needed, update `finished` property
         if execute:
@@ -479,7 +599,7 @@ class App(otbObject):
         Execute with appropriate App method and outputs to disk if needed"
         :return: boolean flag that indicate if command executed with success
         """
-        logger.debug(f"{self.name}: run execute() with parameters={self.parameters}")
+        logger.debug("%s: run execute() with parameters=%s", self.name, self.parameters)
         try:
             if self.__with_output():
                 self.app.ExecuteAndWriteOutput()
@@ -487,7 +607,7 @@ class App(otbObject):
             self.finished = True
         except (RuntimeError, FileNotFoundError) as e:
             raise Exception(f'{self.name}: error during during app execution') from e
-        logger.debug(f"{self.name}: execution succeeded")
+        logger.debug("%s: execution succeeded", self.name)
         return self.finished
 
     def find_output(self):
@@ -496,7 +616,7 @@ class App(otbObject):
         :return: list of files found on disk
         """
         if not self.__with_output():
-            return
+            return None
         files = []
         missing = []
         for param in self.output_parameters_keys:
@@ -507,15 +627,15 @@ class App(otbObject):
                 missing.append(filename)
         if missing:
             for filename in missing:
-                logger.error(f"{self.name}: execution seems to have failed, {filename} does not exist")
-                #raise FileNotFoundError(filename)
+                logger.error("%s: execution seems to have failed, %s does not exist", self.name, filename)
+                # raise FileNotFoundError(filename)
         return files
 
     def clear(self, parameters=True, memory=False):
         """
-        Free ressources and reset App state
-        :param parameters: to clear settings dictionnary
-        :param memory: to free app ressources in memory
+        Free resources and reset App state
+        :param parameters: to clear settings dictionary
+        :param memory: to free app resources in memory
         """
         if parameters:
             for p in self.parameters:
@@ -524,6 +644,9 @@ class App(otbObject):
             self.app.FreeRessources()
 
     def get_output_parameters_keys(self):
+        """
+        :return: output parameters keys
+        """
         return [param for param in self.app.GetParametersKeys()
                 if self.app.GetParameterType(param) == otb.ParameterType_OutputImage]
 
@@ -582,35 +705,37 @@ class App(otbObject):
             self.app.ConnectImage(param, obj.app, obj.output_parameter_key)
         elif isinstance(obj, otb.Application):  # this is for backward comp with plain OTB
             outparamkey = [param for param in obj.GetParametersKeys()
-                            if obj.GetParameterType(param) == otb.ParameterType_OutputImage][0]
+                           if obj.GetParameterType(param) == otb.ParameterType_OutputImage][0]
             self.app.ConnectImage(param, obj, outparamkey)
         elif param == 'ram':  # SetParameterValue in OTB<7.4 doesn't work for ram parameter cf gitlab OTB issue 2200
             self.app.SetParameterInt('ram', int(obj))
         elif not isinstance(obj, list):  # any other parameters (str, int...)
             self.app.SetParameterValue(param, obj)
-        ### Parameter list cases
+        # Parameter list cases
         # Images list
         elif self.__is_key_images_list(param):
             # To enable possible in-memory connections, we go through the list and set the parameters one by one
-            for input in obj:
-                if isinstance(input, App):
-                    self.app.ConnectImage(param, input.app, input.output_parameters_keys[0])
-                elif isinstance(input, (Output, Input, Operation, Slicer)):
-                    self.app.ConnectImage(param, input.app, input.output_parameter_key)
-                elif isinstance(input, otb.Application):  # this is for backward comp with plain OTB
-                    outparamkey = [param for param in input.GetParametersKeys() if
-                                    input.GetParameterType(param) == otb.ParameterType_OutputImage][0]
-                    self.app.ConnectImage(param, input, outparamkey)
+            for inp in obj:
+                if isinstance(inp, App):
+                    self.app.ConnectImage(param, inp.app, inp.output_parameters_keys[0])
+                elif isinstance(inp, (Output, Input, Operation, Slicer)):
+                    self.app.ConnectImage(param, inp.app, inp.output_parameter_key)
+                elif isinstance(inp, otb.Application):  # this is for backward comp with plain OTB
+                    outparamkey = [param for param in inp.GetParametersKeys() if
+                                   inp.GetParameterType(param) == otb.ParameterType_OutputImage][0]
+                    self.app.ConnectImage(param, inp, outparamkey)
                 else:  # here `input` should be an image filepath
                     # Append `input` to the list, do not overwrite any previously set element of the image list
-                    self.app.AddParameterStringList(param, input)
+                    self.app.AddParameterStringList(param, inp)
         # List of any other types (str, int...)
         else:
             self.app.SetParameterValue(param, obj)
 
     def __propagate_pixel_type(self):
-        """Propagate the pixel type from inputs to output. If several inputs, the type of an arbitrary input
-           is considered. If several outputs, all outputs will have the same type."""
+        """
+        Propagate the pixel type from inputs to output. If several inputs, the type of an arbitrary input
+        is considered. If several outputs, all outputs will have the same type.
+        """
         pixel_type = None
         for param in self.parameters.values():
             try:
@@ -618,18 +743,23 @@ class App(otbObject):
             except TypeError:
                 pass
         if pixel_type is None:  # we use this syntax because pixel_type can be equal to 0
-            logger.warning(f"{self.name}: Could not propagate pixel type from inputs to output, " +
-                           f"no valid input found")
+            logger.warning("%s: Could not propagate pixel type from inputs to output, no valid input found", self.name)
         else:
             for out_key in self.output_parameters_keys:
                 self.app.SetParameterOutputImagePixelType(out_key, pixel_type)
 
     def __with_output(self):
-        """Check if App has any output parameter key"""
-        return any([k in self.parameters for k in self.output_parameters_keys])
+        """
+        Check if App has any output parameter key
+        :return: True or False
+        """
+        return any(k in self.parameters for k in self.output_parameters_keys)
 
     def __is_key_list(self, key):
-        """Check if a key of the App is an input parameter list"""
+        """
+        Check if a key of the App is an input parameter list
+        :return: True or False
+        """
         return self.app.GetParameterType(key) in (
             otb.ParameterType_InputImageList,
             otb.ParameterType_StringList,
@@ -639,7 +769,11 @@ class App(otbObject):
         )
 
     def __is_key_images_list(self, key):
-        """Check if a key of the App is an input parameter image list"""
+        """
+        Check if a key of the App is an input parameter image list
+        :param key: key
+        :return: True or False
+        """
         return self.app.GetParameterType(key) in (
             otb.ParameterType_InputImageList,
             otb.ParameterType_InputFilenameList
@@ -647,13 +781,17 @@ class App(otbObject):
 
     # Special methods
     def __str__(self):
+        """
+        Return a nice str
+        """
         return f'<pyotb.App {self.appname} object id {id(self)}>'
 
 
 class Operation(otbObject):
-    """Class for all arithmetic operations.
+    """
+    Class for all arithmetic operations.
 
-    Example
+    Example:
     -------
     Consider the python expression (input1 + 2 * input2)  >  0.
     This class enables to create a BandMathX app, with expression such as (im2 + 2 * im1) > 0 ? 1 : 0
@@ -686,6 +824,11 @@ class Operation(otbObject):
 
         # We first create a 'fake' expression. E.g for the operation `input1 + input2` , we create a fake expression
         # that is like "str(input1) + str(input2)"
+        self.inputs = []
+        self.nb_channels = {}
+        self.fake_exp_bands = []
+        self.logical_fake_exp_bands = []
+
         self.create_fake_exp(operator, inputs, nb_bands=nb_bands)
 
         # Transforming images to the adequate im#, e.g. `input1` to "im1"
@@ -694,11 +837,11 @@ class Operation(otbObject):
         self.im_dic = {}
         self.im_count = 1
         mapping_str_to_input = {}  # to be able to retrieve the real python object from its string representation
-        for input in self.inputs:
-            if not isinstance(input, (int, float)):
-                if str(input) not in self.im_dic:
-                    self.im_dic[str(input)] = 'im{}'.format(self.im_count)
-                    mapping_str_to_input[str(input)] = input
+        for inp in self.inputs:
+            if not isinstance(inp, (int, float)):
+                if str(inp) not in self.im_dic:
+                    self.im_dic[str(inp)] = f'im{self.im_count}'
+                    mapping_str_to_input[str(inp)] = inp
                     self.im_count += 1
 
         # getting unique image inputs, in the order im1, im2, im3 ...
@@ -723,16 +866,17 @@ class Operation(otbObject):
         :param inputs: inputs. Can be App, Output, Input, Operation, Slicer, filepath, int or float
         :param nb_bands: to specify the output nb of bands. Optional
         """
-        self.inputs = []
-        self.nb_channels = {}
 
-        logger.debug(f"{operator}, {inputs}")
+        self.inputs.clear()
+        self.nb_channels.clear()
+
+        logger.debug("%s, %s", operator, inputs)
         # this is when we use the ternary operator with `pyotb.where` function. The output nb of bands is already known
         if operator == '?' and nb_bands:
-            nb_bands = nb_bands
+            pass
         # For any other operations, the output number of bands is the same as inputs
         else:
-            if any([isinstance(input, Slicer) and hasattr(input, 'one_band_sliced') for input in inputs]):
+            if any(isinstance(input, Slicer) and hasattr(input, 'one_band_sliced') for input in inputs):
                 nb_bands = 1
             else:
                 nb_bands_list = [get_nbchannels(input) for input in inputs if not isinstance(input, (float, int))]
@@ -743,23 +887,23 @@ class Operation(otbObject):
                 nb_bands = nb_bands_list[0]
 
         # Create a list of fake expressions, each item of the list corresponding to one band
-        self.fake_exp_bands = []
+        self.fake_exp_bands.clear()
         for i, band in enumerate(range(1, nb_bands + 1)):
             fake_exps = []
-            for k, input in enumerate(inputs):
+            for k, inp in enumerate(inputs):
                 # Generating the fake expression of the current input
                 # this is a special case for the condition of the ternary operator `cond ? x : y`
                 if len(inputs) == 3 and k == 0:
                     # when cond is monoband whereas the result is multiband, we expand the cond to multiband
-                    if nb_bands != input.shape[2]:
+                    if nb_bands != inp.shape[2]:
                         cond_band = 1
                     else:
                         cond_band = band
-                    fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(input, cond_band,
+                    fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(inp, cond_band,
                                                                                                   keep_logical=True)
                 # any other input
                 else:
-                    fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(input, band,
+                    fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(inp, band,
                                                                                                   keep_logical=False)
 
                 fake_exps.append(fake_exp)
@@ -780,64 +924,71 @@ class Operation(otbObject):
 
             self.fake_exp_bands.append(fake_exp)
 
-    def _create_one_input_fake_exp(self, input, band, keep_logical=False):
+    @staticmethod
+    def _create_one_input_fake_exp(x, band, keep_logical=False):
         """
         This an internal function, only to be used by `create_fake_exp`. Enable to create a fake expression just for one
         input and one band.
-        :param input:
+        :param x: input
         :param band: which band to consider (bands start at 1)
         :param keep_logical: whether to keep the logical expressions "as is" in case the input is a logical operation.
                     ex: if True, for `input1 > input2`, returned fake expression is "str(input1) > str(input2)"
                         if False, for `input1 > input2`, returned fake expression is "str(input1) > str(input2) ? 1 : 0"
+        :return: fake_exp, inputs, nb_channels
         """
         # Special case for one-band slicer
-        if isinstance(input, Slicer) and hasattr(input, 'one_band_sliced'):
-            if keep_logical and isinstance(input.input, logicalOperation):
-                fake_exp = input.input.logical_fake_exp_bands[input.one_band_sliced - 1]
-                inputs = input.input.inputs
-                nb_channels = input.input.nb_channels
-            elif isinstance(input.input, Operation):
+        if isinstance(x, Slicer) and hasattr(x, 'one_band_sliced'):
+            if keep_logical and isinstance(x.input, logicalOperation):
+                fake_exp = x.input.logical_fake_exp_bands[x.one_band_sliced - 1]
+                inputs = x.input.inputs
+                nb_channels = x.input.nb_channels
+            elif isinstance(x.input, Operation):
                 # keep only one band of the expression
-                fake_exp = input.input.fake_exp_bands[input.one_band_sliced - 1]
-                inputs = input.input.inputs
-                nb_channels = input.input.nb_channels
+                fake_exp = x.input.fake_exp_bands[x.one_band_sliced - 1]
+                inputs = x.input.inputs
+                nb_channels = x.input.nb_channels
             else:
                 # Add the band number (e.g. replace '<pyotb.App object>' by '<pyotb.App object>b1')
-                fake_exp = str(input.input) + f'b{input.one_band_sliced}'
-                inputs = [input.input]
-                nb_channels = {input.input: 1}
+                fake_exp = str(x.input) + f'b{x.one_band_sliced}'
+                inputs = [x.input]
+                nb_channels = {x.input: 1}
         # For logicalOperation, we save almost the same attributes as an Operation
-        elif keep_logical and isinstance(input, logicalOperation):
-            fake_exp = input.logical_fake_exp_bands[band - 1]
-            inputs = input.inputs
-            nb_channels = input.nb_channels
-        elif isinstance(input, Operation):
-            fake_exp = input.fake_exp_bands[band - 1]
-            inputs = input.inputs
-            nb_channels = input.nb_channels
+        elif keep_logical and isinstance(x, logicalOperation):
+            fake_exp = x.logical_fake_exp_bands[band - 1]
+            inputs = x.inputs
+            nb_channels = x.nb_channels
+        elif isinstance(x, Operation):
+            fake_exp = x.fake_exp_bands[band - 1]
+            inputs = x.inputs
+            nb_channels = x.nb_channels
         # For int or float input, we just need to save their value
-        elif isinstance(input, (int, float)):
-            fake_exp = str(input)
+        elif isinstance(x, (int, float)):
+            fake_exp = str(x)
             inputs = None
             nb_channels = None
         # We go on with other inputs, i.e. pyotb objects, filepaths...
         else:
-            nb_channels = {input: get_nbchannels(input)}
-            inputs = [input]
+            nb_channels = {x: get_nbchannels(x)}
+            inputs = [x]
             # Add the band number (e.g. replace '<pyotb.App object>' by '<pyotb.App object>b1')
-            fake_exp = str(input) + f'b{band}'
+            fake_exp = str(x) + f'b{band}'
 
         return fake_exp, inputs, nb_channels
 
     def get_real_exp(self, fake_exp_bands):
-        """Generates the BandMathX expression"""
+        """
+        Generates the BandMathX expression
+        :param fake_exp_bands: list of fake expressions, each item corresponding to one band
+        :return exp_bands: BandMath expression, split in a list, each item corresponding to one band
+        :return exp: BandMath expression
+        """
         # Create a list of expression, each item corresponding to one band (e.g. ['im1b1 + 1', 'im1b2 + 1'])
         exp_bands = []
         for one_band_fake_exp in fake_exp_bands:
             one_band_exp = one_band_fake_exp
-            for input in self.inputs:
+            for inp in self.inputs:
                 # replace the name of in-memory object (e.g. '<pyotb.App object>b1' by 'im1b1')
-                one_band_exp = one_band_exp.replace(str(input), self.im_dic[str(input)])
+                one_band_exp = one_band_exp.replace(str(inp), self.im_dic[str(inp)])
             exp_bands.append(one_band_exp)
 
         # Form the final expression (e.g. 'im1b1 + 1; im1b2 + 1')
@@ -846,6 +997,9 @@ class Operation(otbObject):
         return exp_bands, exp
 
     def __str__(self):
+        """
+        :return: a nice str
+        """
         return f'<pyotb.Operation `{self.operator}` object, id {id(self)}>'
 
 
@@ -857,16 +1011,25 @@ class logicalOperation(Operation):
     """
 
     def __init__(self, operator, *inputs, nb_bands=None):
+        """
+        operator: Operator
+        inputs: inputs
+        nb_bands: number of channels
+        """
         super().__init__(operator, *inputs, nb_bands=nb_bands)
 
         self.logical_exp_bands, self.logical_exp = self.get_real_exp(self.logical_fake_exp_bands)
 
     def create_fake_exp(self, operator, inputs, nb_bands=None):
-        self.inputs = []
-        self.nb_channels = {}
+        """
+        Create a dummy bandmath expression
+        :param operator: (str) one of >, <, >=, <=, ==, !=, &, |
+        :param inputs: Can be App, Output, Input, Operation, Slicer, filepath, int or float
+        :param nb_bands: to specify the output nb of bands. Optional, should only be used for `?` operation
+        """
 
         # For any other operations, the output number of bands is the same as inputs
-        if any([isinstance(input, Slicer) and hasattr(input, 'one_band_sliced') for input in inputs]):
+        if any(isinstance(input, Slicer) and hasattr(input, 'one_band_sliced') for input in inputs):
             nb_bands = 1
         else:
             nb_bands_list = [get_nbchannels(input) for input in inputs if not isinstance(input, (float, int))]
@@ -877,12 +1040,10 @@ class logicalOperation(Operation):
             nb_bands = nb_bands_list[0]
 
         # Create a list of fake exp, each item of the list corresponding to one band
-        self.fake_exp_bands = []
-        self.logical_fake_exp_bands = []
         for i, band in enumerate(range(1, nb_bands + 1)):
             fake_exps = []
-            for input in inputs:
-                fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(input, band,
+            for inp in inputs:
+                fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(inp, band,
                                                                                               keep_logical=True)
 
                 fake_exps.append(fake_exp)
@@ -915,8 +1076,8 @@ def get_nbchannels(inp):
         try:
             info = App("ReadImageInfo", inp, otb_stdout=False)
             nb_channels = info.GetParameterInt("numberbands")
-        except Exception:  # this happens when we pass a str that is not a filepath
-            raise TypeError(f'Could not get the number of channels of `{inp}`. Not a filepath or wrong filepath')
+        except Exception as e:  # this happens when we pass a str that is not a filepath
+            raise TypeError(f'Could not get the number of channels of `{inp}`. Not a filepath or wrong filepath') from e
     return nb_channels
 
 
@@ -932,8 +1093,8 @@ def get_pixel_type(inp):
         # Executing the app, without printing its log
         try:
             info = App("ReadImageInfo", inp, otb_stdout=False)
-        except Exception:  # this happens when we pass a str that is not a filepath
-            raise TypeError(f'Could not get the pixel type of `{inp}`. Not a filepath or wrong filepath')
+        except Exception as info_err:  # this happens when we pass a str that is not a filepath
+            raise TypeError(f'Could not get the pixel type of `{inp}`. Not a filepath or wrong filepath') from info_err
         datatype = info.GetParameterString("datatype")  # which is such as short, float...
         dataype_to_pixeltype = {'unsigned_char': 'uint8', 'short': 'int16', 'unsigned_short': 'uint16',
                                 'int': 'int32', 'unsigned_int': 'uint32', 'long': 'int32', 'ulong': 'uint32',
diff --git a/pyotb/functions.py b/pyotb/functions.py
index e3bf12ed432430627cf3d05fafd6f851671abd3a..7523924047ca2b4bffc8804af733ba3ef26e1499 100644
--- a/pyotb/functions.py
+++ b/pyotb/functions.py
@@ -1,4 +1,7 @@
 # -*- coding: utf-8 -*-
+"""
+This module provides a set of functions for pyotb
+"""
 import inspect
 import os
 import sys
@@ -8,6 +11,7 @@ import logging
 from collections import Counter
 
 from .core import (App, Input, Operation, logicalOperation, get_nbchannels)
+
 logger = logging.getLogger()
 """
 Contains several useful functions base on pyotb
@@ -22,7 +26,7 @@ def where(cond, x, y):
                  multiband, cond channels are expanded to match x & y ones.
     :param x: value if cond is True. Can be float, int, App, filepath, Operation...
     :param y: value if cond is False. Can be float, int, App, filepath, Operation...
-    :return:
+    :return: an output where pixels are x if cond is True, else y
     """
     # Checking the number of bands of rasters. Several cases :
     # - if cond is monoband, x and y can be multibands. Then cond will adapt to match x and y nb of bands
@@ -35,8 +39,8 @@ def where(cond, x, y):
 
     if x_nb_channels and y_nb_channels:
         if x_nb_channels != y_nb_channels:
-            raise Exception('X and Y images do not have the same number of bands. ' +
-                            f'X has {x_nb_channels} bands whereas Y has {y_nb_channels} bands')
+            raise ValueError('X and Y images do not have the same number of bands. '
+                             f'X has {x_nb_channels} bands whereas Y has {y_nb_channels} bands')
 
     x_or_y_nb_channels = x_nb_channels if x_nb_channels else y_nb_channels
     cond_nb_channels = get_nbchannels(cond)
@@ -48,13 +52,13 @@ def where(cond, x, y):
         out_nb_channels = cond_nb_channels
 
     if cond_nb_channels != 1 and x_or_y_nb_channels and cond_nb_channels != x_or_y_nb_channels:
-        raise Exception(f'Condition and X&Y do not have the same number of bands. Condition has '
-                        '{cond_nb_channels} bands whereas X&Y have {x_or_y_nb_channels} bands')
+        raise ValueError('Condition and X&Y do not have the same number of bands. Condition has '
+                         f'{cond_nb_channels} bands whereas X&Y have {x_or_y_nb_channels} bands')
 
     # If needed, duplicate the single band binary mask to multiband to match the dimensions of x & y
     if cond_nb_channels == 1 and x_or_y_nb_channels and x_or_y_nb_channels != 1:
-        logger.info(f'The condition has one channel whereas X/Y has/have {x_or_y_nb_channels} channels. Expanding number of channels '
-                    'of condition to match the number of channels or X/Y')
+        logger.info('The condition has one channel whereas X/Y has/have %s channels. Expanding number'
+                    ' of channels of condition to match the number of channels of X/Y', x_or_y_nb_channels)
 
     operation = Operation('?', cond, x, y, nb_bands=out_nb_channels)
 
@@ -66,7 +70,7 @@ def clip(a, a_min, a_max):
     Clip values of image in a range of values
 
     :param a: input raster, can be filepath or any pyotb object
-    :param a_min: minumum value of the range
+    :param a_min: minimum value of the range
     :param a_max: maximum value of the range
     :return: raster whose values are clipped in the range
     """
@@ -78,31 +82,31 @@ def clip(a, a_min, a_max):
     return res
 
 
-def all(*inputs):
+def all(*inputs):  # pylint: disable=redefined-builtin
     """
     For only one image, this function checks that all bands of the image are True (i.e. !=0) and outputs
     a singleband boolean raster
     For several images, this function checks that all images are True (i.e. !=0) and outputs
     a boolean raster, with as many bands as the inputs
     :param inputs:
-    :return:
+    :return: AND intersection
     """
     # Transforming potential filepaths to pyotb objects
     inputs = [Input(input) if isinstance(input, str) else input for input in inputs]
 
     # Checking that all bands of the single image are True
     if len(inputs) == 1:
-        input = inputs[0]
-        if isinstance(input, logicalOperation):
-            res = input[:, :, 0]
+        inp = inputs[0]
+        if isinstance(inp, logicalOperation):
+            res = inp[:, :, 0]
         else:
-            res = (input[:, :, 0] != 0)
+            res = (inp[:, :, 0] != 0)
 
-        for band in range(1, input.shape[-1]):
-            if isinstance(input, logicalOperation):
-                res = res & input[:, :, band]
+        for band in range(1, inp.shape[-1]):
+            if isinstance(inp, logicalOperation):
+                res = res & inp[:, :, band]
             else:
-                res = res & (input[:, :, band] != 0)
+                res = res & (inp[:, :, band] != 0)
 
     # Checking that all images are True
     else:
@@ -110,40 +114,40 @@ def all(*inputs):
             res = inputs[0]
         else:
             res = (inputs[0] != 0)
-        for input in inputs[1:]:
-            if isinstance(input, logicalOperation):
-                res = res & input
+        for inp in inputs[1:]:
+            if isinstance(inp, logicalOperation):
+                res = res & inp
             else:
-                res = res & (input != 0)
+                res = res & (inp != 0)
 
     return res
 
 
-def any(*inputs):
+def any(*inputs):  # pylint: disable=redefined-builtin
     """
     For only one image, this function checks that at least one band of the image is True (i.e. !=0) and outputs
-    a singleband boolean raster
+    a single band boolean raster
     For several images, this function checks that at least one of the images is True (i.e. !=0) and outputs
     a boolean raster, with as many bands as the inputs
     :param inputs:
-    :return:
+    :return: OR intersection
     """
     # Transforming potential filepaths to pyotb objects
     inputs = [Input(input) if isinstance(input, str) else input for input in inputs]
 
     # Checking that at least one band of the image is True
     if len(inputs) == 1:
-        input = inputs[0]
-        if isinstance(input, logicalOperation):
-            res = input[:, :, 0]
+        inp = inputs[0]
+        if isinstance(inp, logicalOperation):
+            res = inp[:, :, 0]
         else:
-            res = (input[:, :, 0] != 0)
+            res = (inp[:, :, 0] != 0)
 
-        for band in range(1, input.shape[-1]):
-            if isinstance(input, logicalOperation):
-                res = res | input[:, :, band]
+        for band in range(1, inp.shape[-1]):
+            if isinstance(inp, logicalOperation):
+                res = res | inp[:, :, band]
             else:
-                res = res | (input[:, :, band] != 0)
+                res = res | (inp[:, :, band] != 0)
 
     # Checking that at least one image is True
     else:
@@ -151,20 +155,137 @@ def any(*inputs):
             res = inputs[0]
         else:
             res = (inputs[0] != 0)
-        for input in inputs[1:]:
-            if isinstance(input, logicalOperation):
-                res = res | input
+        for inp in inputs[1:]:
+            if isinstance(inp, logicalOperation):
+                res = res | inp
             else:
-                res = res | (input != 0)
+                res = res | (inp != 0)
 
     return res
 
 
+def run_tf_function(func):
+    """
+    This function enables using a function that calls some TF operations, with pyotb object as inputs.
+
+    For example, you can write a function that uses TF operations like this :
+        @run_tf_function
+        def multiply(input1, input2):
+            import tensorflow as tf
+            return tf.multiply(input1, input2)
+
+    Then you can use it like this :
+        result = multiply(pyotb_object1, pyotb_object1)  # this is a pyotb object
+
+    :param func: function taking one or several inputs and returning *one* output
+    :return wrapper: a function that returns a pyotb object
+    """
+    try:
+        from .apps import TensorflowModelServe
+    except ImportError:
+        logger.error('Could not run Tensorflow function: failed to import TensorflowModelServe. Check that you '
+                     'have OTBTF configured (https://github.com/remicres/otbtf#how-to-install)')
+        raise
+
+    def get_tf_pycmd(output_dir, channels, scalar_inputs):
+        """
+        Create a string containing all python instructions necessary to create and save the Keras model
+
+        :param output_dir: directory under which to save the model
+        :param channels: list of raster channels (int). Contain `None` entries for non-raster inputs
+        :param scalar_inputs: list of scalars (int/float). Contain `None` entries for non-scalar inputs
+        """
+
+        # Getting the string definition of the tf function (e.g. "def multiply(x1, x2):...")
+        # TODO: maybe not entirely foolproof, maybe we should use dill instead? but it would add a dependency
+        func_def_str = inspect.getsource(func)
+        func_name = func.__name__
+
+        create_and_save_model_str = func_def_str
+
+        # Adding the instructions to create the model and save it to output dir
+        create_and_save_model_str += textwrap.dedent(f"""
+            import tensorflow as tf
+
+            model_inputs = []
+            tf_inputs = []
+            for channel, scalar_input in zip({channels}, {scalar_inputs}):
+                if channel:
+                    input = tf.keras.Input((None, None, channel))
+                    tf_inputs.append(input)
+                    model_inputs.append(input)
+                else:
+                    if isinstance(scalar_input, int):  # TF doesn't like mixing float and int
+                        scalar_input = float(scalar_input)
+                    tf_inputs.append(scalar_input)
+
+            output = {func_name}(*tf_inputs)
+
+            # Create and save the .pb model
+            model = tf.keras.Model(inputs=model_inputs, outputs=output)
+            model.save("{output_dir}")
+            """)
+
+        return create_and_save_model_str
+
+    def wrapper(*inputs, tmp_dir='/tmp'):
+        """
+        For the user point of view, this function simply applies some TensorFlow operations to some rasters.
+        Implicitly, it saves a .pb model that describe the TF operations, then creates an OTB ModelServe application
+        that applies this .pb model to the inputs.
+
+        :param inputs: a list of pyotb objects, filepaths or int/float numbers
+        :param tmp_dir: directory where temporary models can be written
+        :return: a pyotb object, output of TensorFlowModelServe
+        """
+        # Get infos about the inputs
+        channels = []
+        scalar_inputs = []
+        raster_inputs = []
+        for inp in inputs:
+            try:
+                # this is for raster input
+                channel = get_nbchannels(inp)
+                channels.append(channel)
+                scalar_inputs.append(None)
+                raster_inputs.append(inp)
+            except TypeError:
+                # this is for other inputs (float, int)
+                channels.append(None)
+                scalar_inputs.append(inp)
+
+        # Create and save the model. This is executed **inside an independent process** because (as of 2022-03),
+        # tensorflow python library and OTBTF are incompatible
+        out_savedmodel = os.path.join(tmp_dir, f'tmp_otbtf_model_{uuid.uuid4()}')
+        pycmd = get_tf_pycmd(out_savedmodel, channels, scalar_inputs)
+        cmd_args = [sys.executable, "-c", pycmd]
+        try:
+            import subprocess
+            subprocess.run(cmd_args, env=os.environ, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
+        except subprocess.SubprocessError:
+            logger.debug("Failed to call subprocess")
+        if not os.path.isdir(out_savedmodel):
+            logger.info("Failed to save the model")
+
+        # Initialize the OTBTF model serving application
+        model_serve = TensorflowModelServe({'model.dir': out_savedmodel, 'optim.disabletiling': 'on',
+                                            'model.fullyconv': 'on'}, n_sources=len(raster_inputs), execute=False)
+        # Set parameters and execute
+        for i, inp in enumerate(raster_inputs):
+            model_serve.set_parameters({f'source{i + 1}.il': [inp]})
+        model_serve.Execute()
+        # TODO: handle the deletion of the temporary model ?
+
+        return model_serve
+
+    return wrapper
+
+
 def define_processing_area(*args, window_rule='intersection', pixel_size_rule='minimal', interpolator='nn',
                            reference_window_input=None, reference_pixel_size_input=None):
     """
     Given several inputs, this function handles the potential resampling and cropping to same extent.
-    //!\\ Not fully implemented / tested
+    WARNING: Not fully implemented / tested
 
     :param args: list of raster inputs. Can be str (filepath) or pyotb objects
     :param window_rule: Can be 'intersection', 'union', 'same_as_input', 'specify'
@@ -185,14 +306,14 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m
 
     # Getting metadatas of inputs
     metadatas = {}
-    for input in inputs:
-        if isinstance(input, str):  # this is for filepaths
-            metadata = Input(input).GetImageMetaData('out')
-        elif hasattr(input, 'output_parameter_key'):  # this is for Output, Input, Operation
-            metadata = input.GetImageMetaData(input.output_parameter_key)
+    for inp in inputs:
+        if isinstance(inp, str):  # this is for filepaths
+            metadata = Input(inp).GetImageMetaData('out')
+        elif hasattr(inp, 'output_parameter_key'):  # this is for Output, Input, Operation
+            metadata = inp.GetImageMetaData(inp.output_parameter_key)
         else:  # this is for App
-            metadata = input.GetImageMetaData(input.output_parameters_keys[0])
-        metadatas[input] = metadata
+            metadata = inp.GetImageMetaData(inp.output_parameters_keys[0])
+        metadatas[inp] = metadata
 
     # Get a metadata of an arbitrary image. This is just to compare later with other images
     any_metadata = next(iter(metadatas.values()))
@@ -212,50 +333,49 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m
         if window_rule == 'intersection':
             # The coordinates depend on the orientation of the axis of projection
             if any_metadata['GeoTransform'][1] >= 0:
-                ULX = max(metadata['UpperLeftCorner'][0] for metadata in metadatas.values())
-                LRX = min(metadata['LowerRightCorner'][0] for metadata in metadatas.values())
+                ulx = max(metadata['UpperLeftCorner'][0] for metadata in metadatas.values())
+                lrx = min(metadata['LowerRightCorner'][0] for metadata in metadatas.values())
             else:
-                ULX = min(metadata['UpperLeftCorner'][0] for metadata in metadatas.values())
-                LRX = max(metadata['LowerRightCorner'][0] for metadata in metadatas.values())
+                ulx = min(metadata['UpperLeftCorner'][0] for metadata in metadatas.values())
+                lrx = max(metadata['LowerRightCorner'][0] for metadata in metadatas.values())
             if any_metadata['GeoTransform'][-1] >= 0:
-                LRY = min(metadata['LowerRightCorner'][1] for metadata in metadatas.values())
-                ULY = max(metadata['UpperLeftCorner'][1] for metadata in metadatas.values())
+                lry = min(metadata['LowerRightCorner'][1] for metadata in metadatas.values())
+                uly = max(metadata['UpperLeftCorner'][1] for metadata in metadatas.values())
             else:
-                LRY = max(metadata['LowerRightCorner'][1] for metadata in metadatas.values())
-                ULY = min(metadata['UpperLeftCorner'][1] for metadata in metadatas.values())
+                lry = max(metadata['LowerRightCorner'][1] for metadata in metadatas.values())
+                uly = min(metadata['UpperLeftCorner'][1] for metadata in metadatas.values())
 
         elif window_rule == 'same_as_input':
-            ULX = metadatas[reference_window_input]['UpperLeftCorner'][0]
-            LRX = metadatas[reference_window_input]['LowerRightCorner'][0]
-            LRY = metadatas[reference_window_input]['LowerRightCorner'][1]
-            ULY = metadatas[reference_window_input]['UpperLeftCorner'][1]
+            ulx = metadatas[reference_window_input]['UpperLeftCorner'][0]
+            lrx = metadatas[reference_window_input]['LowerRightCorner'][0]
+            lry = metadatas[reference_window_input]['LowerRightCorner'][1]
+            uly = metadatas[reference_window_input]['UpperLeftCorner'][1]
         elif window_rule == 'specify':
             pass
-            # TODO : it is when the user explicitely specifies the bounding box -> add some arguments in the function
+            # TODO : it is when the user explicitly specifies the bounding box -> add some arguments in the function
         elif window_rule == 'union':
             pass
             # TODO : it is when the user wants the final bounding box to be the union of all bounding box
             #  It should replace any 'outside' pixel by some NoData -> add `fillvalue` argument in the function
 
-        logger.info(f'Cropping all images to extent Upper Left ({ULX}, {ULY}), Lower Right ({LRX}, {LRY})')
+        logger.info('Cropping all images to extent Upper Left (%s, %s), Lower Right (%s, %s)', ulx, uly, lrx, lry)
 
         # Applying this bounding box to all inputs
         new_inputs = []
-        for input in inputs:
+        for inp in inputs:
             try:
-                new_input = App('ExtractROI', {'in': input, 'mode': 'extent', 'mode.extent.unit': 'phy',
-                                               'mode.extent.ulx': ULX, 'mode.extent.uly': LRY,  # bug in OTB <= 7.3 :
-                                               'mode.extent.lrx': LRX, 'mode.extent.lry': ULY})  # ULY/LRY are inverted
+                new_input = App('ExtractROI', {'in': inp, 'mode': 'extent', 'mode.extent.unit': 'phy',
+                                               'mode.extent.ulx': ulx, 'mode.extent.uly': lry,  # bug in OTB <= 7.3 :
+                                               'mode.extent.lrx': lrx, 'mode.extent.lry': uly})  # ULY/LRY are inverted
                 # TODO: OTB 7.4 fixes this bug, how to handle different versions of OTB?
                 new_inputs.append(new_input)
                 # Potentially update the reference inputs for later resampling
-                if str(input) == str(reference_pixel_size_input):  # we use comparison of string because calling '=='
-                    # on pyotb objects underlyingly calls BandMathX application, which is not desirable
+                if str(inp) == str(reference_pixel_size_input):  # we use comparison of string because calling '=='
+                    # on pyotb objects implicitly calls BandMathX application, which is not desirable
                     reference_pixel_size_input = new_input
-            except Exception as e:
-                logger.error(e)
-                logger.error(f'Images may not intersect : {input}')
-                # TODO: what should we do then? return an empty raster ? fail ? return None ?
+            except RuntimeError as e:
+                logger.error('Cannot define the processing area for input %s: %s', inp, e)
+                raise
         inputs = new_inputs
 
         # Update metadatas
@@ -279,18 +399,18 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m
             reference_input = reference_pixel_size_input
         elif pixel_size_rule == 'specify':
             pass
-            # TODO : when the user explicitely specify the pixel size -> add argument inside the function
+            # TODO : when the user explicitly specify the pixel size -> add argument inside the function
         pixel_size = metadatas[reference_input]['GeoTransform'][1]
-        logger.info(f'Resampling all inputs to resolution : {pixel_size}')
+        logger.info('Resampling all inputs to resolution: %s', pixel_size)
 
         # Perform resampling on inputs that do not comply with the target pixel size
         new_inputs = []
-        for input in inputs:
-            if metadatas[input]['GeoTransform'][1] != pixel_size:
-                superimposed = App('Superimpose', inr=reference_input, inm=input, interpolator=interpolator)
+        for inp in inputs:
+            if metadatas[inp]['GeoTransform'][1] != pixel_size:
+                superimposed = App('Superimpose', inr=reference_input, inm=inp, interpolator=interpolator)
                 new_inputs.append(superimposed)
             else:
-                new_inputs.append(input)
+                new_inputs.append(inp)
         inputs = new_inputs
 
         # Update metadatas
@@ -299,10 +419,10 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m
     # Final superimposition to be sure to have the exact same image sizes
     # Getting the sizes of images
     image_sizes = {}
-    for input in inputs:
-        if isinstance(input, str):
-            input = Input(input)
-        image_sizes[input] = input.shape[:2]
+    for inp in inputs:
+        if isinstance(inp, str):
+            inp = Input(inp)
+        image_sizes[inp] = inp.shape[:2]
 
     # Selecting the most frequent image size. It will be used as reference.
     most_common_image_size, _ = Counter(image_sizes.values()).most_common(1)[0]
@@ -310,128 +430,12 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m
 
     # Superimposition for images that do not have the same size as the others
     new_inputs = []
-    for input in inputs:
-        if image_sizes[input] != most_common_image_size:
-            new_input = App('Superimpose', inr=same_size_images[0], inm=input, interpolator=interpolator)
+    for inp in inputs:
+        if image_sizes[inp] != most_common_image_size:
+            new_input = App('Superimpose', inr=same_size_images[0], inm=inp, interpolator=interpolator)
             new_inputs.append(new_input)
         else:
-            new_inputs.append(input)
+            new_inputs.append(inp)
     inputs = new_inputs
 
     return inputs
-
-
-def run_tf_function(func):
-    """
-    This function enables using a function that calls some TF operations, with pyotb object as inputs.
-
-    For example, you can write a function that uses TF operations like this :
-        @run_tf_function
-        def multiply(input1, input2):
-            import tensorflow as tf
-            return tf.multiply(input1, input2)
-
-    Then you can use it like this :
-        result = multiply(pyotb_object1, pyotb_object1)  # this is a pyotb object
-
-    :param func: function taking one or several inputs and returning *one* output
-    :return wrapper: a function that returns a pyotb object
-    """
-    try:
-        from .apps import TensorflowModelServe
-    except ImportError:
-        raise Exception('Could not run Tensorflow function: failed to import TensorflowModelServe. Check that you '
-                        'have OTBTF configured (https://github.com/remicres/otbtf#how-to-install)')
-
-    def get_tf_pycmd(output_dir, channels, scalar_inputs):
-        """
-        Create a string containing all python instructions necessary to create and save the Keras model
-
-        :param output_dir: directory under which to save the model
-        :param channels: list of raster channels (int). Contain `None` entries for non-raster inputs
-        :param scalar_inputs: list of scalars (int/float). Contain `None` entries for non-scalar inputs
-        """
-
-        # Getting the string definition of the tf function (e.g. "def multiply(x1, x2):...")
-        # TODO: maybe not entirely foolproof, maybe we should use dill instead? but it would add a dependency
-        func_def_str = inspect.getsource(func)
-        func_name = func.__name__
-
-        create_and_save_model_str = func_def_str
-
-        # Adding the instructions to create the model and save it to output dir
-        create_and_save_model_str += textwrap.dedent(f"""
-            import tensorflow as tf
-
-            model_inputs = []
-            tf_inputs = []
-            for channel, scalar_input in zip({channels}, {scalar_inputs}):
-                if channel:
-                    input = tf.keras.Input((None, None, channel))
-                    tf_inputs.append(input)
-                    model_inputs.append(input)
-                else:
-                    if isinstance(scalar_input, int):  # TF doesn't like mixing float and int
-                        scalar_input = float(scalar_input)
-                    tf_inputs.append(scalar_input)
-
-            output = {func_name}(*tf_inputs)
-            
-            # Create and save the .pb model
-            model = tf.keras.Model(inputs=model_inputs, outputs=output)
-            model.save("{output_dir}")
-            """)
-
-        return create_and_save_model_str
-
-    def wrapper(*inputs, tmp_dir='/tmp'):
-        """
-        For the user point of view, this function simply applies some TensorFlow operations to some rasters.
-        Underlyingly, it saves a .pb model that describe the TF operations, then creates an OTB ModelServe application
-        that applies this .pb model to the inputs.
-
-        :param inputs: a list of pyotb objects, filepaths or int/float numbers
-        :param tmp_dir: directory where temporary models can be written
-        :return: a pyotb object, output of TensorFlowModelServe
-        """
-        # Get infos about the inputs
-        channels = []
-        scalar_inputs = []
-        raster_inputs = []
-        for input in inputs:
-            try:
-                # this is for raster input
-                channel = get_nbchannels(input)
-                channels.append(channel)
-                scalar_inputs.append(None)
-                raster_inputs.append(input)
-            except Exception:
-                # this is for other inputs (float, int)
-                channels.append(None)
-                scalar_inputs.append(input)
-
-        # Create and save the model. This is executed **inside an independent process** because (as of 2022-03),
-        # tensorflow python library and OTBTF are incompatible
-        out_savedmodel = os.path.join(tmp_dir, f'tmp_otbtf_model_{uuid.uuid4()}')
-        pycmd = get_tf_pycmd(out_savedmodel, channels, scalar_inputs)
-        cmd_args = [sys.executable, "-c", pycmd]
-        try:
-            import subprocess
-            subprocess.run(cmd_args, env=os.environ, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-        except subprocess.SubprocessError:
-            logger.debug("Failed to call subprocess")
-        if not os.path.isdir(out_savedmodel):
-            logger.info("Failed to save the model")
-
-        # Initialize the OTBTF model serving application
-        model_serve = TensorflowModelServe({'model.dir': out_savedmodel, 'optim.disabletiling': 'on',
-                                            'model.fullyconv': 'on'}, n_sources=len(raster_inputs), execute=False)
-        # Set parameters and execute
-        for i, input in enumerate(raster_inputs):
-            model_serve.set_parameters({f'source{i + 1}.il': [input]})
-        model_serve.Execute()
-        # TODO: handle the deletion of the temporary model ?
-
-        return model_serve
-
-    return wrapper
diff --git a/pyotb/tools.py b/pyotb/tools.py
index 7e82f850a8671c57fec769aa932d79a1ca8bb0c6..aa67ece7d5e12bbcd9b727520fb4f5620860db2f 100644
--- a/pyotb/tools.py
+++ b/pyotb/tools.py
@@ -1,4 +1,7 @@
 # -*- coding: utf-8 -*-
+"""
+This module provides some helpers to properly initialize pyotb
+"""
 import os
 import sys
 import logging
@@ -29,7 +32,7 @@ def find_otb(root="", scan=True, scan_userdir=True):
     # OTB_ROOT env variable is first (allow override default OTB version)
     prefix = root or os.environ.get("OTB_ROOT") or os.environ.get("OTB_DIR")
     if prefix:
-        logger.info(f"Found OTB Python API in {prefix}")
+        logger.info("Found OTB Python API in %s", prefix)
         # Add path first in PYTHONPATH
         otb_api = get_python_api(prefix)
         sys.path.insert(0, str(otb_api))
@@ -44,7 +47,7 @@ def find_otb(root="", scan=True, scan_userdir=True):
                 return str(prefix), str(apps_path)
 
             except ImportError:
-                logging.critical(f"Can't import OTB with OTB_ROOT={prefix}")
+                logging.critical("Can't import OTB with OTB_ROOT=%s", prefix)
                 return None, None
         else:
             logger.warning("Can't reset otb module with new path. You need to restart Python")
@@ -60,15 +63,15 @@ def find_otb(root="", scan=True, scan_userdir=True):
     # Else search system
     except ImportError:
         PYTHONPATH = os.environ.get("PYTHONPATH")
-        logger.info(f"Failed to import otbApplication with PYTHONPATH={PYTHONPATH}")
+        logger.info("Failed to import otbApplication with PYTHONPATH=%s", PYTHONPATH)
         if not scan:
             return None, None
         logger.info("Searching for it...")
         lib_dir = None
         # Scan user's HOME directory tree (this may take some time)
-        if scan_userdir :
+        if scan_userdir:
             for path in Path().home().glob("**/OTB-*/lib"):
-                logger.info(f"Found {path.parent}")
+                logger.info("Found %s", path.parent)
                 lib_dir = path
                 prefix = str(path.parent.absolute())
         # Or search possible known locations (system scpecific)
@@ -81,7 +84,7 @@ def find_otb(root="", scan=True, scan_userdir=True):
                 path = Path(str_path)
                 if not path.exists():
                     continue
-                logger.info(f"Found " + str_path)
+                logger.info("Found %s", str_path)
                 if not prefix:
                     if path.parent.name == "x86_64-linux-gnu":
                         prefix = path.parent.parent.parent
@@ -103,18 +106,19 @@ def find_otb(root="", scan=True, scan_userdir=True):
             # Try to import one last time before sys.exit (in apps.py)
             try:
                 sys.path.insert(0, str(otb_api))
-                import otbApplication as otb
-                logger.info(f"Using OTB in {prefix}")
+                import otbApplication
+                logger.info("Using OTB in %s", prefix)
                 return prefix, get_apps_path(lib_dir)
             except ModuleNotFoundError:
-                logger.critical(f"Unable to find OTB Python bindings", exc_info=1)
+                logger.critical("Unable to find OTB Python bindings", exc_info=1)
                 return None, None
             except ImportError as e:
-                logger.critical(f"An error occured while importing Python API")
+                logger.critical("An error occurred while importing Python API")
                 if str(e).startswith('libpython3.'):
                     logger.critical("It seems like you need to symlink or recompile OTB SWIG bindings")
                     if sys.platform == "linux":
-                        logger.critical(f"Use 'cd {prefix} ; source otbenv.profile ; ctest -S share/otb/swig/build_wrapping.cmake -VV'")
+                        logger.critical("Use 'cd %s ; source otbenv.profile ; "
+                                        "ctest -S share/otb/swig/build_wrapping.cmake -VV'", prefix)
                         return None, None
                 logger.critical("full traceback", exc_info=1)
 
@@ -122,17 +126,27 @@ def find_otb(root="", scan=True, scan_userdir=True):
 
 
 def get_python_api(prefix):
+    """
+    Try to find the python path
+    :param prefix: prefix
+    :return: the path if found, else None
+    """
     root = Path(prefix)
     if root.exists():
         otb_api = root / "lib/python"
         if not otb_api.exists():
             otb_api = root / "lib/otb/python"
-        if not otb_api.exists():
-            return
-        return otb_api.absolute()
+        if otb_api.exists():
+            return otb_api.absolute()
+    return None
 
 
 def get_lib(otb_module):
+    """
+    Try to find the otb library path
+    :param otb_module: otb module (otbApplication)
+    :return: library path
+    """
     lib_dir = Path(otb_module.__file__).parent.parent
     # OTB .run file
     if lib_dir.name == "lib":
@@ -147,17 +161,27 @@ def get_lib(otb_module):
 
 
 def get_apps_path(lib_dir):
+    """
+    Try to get the otb applications path
+    :param lib_dir: library path
+    :return: library path if found, else ""
+    """
     if isinstance(lib_dir, Path) and lib_dir.exists():
-        logger.debug(f"Using library from {lib_dir}")
+        logger.debug("Using library from %s", lib_dir)
         otb_application_path = lib_dir / "otb/applications"
         if otb_application_path.exists():
             return str(otb_application_path.absolute())
         # This should not happen, may be with failed builds ?
-        logger.debug(f"Library directory found but no 'applications' directory whithin it")
-        return ""
+        logger.debug("Library directory found but no 'applications' directory inside")
+    return ""
 
 
 def set_gdal_vars(root):
+    """
+    Set GDAL environment variables
+    :param root: root directory
+    :return: True is succeed, False else
+    """
     if (Path(root) / "share/gdal").exists():
         # Local GDAL (OTB Superbuild, .run, .exe)
         gdal_data = str(Path(root + "/share/gdal"))
@@ -167,7 +191,7 @@ def set_gdal_vars(root):
         gdal_data = "/usr/share/gdal"
         proj_lib = "/usr/share/proj"
     else:
-        logger.warning(f"Can't find GDAL directory with prefix {root}")
+        logger.warning("Can't find GDAL directory with prefix %s", root)
         return False
     # Not sure if SWIG will see these
     os.environ["LC_NUMERIC"] = "C"
diff --git a/tests/Data/Input/QB_MUL_ROI_1000_100.tif b/tests/Data/Input/QB_MUL_ROI_1000_100.tif
new file mode 100644
index 0000000000000000000000000000000000000000..ed48fe828f71d4ad8cd0945c35aee1d44d3fcaa2
Binary files /dev/null and b/tests/Data/Input/QB_MUL_ROI_1000_100.tif differ
diff --git a/tests/ndvi_test.py b/tests/ndvi_test.py
new file mode 100644
index 0000000000000000000000000000000000000000..c17a8c3428ae5aa5c380491b255a5d5f0ec712ec
--- /dev/null
+++ b/tests/ndvi_test.py
@@ -0,0 +1,28 @@
+import pyotb
+
+filepath = 'Data/Input/QB_MUL_ROI_1000_100.tif'
+inp = pyotb.Input(filepath)
+
+# Compute NDVI with bandmath
+ndvi_bandmath = (inp[:, :, -1] - inp[:, :, [2]]) / (inp[:, :, -1] + inp[:, :, [2]])
+assert ndvi_bandmath.exp == '((im1b4 - im1b3) / (im1b4 + im1b3))'
+ndvi_bandmath.write('/tmp/ndvi_bandmath.tif', pixel_type='float')
+
+# Compute NDVI with RadiometricIndices app
+ndvi_indices = pyotb.RadiometricIndices({'in': inp, 'list': 'Vegetation:NDVI',
+                                         'channels.red': 3, 'channels.nir': 4})
+ndvi_indices.write('/tmp/ndvi_indices.tif', pixel_type='float')
+
+compared = pyotb.CompareImages({'ref.in': ndvi_indices, 'meas.in': '/tmp/ndvi_bandmath.tif'})
+assert compared.count == 0
+assert compared.mse == 0
+
+# Threshold
+thresholded_indices = pyotb.where(ndvi_indices >= 0.3, 1, 0)
+thresholded_bandmath = pyotb.where(ndvi_bandmath >= 0.3, 1, 0)
+assert thresholded_indices.exp == '((im1b1 >= 0.3) ? 1 : 0)'
+assert thresholded_bandmath.exp == '((((im1b4 - im1b3) / (im1b4 + im1b3)) >= 0.3) ? 1 : 0)'
+
+# Sum of bands
+summed = sum(inp[:, :, b] for b in range(inp.shape[-1]))
+assert summed.exp == '((((0 + im1b1) + im1b2) + im1b3) + im1b4)'