Repository: davidfischer-ch/pytoolbox (language: python)

Issue:
Publish some code that was private
Integrate code I developed to various modules :

* [ ] Add Ansible module
* [x] Add Git module
* [ ] Add GitLab module
* [ ] Add MkDocs module
* [ ] Add Security module
* [ ] Add Terraform module
* [x] Other to various modules
* [ ] GitLab CLI (processor & co)


Discussion / hints:

Fix patch:
diff --git a/pytoolbox/__init__.py b/pytoolbox/__init__.py
index 59fdd26f..1d525868 100644
--- a/pytoolbox/__init__.py
+++ b/pytoolbox/__init__.py
@@ -1,1 +1,9 @@
 __version__ = '14.8.0'
+
+import logging as _logging
+
+# https://docs.python.org/3/howto/logging.html#library-config
+# A do-nothing handler is included in the logging package: NullHandler (since Python 3.1).
+# An instance of this handler could be added to the top-level logger of the logging namespace used
+# by the library (if you want to prevent your library’s logged events being output to sys.stderr
+_logging.getLogger(__name__).addHandler(_logging.NullHandler())
diff --git a/pytoolbox/argparse.py b/pytoolbox/argparse.py
index 2d3c39a1..72c9c2e3 100644
--- a/pytoolbox/argparse.py
+++ b/pytoolbox/argparse.py
@@ -3,12 +3,10 @@
 
 **Example usage**
 
->>> import argparse
 >>> from pathlib import Path
->>> parser = argparse.ArgumentParser(
-...     formatter_class=argparse.ArgumentDefaultsHelpFormatter,
-...     epilog='My super cool software.')
->>> x = parser.add_argument('directory', action=FullPaths, type=is_dir)
+>>> from pytoolbox import argparse
+>>> parser = argparse.ArgumentParser(epilog='My super cool software.')
+>>> x = parser.add_argument('directory', action=argparse.FullPaths, type=argparse.is_dir)
 >>> str(parser.parse_args(['/usr/lib']).directory)
 '/usr/lib'
 >>> assert parser.parse_args(['.']).directory == Path(os.getcwd()).expanduser().resolve()
@@ -21,19 +19,63 @@
 
 from collections.abc import Callable
 from pathlib import Path
-from typing import Any
+from typing import Any, Final
 import argparse
 import getpass
+import functools
 import os
-import shutil
+import sys
 
-from . import itertools, module
+from . import console, exceptions, itertools, logging, module
+from .decorators import deprecated
+
+log = logging.get_logger(__name__)
 
 _all = module.All(globals())
 
-from argparse import Namespace  # noqa:E402 pylint:disable=wrong-import-position
+from argparse import (  # noqa:E402,F401 pylint:disable=unused-import,wrong-import-position
+    ArgumentTypeError,
+    Namespace
+)
+
+
+# Argument Parsing Actions -------------------------------------------------------------------------
+
+
+class ChainAction(argparse._AppendAction):  # pylint:disable=protected-access
+    def __call__(
+        self,
+        parser: argparse.ArgumentParser,
+        namespace: Namespace,
+        values,
+        option_string: str | None = None
+    ) -> None:
+        items = getattr(namespace, self.dest, None)
+        items = argparse._copy_items(items)  # type: ignore[attr-defined]
+        items.extend(itertools.chain(*values))
+        setattr(namespace, self.dest, items)
+
+
+class FullPaths(argparse.Action):
+    """
+    Expand user/relative paths.
+
+    Credits https://gist.github.com/brantfaircloth/1443543
+    """
+    def __call__(
+        self,
+        parser: argparse.ArgumentParser,
+        namespace: Namespace,
+        values,
+        option_string: str | None = None
+    ) -> None:
+        def fullpath(path: Path | str) -> Path:
+            return Path(path).expanduser().resolve()
+        value = itertools.extract_single([fullpath(v) for v in itertools.chain(values)])
+        setattr(namespace, self.dest, value)
+
 
-# Credits https://gist.github.com/brantfaircloth/1443543
+# Argument Parsing Types ---------------------------------------------------------------------------
 
 
 def is_dir(path: Path | str) -> Path:
@@ -63,24 +105,14 @@ def password(value: str | None) -> str:
     return value or getpass.getpass('Password: ')
 
 
-def set_columns(value: int | None = None, *, default: int = 120) -> int:
-    if value is None:
-        try:
-            value = shutil.get_terminal_size().columns
-        except AttributeError:
-            value = default
-    os.environ['COLUMNS'] = str(value)
-    return value
-
-
-class FullPaths(argparse.Action):
-    """Expand user/relative paths."""
+def separator(value: str, sep: str | None) -> list[str]:
+    """
+    Split a string to items using given separator and strip every item, drop empty items.
 
-    def __call__(self, parser, namespace: Namespace, values, option_string=None):
-        def fullpath(path: Path | str) -> Path:
-            return Path(path).expanduser().resolve()
-        value = itertools.extract_single([fullpath(v) for v in itertools.chain(values)])
-        setattr(namespace, self.dest, value)
+    Can be used as the type of an argparse argument.
+    """
+    values = [value.strip()] if sep is None else (v.strip() for v in value.split(sep))
+    return [v for v in values if v]
 
 
 class Range(object):  # pylint:disable=too-few-public-methods
@@ -100,15 +132,101 @@ def __call__(self, value):
         return value
 
 
-class HelpArgumentParser(argparse.ArgumentParser):
+# Argument Parsing Defaults ------------------------------------------------------------------------
+
+
+def env_default(name: str) -> dict[str, Any]:
+    """Set argument's default value from environment if available else make it mandatory."""
+    value = os.getenv(name)
+    return {'required': True} if value is None else {'default': value}
+
+
+# Argument Parsing Configuration Combos ------------------------------------------------------------
 
-    def __init__(self, *args, **kwargs) -> None:
-        set_columns(kwargs.pop('columns', None))
-        super().__init__(*args, formatter_class=HelpFormatter, **kwargs)
+DIRECTORY_ARG: Final[dict[str, str | Callable]] = {'action': 'fullpaths', 'type': is_dir}
+FILE_ARG: Final[dict[str, str | Callable]] = {'action': 'fullpaths', 'type': is_file}
+REMAINDER_ARG: Final[dict[str, Any]] = {'nargs': argparse.REMAINDER}
+
+
+def MULTI_ARG(sep: str | None = None) -> dict[str, str | Callable]:  # pylint:disable=invalid-name
+    return {'action': 'chain', 'nargs': '+', 'type': functools.partial(separator, sep=sep)}
+
+
+# Argument Parsing Core ----------------------------------------------------------------------------
 
 
 class HelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter):
     pass
 
 
+class ArgumentParser(argparse.ArgumentParser):
+
+    formatter_cls: type[argparse.HelpFormatter] = HelpFormatter
+
+    def __init__(self, *args, columns: int | None = None, **kwargs) -> None:
+        """Base class to build simple CLIs."""
+        console.set_columns(columns)
+        kwargs.setdefault('formatter_class', self.formatter_cls)
+        super().__init__(*args, **kwargs)
+        self.register('action', 'chain', ChainAction)
+        self.register('action', 'fullpaths', FullPaths)
+
+
+class ActionArgumentParser(ArgumentParser):
+
+    def __init__(self, *args, version: str | None = None, **kwargs) -> None:
+        """Specialized class to build CLIs that expose actions (sub-commands)."""
+        super().__init__(*args, **kwargs)
+        self._action = self.add_subparsers(help='action', required=False)
+        self._version = version
+        if version is not None:
+            self.add_version_action(version)
+
+    def action_version(self, args: Namespace) -> None:  # pylint:disable=unused-argument
+        print(self._version)
+
+    def add_action(self, name: str, func) -> Callable:
+        parser = self._action.add_parser(name, description=func.__doc__)
+        parser.set_defaults(func=func)
+        return parser.add_argument
+
+    def add_version_action(self, version: str) -> None:
+        self._version = version
+        self.add_action('version', self.action_version)
+
+    def execute(self, args: Namespace | None = None) -> None:
+        args = sys.argv[1:] if args is None else args  # type: ignore[assignment]
+        if len(args) < 1:  # type: ignore[arg-type]
+            sys.exit('An action is required')
+        parsed_args = self.parse_args(args)  # type: ignore[arg-type]
+        try:
+            parsed_args.func(parsed_args)
+        except Exception as ex:  # pylint:disable=broad-exception-caught
+            self.handle_exception(ex)
+            raise  # If not handled by the exception handler
+
+    def handle_exception(self, ex: Exception) -> None:
+        if isinstance(ex, exceptions.CalledProcessError):
+            log.error(repr(ex))
+            sys.exit(1)
+        if isinstance(ex, exceptions.MessageMixin):
+            log.error(ex)
+            sys.exit(1)
+
+
 __all__ = _all.diff(globals())
+
+
+# Deprecated ---------------------------------------------------------------------------------------
+
+
+@deprecated('Use pytoolbox.console.set_columns instead (drop-in replacement)')
+def set_columns(*args, **kwargs) -> int:  # pragma: no cover
+    return console.set_columns(*args, **kwargs)
+
+
+class HelpArgumentParser(ArgumentParser):
+
+    @deprecated('Use pytoolbox.argparse.ArgumentParser instead (drop-in replacement)')
+    def __init__(self, *args, **kwargs) -> None:  # pragma: no cover
+        super().__init__(*args, **kwargs)
diff --git a/pytoolbox/comparison.py b/pytoolbox/comparison.py
index fb198453..d7cbaea0 100644
--- a/pytoolbox/comparison.py
+++ b/pytoolbox/comparison.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from collections.abc import Iterator
+from collections.abc import Iterable, Iterator
 from typing import TypeAlias
 import difflib
 import operator as op
@@ -44,6 +44,7 @@ def __ne__(self, other) -> bool:
 
 # Content ------------------------------------------------------------------------------------------
 
+
 def unified_diff(before: str, after: str, *, colorize: bool = True, **kwargs) -> str:
     """
     Colorization is not guaranteed (your environment may disable it).
@@ -53,7 +54,7 @@ def unified_diff(before: str, after: str, *, colorize: bool = True, **kwargs) ->
     return os.linesep.join(_colorize(diff) if colorize else diff)
 
 
-def _colorize(diff: Iterator[str]) -> Iterator[str]:
+def _colorize(diff: Iterable[str]) -> Iterator[str]:
     for line in diff:
         if line.startswith('+'):
             yield termcolor.colored(line, 'green')
@@ -67,6 +68,7 @@ def _colorize(diff: Iterator[str]) -> Iterator[str]:
 
 # Versions -----------------------------------------------------------------------------------------
 
+
 def _eqn(a, b) -> bool | None:  # pylint:disable=invalid-name
     return True if a == b else None
 
@@ -104,7 +106,8 @@ def compare_versions(
 
 def satisfy_version_constraints(
     version: str | None,
-    constraints: tuple[str, ...], *,
+    constraints: Iterable[str],
+    *,
     default: str = '<undefined>',
 ) -> bool:
     """
diff --git a/pytoolbox/console.py b/pytoolbox/console.py
index 45064771..d96bc6f5 100644
--- a/pytoolbox/console.py
+++ b/pytoolbox/console.py
@@ -5,9 +5,21 @@
 import atexit
 import code
 import os
+import shutil
 import sys
 
-__all__ = ['confirm', 'choice', 'print_error', 'progress_bar', 'shell', 'toggle_colors']
+__all__ = [
+    'confirm',
+    'choice',
+    'print_error',
+    'progress_bar',
+    'shell',
+    'set_columns',
+    'toggle_colors'
+]
+
+
+# Input --------------------------------------------------------------------------------------------
 
 
 def confirm(
@@ -22,7 +34,6 @@ def confirm(
     **Example usage**
 
     ::
-
         >> confirm('Do it now', default=True)
         Do it now ? [Y/n]:
         True
@@ -50,7 +61,7 @@ def confirm(
 def choice(
     question: str = '',
     *,
-    choices: tuple[str, ...] | list[str],
+    choices: list[str] | tuple[str, ...],
     stream: TextIO = sys.stdout
 ) -> str:
     """
@@ -82,6 +93,9 @@ def choice(
         stream.write(f'Please choose between {choices_string}.{os.linesep}')
 
 
+# Output -------------------------------------------------------------------------------------------
+
+
 def print_error(message: str, exit_code: int | None = 1, stream: TextIO = sys.stderr) -> None:
     """
     Print an error message and exit if `exit_code` is not None.
@@ -132,6 +146,9 @@ def progress_bar(
         stream.flush()
 
 
+# Interactivity ------------------------------------------------------------------------------------
+
+
 def shell(
     banner: str | None = None,
     *,
@@ -159,12 +176,25 @@ def shell(
     code.interact(banner=banner, local=imported)
 
 
+# Configuration ------------------------------------------------------------------------------------
+
+
+def set_columns(value: int | None = None, *, default: int = 120) -> int:
+    if value is None:
+        try:
+            value = shutil.get_terminal_size().columns
+        except AttributeError:
+            value = default
+    os.environ['COLUMNS'] = str(value)
+    return value
+
+
 def toggle_colors(
     env: dict[str, str] | None = None,
     *,
     colorize: bool,
-    disable_vars: tuple[str, ...] | list[str] = ('NO_COLOR', 'ANSI_COLORS_DISABLED'),
-    enable_vars: tuple[str, ...] | list[str] = ('FORCE_COLOR', )
+    disable_vars: list[str] | tuple[str, ...] = ('NO_COLOR', 'ANSI_COLORS_DISABLED'),
+    enable_vars: list[str] | tuple[str, ...] = ('FORCE_COLOR', )
 ) -> dict[str, str]:
     """
     Return `env` (defaulting to `os.environ`) updated to enable or disable colors.
diff --git a/pytoolbox/decorators.py b/pytoolbox/decorators.py
index 1169c7c5..a0bfc6cb 100644
--- a/pytoolbox/decorators.py
+++ b/pytoolbox/decorators.py
@@ -41,21 +41,24 @@ def __get__(self, instance, type=None):  # pylint:disable=redefined-builtin
         return res
 
 
-def deprecated(func: Callable) -> Callable:
-    """
-    Decorator that can be used to mark functions as deprecated.
-    It will result in a warning being emitted when the function is used.
-
-    Credits: https://wiki.python.org/moin/PythonDecoratorLibrary.
-    """
-    @functools.wraps(func)
-    def _deprecated(*args, **kwargs) -> Any:
-        warnings.warn_explicit(
-            f'Call to deprecated function {func.__name__}.',
-            category=DeprecationWarning,
-            filename=func.__code__.co_filename,
-            lineno=func.__code__.co_firstlineno + 1)
-        return func(*args, **kwargs)
+def deprecated(guidelines: str = '') -> Callable:
+    def _deprecated(func: Callable) -> Callable:
+        """
+        Decorator that can be used to mark functions as deprecated.
+        It will result in a warning being emitted when the function is used.
+
+        Credits: https://wiki.python.org/moin/PythonDecoratorLibrary.
+        """
+        @functools.wraps(func)
+        def wrapper(*args, **kwargs) -> Any:
+            guidelines_str = f': {guidelines}' if guidelines else ''
+            warnings.warn_explicit(
+                f'Call to deprecated function {func.__module__}.{func.__name__}{guidelines_str}.',
+                category=DeprecationWarning,
+                filename=func.__code__.co_filename,
+                lineno=func.__code__.co_firstlineno + 1)
+            return func(*args, **kwargs)
+        return wrapper
     return _deprecated
 
 
diff --git a/pytoolbox/django/core/validators.py b/pytoolbox/django/core/validators.py
index 781fa78f..8a114453 100644
--- a/pytoolbox/django/core/validators.py
+++ b/pytoolbox/django/core/validators.py
@@ -62,7 +62,7 @@ def __call__(self, value):
 
     def __eq__(self, other):
         return (
-            isinstance(other, self.__class__)
+            isinstance(other, type(self))
             and self.required_keys == other.required_keys
             and self.optional_keys == other.optional_keys
             and self.messages == other.messages
diff --git a/pytoolbox/django/forms/mixins.py b/pytoolbox/django/forms/mixins.py
index b4d0e470..a8b790e4 100644
--- a/pytoolbox/django/forms/mixins.py
+++ b/pytoolbox/django/forms/mixins.py
@@ -144,7 +144,7 @@ class UpdateWidgetAttributeMixin(object):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         for field in self.fields.values():
-            updates = self.widgets_rules.get(field.__class__)
+            updates = self.widgets_rules.get(type(field))
             # May Update widget class with rules-based replacement class
             if updates and updates[0]:
                 field.widget = updates[0]()
diff --git a/pytoolbox/django/models/mixins.py b/pytoolbox/django/models/mixins.py
index b8218446..c7271ce4 100644
--- a/pytoolbox/django/models/mixins.py
+++ b/pytoolbox/django/models/mixins.py
@@ -170,7 +170,7 @@ def save(self, *args, **kwargs):
                     if fields in (set(u) for u in self._meta.unique_together):
                         fields = sorted(fields - set(self.unique_together_hide_fields))
                         if fields:
-                            error = self.unique_error_message(self.__class__, fields)
+                            error = self.unique_error_message(type(self), fields)
                             raise ValidationError({fields[0]: error}) if len(fields) == 1 else error
                         return self._handle_hidden_duplicate_key_error(ex)
             raise
@@ -190,7 +190,7 @@ def _perform_unique_checks(self, unique_checks):
                 if len(error.params.get('unique_check', [])) > 1:
                     fields = [f for f in error.params['unique_check'] if f not in hidden_fields]
                     if fields:
-                        error = self.unique_error_message(self.__class__, fields)
+                        error = self.unique_error_message(type(self), fields)
                         filtered_errors_by_field[
                             fields[0] if len(fields) == 1 else field
                         ].append(error)
diff --git a/pytoolbox/django/models/utils.py b/pytoolbox/django/models/utils.py
index 863c68ff..4197caeb 100644
--- a/pytoolbox/django/models/utils.py
+++ b/pytoolbox/django/models/utils.py
@@ -25,7 +25,7 @@ def get_related_model(cls_or_instance, field):
 def get_content_type_dict(instance):
     """Return a dictionary with the serialized content type and private key of given instance."""
     from django.contrib.contenttypes import models as ct_models
-    content_type = ct_models.ContentType.objects.get_for_model(instance.__class__)
+    content_type = ct_models.ContentType.objects.get_for_model(type(instance))
     return {'app_label': content_type.app_label, 'model': content_type.model, 'pk': instance.pk}
 
 
@@ -40,7 +40,7 @@ def try_get_field(instance, field_name):
     try:
         return getattr(instance, field_name)
     except Exception as ex:
-        if ex.__class__.__name__ != 'RelatedObjectDoesNotExist':
+        if type(ex).__name__ != 'RelatedObjectDoesNotExist':
             raise
 
 
diff --git a/pytoolbox/django/signals/handlers.py b/pytoolbox/django/signals/handlers.py
index 2ef413c7..84670467 100644
--- a/pytoolbox/django/signals/handlers.py
+++ b/pytoolbox/django/signals/handlers.py
@@ -35,12 +35,12 @@ def connect(config):
 """
 from __future__ import annotations
 
-import logging
-
 from django.conf import settings
 from django.db.models import fields
 from django.db.models.fields.files import FileField
 
+from pytoolbox import logging
+
 __all__ = [
     'logger',
     'clean_files_delete_handler',
@@ -49,7 +49,7 @@ def connect(config):
     'strip_strings_and_validate_model'
 ]
 
-logger = logging.getLogger(__name__)
+logger = logging.get_logger(__name__)
 
 
 def clean_files_delete_handler(instance, signal, **kwargs):
diff --git a/pytoolbox/django/storage.py b/pytoolbox/django/storage.py
index 4406fa04..d6983aa8 100644
--- a/pytoolbox/django/storage.py
+++ b/pytoolbox/django/storage.py
@@ -3,16 +3,15 @@
 """
 from __future__ import annotations
 
-import logging
 import time
 
 from django.core.files.storage import FileSystemStorage
 
-from pytoolbox import module
+from pytoolbox import logging, module
 
 _all = module.All(globals())
 
-logger = logging.getLogger(__name__)
+logger = logging.get_logger(__name__)
 
 
 class ExpressTemporaryFileMixin(object):
diff --git a/pytoolbox/django/templatetags.py b/pytoolbox/django/templatetags.py
index c49f2e7d..af4ffd6d 100644
--- a/pytoolbox/django/templatetags.py
+++ b/pytoolbox/django/templatetags.py
@@ -267,7 +267,8 @@ def verbose_name_plural(instance):
     return constants.DEFFERED_REGEX.sub('', force_str(instance._meta.verbose_name))
 
 
-# TAGS ---------------------------------------------------------------------------------------------
+# Tags ---------------------------------------------------------------------------------------------
+
 
 class StaticPathNode(StaticNode):
 
diff --git a/pytoolbox/django_filter/filterset/mixins.py b/pytoolbox/django_filter/filterset/mixins.py
index 99da785e..246b251f 100644
--- a/pytoolbox/django_filter/filterset/mixins.py
+++ b/pytoolbox/django_filter/filterset/mixins.py
@@ -18,6 +18,6 @@ def filter_for_field(cls, f, name, *, lookup_type='exact'):
         value = super().filter_for_field(f, name, lookup_type)
         if not value:
             raise NotImplementedError(
-                f"Unable to find a suitable filter for field '{name}' of class {f.__class__} "
+                f"Unable to find a suitable filter for field '{name}' of class {type(f)} "
                 f"with lookup type '{lookup_type}'")
         return value
diff --git a/pytoolbox/enum.py b/pytoolbox/enum.py
index 19dbb2ac..fe1a19eb 100644
--- a/pytoolbox/enum.py
+++ b/pytoolbox/enum.py
@@ -20,31 +20,31 @@ def __hash__(self) -> int:
         return hash(self.value)
 
     def __eq__(self, other: Any) -> bool:
-        if self.__class__ is other.__class__:
+        if type(self) is type(other):
             return self.value == other.value  # pylint:disable=comparison-with-callable
         return NotImplemented
 
     def __ge__(self, other: Any) -> bool:
-        if self.__class__ is other.__class__:
+        if type(self) is type(other):
             return self.value >= other.value  # pylint:disable=comparison-with-callable
         return NotImplemented
 
     def __gt__(self, other: Any) -> bool:
-        if self.__class__ is other.__class__:
+        if type(self) is type(other):
             return self.value > other.value  # pylint:disable=comparison-with-callable
         return NotImplemented
 
     def __le__(self, other: Any) -> bool:
-        if self.__class__ is other.__class__:
+        if type(self) is type(other):
             return self.value <= other.value  # pylint:disable=comparison-with-callable
         return NotImplemented
 
     def __lt__(self, other: Any) -> bool:
-        if self.__class__ is other.__class__:
+        if type(self) is type(other):
             return self.value < other.value  # pylint:disable=comparison-with-callable
         return NotImplemented
 
     def __ne__(self, other: Any) -> bool:
-        if self.__class__ is other.__class__:
+        if type(self) is type(other):
             return self.value != other.value  # pylint:disable=comparison-with-callable
         return NotImplemented
diff --git a/pytoolbox/exceptions.py b/pytoolbox/exceptions.py
index a8653a1d..7ee8145d 100644
--- a/pytoolbox/exceptions.py
+++ b/pytoolbox/exceptions.py
@@ -19,18 +19,19 @@ class MessageMixin(Exception):
     message: str
 
     def __init__(self, message: str | None = None, **kwargs) -> None:
-        assert hasattr(self, 'message'), type(self)          # TODO metaclass to check this
-        assert set(self.attrs).issubset(set(kwargs.keys()))  # TODO metaclass to check this
         if message is not None:
             self.message = message
         for key, value in kwargs.items():
             setattr(self, key, value)
         super().__init__()
+        if missing := [attr for attr in self.attrs if not hasattr(self, attr)]:
+            missing = ', '.join(missing)  # type: ignore[assignment]
+            raise AttributeError(f'{type(self)} is missing attributes or properties: {missing}')
 
     def __repr__(self) -> str:
         args = [] if self.message == type(self).message else [f'{repr(self.message)}']
         args.extend(f'{a}={repr(getattr(self, a))}' for a in self.attrs)
-        return f"{self.__class__.__name__}({', '.join(args)})"
+        return f"{type(self).__name__}({', '.join(args)})"
 
     def __str__(self) -> str:
         attributes = inspect.getmembers(self, lambda a: not inspect.isroutine(a))
@@ -46,13 +47,20 @@ class BadHTTPResponseCodeError(MessageMixin, Exception):
 
 
 class CalledProcessError(MessageMixin, Exception):
-    attrs: tuple[str, ...] = ('returncode', 'cmd', 'stdout', 'stderr')
-    message: str = 'Process {cmd!r} failed with return code {returncode}'
+    attrs: tuple[str, ...] = ('cmd_short', 'returncode')
+    message: str = 'Process {cmd_short} failed with return code {returncode}'
+    cmd: list[str]
     returncode: int
-    cmd: str
     stdout: bytes | None
     stderr: bytes | None
 
+    @property
+    def cmd_short(self) -> list[str]:
+        short_cmd = self.cmd[:4]
+        if len(self.cmd) > 4:
+            short_cmd.append('(…)')
+        return short_cmd
+
 
 class CorruptedFileError(MessageMixin, Exception):
     attrs: tuple[str, ...] = ('path', 'file_hash', 'expected_hash')
@@ -175,7 +183,7 @@ def assert_raises_item(
     except Exception as ex:  # pylint:disable=broad-except
         if not isinstance(ex, exception_cls):
             raise ValueError(
-                f'Exception {ex.__class__.__name__} is not '
+                f'Exception {type(ex).__name__} is not '
                 f'an instance of {exception_cls.__name__}.') from ex
         return
     raise AssertionError(f'Exception {exception_cls.__name__} not raised.')
diff --git a/pytoolbox/filesystem.py b/pytoolbox/filesystem.py
index 4f68b25d..b01da48a 100644
--- a/pytoolbox/filesystem.py
+++ b/pytoolbox/filesystem.py
@@ -29,7 +29,11 @@
 
 _all = module.All(globals())
 
-FindPatterns: TypeAlias = re.Pattern | str | list[re.Pattern] | list[str] | list[re.Pattern | str]
+FindPatterns: TypeAlias = (
+    re.Pattern | str |  # noqa: W504
+    list[re.Pattern] | list[str] | list[re.Pattern | str] |  # noqa: W504
+    tuple[re.Pattern, ...] | tuple[str, ...] | tuple[re.Pattern | str, ...]
+)
 
 
 class CopyProgressCallback(Protocol):  # pylint:disable=too-few-public-methods
@@ -480,11 +484,6 @@ def makedirs(path: Path, *, mode: int = 0o777, parent: bool = False) -> bool:
         raise  # Re-raise exception if a different error occurred
 
 
-@deprecated
-def recursive_copy(*args, **kwargs):  # pragma: no cover
-    return copy_recursive(*args, **kwargs)
-
-
 def remove(path: Path | str, *, recursive: bool = False):
     """
     Remove a file/directory (which may not exists) without throwing an exception.
@@ -842,3 +841,11 @@ def remove_all(self) -> None:
 
 
 __all__ = _all.diff(globals())
+
+
+# Deprecated ---------------------------------------------------------------------------------------
+
+
+@deprecated('Use pytoolbox.filesystem.copy_recursive instead (drop-in replacement)')
+def recursive_copy(*args, **kwargs):  # pragma: no cover
+    return copy_recursive(*args, **kwargs)
diff --git a/pytoolbox/flask.py b/pytoolbox/flask.py
index 18354bd9..2bad9f6a 100644
--- a/pytoolbox/flask.py
+++ b/pytoolbox/flask.py
@@ -81,5 +81,5 @@ def map_exceptions(exception):
     elif isinstance(exception, NotImplementedError):
         abort(501, str(exception))
     else:
-        abort(500, f'{exception.__class__.__name__} {repr(exception)} {str(exception)}')
+        abort(500, f'{type(exception).__name__} {repr(exception)} {str(exception)}')
     return None
diff --git a/pytoolbox/git.py b/pytoolbox/git.py
index b238144c..8311ee5d 100644
--- a/pytoolbox/git.py
+++ b/pytoolbox/git.py
@@ -1,17 +1,16 @@
 from __future__ import annotations
 
-from collections.abc import Iterator
+from collections.abc import Iterable, Iterator
 from pathlib import Path
 import contextlib
-import logging
 import os
 import stat
 import tempfile
 
-from . import exceptions, humanize, subprocess
+from . import exceptions, humanize, logging, subprocess
 from .subprocess import CallArgType
 
-log = logging.getLogger(__name__)
+log = logging.get_logger(__name__)
 
 __all__ = ['blame', 'clone_or_pull', 'create_tag', 'get_ref', 'get_tags', 'scoped_ssh_key']
 
@@ -91,7 +90,7 @@ def scoped_ssh_key(
     directory: Path,
     content: str,
     *,
-    options: tuple[str] = tuple(),  # type: ignore[assignment]
+    options: Iterable[str] = tuple(),  # type: ignore[assignment]
 ) -> Iterator[str]:
     """Load an SSH key (content is the private key)."""
     with tempfile.NamedTemporaryFile('w') as key_file:
diff --git a/pytoolbox/humanize.py b/pytoolbox/humanize.py
index 247cda10..613bf67e 100644
--- a/pytoolbox/humanize.py
+++ b/pytoolbox/humanize.py
@@ -12,7 +12,7 @@
     'bit/s', 'kb/s', 'Mb/s', 'Gb/s', 'Tb/s', 'Pb/s', 'Eb/s', 'Zb/s', 'Yb/s'
 )
 # pylint:disable=consider-using-namedtuple-or-dataclass)
-DEFAULT_FILESIZE_ARGS: Final[dict[str, dict[str, int | tuple[str, ...]]]] = {
+DEFAULT_FILESIZE_ARGS: Final[dict[str, dict[str, int | list[str] | tuple[str, ...]]]] = {
     'gnu': {'base': 1000, 'units': ('B', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y')},
     'nist': {'base': 1024, 'units': ('B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')},
     'si': {'base': 1000, 'units': ('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB')}
@@ -40,7 +40,7 @@ def naturalbitrate(
     bps: int | float,
     fmt: str = '{sign}{value:.3g} {unit}',
     scale: int | None = None,
-    units: tuple[str, ...] = DEFAULT_BITRATE_UNITS
+    units: list[str] | tuple[str, ...] = DEFAULT_BITRATE_UNITS
 ) -> str:
     """
     Return a human readable representation of a bit rate taking `bps` as the rate in bits/s.
@@ -77,7 +77,7 @@ def naturalfilesize(  # pylint:disable=dangerous-default-value
     system: str = 'nist',
     fmt: str = '{sign}{value:.3g} {unit}',
     scale: int | None = None,
-    args: dict[str, dict[str, int | tuple[str, ...]]] = DEFAULT_FILESIZE_ARGS
+    args: dict[str, dict[str, int | list[str] | tuple[str, ...]]] = DEFAULT_FILESIZE_ARGS
 ) -> str:
     """
     Return a human readable representation of a *file* size taking `bytes` as the size in bytes.
@@ -128,7 +128,7 @@ def naturalfrequency(
     hertz: int | float,
     fmt: str = '{sign}{value:.3g} {unit}',
     scale: int | None = None,
-    units: tuple[str, ...] = DEFAULT_FREQUENCY_UNITS
+    units: list[str] | tuple[str, ...] = DEFAULT_FREQUENCY_UNITS
 ) -> str:  # pylint:disable=dangerous-default-value
     """
     Return a human readable representation of a frequency taking `hertz` as the frequency in Hz.
@@ -164,7 +164,7 @@ def naturalweight(
     grams: int | float,
     fmt: str = '{sign}{value:.3g} {unit}',
     scale: int | None = None,
-    units: tuple[str, ...] = DEFAULT_WEIGHT_UNITS
+    units: list[str] | tuple[str, ...] = DEFAULT_WEIGHT_UNITS
 ) -> str:  # pylint:disable=dangerous-default-value
     """
     Return a human readable representation of a weight in `grams`.
@@ -213,7 +213,7 @@ def natural_int_key(text: str) -> list[int | str]:
 
 def parse_bitrate(
     bitrate: str,
-    units: tuple[str, ...] = DEFAULT_BITRATE_UNITS,
+    units: list[str] | tuple[str, ...] = DEFAULT_BITRATE_UNITS,
     pattern: re.Pattern = BITRATE_PATTERN
 ) -> float:
     """
@@ -251,7 +251,7 @@ def parse_bitrate(
 def parse_filesize(  # pylint:disable=dangerous-default-value
     size: str,
     system: str = 'nist',
-    args: dict[str, dict[str, int | tuple[str, ...]]] = DEFAULT_FILESIZE_ARGS,
+    args: dict[str, dict[str, int | list[str] | tuple[str, ...]]] = DEFAULT_FILESIZE_ARGS,
     pattern: re.Pattern = FILESIZE_PATTERN
 ) -> float:
     """
@@ -293,7 +293,7 @@ def parse_filesize(  # pylint:disable=dangerous-default-value
 
 def parse_frequency(
     frequency: str,
-    units: tuple[str, ...] = DEFAULT_FREQUENCY_UNITS,
+    units: list[str] | tuple[str, ...] = DEFAULT_FREQUENCY_UNITS,
     pattern: re.Pattern = FREQUENCY_PATTERN
 ) -> float:
     """
@@ -338,7 +338,7 @@ def parse_frequency(
 
 def parse_weight(
     weight: str,
-    units: tuple[str, ...] = DEFAULT_WEIGHT_UNITS,
+    units: list[str] | tuple[str, ...] = DEFAULT_WEIGHT_UNITS,
     pattern: re.Pattern = WEIGHT_PATTERN
 ) -> float:
     """
@@ -374,7 +374,7 @@ def parse_weight(
 def _natural_number(
     number: int | float,
     base: int,
-    units: tuple[str, ...],
+    units: list[str] | tuple[str, ...],
     fmt: str = '{sign}{value:.3g} {unit}',
     scale: int | None = None
 ) -> str:
@@ -390,7 +390,7 @@ def _parse_natural_number(
     value: str,
     kind: str,
     base: int,
-    units: tuple[str, ...],
+    units: list[str] | tuple[str, ...],
     pattern: re.Pattern
 ) -> float:
     if match := pattern.match(value.strip()):
diff --git a/pytoolbox/juju.py b/pytoolbox/juju.py
index d3b8a360..8915519d 100644
--- a/pytoolbox/juju.py
+++ b/pytoolbox/juju.py
@@ -1,21 +1,22 @@
 # pylint:disable=too-many-lines
 from __future__ import annotations
 
-import argparse
 import json
 import os
-import socket
 import random
-import subprocess
+import socket
+import subprocess as _subprocess
 import sys
 import time
 import uuid
 
 from . import (  # pylint:disable=reimported
+    argparse,
     console,
+    exceptions,
     filesystem,
     module,
-    subprocess as py_subprocess,
+    subprocess,
     serialization
 )
 from .argparse import FullPaths, is_dir
@@ -101,9 +102,9 @@ def juju_do(command, environment=None, options=None, fail=True, log=None, **kwar
 
     if is_destroy:
         # TODO Automate yes answer to destroy-environment
-        method = subprocess.check_call if fail else subprocess.call
+        method = _subprocess.check_call if fail else _subprocess.call
         return method(arguments)
-    result = py_subprocess.cmd(arguments, fail=False, log=log, env=env, **kwargs)
+    result = subprocess.cmd(arguments, fail=False, log=log, env=env, **kwargs)
 
     if result['returncode'] != 0 and fail:
         raise RuntimeError(f"Subprocess failed {' '.join(arguments)} : {result['stderr']}.")
@@ -146,11 +147,12 @@ def save_unit_config(path, service, config):
             if isinstance(value, bool):
                 config[option] = str(value)
         config = {service: config}
-        f.write(yaml.dump(config))
+        yaml.dump(config, f)
 
 
 # Environments -------------------------------------------------------------------------------------
 
+
 def add_environment(
     environment,
     kind,
@@ -188,7 +190,7 @@ def add_environment(
     environments_dict['environments'][environment] = environment_dict
 
     with open(environments, 'w', encoding='utf-8') as f:
-        f.write(yaml.dump(environments_dict))
+        yaml.dump(environments_dict, f)
 
     try:
         return juju_do('bootstrap', environment)
@@ -196,7 +198,7 @@ def add_environment(
         if 'configuration error' in str(ex):
             del environments_dict['environments'][environment]
             with open(environments, 'w', encoding='utf-8') as f:
-                f.write(yaml.dump(environments_dict))
+                yaml.dump(environments_dict, f)
             raise ValueError(f'Cannot add environment {environment} ({ex}).') from ex
         raise
 
@@ -242,12 +244,14 @@ def get_environments_count(environments=None):
 
 # Units --------------------------------------------------------------------------------------------
 
+
 def get_unit_path(service, number, *args):
     return os.path.join(f'/var/lib/juju/agents/unit-{service}-{number}/charm', *args)
 
 
 # Helpers ------------------------------------------------------------------------------------------
 
+
 __get_ip = None  # pylint:disable=invalid-name
 
 
@@ -347,7 +351,7 @@ def __init__(self, metadata, default_config, default_os_env, force_disable_juju=
             self.name = os.environ['JUJU_UNIT_NAME']
             self.private_address = socket.getfqdn(self.unit_get('private-address'))
             self.public_address = socket.getfqdn(self.unit_get('public-address'))
-        except (subprocess.CalledProcessError, OSError) as ex:
+        except (exceptions.CalledProcessError, OSError) as ex:
             reason = ex
             self.juju_ok = False
             if default_config is not None:
@@ -549,7 +553,7 @@ def cmd(self, command, logging=True, **kwargs):
         """
         Calls the `command` and returns a dictionary with `stdout`, `stderr`, and the `returncode`.
         """
-        return py_subprocess.cmd(command, log=self.debug if logging else None, **kwargs)
+        return subprocess.cmd(command, log=self.debug if logging else None, **kwargs)
 
     def template_to_config(self, template, config, values):
         filesystem.from_template(template, config, values)
@@ -572,15 +576,15 @@ def trigger(self, hook_name=None):
             hook_name = sys.argv[1]
 
         try:  # Call the function hooks_...
-            self.hook(f'Execute {self.__class__.__name__} hook {hook_name}')
+            self.hook(f'Execute {type(self).__name__} hook {hook_name}')
             getattr(self, f"hook_{hook_name.replace('-', '_')}")()
             self.save_local_config()
-        except subprocess.CalledProcessError as ex:
+        except (_subprocess.CalledProcessError, exceptions.CalledProcessError) as ex:
             self.log('Exception caught:')
             self.log(ex.output)
             raise
         finally:
-            self.hook(f'Exiting {self.__class__.__name__} hook {hook_name}')
+            self.hook(f'Exiting {type(self).__name__} hook {hook_name}')
 
 
 class Environment(object):  # pylint:disable=too-many-instance-attributes,too-many-public-methods
@@ -742,7 +746,7 @@ def destroy(
 
             del environments_dict['environments'][name]
             with open(environments, 'w', encoding='utf-8') as f:
-                f.write(yaml.dump(environments_dict))
+                yaml.dump(environments_dict, f)
         return result
 
     # Services
@@ -1145,9 +1149,7 @@ def get_parser(
         HELP_A = 'Toggle automatic confirmation of the actions, WARNING: Use it with care.'
         HELP_M = 'Directory (repository) of any local charm.'
         HELP_R = 'Ubuntu serie to deploy by JuJu.'
-        parser = argparse.ArgumentParser(
-            formatter_class=argparse.ArgumentDefaultsHelpFormatter,
-            epilog=epilog)
+        parser = argparse.ArgumentParser(epilog=epilog)
         arg = parser.add_argument
         arg('-a', '--auto', action='store_true', default=auto, help=HELP_A)
         arg('-m', '--charms_path', action=FullPaths, default=charms_path, type=is_dir, help=HELP_M)
@@ -1164,6 +1166,7 @@ def run(self):
 # same methods to drive a real or a simulated juju process ... The following code is a partial/light
 # implementation of ... Do not use it for your own purposes !!!
 
+
 class SimulatedUnit(object):
     """A simulated unit with a really simple state machine having a latency to start and stop."""
 
diff --git a/pytoolbox/logging.py b/pytoolbox/logging.py
index a7260279..820a56bf 100644
--- a/pytoolbox/logging.py
+++ b/pytoolbox/logging.py
@@ -7,7 +7,55 @@
 
 from .collections import merge_dicts
 
-__all__ = ['LoggerType', 'setup_logging', 'ColorizeFilter']
+__all__ = [
+    'CRITICAL',
+    'FATAL',
+    'ERROR',
+    'WARN',
+    'WARNING',
+    'INFO',
+    'DEBUG',
+    'NOTSET',
+    'Color',
+    'BasicLoggerFunc',
+    'BasicFuncLogger',
+    'LoggerType',
+    'Logger',
+    'LogRecord',
+    'get_logger',
+    'setup_logging',
+    'ColorizeFilter'
+]
+
+CRITICAL: int = logging.CRITICAL
+FATAL: int = logging.FATAL
+ERROR: int = logging.ERROR
+WARN: int = logging.WARNING
+WARNING: int = logging.WARNING
+INFO: int = logging.INFO
+DEBUG: int = logging.DEBUG
+NOTSET: int = logging.NOTSET
+
+# Terminal colors (termcolor)
+Color: TypeAlias = Literal[
+    'black',
+    'grey',
+    'red',
+    'green',
+    'yellow',
+    'blue',
+    'magenta',
+    'cyan',
+    'light_grey',
+    'dark_grey',
+    'light_red',
+    'light_green',
+    'light_yellow',
+    'light_blue',
+    'light_magenta',
+    'light_cyan',
+    'white'
+]
 
 
 class BasicLoggerFunc(Protocol):  # pylint:disable=too-few-public-methods
@@ -19,32 +67,30 @@ class BasicFuncLogger(logging.Logger):
 
     def __init__(self, log_func) -> None:
         self._log_func = log_func
-        super().__init__(name=log_func.__name__)
+        super().__init__(name=f'{log_func.__module__}.{log_func.__name__}')
 
     def _log(self, level, msg, *args, **kwargs) -> None:  # pylint:disable=unused-argument
         self._log_func(msg)
 
 
-LoggerType: TypeAlias = BasicLoggerFunc | logging.Logger | None
+LoggerType: TypeAlias = BasicLoggerFunc | logging.Logger | str | None
+Logger = logging.Logger
+LogRecord = logging.LogRecord
 
 
 def get_logger(log: LoggerType) -> logging.Logger:
     """Convenient function returning an instance of logger for various use cases."""
     if isinstance(log, logging.Logger):
         return log
-    if log is None:
-        log = logging.getLogger('noop')
-        log.setLevel('NOTSET')
-        return log
+    if isinstance(log, str):
+        return logging.getLogger(log)
     if hasattr(log, '__call__'):
-        log = BasicFuncLogger(log_func=log)
-        log.setLevel('NOTSET')
-        return log
+        return BasicFuncLogger(log_func=log)
     raise NotImplementedError(f'Logging with {log!r}')
 
 
 def setup_logging(
-    name_or_log: str | logging.Logger = '',
+    log: LoggerType = '',
     reset: bool = False,
     path: Path | str | None = None,
     console: bool = False,
@@ -106,7 +152,7 @@ def setup_logging(
     ...
     >>> assert 'INFO     - Ceci va probablement jamais être lu!' in lines[0]
     """
-    log = logging.getLogger(name_or_log) if isinstance(name_or_log, str) else name_or_log
+    log = get_logger(log)
     if reset:
         log.handlers = []
     log.setLevel(level)
@@ -125,7 +171,7 @@ def setup_logging(
 
 class ColorizeFilter(logging.Filter):  # pylint:disable=too-few-public-methods
 
-    color_by_level: dict[int | str, str] = {
+    color_by_level: dict[int | str, Color] = {
         logging.DEBUG: 'cyan',
         logging.ERROR: 'red',
         logging.INFO: 'white',
diff --git a/pytoolbox/multimedia/exif/equipment.py b/pytoolbox/multimedia/exif/equipment.py
index 514a2caf..4c2911cc 100644
--- a/pytoolbox/multimedia/exif/equipment.py
+++ b/pytoolbox/multimedia/exif/equipment.py
@@ -26,7 +26,7 @@ def __hash__(self):
         return hash(repr(self))
 
     def __repr__(self):
-        return f'<{self.__class__.__name__} {self.brand} {self.model}>'
+        return f'<{type(self).__name__} {self.brand} {self.model}>'
 
     @property
     @abc.abstractmethod
diff --git a/pytoolbox/multimedia/exif/metadata.py b/pytoolbox/multimedia/exif/metadata.py
index b8671ada..9b8cf73f 100644
--- a/pytoolbox/multimedia/exif/metadata.py
+++ b/pytoolbox/multimedia/exif/metadata.py
@@ -1,9 +1,11 @@
 from __future__ import annotations
 
+from collections.abc import Iterable
 from pathlib import Path
 import datetime
 
 from pytoolbox import exceptions
+from pytoolbox.itertools import chain
 
 from . import camera, image, lens, photo, tag
 
@@ -24,7 +26,7 @@ def __init__(
         buf=None,
         orientation: image.Orientation | int | None = None,
         gexiv2_version: str = '0.10'
-    ):
+    ) -> None:
         import gi
         gi.require_version('GExiv2', gexiv2_version)
         from gi.repository import GExiv2  # type: ignore[attr-defined]
@@ -45,7 +47,7 @@ def __getitem__(self, key: str) -> tag.Tag:
         # TODO make it more strict and re-implement less strict self.get(key)
         return self.tag_class(self, key)
 
-    def __setitem__(self, key: str, value):
+    def __setitem__(self, key: str, value) -> None:
         self.exiv2[key] = value  # pylint: disable=unsupported-assignment-operation
 
     @property
@@ -54,12 +56,9 @@ def tags(self) -> dict:
 
     def get_date(
         self,
-        keys: tuple[str, ...] | str = ('Exif.Photo.DateTimeOriginal', 'Exif.Image.DateTime')
+        keys: Iterable[str] | str = ('Exif.Photo.DateTimeOriginal', 'Exif.Image.DateTime')
     ) -> datetime.datetime | None:
-
-        if isinstance(keys, str):
-            keys = tuple(keys)
-        for key in keys:
+        for key in chain(keys):
             date = self[key].data
             if isinstance(date, datetime.datetime):
                 return date
diff --git a/pytoolbox/multimedia/exif/tag.py b/pytoolbox/multimedia/exif/tag.py
index 32d92cc0..69bd94bd 100644
--- a/pytoolbox/multimedia/exif/tag.py
+++ b/pytoolbox/multimedia/exif/tag.py
@@ -52,7 +52,7 @@ def __init__(self, metadata, key):
         self.key = key
 
     def __repr__(self):
-        return f'<{self.__class__.__name__} {self.key}: {str(self.data)[:20]}>'
+        return f'<{type(self).__name__} {self.key}: {str(self.data)[:20]}>'
 
     @property
     def data(self):
diff --git a/pytoolbox/network/rtp.py b/pytoolbox/network/rtp.py
index 5b05845a..71ca1fb4 100644
--- a/pytoolbox/network/rtp.py
+++ b/pytoolbox/network/rtp.py
@@ -424,7 +424,7 @@ def __eq__(self, other):
             Test equality only on some fields (not all) !
         """
         return (
-            isinstance(other, self.__class__)
+            isinstance(other, type(self))
             and self.sequence == other.sequence
             and self.timestamp == other.timestamp
             and self.payload_type == other.payload_type
diff --git a/pytoolbox/network/smpte2022/base.py b/pytoolbox/network/smpte2022/base.py
index 1679d28a..346cd4b4 100644
--- a/pytoolbox/network/smpte2022/base.py
+++ b/pytoolbox/network/smpte2022/base.py
@@ -658,7 +658,7 @@ def __eq__(self, other):
         Test equality field by field !
         """
         return (
-            isinstance(other, self.__class__)
+            isinstance(other, type(self))
             and self.sequence == other.sequence and self.algorithm == other.algorithm
             and self.direction == other.direction and self.snbase == other.snbase
             and self.offset == other.offset and self.na == other.na
diff --git a/pytoolbox/regex.py b/pytoolbox/regex.py
index 184355cb..f09d865f 100644
--- a/pytoolbox/regex.py
+++ b/pytoolbox/regex.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from collections.abc import Iterator
+from collections.abc import Iterable, Iterator
 from typing import Any, Final, Literal, overload
 import fnmatch
 import re
@@ -28,7 +28,7 @@
 @overload
 def embed_in_regex(
     string: str,
-    regex_parts: list[str],
+    regex_parts: list[str] | tuple[str, ...],
     index: int,
     *,
     as_string: Literal[True] = True
@@ -39,7 +39,7 @@ def embed_in_regex(
 @overload
 def embed_in_regex(
     string: str,
-    regex_parts: list[str],
+    regex_parts: list[str] | tuple[str, ...],
     index: int,
     *,
     as_string: Literal[False]
@@ -47,7 +47,13 @@ def embed_in_regex(
     ...
 
 
-def embed_in_regex(string, regex_parts, index, as_string=True):
+def embed_in_regex(
+    string: str,
+    regex_parts: list[str] | tuple[str, ...],
+    index: int,
+    *,
+    as_string: bool = True
+):
     """
     **Example usage**
 
@@ -58,12 +64,15 @@ def embed_in_regex(string, regex_parts, index, as_string=True):
     >>> embed_in_regex('L', ['[a-z]', '[a-z]'], 1, as_string=False)
     (1, ['[a-z]', 'L'])
     """
-    regex = regex_parts[:]
+    regex: list[str] = list(regex_parts)
     regex[index:index + len(string)] = string
     return index, ''.join(regex) if as_string else regex
 
 
-def findall_partial(string: str, regex_parts: list[str]) -> Iterator[tuple[str, list[str], int]]:
+def findall_partial(
+    string: str,
+    regex_parts: list[str] | tuple[str, ...]
+) -> Iterator[tuple[str, list[str] | tuple[str, ...], int]]:
     """
     **Example usage**
 
@@ -83,7 +92,7 @@ def findall_partial(string: str, regex_parts: list[str]) -> Iterator[tuple[str,
 
 
 def from_path_patterns(
-    patterns: re.Pattern | str | list[re.Pattern] | list[str] | list[re.Pattern | str],
+    patterns: re.Pattern | str | Iterable[re.Pattern] | Iterable[str] | Iterable[re.Pattern | str],
     *,
     regex: bool = False
 ) -> list[re.Pattern]:
@@ -119,7 +128,7 @@ def group_replace(
     string: str,
     match: re.Match, *,
     offset: int = 0,
-    mapping: tuple[tuple[str, str | None], ...]
+    mapping: Iterable[tuple[str, str | None]]
 ) -> str:
     """
     Replace matched groups in `string` by content from given `mapping` (group name -> replacement).
@@ -130,6 +139,7 @@ def group_replace(
 
     Please check the code and unit-tests :)
     """
+    # TODO Reorder mapping in reverse(start) to prevent indexing issues while replacing parts
     for group, value in mapping:
         if value is not None:
             try:
diff --git a/pytoolbox/rest_framework/metadata/mixins.py b/pytoolbox/rest_framework/metadata/mixins.py
index 826d6aeb..42088c20 100644
--- a/pytoolbox/rest_framework/metadata/mixins.py
+++ b/pytoolbox/rest_framework/metadata/mixins.py
@@ -18,7 +18,7 @@ class ExcludeRelatedChoicesMixin(object):
 
     def get_field_info(self, field):
         if hasattr(field, 'choices') and isinstance(field, self.related_fields):
-            field_class = field.__class__
+            field_class = type(field)
 
             class HaveNoChoicesProxy(field_class):
 
diff --git a/pytoolbox/serialization.py b/pytoolbox/serialization.py
index d51174d4..1ce9da07 100644
--- a/pytoolbox/serialization.py
+++ b/pytoolbox/serialization.py
@@ -20,6 +20,7 @@
 
 # Data -> File -------------------------------------------------------------------------------------
 
+
 def to_file(
     path,
     data=None,
@@ -101,6 +102,7 @@ def to_file(
 
 # Object <-> Pickle file ---------------------------------------------------------------------------
 
+
 class PickleableObject(object):
     """An :class:`object` serializable/deserializable by :mod:`pickle`."""
     @classmethod
@@ -143,6 +145,7 @@ def write(self, path=None, *, store_path=False, safe=False, backup=False, makedi
 
 # Object <-> JSON string ---------------------------------------------------------------------------
 
+
 # http://stackoverflow.com/questions/6255387/mongodb-object-serialized-as-json
 class SmartJSONEncoderV1(json.JSONEncoder):
 
@@ -368,6 +371,7 @@ def from_json(cls, json_string, inspect_constructor):
 
 # Object <-> Dictionary ----------------------------------------------------------------------------
 
+
 def object_to_dict(
     obj,
     schema,
@@ -615,7 +619,7 @@ def to_dict(self, *, extra_slots=True):
         self_dict = {}
         slots = set(s for s in get_slots(self) if s[0] != '_')
         if extra_slots:
-            slots.update(self.__class__.extra_slots or [])
+            slots.update(type(self).extra_slots or [])
         for attribute in slots:
             value = getattr(self, attribute)
             if value is not None:
@@ -625,6 +629,7 @@ def to_dict(self, *, extra_slots=True):
 
 # YAML ---------------------------------------------------------------------------------------------
 
+
 YamlDataTypes: TypeAlias = dict | list | str | None
 
 
diff --git a/pytoolbox/ssh.py b/pytoolbox/ssh.py
index f65090cb..9334b3f6 100644
--- a/pytoolbox/ssh.py
+++ b/pytoolbox/ssh.py
@@ -4,14 +4,13 @@
 from pathlib import Path
 from typing import Final
 import contextlib
-import logging
 import os
 import re
 import signal
 import stat
 import tempfile
 
-from . import exceptions, subprocess
+from . import exceptions, logging, subprocess
 
 __all__ = [
     'AGENT_START_REGEX',
@@ -24,7 +23,7 @@
     'ssh'
 ]
 
-log = logging.getLogger(__name__)
+log = logging.get_logger(__name__)
 
 AGENT_START_REGEX: Final[re.Pattern] = re.compile(
     r'SSH_AUTH_SOCK=(?P<SSH_AUTH_SOCK>[^;]+).*'
diff --git a/pytoolbox/subprocess.py b/pytoolbox/subprocess.py
index 14a97dc5..9810b6ea 100644
--- a/pytoolbox/subprocess.py
+++ b/pytoolbox/subprocess.py
@@ -1,12 +1,11 @@
 from __future__ import annotations
 
-from collections.abc import Iterable
+from collections.abc import Callable, Iterable
 from pathlib import Path
-from typing import Final, TypeAlias
+from typing import TypeAlias, TypedDict
 import errno
 import fcntl
 import grp
-import logging
 import multiprocessing
 import os
 import pwd
@@ -20,20 +19,11 @@
 import time
 
 from . import exceptions, filesystem, module
-from .logging import LoggerType, get_logger
 from .decorators import deprecated
-
-log = logging.getLogger(__name__)
+from .logging import get_logger, LoggerType
 
 _all = module.All(globals())
 
-EMPTY_CMD_RETURN: Final[dict[str, None]] = {
-    'process': None,
-    'stdout': None,
-    'stderr': None,
-    'returncode': None
-}
-
 # import Popen on steroids if available
 try:
     from psutil import NoSuchProcess, Popen
@@ -53,7 +43,15 @@
 CallArgsType: TypeAlias = str | Iterable[CallArgType]
 
 
-def kill(process):
+class CallResult(TypedDict):
+    process: Popen | None
+    returncode: int
+    stdout: bytes | None
+    stderr: bytes | None
+    exception: OSError | None
+
+
+def kill(process: Popen) -> None:
     try:
         process.kill()
     except OSError as ex:
@@ -64,7 +62,7 @@ def kill(process):
             raise
 
 
-def su(user, group):  # pylint:disable=invalid-name
+def su(user: str | int, group: str | int) -> Callable:  # pylint:disable=invalid-name
     """
     Return a function to change current user/group id.
 
@@ -81,13 +79,13 @@ def set_ids():
 
 
 # http://stackoverflow.com/a/7730201/190597
-def make_async(fd):  # pylint:disable=invalid-name
+def make_async(fd) -> None:  # pylint:disable=invalid-name
     """Add the O_NONBLOCK flag to a file descriptor."""
     fcntl.fcntl(fd, fcntl.F_SETFL, fcntl.fcntl(fd, fcntl.F_GETFL) | os.O_NONBLOCK)
 
 
 # http://stackoverflow.com/a/7730201/190597
-def read_async(fd):  # pylint:disable=invalid-name
+def read_async(fd) -> str:  # pylint:disable=invalid-name
     """Read some data from a file descriptor, ignoring EAGAIN errors."""
     try:
         return fd.read()
@@ -111,6 +109,7 @@ def to_args_string(args: CallArgsType | None) -> str:
 
 # --------------------------------------------------------------------------------------------------
 
+
 def raw_cmd(arguments: CallArgsType, *, shell: bool = False, **kwargs) -> Popen:
     """
     Launch a subprocess.
@@ -128,10 +127,11 @@ def raw_cmd(arguments: CallArgsType, *, shell: bool = False, **kwargs) -> Popen:
 
 
 # thanks http://stackoverflow.com/questions/1191374$
-def _communicate_with_timeout(*, data, process, input):  # pylint:disable=redefined-builtin
+def _communicate_with_timeout(*, data, process, input) -> None:  # pylint:disable=redefined-builtin
     data['stdout'], data['stderr'] = process.communicate(input=input)
 
 
+# TODO Refine type hints with overloads
 def cmd(  # pylint:disable=too-many-arguments,too-many-branches,too-many-locals,too-many-statements
     command: CallArgsType,
     *,
@@ -142,13 +142,13 @@ def cmd(  # pylint:disable=too-many-arguments,too-many-branches,too-many-locals,
     communicate: bool = True,
     timeout: float | None = None,
     fail: bool = True,
-    log: LoggerType = log,  # pylint:disable=redefined-outer-name
+    log: LoggerType = None,  # pylint:disable=redefined-outer-name
     tries: int = 1,
     delay_min: float = 5,
     delay_max: float = 10,
-    success_codes: tuple[int, ...] = (0, ),
+    success_codes: Iterable[int] = (0, ),
     **kwargs
-) -> dict:
+):
     """
     Calls the `command` and returns a dictionary with process, stdout, stderr, and the returncode.
 
@@ -160,7 +160,7 @@ def cmd(  # pylint:disable=too-many-arguments,too-many-branches,too-many-locals,
     :param cli_input: If set, sended to stdin (no condition).
     :param cli_output: Set to True to output (in real-time) stdout to stdout and stderr to stderr.
     :param fail: Set to False to avoid the exception `exceptions.CalledProcessError`.
-    :param log: A function to log/print details about what is executed/any failure, can be a logger.
+    :param log: If set then override the default logger named `pytoolbox.subprocess.cmd.<binary>`.
     :param communicate: Set to True to communicate with the process, this is a locking call
                         (if timeout is None).
     :param timeout: Time-out for the communication with the process, in seconds.
@@ -174,19 +174,18 @@ def cmd(  # pylint:disable=too-many-arguments,too-many-branches,too-many-locals,
     The delay will be a random number in range (`delay_min`, `delay_max`).
 
     """
-    log = get_logger(log)
-
     # Process arguments
-    args_list = to_args_list(command)
+    process_cmd: list[str] = to_args_list(command)
+    log = get_logger(log or f'{__name__}.cmd.{Path(process_cmd[0]).name}')
+
     if user is not None:
-        args_list = ['sudo', '-u', user, *command]
-    args_string = to_args_string(args_list)
+        process_cmd = ['sudo', '-u', user, *process_cmd]
 
     # log the execution
     log.debug(''.join([
         'Execute ',
         '' if input is None else f'echo {repr(input)} | ',
-        args_string,
+        to_args_string(process_cmd),
         '' if cli_input is None else f' < {repr(cli_input)}'
     ]))
 
@@ -194,16 +193,23 @@ def cmd(  # pylint:disable=too-many-arguments,too-many-branches,too-many-locals,
         # create the sub-process
         try:
             process = Popen(
-                args_list,
+                process_cmd,
                 stdin=subprocess.PIPE,
                 stdout=None if cli_output else subprocess.PIPE,
-                stderr=None if cli_output else subprocess.PIPE, **kwargs)
+                stderr=None if cli_output else subprocess.PIPE,
+                **kwargs)
         except OSError as ex:
             # Unable to execute the program (e.g. does not exist)
             log.exception(ex)
             if fail:
                 raise
-            return {'process': None, 'stdout': '', 'stderr': ex, 'returncode': 2}
+            return {
+                'process': None,
+                'returncode': 2,
+                'stdout': None,
+                'stderr': None,
+                'exception': ex
+            }
 
         # Write to stdin (answer to questions, ...)
         if cli_input is not None:
@@ -233,11 +239,12 @@ def cmd(  # pylint:disable=too-many-arguments,too-many-branches,too-many-locals,
             process.poll()
             stdout = stderr = None
 
-        result = {
+        result: CallResult = {
             'process': process,
+            'returncode': process.returncode,
             'stdout': stdout,
             'stderr': stderr,
-            'returncode': process.returncode
+            'exception': None
         }
 
         if process.returncode in success_codes:
@@ -254,8 +261,8 @@ def cmd(  # pylint:disable=too-many-arguments,too-many-branches,too-many-locals,
         # raise if this is the last try
         if fail and not do_retry:
             raise exceptions.CalledProcessError(
+                cmd=process_cmd,
                 returncode=process.returncode,
-                cmd=args_string,
                 stdout=stdout,
                 stderr=stderr)
 
@@ -267,13 +274,6 @@ def cmd(  # pylint:disable=too-many-arguments,too-many-branches,too-many-locals,
 
 # --------------------------------------------------------------------------------------------------
 
-@deprecated
-def git_clone_or_pull(*args, **kwargs) -> None:  # pragma: no cover
-    from pytoolbox.git import clone_or_pull  # pylint:disable=import-outside-toplevel
-    return clone_or_pull(*args, **kwargs)
-
-
-# --------------------------------------------------------------------------------------------------
 
 def make(
     archive: Path,
@@ -285,7 +285,7 @@ def make(
     remove_temporary: bool = True,
     make_options: str = f'-j{multiprocessing.cpu_count()}',
     **kwargs
-) -> dict[str, dict]:
+) -> dict[str, CallResult]:
     """Build and optionally install a piece of software from source."""
     results = {}
     setuptools.archive_util.unpack_archive(archive, directory)
@@ -307,6 +307,7 @@ def make(
 
 # --------------------------------------------------------------------------------------------------
 
+
 def rsync(  # pylint:disable=too-many-arguments,too-many-locals
     source: Path,
     destination: Path,
@@ -324,9 +325,9 @@ def rsync(  # pylint:disable=too-many-arguments,too-many-locals
     rsync_path: Path | None = None,
     size_only: bool = False,
     extra: str | None = None,
-    extra_args: list[CallArgType] | None = None,
+    extra_args: Iterable[CallArgType] | None = None,
     **kwargs
-) -> dict:
+) -> CallResult:
     """Execute the famous rsync remote (or local) synchronization tool."""
     source_string = str(source)
     if source.is_dir() or source_is_dir:
@@ -357,7 +358,7 @@ def rsync(  # pylint:disable=too-many-arguments,too-many-locals
     if exclude_vcs:
         command += ['--exclude=.svn', '--exclude=.git']
     if extra_args is not None:
-        command += extra_args
+        command.extend(extra_args)
     command += [source_string, destination_string]
 
     return cmd([c for c in command if c], **kwargs)
@@ -369,7 +370,7 @@ def screen_kill(name: str | None = None, *, fail: bool = True, **kwargs):
         cmd(['screen', '-S', instance_name, '-X', 'quit'], fail=fail, **kwargs)
 
 
-def screen_launch(name: str, command: CallArgsType, **kwargs) -> dict:
+def screen_launch(name: str, command: CallArgsType, **kwargs) -> CallResult:
     """Launch a new named screen instance."""
     return cmd(['screen', '-dmS', name, *to_args_list(command)], **kwargs)
 
@@ -377,13 +378,22 @@ def screen_launch(name: str, command: CallArgsType, **kwargs) -> dict:
 def screen_list(name: str | None = None, **kwargs) -> list[str]:
     """Returns a list containing all instances of screen. Can be filtered by `name`."""
     screens = cmd(['screen', '-ls', name], fail=False, **kwargs)['stdout']
-    return re.findall(r'\s+(\d+.\S+)\s+\(.*\).*', screens.decode('utf-8'))
+    return re.findall(r'\s+(\d+.\S+)\s+\(.*\).*', (screens or b'').decode('utf-8'))
 
 
-@deprecated
+__all__ = _all.diff(globals())
+
+
+# Deprecated ---------------------------------------------------------------------------------------
+
+
+@deprecated('Use pytoolbox.git.clone_or_pull instead (drop-in replacement)')
+def git_clone_or_pull(*args, **kwargs) -> None:  # pragma: no cover
+    from pytoolbox.git import clone_or_pull  # pylint:disable=import-outside-toplevel
+    return clone_or_pull(*args, **kwargs)
+
+
+@deprecated('Use pytoolbox.ssh.ssh instead (drop-in replacement)')
 def ssh(*args, **kwargs) -> dict:  # pragma: no cover
     from pytoolbox.ssh import ssh as _ssh
     return _ssh(*args, **kwargs)
-
-
-__all__ = _all.diff(globals())
diff --git a/pytoolbox/types.py b/pytoolbox/types.py
index 37abccbc..1cb56e12 100644
--- a/pytoolbox/types.py
+++ b/pytoolbox/types.py
@@ -39,7 +39,7 @@ def get_arguments_names(function: Callable) -> list[str]:
 def get_properties(obj: Any) -> Generator[tuple[str, Any], None, None]:
     return (
         (n, getattr(obj, n))
-        for n, p in inspect.getmembers(obj.__class__, lambda m: isinstance(m, property))
+        for n, p in inspect.getmembers(type(obj), lambda m: isinstance(m, property))
     )
 
 
@@ -47,7 +47,7 @@ def get_slots(obj: Any) -> set[str]:
     """Return a set with the `__slots__` of the `obj` including all parent classes `__slots__`."""
     return set(itertools.chain.from_iterable(
         getattr(cls, '__slots__', ())
-        for cls in obj.__class__.__mro__)
+        for cls in type(obj).__mro__)
     )
 
 
@@ -249,10 +249,10 @@ def __init__(self, name: str, **attrs) -> None:
         self._name = name
 
     def __getattr__(self, name: str) -> Any:
-        return (self.attr_class or self.__class__)(f'{self._name}.{name}')
+        return (self.attr_class or type(self))(f'{self._name}.{name}')
 
     def __getitem__(self, key: str) -> Any:
-        return (self.attr_class or self.__class__)(f'{self._name}[{repr(key)}]')
+        return (self.attr_class or type(self))(f'{self._name}[{repr(key)}]')
 
     def __str__(self) -> str:
         return self._name
diff --git a/pytoolbox/validation.py b/pytoolbox/validation.py
index a3088d66..1e44d277 100644
--- a/pytoolbox/validation.py
+++ b/pytoolbox/validation.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from collections.abc import Iterable
 from typing import Any, get_type_hints
 from urllib.parse import urlparse
 import errno
@@ -211,7 +212,7 @@ def valid_uri(
     scheme_mandatory: bool = False,
     port_mandatory: bool = False,
     default_port: int = 80,
-    excepted_errnos: tuple[int, ...] = (errno.ENOENT, errno.ECONNREFUSED, errno.ENETUNREACH),
+    excepted_errnos: Iterable[int] = (errno.ENOENT, errno.ECONNREFUSED, errno.ENETUNREACH),
     timeout: int | None = None
 ) -> bool:
     """
@@ -310,7 +311,10 @@ def valid_uuid(value: Any, *, objectid_allowed: bool = False, none_allowed: bool
     return True
 
 
-def validate_list(the_list: list[Any], regexes: list[re.Pattern | str]) -> bool:
+def validate_list(
+    the_list: list[Any],
+    regexes: list[re.Pattern | str] | tuple[re.Pattern | str, ...]
+) -> None:
     """
     Validate every element of `the_list` with corresponding regular expression picked-in from
     `regexes`.


Test patch:
diff --git a/pytoolbox/selenium/test.py b/pytoolbox/selenium/test.py
index 1dc49b46..71223437 100644
--- a/pytoolbox/selenium/test.py
+++ b/pytoolbox/selenium/test.py
@@ -14,9 +14,9 @@ class LiveTestCaseMixin(object):
     def setUp(self):  # pylint:disable=invalid-name
         """Call super's setUp and instantiate a live test client, only once."""
         super().setUp()
-        if not hasattr(self.__class__, 'client'):
-            self.__class__.client = self.live_client_class(self.live_server_url)
-        self.client = self.__class__.client
+        if not hasattr(type(self), 'client'):
+            type(self).client = self.live_client_class(self.live_server_url)
+        self.client = type(self).client
 
     @classmethod
     def tearDownClass(cls):  # pylint:disable=invalid-name
diff --git a/tests/test_argparse.py b/tests/test_argparse.py
index 922f8f36..f801d324 100644
--- a/tests/test_argparse.py
+++ b/tests/test_argparse.py
@@ -2,33 +2,79 @@
 
 from pathlib import Path
 from typing import no_type_check
-import argparse
+from unittest.mock import patch
+import os
 
 import pytest
-from pytoolbox import types
-from pytoolbox.argparse import is_dir, is_file, FullPaths
+from pytest import mark
+from pytoolbox import argparse, types
 
 
 def test_is_dir() -> None:
-    assert is_dir('/home') == Path('/home')
-    assert is_dir(Path('/home')) == Path('/home')
+    assert argparse.is_dir('/home') == Path('/home')
+    assert argparse.is_dir(Path('/home')) == Path('/home')
     with pytest.raises(argparse.ArgumentTypeError):
-        is_dir('sjdsajkd')
+        argparse.is_dir('sjdsajkd')
 
 
 def test_is_file() -> None:
-    assert is_file('/etc/hosts') == Path('/etc/hosts')
-    assert is_file(Path('/etc/hosts')) == Path('/etc/hosts')
+    assert argparse.is_file('/etc/hosts') == Path('/etc/hosts')
+    assert argparse.is_file(Path('/etc/hosts')) == Path('/etc/hosts')
     with pytest.raises(argparse.ArgumentTypeError):
-        is_file('wdjiwdji')
+        argparse.is_file('wdjiwdji')
 
 
 @no_type_check
 def test_full_paths() -> None:
     namespace = types.DummyObject()
-    multi = FullPaths(None, 'multi')
+    multi = argparse.FullPaths(None, 'multi')
     multi(None, namespace, ['a', 'b'])
-    single = FullPaths(None, 'single')
+    single = argparse.FullPaths(None, 'single')
     single(None, namespace, 'c')
     assert namespace.multi == [Path(e).resolve() for e in ('a', 'b')]  # pylint:disable=no-member
     assert namespace.single == Path('c').resolve()                     # pylint:disable=no-member
+
+
+@mark.parametrize(('sep', 'arguments', 'expected'), [
+
+    (None, ['some'], None),
+    (None, ['some', '--patterns', ''], []),
+    (None, ['some', '--patterns', 'a'], ['a']),
+    (None, ['some', '--patterns', 'a', 'b', 'c d  '], ['a', 'b', 'c d']),
+    (None, ['some', '--patterns', 'a', '--patterns', ' b  '], ['a', 'b']),
+    (None, ['some', '--patterns', 'a', '--patterns', 'b', 'c d  e'], ['a', 'b', 'c d  e']),
+
+    (' ', ['some'], None),
+    (' ', ['some', '--patterns', ''], []),
+    (' ', ['some', '--patterns', 'a'], ['a']),
+    (' ', ['some', '--patterns', 'a', 'b', 'c d  '], ['a', 'b', 'c', 'd']),
+    (' ', ['some', '--patterns', 'a', '--patterns', ' b  '], ['a', 'b']),
+    (' ', ['some', '--patterns', 'a', '--patterns', 'b', 'c d  e'], ['a', 'b', 'c', 'd', 'e']),
+
+    (',', ['some'], None),
+    (',', ['some', '--patterns', ''], []),
+    (',', ['some', '--patterns', 'a'], ['a']),
+    (',', ['some', '--patterns', 'a', 'b', 'c, d , '], ['a', 'b', 'c', 'd']),
+    (',', ['some', '--patterns', 'a', '--patterns', ' b  ,'], ['a', 'b']),
+    (',', ['some', '--patterns', 'a', '--patterns', 'b', 'c, d,  ,e'], ['a', 'b', 'c', 'd', 'e']),
+
+])
+def test_chain_action(sep, arguments, expected):
+
+    def action_some(args):
+        assert args.patterns == expected
+
+    parser = argparse.ActionArgumentParser(epilog='This is a test')
+    arg = parser.add_action('some', action_some)
+    arg('-p', '--patterns', **argparse.MULTI_ARG(sep))
+    parser.execute(arguments)
+
+
+@mark.parametrize('env, name, expected', [
+    ({'SOME': ''}, 'SOME', {'default': ''}),
+    ({'SOME': 'value'}, 'SOME', {'default': 'value'}),
+    ({'SOME': ''}, 'OTHER', {'required': True})
+])
+def test_env_default(env, name, expected):
+    with patch.dict(os.environ, env, clear=True):
+        assert argparse.env_default(name) == expected
diff --git a/tests/test_comparison.py b/tests/test_comparison.py
index 0905f5b7..6c8d4456 100644
--- a/tests/test_comparison.py
+++ b/tests/test_comparison.py
@@ -60,6 +60,7 @@ def test_equality_different_class() -> None:
 
 # Content ------------------------------------------------------------------------------------------
 
+
 def test_unified_diff() -> None:
     assert comparison.unified_diff(
         'Some T',
@@ -99,6 +100,7 @@ def test_unified_diff_colorize() -> None:
 
 # Versions -----------------------------------------------------------------------------------------
 
+
 @mark.parametrize(('version_a', 'version_b', 'operation', 'expected'), [
     ('master', 'master', '<', False),
     ('master', 'master', '<=', True),
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index bda7d79f..61436e1e 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -1,9 +1,21 @@
 from __future__ import annotations
 
 import pytest
+from pytest import raises
 from pytoolbox import exceptions
 
 
+def test_message_mixin_validation() -> None:
+
+    class NewError(exceptions.MessageMixin, Exception):
+        attrs: tuple[str, ...] = ('b', 'c')
+        message: str = 'Ten equals {ten} an empty dict {dict} a string is a {string}'
+
+    with raises(AttributeError, match=r"is missing attributes or properties: b, c"):
+        # pylint:disable=pointless-exception-statement
+        NewError(ten=10, dict={}, string='chaîne de caractères')
+
+
 def test_message_mixin_str() -> None:
 
     class NewError(exceptions.MessageMixin, Exception):
@@ -33,5 +45,19 @@ class NewError(exceptions.MessageMixin, Exception):
         str(ex)
 
 
+def test_called_process_error_repr_and_str() -> None:
+    ex = exceptions.CalledProcessError(
+        cmd=['find', '-maxdepth=2', '-type=f', '-gid=0', '-uid=0', '/'],
+        returncode=1,
+        stdout=b'',
+        stderr=b'Error something wrong happened.')
+
+    assert repr(ex) == (
+        "CalledProcessError(cmd_short=['find', '-maxdepth=2', '-type=f', '-gid=0', '(…)'], "
+        "returncode=1)")
+    assert str(ex) == (
+        "Process ['find', '-maxdepth=2', '-type=f', '-gid=0', '(…)'] failed with return code 1")
+
+
 def test_ssh_error_repr() -> None:
     assert repr(exceptions.SSHAgentParsingError(output='A')) == "SSHAgentParsingError(output='A')"
diff --git a/tests/test_git.py b/tests/test_git.py
index fdbbb5f1..b8a0e7e1 100644
--- a/tests/test_git.py
+++ b/tests/test_git.py
@@ -1,8 +1,8 @@
 from __future__ import annotations
 
 from pathlib import Path
-from unittest.mock import patch as M
 from typing import Final
+from unittest.mock import patch
 import os
 import tempfile
 
@@ -56,7 +56,7 @@ def test_create_tag(pytoolbox_git: Path) -> None:  # pylint:disable=redefined-ou
 
 def test_get_ref(pytoolbox_git: Path) -> None:
     get_ref = git.get_ref
-    with M.dict(os.environ, {}, clear=True):
+    with patch.dict(os.environ, {}, clear=True):
         assert get_ref(pytoolbox_git, kind='branch') == 'main'
         assert get_ref(pytoolbox_git, kind='commit') == '4863c99a97fe358caa24e48b5c477b852b5a6721'
         with raises(exceptions.GitReferenceError):
@@ -65,7 +65,7 @@ def test_get_ref(pytoolbox_git: Path) -> None:
 
 def test_get_ref_from_gitlab_ci(pytoolbox_git: Path) -> None:
     get_ref = git.get_ref
-    with M.dict(os.environ, {'CI_COMMIT_REF_NAME': 'toto'}, clear=True):
+    with patch.dict(os.environ, {'CI_COMMIT_REF_NAME': 'toto'}, clear=True):
         assert get_ref(pytoolbox_git, kind='branch') == 'toto'
         assert get_ref(pytoolbox_git, kind='commit') == 'toto'
         assert get_ref(pytoolbox_git, kind='branch', ci_vars=False) != 'toto'
@@ -88,7 +88,7 @@ def test_get_tags_with_ref(pytoolbox_git: Path) -> None:  # pylint:disable=redef
 
 
 def test_scoped_ssh_key() -> None:
-    with M('pytoolbox.subprocess.cmd') as cmd:
+    with patch('pytoolbox.subprocess.cmd') as cmd:
         cmd.return_value = {'stdout': None, 'stderr': None, 'returncode': 0}
         with git.scoped_ssh_key('.', 'key-data') as name:
             subprocess.cmd(['git', 'push', 'somewhere'])
@@ -104,9 +104,13 @@ def test_scoped_ssh_key() -> None:
 
 
 def test_scoped_ssh_key_with_options() -> None:
-    with M('pytoolbox.subprocess.cmd') as cmd:
+    with patch('pytoolbox.subprocess.cmd') as cmd:
         cmd.return_value = {'stdout': None, 'stderr': None, 'returncode': 0}
-        with git.scoped_ssh_key('.', 'key-data', options=['StrictHostKeyChecking=no']) as name:
+        with git.scoped_ssh_key(
+            Path('.'),
+            'key-data',
+            options=['StrictHostKeyChecking=no']
+        ) as name:
             subprocess.cmd(['git', 'push', 'somewhere'])
             assert Path(name).read_text(encoding='utf-8') == 'key-data\n'
 
diff --git a/tests/test_logging.py b/tests/test_logging.py
index 4c0a5309..13be6f8a 100644
--- a/tests/test_logging.py
+++ b/tests/test_logging.py
@@ -3,6 +3,8 @@
 import logging
 from unittest import mock
 
+from pytest import raises
+
 from pytoolbox.logging import get_logger
 
 
@@ -11,18 +13,17 @@ def test_get_logger_from_callable() -> None:
     func.__name__ = 'func'
     log = get_logger(func)
     assert isinstance(log, logging.Logger)
-    assert log.name == 'func'
+    assert log.name == 'unittest.mock.func'
     assert log.level == logging.NOTSET
     log.debug('Some message')
     func.assert_called_once_with('Some message')
 
 
 def test_get_logger_from_logger() -> None:
-    assert get_logger(logging.getLogger(__name__)) is logging.getLogger(__name__)
+    assert get_logger(logging.getLogger(__name__)) is get_logger(__name__)
+    assert get_logger(get_logger(__name__)) is get_logger(__name__)
 
 
 def test_get_logger_from_none() -> None:
-    log = get_logger(None)
-    assert isinstance(log, logging.Logger)
-    assert log.name == 'noop'
-    assert log.level == logging.NOTSET
+    with raises(NotImplementedError, match='Logging with None'):
+        get_logger(None)
diff --git a/tests/test_subprocess.py b/tests/test_subprocess.py
index 1168016d..26b072d5 100644
--- a/tests/test_subprocess.py
+++ b/tests/test_subprocess.py
@@ -1,9 +1,10 @@
 from __future__ import annotations
 
+from pathlib import Path
 from unittest import mock
 
 import pytest
-from pytoolbox import regex, subprocess
+from pytoolbox import logging, regex, subprocess
 from pytoolbox.validation import validate_list
 
 
@@ -24,34 +25,73 @@ def test_to_args_string() -> None:
     assert subprocess.to_args_string([10, None, 'string "salut"']) == '10 \'string "salut"\''
 
 
-def test_cmd() -> None:
+def test_cmd_happy_case():
+    result = subprocess.cmd(['cat', __file__])
+    assert isinstance(result['process'], subprocess.Popen)
+    assert result['returncode'] == 0
+    assert len(result['stdout'].splitlines()) > 30  # At least 30 lines in this source file !
+    assert result['stderr'] == b''
+
+
+def test_cmd_logging(caplog, capsys):
+    """
+    Ensure logging is not outputting to stderr by default.
+
+    See https://docs.python.org/3/howto/logging.html#library-config
+    """
+    caplog.set_level(logging.DEBUG)
+    subprocess.cmd(['cat', __file__])
+    subprocess.cmd(['echo', 'toto tata'])
+    subprocess.cmd(['/usr/bin/env', 'python'])
+    subprocess.cmd(['ls', Path(__file__).parent], log=lambda msg: None)
+    captured = capsys.readouterr()
+    assert captured.out == ''
+    assert captured.err == ''
+    # log = logging.get_logger('pytoolbox.subprocess.cmd.cat')
+    cat_record, echo_record, env_record = caplog.records
+    assert cat_record.name == 'pytoolbox.subprocess.cmd.cat'
+    assert cat_record.levelname == 'DEBUG'
+    assert cat_record.msg == f'Execute cat {__file__}'
+    assert echo_record.name == 'pytoolbox.subprocess.cmd.echo'
+    assert echo_record.levelname == 'DEBUG'
+    assert echo_record.msg == "Execute echo 'toto tata'"
+    assert env_record.name == 'pytoolbox.subprocess.cmd.env'
+    assert env_record.levelname == 'DEBUG'
+    assert env_record.msg == "Execute /usr/bin/env python"
+
+
+def test_cmd_log_to_func() -> None:
     log = mock.Mock()
     log.__name__ = 'Mock'
     subprocess.cmd(['echo', 'it seem to work'], log=log)
-    assert subprocess.cmd('cat missing_file', fail=False, log=log)['returncode'] == 1
+    result = subprocess.cmd('cat missing_file', fail=False, log=log)
+    assert isinstance(result['process'], subprocess.Popen)
+    assert result['returncode'] == 1
+    assert result['stdout'] == b''
+    assert result['stderr'] == b'cat: missing_file: No such file or directory\n'
     assert log.call_args_list == [
         mock.call("Execute echo 'it seem to work'"),
         mock.call('Execute cat missing_file'),
         mock.call('Attempt 1 out of 1: Failed')
     ]
-    assert subprocess.cmd('my.funny.missing.script.sh', fail=False)['stderr'] != ''
-    result = subprocess.cmd(f'cat {__file__}')
-    # There are at least 30 lines in this source file !
-    assert len(result['stdout'].splitlines()) > 30
 
 
 def test_cmd_missing_binary() -> None:
-    assert subprocess.cmd('hfuejnvwqkdivengz', fail=False)['returncode'] == 2
+    result = subprocess.cmd('hfuejnvwqkdivengz', fail=False)
+    assert result['process'] is None
+    assert result['returncode'] == 2
+    assert result['stdout'] is None
+    assert result['stderr'] is None
 
 
-def test_retry_first_try() -> None:
+def test_cmd_retry_first_try() -> None:
     log = mock.Mock()
     log.__name__ = 'Mock'
     subprocess.cmd('ls', log=log, tries=5, delay_min=1, delay_max=1)
     log.assert_called_once_with('Execute ls')
 
 
-def test_retry_missing_binary_no_retry() -> None:
+def test_cmd_retry_missing_binary_no_retry() -> None:
     log = mock.Mock()
     log.__name__ = 'Mock'
     with pytest.raises(OSError):
@@ -62,7 +102,7 @@ def test_retry_missing_binary_no_retry() -> None:
     ])
 
 
-def test_retry_no_success() -> None:
+def test_cmd_retry_no_success() -> None:
     log = mock.Mock()
     log.__name__ = 'Mock'
     subprocess.cmd(