Source code for dataclass_builder.factory

"""Create :func:`dataclasses.dataclass` builders for specific dataclasses.

This module uses a factory to build builder classes that build a specific
dataclass.  These builder classes implement the builder pattern and allow
constructing dataclasses over a period of time instead of all at once.


Examples
--------
Using specialized builders allows for better documentation than the
:class:`DataclassBuilder` wrapper and allows for type checking because
annotations are dynamically generated.

.. testcode::

    from dataclasses import dataclass
    from dataclass_builder import (dataclass_builder, build, fields,
                                   REQUIRED, OPTIONAL)

    @dataclass
    class Point:
        x: float
        y: float
        w: float = 1.0

    PointBuilder = dataclass_builder(Point)

Now we can build a point.

.. doctest::

    >>> builder = PointBuilder()
    >>> builder.x = 5.8
    >>> builder.y = 8.1
    >>> builder.w = 2.0
    >>> build(builder)
    Point(x=5.8, y=8.1, w=2.0)

As long as the dataclass the builder was constructed for does not have a
`build` field then a `build` method will be generated as well.

    >>> builder.build()
    Point(x=5.8, y=8.1, w=2.0)

Field values can also be provided in the constructor.

.. doctest::

    >>> builder = PointBuilder(x=5.8, w=100)
    >>> builder.y = 8.1
    >>> builder.build()
    Point(x=5.8, y=8.1, w=100)

.. note::

    Positional arguments are not allowed.

Fields with default values in the dataclass are optional in the builder.

.. doctest::

    >>> builder = PointBuilder()
    >>> builder.x = 5.8
    >>> builder.y = 8.1
    >>> builder.build()
    Point(x=5.8, y=8.1, w=1.0)

Fields that don't have default values in the dataclass are not optional.

.. doctest::

    >>> builder = PointBuilder()
    >>> builder.y = 8.1
    >>> builder.build()
    Traceback (most recent call last):
    ...
    MissingFieldError: field 'x' of dataclass 'Point' is not optional

Fields not defined in the dataclass cannot be set in the builder.

.. doctest::

    >>> builder.z = 3.0
    Traceback (most recent call last):
    ...
    UndefinedFieldError: dataclass 'Point' does not define field 'z'

.. note::

    No exception will be raised for fields beginning with an underscore as they
    are reserved for use by subclasses.

Accessing a field of the builder before it is set gives either the `REQUIRED`
or `OPTIONAL` constant

.. doctest::

    >>> builder = PointBuilder()
    >>> builder.x
    REQUIRED
    >>> builder.w
    OPTIONAL

The `fields` method can be used to retrieve a dictionary of settable fields for
the builder.  This is a mapping of field names to :class:`dataclasses.Field`
objects from which extra data can be retrieved such as the type of the data
stored in the field.

.. doctest::

    >>> list(builder.fields().keys())
    ['x', 'y', 'w']
    >>> [f.type.__name__ for f in builder.fields().values()]
    ['float', 'float', 'float']

A subset of the fields can be also be retrieved, for instance, to only get
required fields:

.. doctest::

    >>> list(builder.fields(optional=False).keys())
    ['x', 'y']

or only the optional fields.

.. doctest::

    >>> list(builder.fields(required=False).keys())
    ['w']

.. note::

    If the underlying dataclass has a field named `fields` this method will
    not be generated and instead the :func:`fields` function should be used
    instead.

"""

from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Dict,
    Mapping,
    MutableMapping,
    Optional,
    Sequence,
    Type,
    cast,
)

from ._common import (
    MISSING,
    OPTIONAL,
    REQUIRED,
    _is_required,
    _optional_fields,
    _required_fields,
    _settable_fields,
)
from .exceptions import MissingFieldError, UndefinedFieldError

if TYPE_CHECKING:
    from dataclasses import Field, is_dataclass
else:
    from dataclasses import is_dataclass

__all__ = ["dataclass_builder"]


# copied (and modified) from dataclasses._create_fn to avoid dependency on
# private functions in dataclasses
def _create_fn(
    name: str,
    args: Sequence[str],
    body: Sequence[str],
    env: Optional[Dict[str, Any]] = None,
    *,
    return_type: Any = MISSING,
) -> Callable[..., Any]:
    locals_: MutableMapping[str, Any] = {}
    return_annotation = ""
    if env is None:
        env = {}
    if return_type is not MISSING:
        env["_return_type"] = return_type
        return_annotation = "->_return_type"
    args = ", ".join(args)
    body = "\n".join(f" {line}" for line in body)
    txt = f"def {name}({args}){return_annotation}:\n{body}"
    # this is how the dataclasses module makes custom methods so it's good
    # enough for this package
    exec(txt, env, locals_)  # pylint: disable=exec-used
    return cast(Callable[..., Any], locals_[name])


def _create_init_method(fields: Mapping[str, "Field[Any]"]) -> Callable[..., None]:
    env: Dict[str, Any] = {
        f"_{name}_type": field.type for name, field in fields.items()
    }
    env["REQUIRED"] = REQUIRED
    env["OPTIONAL"] = OPTIONAL

    def is_required(field: "Field[Any]") -> str:
        return "REQUIRED" if _is_required(field) else "OPTIONAL"

    if fields:
        args = ["self", "*"] + [
            f"{name}: _{name}_type = {is_required(field)}"
            for name, field in fields.items()
        ]
    else:
        args = ["self"]
    body = [f"self.{name}: _{name}_type = {name}" for name in fields]
    body = ["self.__initialized = False"] + body + ["self.__initialized = True"]
    return _create_fn("__init__", args, body, env, return_type=None)


def _create_class_docstring(dataclass: Any) -> str:
    dname = dataclass.__qualname__
    try:
        dname = dataclass.__module__ + "." + dname
    except AttributeError:
        pass
    params = []
    for name in _settable_fields(dataclass).keys():
        params.append(f"    :param {name}: Optionally initialize `{name}` field.\n")
    docstring = rf"""Builder for the :class:`{dname}` dataclass.

    This class allows the :class:`{dname}` dataclass to be constructed with the
    builder pattern.  Once an instance is constructed simply assign to it's
    attributes, which are identical to the :class:`{dname}` dataclass.  When
    done use it's `build` method, or the :func:`build` function if one of the
    fields is `build`, to make an instance of the :class:`{dname}` dataclass
    using the field values set on this builder.

    .. warning::

        Because this class overrides attribute assignment, care must be taken
        when extending to only use private and/or "dunder" attributes and
        methods.

    See :class:`{dname}` for further information on each filed.

{''.join(params)}

    :raises dataclass_builder.exceptions.UndefinedFieldError:
        If you try to assign to a field that is not part of :class:`{dname}`\ 's
        `__init__` method.
    :raises dataclass_builder.exceptions.MissingFieldError:
        If :func:`build` is called on this builder before all non default
        fields of the dataclass are assigned.
        """
    return docstring


def dataclass_builder(  # noqa: C901
    dataclass: Type[Any], *, name: Optional[str] = None
) -> Type[Any]:
    """Create a new builder class specialized to a given dataclass.

    :param dataclass:
        The :func:`dataclasses.dataclass` to create the builder for.
    :param name:
        Override the name of the builder, by default it will be
        '<dataclass>Builder' where <dataclass> is replaced by the name of the
        dataclass.

    :return object:
        A new dataclass builder class that is specialized to the given
        `dataclass`.  If the given :func:`dataclasses.dataclass` does not
        contain the fields `build` or `fields` these will be exposed as public
        methods with the same signature as the
        :func:`dataclass_builder.utility.build` and
        :func:`dataclass_builder.utility.fields` functions respectively.

    :raises TypeError:
        If `dataclass` is not a :func:`dataclasses.dataclass`. This is decided
        via :func:`dataclasses.is_dataclass`.
    """
    if not is_dataclass(dataclass):
        raise TypeError("must be called with a dataclass type")

    settable_fields = _settable_fields(dataclass)
    required_fields = _required_fields(dataclass)
    optional_fields = _optional_fields(dataclass)

    # validate identifiers
    for name_ in _settable_fields(dataclass):
        # there should not be anyway to trigger this branch
        if not name_.isidentifier():  # pragma: no cover
            raise RuntimeError(
                f"field name '{name_}'' could cause a security issue, refusing"
                f" to construct builder for '{dataclass.__qualname__}'"
            )

    dname = dataclass.__qualname__
    try:
        dname = dataclass.__module__ + "." + dname
    except AttributeError:
        pass

    def _setattr_method(self: Any, name: str, value: Any) -> None:
        # self.__initialized is not protected member access, since this is
        # a class method
        if (
            name.startswith("_") or hasattr(self, name) or not self.__initialized
        ):  # pylint: disable=protected-access
            object.__setattr__(self, name, value)
        else:
            raise UndefinedFieldError(
                f"dataclass '{dataclass.__qualname__}' does not define "
                f"field '{name}'",
                dataclass,
                name,
            )

    _setattr_method.__doc__ = f"""\
    Set a field value, or an object attribute if it is private.

        .. note::

            This will pass through all attributes beginning with an underscore.
            If this is a valid field of the dataclass it will still be built
            correctly but UndefinedFieldError will not be thrown for attributes
            beginning with an underscore.

            If you need the exception to be thrown then set the field in the
            constructor.

        :param name:
            Name of the dataclass field or private/dunder attribute to set.
        :param value:
            Value to assign to the dataclass field or private/dunder
            attribute.

        :raises dataclass_builder.exceptions.UndefinedFieldError:
            If `name` is not initialisable in the :class:`{dname}` dataclass.
            If `name` is private (begins with an underscore) or is a "dunder"
            then this exception will not be raised.
        """

    def _repr_method(self: Any) -> str:
        """Print a representation of the builder.

        >>> PointBuilder = dataclass_builder(Point)
        >>> PointBuilder(x=4.0, w=2.0)
        PointBuilder(x=4.0, w=2.0)

        :return:
            String representation that can be used to construct this builder
            instance.
        """
        args = []
        for name in settable_fields:
            value = getattr(self, name)
            if value not in (REQUIRED, OPTIONAL):
                args.append(f"{name}={repr(value)}")
        return f'{self.__class__.__qualname__}({", ".join(args)})'

    def _build_method(self: Any) -> Any:
        # check for missing required fields
        for name, field in required_fields.items():
            if getattr(self, name) is REQUIRED:
                raise MissingFieldError(
                    f"field '{name}' of dataclass '{dataclass.__qualname__}' "
                    "is not optional",
                    dataclass,
                    field,
                )
        # build dataclass
        kwargs = {
            name: getattr(self, name)
            for name in settable_fields
            if getattr(self, name) is not OPTIONAL
        }
        return dataclass(**kwargs)

    _build_method.__doc__ = f"""\
    Build a :class:`{dname}` dataclass using the fields from this builder.

    :return:
        An instance of the :class:`{dname}` dataclass using the fields set on
        this builder instance.

    :raises dataclass_builder.exceptions.MissingFieldError:
        If not all of the required fields have been assigned to this
        builder instance.
    """

    def _fields_method(
        _: Any, required: bool = True, optional: bool = True
    ) -> Mapping[str, "Field[Any]"]:
        if not required and not optional:
            return {}
        if required and not optional:
            return required_fields
        if not required and optional:
            return optional_fields
        return settable_fields

    _fields_method.__doc__ = f"""Get a dictionary of the builder's fields.

        :param required:
            Set to False to not report required fields.
        :param optional:
            Set to False to not report optional fields.

        :return:
            A mapping from field names to actual :class:`dataclasses.Field`'s
            in the same order as in the :class:`{dname}` dataclass.
        """

    # Fix return type of build, it won't help Mypy as it cannot handle
    # classes created at runtime but typing.get_type_hints will work properly.
    #
    # See: https://github.com/python/mypy/wiki/Unsupported-Python-Features
    _build_method.__annotations__["return"] = dataclass

    # assemble new builder class methods
    dict_: Dict[str, Any] = dict()
    dict_["__init__"] = _create_init_method(settable_fields)
    dict_["__setattr__"] = _setattr_method
    dict_["__repr__"] = _repr_method
    dict_["_build"] = _build_method
    dict_["_fields"] = _fields_method
    dict_["__doc__"] = _create_class_docstring(dataclass)

    if "build" not in settable_fields:
        dict_["build"] = _build_method

    if "fields" not in settable_fields:
        dict_["fields"] = _fields_method

    if name is None:
        name = f"{dataclass.__name__}Builder"

    return type(name, (object,), dict_)