diff --git a/NEWS.rst b/NEWS.rst
index 2a99fb809c685eb438448b434979af9b121e03ef_TkVXUy5yc3Q=..606a967c39b15e0a3d167619fb472e374ba176f9_TkVXUy5yc3Q= 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -15,6 +15,8 @@
 
 Use the presence of a Dockerfile as a sign that the current directory is a super project. Avoids starting to create files by error.
 
+Add new command and flags to run pylint on some modules.
+
 14.0.0
 ------
 
diff --git a/README.rst b/README.rst
index 2a99fb809c685eb438448b434979af9b121e03ef_UkVBRE1FLnJzdA==..606a967c39b15e0a3d167619fb472e374ba176f9_UkVBRE1FLnJzdA== 100644
--- a/README.rst
+++ b/README.rst
@@ -97,6 +97,13 @@
 
 This is part of the docker section.
 
+docker_pylint
+-------------
+
+Run dockerized pylint on provided module name. Pylint must be present in the project image.
+
+This is part of the docker section.
+
 conf2reST.py
 ------------
 
diff --git a/odoo_scripts/config.py b/odoo_scripts/config.py
index 2a99fb809c685eb438448b434979af9b121e03ef_b2Rvb19zY3JpcHRzL2NvbmZpZy5weQ==..606a967c39b15e0a3d167619fb472e374ba176f9_b2Rvb19zY3JpcHRzL2NvbmZpZy5weQ== 100644
--- a/odoo_scripts/config.py
+++ b/odoo_scripts/config.py
@@ -28,4 +28,5 @@
         blacklist: str = None,
         read: str = None,
         parent_configuration=None,
+        registry: str = None,
     ):
@@ -31,4 +32,8 @@
     ):
+        """
+        :param registry: use the provided registry instead of the one in the
+        configuration file
+        """
         self._expanded_configuration = dict()
         self.path = path
         setup_path = "setup.cfg"
@@ -176,6 +181,15 @@
         self.registry = section.get("registry", "registry.xcg.io")
         project_path = os.path.realpath(".")
         self.image = section.get("image", os.path.basename(project_path))
+        self.repository = f"{self.registry}/{self.image}"
+        """Registry and image name combination, read from the configuration file"""
+        self.local_tag = "dev"
+        """Tag used for local operations"""
+        # Does not use repository as it is not meant to be pushed
+        self.local_image = self.image
+        self.local_image_name = f"{self.local_image}:{self.local_tag}"
+        """Full image name used for local operations (locally built, used for test and
+        running)"""
         read_expanded("odoo_type", "odoo7")
         if not hasattr(self, "odoo_type"):
             # if path is set, odoo_type might not be in the values to read
diff --git a/odoo_scripts/do_tests.py b/odoo_scripts/do_tests.py
index 2a99fb809c685eb438448b434979af9b121e03ef_b2Rvb19zY3JpcHRzL2RvX3Rlc3RzLnB5..606a967c39b15e0a3d167619fb472e374ba176f9_b2Rvb19zY3JpcHRzL2RvX3Rlc3RzLnB5 100755
--- a/odoo_scripts/do_tests.py
+++ b/odoo_scripts/do_tests.py
@@ -13,6 +13,7 @@
 from odoo_scripts.docker_postgresql import docker_run_postgresql
 
 from .config import Config
+from .docker_build import add_build_options, get_build_options
 from .docker_dev_start import main as docker_dev_start_main
 from .docker_flake8 import apply_flake8, parser_add_flake8_group
 from .docker_isort import apply_isort, parser_add_isort_group
@@ -21,5 +22,5 @@
 
 _logger = logging.getLogger(__name__)
 
-__version__ = "3.0.0"
+__version__ = "3.0.1"
 __date__ = "2018-04-13"
@@ -25,5 +26,5 @@
 __date__ = "2018-04-13"
-__updated__ = "2021-10-21"
+__updated__ = "2022-01-27"
 
 
 def main(argv=None):  # IGNORE:C0111
@@ -144,6 +145,7 @@
     )
     parser_add_flake8_group(parser)
     parser_add_isort_group(parser)
+    add_build_options(parser)
     # TODO options
     # - db host/uri (include socket)
     # - db user for creation/remove
@@ -165,8 +167,6 @@
 
     config = Config()
 
-    registry = config.registry
-    project = config.image
     languages = config.load_language
     postgresql_version = config.postgresql_version
     odoo_type = config.odoo_type
@@ -170,8 +170,7 @@
     languages = config.load_language
     postgresql_version = config.postgresql_version
     odoo_type = config.odoo_type
-    # TODO factorize with docker_dev_start
-    image = "%s/%s:latest" % (registry, project)
+    image = config.local_image_name
     _logger.debug("Docker image: %s", image)
     extensions = config.pg_extensions
 
@@ -245,6 +244,8 @@
     if nmspc.LOAD_LANGUAGE:
         args.append("--load-language")
         args.append(nmspc.LOAD_LANGUAGE)
+    # also pass build options
+    args.extend(get_build_options(nmspc))
     if recreate_db:
         if start_postgresql:
             container, stop_method, socket_path = docker_run_postgresql(
diff --git a/odoo_scripts/docker_build.py b/odoo_scripts/docker_build.py
index 2a99fb809c685eb438448b434979af9b121e03ef_b2Rvb19zY3JpcHRzL2RvY2tlcl9idWlsZC5weQ==..606a967c39b15e0a3d167619fb472e374ba176f9_b2Rvb19zY3JpcHRzL2RvY2tlcl9idWlsZC5weQ== 100644
--- a/odoo_scripts/docker_build.py
+++ b/odoo_scripts/docker_build.py
@@ -2,6 +2,7 @@
 # vim: set shiftwidth=4 softtabstop=4:
 """Script to locally build a docker image
 """
+import argparse
 import datetime
 import json
 import logging
@@ -18,5 +19,5 @@
 
 _logger = logging.getLogger(__name__)
 
-__version__ = "1.1.0"
+__version__ = "1.2.0"
 __date__ = "2018-04-04"
@@ -22,5 +23,25 @@
 __date__ = "2018-04-04"
-__updated__ = "2021-12-13"
+__updated__ = "2022-01-27"
+
+
+def add_build_options(parser: argparse.ArgumentParser):
+    """Add build options to parser"""
+    parser.add_argument(
+        "--build-arg",
+        help="build arg for the image, formatted like FOO=BAR [default: %(default)s]",
+        default=None,
+        nargs="*",
+    )
+
+
+def get_build_options(namespace: argparse.Namespace):
+    """Return build options usable in :func:`build_local_image`."""
+    result = []
+    if namespace.build_arg:
+        for arg in namespace.build_arg:
+            result.append("--build-arg")
+            result.append(arg)
+    return result
 
 
 def __parser():
@@ -34,7 +55,7 @@
     program_license = """%s
 
       Created by Vincent Hatakeyama on %s.
-      Copyright 2018, 2019, 2020, 2021 XCG Consulting. All rights reserved.
+      Copyright 2018-2022 XCG Consulting. All rights reserved.
 
       Licensed under the MIT License
 
@@ -62,6 +83,7 @@
     # TODO add a way to store configuration options in a project file
     # Argument parsing
     parser = __parser()
+    add_build_options(parser)
     # TODO add tag option, and maybe force tag option
     parser.add_argument(
         "--ensureconf",
@@ -77,13 +99,7 @@
         action="store_true",
     )
     parser.add_argument(
-        "--build-arg",
-        help="build arg for the image, formatted like FOO=BAR [default: %(default)s]",
-        default=None,
-        nargs="*",
-    )
-    parser.add_argument(
         "--no-pull",
         help="indicate to docker to not pull the base image [default: %(default)s]",
         action="store_true",
     )
@@ -86,7 +102,14 @@
         "--no-pull",
         help="indicate to docker to not pull the base image [default: %(default)s]",
         action="store_true",
     )
+    parser.add_argument(
+        "--local",
+        help="Use local image name",
+        action="store_true",
+        dest="local",
+        default=None,
+    )
     # TODO (maybe) add argument for other build arg
 
     nmspc = parser.parse_args(argv)
@@ -101,6 +124,4 @@
         return 1
 
     c = Config()
-    registry = c.registry
-    project = c.image
     odoo_type = c.odoo_type
@@ -106,5 +127,5 @@
     odoo_type = c.odoo_type
-    repository = f"{registry}/{project}"
+    repository = c.local_image_name if nmspc.local else c.repository
     _logger.debug("Docker repository: %s", repository)
     # TODO ensureconf
     if ensureconf:
@@ -124,7 +145,7 @@
     # used to retag
     tags = []
 
-    buildargs = dict()
+    buildargs = {}
     if os.path.exists(".hg"):
         tags = check_output(["hg", "identify", "--tags"]).decode()
         buildargs["VERSION"] = tags
@@ -212,5 +233,27 @@
     return 0
 
 
+def build_local_image(build_options, force: bool = False) -> int:
+    """Build the local image if necessary
+    :param force: force build if True
+    :return: 0 if success, docker failed error code otherwise
+    """
+    c = Config()
+    image = c.local_image_name
+
+    image_list = DockerClient.client.images.list(name=image)
+    if not image_list:
+        _logger.info("Image %s does not exist", image)
+    else:
+        _logger.info("Image %s exists", image)
+    if not image_list or force:
+        _logger.info("Building image %s", image)
+        arguments = ["--dev", "--local"] + build_options
+        failed = main(arguments)
+        if failed:
+            return failed
+    return 0
+
+
 if __name__ == "__main__":
     sys.exit(main(sys.argv[1:]))
diff --git a/odoo_scripts/docker_client.py b/odoo_scripts/docker_client.py
index 2a99fb809c685eb438448b434979af9b121e03ef_b2Rvb19zY3JpcHRzL2RvY2tlcl9jbGllbnQucHk=..606a967c39b15e0a3d167619fb472e374ba176f9_b2Rvb19zY3JpcHRzL2RvY2tlcl9jbGllbnQucHk= 100644
--- a/odoo_scripts/docker_client.py
+++ b/odoo_scripts/docker_client.py
@@ -1,2 +1,3 @@
+import atexit
 import logging
 import os
@@ -1,3 +2,4 @@
 import logging
 import os
+import re
 import tarfile
@@ -3,5 +5,5 @@
 import tarfile
-from typing import Dict, List
+from typing import Any, Dict, List, Optional, Tuple
 
 import docker
 import dockerpty
@@ -5,6 +7,7 @@
 
 import docker
 import dockerpty
+from docker.types import Mount
 
 _logger = logging.getLogger(__name__)
 if docker.__version__ < "3.4.0":
@@ -8,9 +11,9 @@
 
 _logger = logging.getLogger(__name__)
 if docker.__version__ < "3.4.0":
-    _logger.warning("Unexepected python docker version: %s", docker.__version__)
+    _logger.warning("Unexpected python docker version: %s", docker.__version__)
 
 # TODO detect that user is member of docker group
 
 
 class DockerClient:
@@ -12,11 +15,13 @@
 
 # TODO detect that user is member of docker group
 
 
 class DockerClient:
+    """Provide additional abstraction and extra methods of the docker client API"""
+
     client = docker.from_env()
 
     @classmethod
     def create_volume(
         cls, volume_name: str, extra_labels: Dict[str, str] = None
     ) -> docker.models.volumes.Volume:
@@ -17,11 +22,10 @@
     client = docker.from_env()
 
     @classmethod
     def create_volume(
         cls, volume_name: str, extra_labels: Dict[str, str] = None
     ) -> docker.models.volumes.Volume:
-        """Return the volume passed in parameter, creating it if it does not
-        exists.
+        """Return the volume passed in parameter, creating it if it does not exist.
         :param volume_name: name of the volume to create
         :param extra_labels: extra labels to put on the volume (only on
         creation)
@@ -77,3 +81,104 @@
             result = run_result.exit_code
             print(run_result.output.decode())
         return result
+
+    @classmethod
+    def run(
+        cls,
+        repository: str,
+        tag: str,
+        run_kwargs: Dict[str, Any],
+        pull: bool = True,
+        target_source_dict: Optional[Dict[str, str]] = None,
+    ):
+        """Run a container with the provided args"""
+        replaces = (
+            [(re.compile(key), value) for key, value in target_source_dict.items()]
+            if target_source_dict
+            else []
+        )
+
+        image = f"{repository}:{tag}"
+
+        if pull:
+            _logger.debug("Pulling %s", image)
+            # TODO find a better way to avoid error when pulling without a connection
+            try:
+                cls.client.images.pull(repository, tag)
+            except docker.errors.APIError:
+                _logger.warning(
+                    "No connection to server, using existing version, if any"
+                )
+
+        container = None
+
+        # XXX When pressing ctrl-c during the container creating, the container
+        #  is not removed as there is no return of the value of the Container
+        #  object. There is several ways to do that:
+        #  - give the container a unique name we know, and use that to clean
+        #  - maybe use some kind of time frame to identify the container
+        #  - warn the user of a potential container leak by using signals
+
+        def remove():
+            """Remove the container"""
+            if container:
+                _logger.debug("Removing container %s", container.name)
+                container.remove()
+
+        def stop_remove():
+            """Stop then remove the container."""
+            if container:
+                if container.status == "running":
+                    _logger.debug("Stopping container %s", container.name)
+                    container.stop()
+                    _logger.debug("Waiting for container %s", container.name)
+                    container.wait()
+            remove()
+
+        atexit.register(stop_remove)
+
+        # split create and start so in case of SIGTERM, container has fewer chances
+        # of being None
+        kwargs = run_kwargs.copy()
+        kwargs["detach"] = True
+        _logger.debug("Create container with image %s", image)
+        container = cls.client.containers.create(image, **kwargs)
+        _logger.debug("Created container %s", container.name)
+        _logger.debug("Starting container %s", container.name)
+        container.start()
+        atexit.unregister(stop_remove)
+        atexit.register(remove)
+
+        for log in container.logs(stream=True, follow=True):
+            log = log.decode().rstrip()
+            # apply all the replacement of the path inside the image to the real path
+            for pattern, value in replaces:
+                log = pattern.sub(value, log)
+            # print to play nicely with pycharm that will linkify the result
+            print(log)
+
+        _logger.debug("Waiting for container %s", container.name)
+        result = container.wait()
+
+        if result.get("Error", None):
+            _logger.warning(result.decode())
+        _logger.debug("Removing container %s", container.name)
+        container.remove(v=True)
+        atexit.unregister(remove)
+        return result["StatusCode"]
+
+
+def modules_mount(
+    modules, project_path, target_path: str
+) -> Tuple[List[Mount], Dict[str, str]]:
+    """Return a list of Mount for the given modules"""
+    mount_list: List[Mount] = []
+    mount_dict: Dict[str, str] = {}
+
+    for module in modules:
+        full_target_path = os.path.join(target_path, os.path.basename(module))
+        full_project_path = os.path.realpath(os.path.join(project_path, module))
+        mount_list.append(Mount(full_target_path, full_project_path, "bind"))
+        mount_dict[full_target_path] = full_project_path
+
+    return mount_list, mount_dict
diff --git a/odoo_scripts/docker_dev_start.py b/odoo_scripts/docker_dev_start.py
index 2a99fb809c685eb438448b434979af9b121e03ef_b2Rvb19zY3JpcHRzL2RvY2tlcl9kZXZfc3RhcnQucHk=..606a967c39b15e0a3d167619fb472e374ba176f9_b2Rvb19zY3JpcHRzL2RvY2tlcl9kZXZfc3RhcnQucHk= 100755
--- a/odoo_scripts/docker_dev_start.py
+++ b/odoo_scripts/docker_dev_start.py
@@ -15,10 +15,10 @@
 from docker.types import Mount
 from psycopg2 import OperationalError, connect
 
-from . import docker_build
-from .config import Config
-from .docker_client import DockerClient
+from .config import ODOO_15, Config
+from .docker_build import add_build_options, build_local_image, get_build_options
+from .docker_client import DockerClient, modules_mount
 from .docker_flake8 import apply_flake8, parser_add_flake8_group
 from .docker_isort import apply_isort, parser_add_isort_group
 from .docker_postgresql import POSTGRES_PASSWORD, docker_run_postgresql
 from .docker_py3o import start_py3o
@@ -21,10 +21,11 @@
 from .docker_flake8 import apply_flake8, parser_add_flake8_group
 from .docker_isort import apply_isort, parser_add_isort_group
 from .docker_postgresql import POSTGRES_PASSWORD, docker_run_postgresql
 from .docker_py3o import start_py3o
+from .docker_pylint import apply_pylint, parser_add_pylint_group
 from .parsing import apply, basic_parser
 
 # TODO auto create list of module
 
 _logger = logging.getLogger(__name__)
 
@@ -25,8 +26,8 @@
 from .parsing import apply, basic_parser
 
 # TODO auto create list of module
 
 _logger = logging.getLogger(__name__)
 
-__version__ = "3.2.0"
+__version__ = "3.3.0"
 __date__ = "2017-08-11"
@@ -32,5 +33,5 @@
 __date__ = "2017-08-11"
-__updated__ = "2022-01-17"
+__updated__ = "2022-01-27"
 
 
 def main(argv=None):  # IGNORE:C0111
@@ -157,6 +158,8 @@
     )
     parser_add_flake8_group(parser)
     parser_add_isort_group(parser)
+    parser_add_pylint_group(parser)
+    add_build_options(parser)
     parser.add_argument(
         "--odoo-help",
         help="Pass --help to odoo binary [default: %(default)s]",
@@ -306,9 +309,6 @@
 
     c = Config()
 
-    modules = c.modules
-    _logger.debug("addon modules: %s", ",".join(modules))
-
     if not db_user and c.db_user is not None:
         db_user = c.db_user
     if not db_password and c.db_password is not None:
@@ -316,6 +316,4 @@
     if c.load_language is not None:
         load_language = c.load_language
 
-    registry = c.registry
-    project = c.image
     odoo_type = c.odoo_type
@@ -321,5 +319,5 @@
     odoo_type = c.odoo_type
-    image = "%s/%s:latest" % (registry, project)
+    image = c.local_image_name
     postgresql_version = c.postgresql_version
     module_list = c.module_list
     if nmspc.start_py3o:
@@ -332,15 +330,8 @@
     _logger.debug("Docker image: %s", image)
     # detect if docker image already exists
     client = DockerClient.client
-    image_list = client.images.list(name=image)
-    if not image_list:
-        _logger.info("Image %s does not exist", image)
-    else:
-        _logger.info("Image %s exists", image)
-    if (not image_list and not nmspc.no_build) or nmspc.force_build:
-        _logger.info("Building image %s", image)
-        arguments = ["--dev"]
-        failed = docker_build.main(arguments)
+    if not nmspc.no_build:
+        failed = build_local_image(get_build_options(nmspc), nmspc.force_build)
         if failed:
             return failed
 
@@ -355,8 +346,8 @@
     }
     if not use_host_network:
         options["ports"][8069] = 8069
-    mounts: List[Mount] = list()
+    mounts: List[Mount] = []
     # TODO handle other version of odoo (base image need to be changed too)
     coverage = (nmspc.test or nmspc.test_default) and odoo_type in (
         "odoo11",
         "odoo13",
@@ -359,8 +350,8 @@
     # TODO handle other version of odoo (base image need to be changed too)
     coverage = (nmspc.test or nmspc.test_default) and odoo_type in (
         "odoo11",
         "odoo13",
-        "odoo15",
+        ODOO_15,
     )
     arg = ["coverage-start" if coverage else "start"]
     if db_password:
@@ -457,7 +448,7 @@
         _logger.info("Local configuration file found: %s", local_conf_path)
         odoo_conf_file = (
             "/etc/odoo/odoo.conf"
-            if odoo_type in ("odoo11", "odoo12", "odoo13", "odoo15")
+            if odoo_type in ("odoo11", "odoo12", "odoo13", ODOO_15)
             else "/opt/odoo/etc/odoo.conf"
         )
         mounts.append(
@@ -668,8 +659,8 @@
                 connection.commit()
                 connection.close()
             else:
-                _logger.fatal("Cannot connect to database with user %s", user)
-                _logger.fatal(exception)
+                _logger.critical("Cannot connect to database with user %s", user)
+                _logger.critical(exception)
                 _logger.info("You can add the --create-user argument to create it")
                 return 16
 
@@ -751,4 +742,6 @@
                 return 16
 
     # volume magic
+    modules = c.modules
+    _logger.debug("addon modules: %s", ",".join(modules))
     target_module_directory = "/mnt/addons"
@@ -754,12 +747,5 @@
     target_module_directory = "/mnt/addons"
-    for module in modules:
-        mounts.append(
-            Mount(
-                "%s/%s" % (target_module_directory, os.path.basename(module)),
-                os.path.realpath(os.path.join(project_path, module)),
-                "bind",
-            )
-        )
+    mounts.extend(modules_mount(modules, project_path, target_module_directory)[0])
     all_addons_dir = [target_module_directory]
     if odoo_type in ("odoo7", "odoo8"):
         all_addons_dir.append("/opt/odoo/sources/odoo/addons")
@@ -769,6 +755,7 @@
 
     apply_flake8(nmspc, odoo_type)
     apply_isort(nmspc, odoo_type)
+    apply_pylint(nmspc)
 
     # developer mode options
     if dev:
@@ -772,7 +759,7 @@
 
     # developer mode options
     if dev:
-        if odoo_type in ("odoo10", "odoo11", "odoo13", "odoo15"):
+        if odoo_type in ("odoo10", "odoo11", "odoo13", ODOO_15):
             # automatically add reload if not already asks for
             if not any(opt in dev_opts for opt in ("all", "reload")):
                 dev_opts.append("reload")
@@ -795,7 +782,7 @@
     # bind Odoo sources
     if odoo_sources:
         _logger.info("Binding Odoo sources at %s", odoo_sources)
-        if odoo_type in ("odoo10", "odoo11", "odoo13", "odoo15"):
+        if odoo_type in ("odoo10", "odoo11", "odoo13", ODOO_15):
             # Image 11 uses python 3.5 if based on xenial, or 3.6 for bionic
             # 13 uses bionic
             if odoo_type == "odoo10":
@@ -804,7 +791,7 @@
                 pythons = ("python3.5", "python3.6")
             elif odoo_type == "odoo13":
                 pythons = ("python3.8",)
-            elif odoo_type == "odoo15":
+            elif odoo_type == ODOO_15:
                 pythons = ("python3.9",)
             else:
                 pythons = ("python3.6",)
diff --git a/odoo_scripts/docker_flake8.py b/odoo_scripts/docker_flake8.py
index 2a99fb809c685eb438448b434979af9b121e03ef_b2Rvb19zY3JpcHRzL2RvY2tlcl9mbGFrZTgucHk=..606a967c39b15e0a3d167619fb472e374ba176f9_b2Rvb19zY3JpcHRzL2RvY2tlcl9mbGFrZTgucHk= 100644
--- a/odoo_scripts/docker_flake8.py
+++ b/odoo_scripts/docker_flake8.py
@@ -3,7 +3,6 @@
 """Run flake8 in a docker
 """
 import argparse
-import atexit
 import logging
 import os
 import sys
@@ -12,5 +11,5 @@
 from .docker_client import DockerClient
 from .parsing import apply, basic_parser
 
-__version__ = "1.1.0"
+__version__ = "1.2.0"
 __date__ = "2020-09-10"
@@ -16,5 +15,5 @@
 __date__ = "2020-09-10"
-__updated__ = "2022-01-25"
+__updated__ = "2022-01-27"
 
 _logger = logging.getLogger(__name__)
 
@@ -30,7 +29,7 @@
     program_license = """%s
 
       Created by Vincent Hatakeyama on %s.
-      Copyright 2020 XCG Consulting. All rights reserved.
+      Copyright 2020, 2022 XCG Consulting. All rights reserved.
 
       Licensed under the MIT License
 
@@ -84,8 +83,6 @@
 
 def flake8(odoo_type: str, pull: bool = True):
     """Run flake8"""
-    client = DockerClient.client
-
     # determine image to use based on odoo version
     repository = "quay.orus.io/odoo/odoo"
     if odoo_type in ("odoo7", "odoo8", "odoo9", "odoo10"):
@@ -96,5 +93,4 @@
     else:
         repository = "xcgd/flake8"
         tag = "3"
-    image = f"{repository}:{tag}"
 
@@ -100,35 +96,15 @@
 
-    if pull:
-        _logger.info("Pulling flake8 (%s)", image)
-        client.images.pull(repository, tag)
-
-    _logger.info("Running flake8 (%s)", image)
-    container = None
-
-    # XXX When pressing ctrl-c during the container creating, the container
-    #  is not removed as there is no return of the value of the Container
-    #  object. There is several ways to do that:
-    #  - give the container a unique name we know, and use that to clean
-    #  - maybe use some kind of time frame to identify the container
-    #  - warn the user of a potential container leak by using signals
-
-    def stop_remove():
-        """Stop then remove the container."""
-        if container:
-            if container.status == "running":
-                container.stop()
-                container.wait()
-            container.remove()
-
-    atexit.register(stop_remove)
-
-    # split create and start so in case of SIGTERM, container has fewer chances
-    # of being None
-    container = client.containers.create(
-        image,
-        entrypoint="flake8",
-        command=["."],
-        volumes={os.environ["PWD"]: {"bind": "/mnt", "mode": "ro"}},
-        working_dir="/mnt",
-        detach=True,
+    _logger.info("Running flake8")
+    pwd = os.environ["PWD"]
+    return DockerClient.run(
+        repository,
+        tag,
+        {
+            "entrypoint": "flake8",
+            "command": ["/mnt"],
+            "volumes": {pwd: {"bind": "/mnt", "mode": "ro"}},
+            "working_dir": "/mnt",
+        },
+        pull,
+        {"/mnt": pwd},
     )
@@ -134,14 +110,4 @@
     )
-    container.start()
-    result = container.wait()
-    atexit.unregister(stop_remove)
-    atexit.register(container.remove)
-    print(container.logs().decode().rstrip())
-    if result.get("Error", None):
-        _logger.warning(result.decode())
-    container.remove(v=True)
-    atexit.unregister(container.remove)
-    return result["StatusCode"]
 
 
 if __name__ == "__main__":
diff --git a/odoo_scripts/docker_isort.py b/odoo_scripts/docker_isort.py
index 2a99fb809c685eb438448b434979af9b121e03ef_b2Rvb19zY3JpcHRzL2RvY2tlcl9pc29ydC5weQ==..606a967c39b15e0a3d167619fb472e374ba176f9_b2Rvb19zY3JpcHRzL2RvY2tlcl9pc29ydC5weQ== 100644
--- a/odoo_scripts/docker_isort.py
+++ b/odoo_scripts/docker_isort.py
@@ -3,8 +3,7 @@
 """Run isort in a docker
 """
 import argparse
-import atexit
 import logging
 import os
 import sys
 
@@ -7,10 +6,8 @@
 import logging
 import os
 import sys
 
-import docker
-
 from .config import ODOO_15, Config
 from .docker_client import DockerClient
 from .parsing import apply, basic_parser
 
@@ -13,6 +10,6 @@
 from .config import ODOO_15, Config
 from .docker_client import DockerClient
 from .parsing import apply, basic_parser
 
-__version__ = "2.0.0"
+__version__ = "2.1.0"
 __date__ = "2020-09-10"
@@ -18,5 +15,5 @@
 __date__ = "2020-09-10"
-__updated__ = "2022-01-25"
+__updated__ = "2022-01-27"
 
 _logger = logging.getLogger(__name__)
 _ISORT_DEST = "isort"
@@ -101,8 +98,6 @@
 
 def isort(mode: str, odoo_type: str, pull: bool = True):
     """Run isort"""
-    client = DockerClient.client
-
     if mode == CHECK_MODE:
         command = ["--check"]
     if mode == DIFF_MODE:
@@ -119,5 +114,4 @@
     else:
         repository = "xcgd/isort"
         tag = "odoo"
-    image = f"{repository}:{tag}"
 
@@ -123,43 +117,18 @@
 
-    if pull:
-        _logger.info("Pulling isort (%s)", image)
-        # TODO find a better way to avoid error when pulling without a
-        #  connection
-        try:
-            client.images.pull(repository=repository, tag=tag)
-        except docker.errors.APIError:
-            _logger.warning("No connection to server, using existing version, if any")
-
-    _logger.info("Running isort for Odoo (%s)", image)
-    container = None
-
-    # XXX When pressing ctrl-c during the container creating, the container
-    #  is not removed as there is no return of the value of the Container
-    #  object. There is several ways to do that:
-    #  - give the container a unique name we know, and use that to clean
-    #  - maybe use some kind of time frame to identify the container
-    #  - warn the user of a potential container leak by using signals
-
-    def stop_remove():
-        """Stop then remove the container."""
-        if container:
-            if container.status == "running":
-                container.stop()
-                container.wait()
-            container.remove()
-
-    atexit.register(stop_remove)
-
-    # split create and start so in case of SIGTERM, container has fewer chances
-    # of being None
-    container = client.containers.create(
-        image,
-        entrypoint="isort",
-        user="root",
-        command=command,
-        volumes={
-            os.environ["PWD"]: {
-                "bind": "/mnt",
-                "mode": "ro" if mode in (CHECK_MODE, DIFF_MODE) else "rw",
-            }
+    _logger.info("Running isort")
+    pwd = os.environ["PWD"]
+    return DockerClient.run(
+        repository,
+        tag,
+        {
+            "entrypoint": "isort",
+            "user": "root",
+            "command": command,
+            "volumes": {
+                pwd: {
+                    "bind": "/mnt",
+                    "mode": "ro" if mode in (CHECK_MODE, DIFF_MODE) else "rw",
+                }
+            },
+            "working_dir": "/mnt",
         },
@@ -165,4 +134,4 @@
         },
-        working_dir="/mnt",
-        detach=True,
+        pull,
+        {"/mnt": pwd},
     )
@@ -168,14 +137,4 @@
     )
-    container.start()
-    result = container.wait()
-    atexit.unregister(stop_remove)
-    atexit.register(container.remove)
-    print(container.logs().decode().rstrip())
-    if result.get("Error", None):
-        _logger.warning(result.decode())
-    container.remove(v=True)
-    atexit.unregister(container.remove)
-    return result["StatusCode"]
 
 
 if __name__ == "__main__":
diff --git a/odoo_scripts/docker_flake8.py b/odoo_scripts/docker_pylint.py
similarity index 27%
copy from odoo_scripts/docker_flake8.py
copy to odoo_scripts/docker_pylint.py
index 2a99fb809c685eb438448b434979af9b121e03ef_b2Rvb19zY3JpcHRzL2RvY2tlcl9mbGFrZTgucHk=..606a967c39b15e0a3d167619fb472e374ba176f9_b2Rvb19zY3JpcHRzL2RvY2tlcl9weWxpbnQucHk= 100644
--- a/odoo_scripts/docker_flake8.py
+++ b/odoo_scripts/docker_pylint.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
 # vim: set shiftwidth=4 softtabstop=4:
-"""Run flake8 in a docker
+"""Run pylint in a docker
 """
 import argparse
@@ -4,6 +4,5 @@
 """
 import argparse
-import atexit
 import logging
 import os
 import sys
@@ -7,5 +6,8 @@
 import logging
 import os
 import sys
+from typing import Dict, List
+
+from docker.types import Mount
 
 from .config import ODOO_15, Config
@@ -10,5 +12,6 @@
 
 from .config import ODOO_15, Config
-from .docker_client import DockerClient
+from .docker_build import add_build_options, build_local_image, get_build_options
+from .docker_client import DockerClient, modules_mount
 from .parsing import apply, basic_parser
 
@@ -13,8 +16,8 @@
 from .parsing import apply, basic_parser
 
-__version__ = "1.1.0"
-__date__ = "2020-09-10"
-__updated__ = "2022-01-25"
+__version__ = "1.0.0"
+__date__ = "2022-01-26"
+__updated__ = "2022-01-27"
 
 _logger = logging.getLogger(__name__)
 
@@ -18,6 +21,9 @@
 
 _logger = logging.getLogger(__name__)
 
+_PYLINT_DEST = "pylint"
+"""Name of destination variable used for pylint in parsing"""
+
 
 def __parser():
     program_version = __version__
@@ -30,7 +36,7 @@
     program_license = """%s
 
       Created by Vincent Hatakeyama on %s.
-      Copyright 2020 XCG Consulting. All rights reserved.
+      Copyright 2022 XCG Consulting. All rights reserved.
 
       Licensed under the MIT License
 
@@ -46,23 +52,15 @@
     return parser
 
 
-def parser_add_flake8_group(parser: argparse.ArgumentParser):
-    """Add flake8 option (--flake8/--no-flake8) to the given parser.
-    Defaults to not running flake8."""
-    flake8_group = parser.add_mutually_exclusive_group()
-    flake8_group.add_argument(
-        "--flake8",
-        help="Run flake8",
-        action="store_true",
-        dest="flake8",
-        default=False,
-    )
-    flake8_group.add_argument(
-        "--no-flake8",
-        help="Do not run flake8 [default]",
-        action="store_false",
-        dest="flake8",
-        default=False,
+def parser_add_pylint_group(parser: argparse.ArgumentParser):
+    """Add pylint option to the given parser.
+    Defaults to not running pylint."""
+    parser.add_argument(
+        "--pylint",
+        help="Run pylint on given python module (for example odoo.addons.base)",
+        dest=_PYLINT_DEST,
+        default=[],
+        nargs="+",
     )
 
 
@@ -66,12 +64,13 @@
     )
 
 
-def apply_flake8(namespace: argparse.Namespace, odoo_type: str):
-    """Run flake8 if the option was set."""
-    if namespace.flake8:
-        flake8(odoo_type)
+def apply_pylint(namespace: argparse.Namespace):
+    """Run pylint if the option was set."""
+    if _PYLINT_DEST in namespace and getattr(namespace, _PYLINT_DEST):
+        return pylint(getattr(namespace, _PYLINT_DEST), get_build_options(namespace))
+    return 0
 
 
 def main(argv=None):
     """Copy modules for a build, callable version that parses arguments"""
     parser = __parser()
@@ -73,7 +72,9 @@
 
 
 def main(argv=None):
     """Copy modules for a build, callable version that parses arguments"""
     parser = __parser()
+    parser_add_pylint_group(parser)
+    add_build_options(parser)
     nmspc = parser.parse_args(argv)
     apply(nmspc)
@@ -78,7 +79,5 @@
     nmspc = parser.parse_args(argv)
     apply(nmspc)
-    c = Config()
-    odoo_type = c.odoo_type
-    return flake8(odoo_type)
+    return apply_pylint(nmspc)
 
 
@@ -83,6 +82,6 @@
 
 
-def flake8(odoo_type: str, pull: bool = True):
-    """Run flake8"""
-    client = DockerClient.client
+def pylint(modules, build_options):
+    """Run pylint"""
+    project_path = os.path.realpath(".")
 
@@ -88,13 +87,4 @@
 
-    # determine image to use based on odoo version
-    repository = "quay.orus.io/odoo/odoo"
-    if odoo_type in ("odoo7", "odoo8", "odoo9", "odoo10"):
-        repository = "xcgd/flake8"
-        tag = "2"
-    elif odoo_type == ODOO_15:
-        tag = "15.0"
-    else:
-        repository = "xcgd/flake8"
-        tag = "3"
-    image = f"{repository}:{tag}"
+    # always use project image
+    c = Config()
 
@@ -100,8 +90,3 @@
 
-    if pull:
-        _logger.info("Pulling flake8 (%s)", image)
-        client.images.pull(repository, tag)
-
-    _logger.info("Running flake8 (%s)", image)
-    container = None
+    odoo_type = c.odoo_type
 
@@ -107,8 +92,5 @@
 
-    # XXX When pressing ctrl-c during the container creating, the container
-    #  is not removed as there is no return of the value of the Container
-    #  object. There is several ways to do that:
-    #  - give the container a unique name we know, and use that to clean
-    #  - maybe use some kind of time frame to identify the container
-    #  - warn the user of a potential container leak by using signals
+    mounts: List[Mount] = []
+    environment = {}
+    target_source_dict: Dict[str, str] = {}
 
@@ -114,11 +96,24 @@
 
-    def stop_remove():
-        """Stop then remove the container."""
-        if container:
-            if container.status == "running":
-                container.stop()
-                container.wait()
-            container.remove()
-
-    atexit.register(stop_remove)
+    # odoo configuration is not read so put the modules in the path
+    if odoo_type in ("odoo10", "odoo11", "odoo13", ODOO_15):
+        if odoo_type == "odoo10":
+            pythons = ("python2.7",)
+        elif odoo_type == "odoo11":
+            pythons = ("python3.5", "python3.6")
+        elif odoo_type == "odoo13":
+            pythons = ("python3.8",)
+        elif odoo_type == ODOO_15:
+            pythons = ("python3.9",)
+        for python in pythons:
+            result = modules_mount(
+                c.modules,
+                project_path,
+                f"/usr/local/lib/{python}/dist-packages/odoo/addons",
+            )
+            mounts.extend(result[0])
+            target_source_dict.update(result[1])
+    elif odoo_type in ("odoo7", "odoo8"):
+        mounts, target_source_dict = modules_mount(
+            c.modules, project_path, "/opt/odoo/sources/odoo/addons"
+        )
 
@@ -124,11 +119,25 @@
 
-    # split create and start so in case of SIGTERM, container has fewer chances
-    # of being None
-    container = client.containers.create(
-        image,
-        entrypoint="flake8",
-        command=["."],
-        volumes={os.environ["PWD"]: {"bind": "/mnt", "mode": "ro"}},
-        working_dir="/mnt",
-        detach=True,
+    # Add a volume to store cache
+    cache_volume_name = "pylint-cache"
+    DockerClient.create_volume(cache_volume_name)
+    cache_target_path = "/var/cache/pylint"
+    mounts.append(Mount(cache_target_path, cache_volume_name))
+    environment["PYLINTHOME"] = cache_target_path
+
+    build_local_image(build_options)
+
+    _logger.info("Running pylint")
+    return DockerClient.run(
+        c.local_image,
+        c.local_tag,
+        {
+            "entrypoint": "pylint",
+            "command": modules + ["--load-plugins=pylint_odoo"],
+            # needed to write in the cache
+            "user": "root",
+            "mounts": mounts,
+            "environment": environment,
+        },
+        False,
+        target_source_dict,
     )
@@ -134,14 +143,4 @@
     )
-    container.start()
-    result = container.wait()
-    atexit.unregister(stop_remove)
-    atexit.register(container.remove)
-    print(container.logs().decode().rstrip())
-    if result.get("Error", None):
-        _logger.warning(result.decode())
-    container.remove(v=True)
-    atexit.unregister(container.remove)
-    return result["StatusCode"]
 
 
 if __name__ == "__main__":
diff --git a/setup.py b/setup.py
index 2a99fb809c685eb438448b434979af9b121e03ef_c2V0dXAucHk=..606a967c39b15e0a3d167619fb472e374ba176f9_c2V0dXAucHk= 100644
--- a/setup.py
+++ b/setup.py
@@ -43,6 +43,7 @@
             "docker_flake8=odoo_scripts.docker_flake8:main [docker]",
             "docker_isort=odoo_scripts.docker_isort:main [docker]",
             "docker_pg=odoo_scripts.docker_postgresql:main [docker]",
+            "docker_pylint=odoo_scripts.docker_pylint:main [docker]",
             "conf2reST=odoo_scripts.conf2reST:main [conf2reST]",
             "list_modules=odoo_scripts.list_modules:main",
             "update_duplicate_sources="