def wrap_class_methods(
    _return_a_copy_of_the_class=True,
    _raise_error_if_non_existent_method=True,
    **wrapper_for_method,
):
    """
    Make a decorator that wraps specific methods.

    IMPORTANT: The decorator will by default return a copy of the class. This might incur some run time overhead.
    If this is desirable, for example, when you want to create several decorations of a same class.
    If you want to change the class itself (e.g. you're only loading it once in a module, and decorating it), then
    specify _return_a_copy_of_the_class=False

    Note that _return_a_copy_of_the_class=True has a side effect of building russian dolls of essentially subclasses
    of the class, which may have some undesirable results if repeated too many times.

    :param _return_a_copy_of_the_class: Specifies whether to
        return a copy of the class (_return_a_copy_of_the_class=True, the default),
        or change the actual loaded class itself (_return_a_copy_of_the_class=False)
    :param wrapper_for_method: method_name=wrapper_function pairs.
    :return: A class wrapper. That is, a decorator that takes a class and returns a decorated version of it
        (or decaorates "in-place" if _return_a_copy_of_the_class=False

    SEE ALSO:
        * wrap_method_output: The function that is called for every method we wrap.
        * transform_class_method_input_and_output: A wrap_class_methods that is specialized for input arg and output
            transformation.

    >>> from functools import wraps
    >>> class A:
    ...     def __init__(self, a=10):
    ...         self.a = a
    ...     def add(self, x):
    ...         return self.a + x
    ...     def multiply(self, x):
    ...         return self.a * x
    ...
    >>> a = A()
    >>> a.add(2)
    12
    >>> a.multiply(2)
    20
    >>>
    >>> def log_calls(func):
    ...     name = func.__name__
    ...     @wraps(func)
    ...     def _func(self, *args, **kwargs):
    ...         print("Calling {} with\\n  args={}\\n  kwargs={}".format(name, args, kwargs))
    ...         return func(self, *args, **kwargs)
    ...     return _func
    ...
    >>> AA = wrap_class_methods(**{k: log_calls for k in ['add', 'multiply']})(A)
    >>> a = AA()
    >>> a.add(x=3)
    Calling add with
      args=()
      kwargs={'x': 3}
    13
    >>> a.multiply(3)
    Calling multiply with
      args=(3,)
      kwargs={}
    30
    """

    def class_wrapper(cls):
        if _return_a_copy_of_the_class:
            _cls = type('_' + cls.__name__, cls.__bases__, dict(cls.__dict__))
            # class _cls(cls):
            #     pass
        else:
            _cls = cls
        for method, wrapper in wrapper_for_method.items():
            if hasattr(_cls, method):
                setattr(_cls, method, wrapper(getattr(_cls, method)))
            elif _raise_error_if_non_existent_method:
                raise ValueError(
                    f"{getattr(_cls, '__name__', str(cls))} has no '{method}' method!"
                )
        return _cls

    return class_wrapper