Metadata-Version: 2.4
Name: dewi-dataclass
Version: 2.0.0
Summary: DEWI DataClass: a (mutable) data class / config object implementation
Author-email: Laszlo Attila Toth <python-dewi@laszloattilatoth.me>
Maintainer-email: Laszlo Attila Toth <python-dewi@laszloattilatoth.me>
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/LA-Toth/dewi-dataclass
Keywords: dataclass,config,configtree,node
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: English
Classifier: Operating System :: POSIX
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Description-Content-Type: text/x-rst
License-File: COPYING
Dynamic: license-file

DEWI dataclass
==============

A small, dependency-free package providing ``Node``: a **typed dict tree**.

A ``Node`` is a ``collections.abc.MutableMapping`` whose members live in the
instance ``__dict__``, so each member can be reached both as an attribute and
as a mapping key. That dual nature is the point of the class: a tree of typed
objects that can still be walked by key, loaded wholesale from parsed YAML or
JSON, and converted back to plain dicts and lists for output.

It was written around the time ``dataclasses`` (PEP 557) appeared, and shares
some ground with it and with ``attrs`` — the ``@frozen`` decorator is the
clearest borrowing — but it is not a reimplementation of either. Where
``@dataclass`` gives you a record with a generated ``__init__``, ``Node`` gives
you a mapping that fills in typed defaults from annotations. See
`Compared to @dataclass`_ below.

Unlike a plain ``dict``, a missing entry raises ``AttributeError`` rather than
``KeyError``.

Requires Python 3.10 or newer. No runtime dependencies.


Basic usage
-----------

At its simplest, an enhanced dict:

.. code-block:: python

    from dewi_dataclass import DataClass

    val = DataClass()

    # use as a regular dict
    val['key1'] = 42
    # ...and as a class member
    print(val.key1)   # 42

    # a missing item raises AttributeError, not KeyError
    print(val['missing'])
    print(val.missing)

``DataClass`` is an alias of ``Node``, and ``DataList`` of ``NodeList``; both
names are exported from ``dewi_dataclass``. The rest of this document uses the
``DataClass`` spelling.

Keys need not be valid Python identifiers — ``val['a-key'] = 1`` works, and the
value is then reachable with ``getattr(val, 'a-key')``.

Members cannot be deleted. Both ``del val['key1']`` and ``del val.key1`` raise
``TypeError``.


Subclasses
----------

The features come alive in a subclass.

Members from annotations
~~~~~~~~~~~~~~~~~~~~~~~~

A member that carries only a type annotation is created on first read, from the
annotated type's no-argument constructor. If that is good enough, the subclass
needs no ``__init__`` at all:

.. code-block:: python

    class Data(DataClass):
        x: int
        y: list[str]

    d = Data()
    print(d.x)  # 0
    print(d.y)  # []

A class-level default is used when there is one:

.. code-block:: python

    class Point(DataClass):
        x: int = 42

Two things to know about this mechanism:

* **Reading a member stores it.** Before ``d.x`` is read, ``'x' in d`` is true
  (annotations count for membership) but ``len(d)`` does not include it. After
  the read it does. So ``len()``, iteration and ``as_dict()`` reflect which
  members have been touched.
* **What each annotation produces:**

  * a bare type or a parameterised builtin is constructed — ``int`` → ``0``,
    ``str`` → ``''``, ``list[str]`` → ``[]``, ``dict[str, int]`` → ``{}``, and
    any ``DataClass`` subclass → a new instance;
  * ``DataList[Item]`` → ``DataList(Item)``, with the member type taken from
    the subscript. A bare ``DataList`` annotation carries no member type and is
    rejected;
  * a union containing ``None`` — ``str | None``, ``Optional[int]`` — →
    ``None``. A union without ``None`` has no obvious default and is rejected;
  * a string annotation, from a forward reference or from
    ``from __future__ import annotations``, is resolved against the module the
    class was defined in, then handled as above.

  Anything that cannot produce a default raises ``NodeDefinitionError``, naming
  the class and saying what to do instead.

A mutable class-level default would be shared between instances, so it is
rejected outright — see below.

What the class definition rejects
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Two mistakes are caught when the subclass is created, both raising
``NodeDefinitionError`` (a ``TypeError``).

**Reserved names.** Because ``DataClass`` is a ``MutableMapping``, a member
named after one of the mapping methods would be shadowed by the method and
never reach the annotation machinery:

.. code-block:: python

    class Bad(DataClass):
        items: list[int]   # ❌ NodeDefinitionError

The reserved names are ``clear``, ``get``, ``items``, ``keys``, ``pop``,
``popitem``, ``setdefault``, ``update`` and ``values`` — read from
``collections.abc.MutableMapping`` at import, so a name added to it in a future
Python is caught rather than silently swallowing a member.

Assigning such a name in ``__init__`` without annotating it does work, because
the instance ``__dict__`` wins over a method, so that case is left alone. The
name is still a poor choice.

**Mutable defaults.** A mutable class-level default would be shared by every
instance:

.. code-block:: python

    class Bad(DataClass):
        values_: list = []    # ❌ NodeDefinitionError

The fix is to delete the default. The annotation on its own already gives each
instance a fresh ``list`` on first read:

.. code-block:: python

    class Fine(DataClass):
        values_: list         # ✅ each instance gets its own []

Immutable defaults — numbers, strings, tuples — are unaffected. An unannotated
class attribute is a plain class constant and is never touched.

create() and create_from()
~~~~~~~~~~~~~~~~~~~~~~~~~~

``create()`` builds an instance from keyword arguments and **rejects** anything
that is neither an existing member nor an annotated one:

.. code-block:: python

    class Point(DataClass):
        x: int

        def __init__(self):
            self.y = 4

    Point.create(x=3)        # ok
    Point.create(x=3, y=22)  # ok -- y was set in __init__
    Point.create(z=1)        # ❌ AttributeError: z

``create_from(a_dict)`` does the same from a dict but **accepts** unknown keys,
adding them to the instance. Use ``create()`` when the input is meant to match
a known schema, ``create_from()`` when it is arbitrary data.

The check applies to the top level only: a nested dict passed for a ``Node``
member is not validated.


DataList: a list of DataClass objects
-------------------------------------

``DataList`` is a list that remembers the type of its members, so raw dicts
loaded into it are converted to that type:

.. code-block:: python

    from dewi_dataclass import DataClass, DataList

    class Module(DataClass):
        name: str

        def __init__(self):
            self.name = ''

    class Params(DataClass):
        modules: DataList[Module]   # the member type comes from the subscript

    params = Params()
    params.modules.load_from([dict(name='first'), dict(name='second')])
    print(type(params.modules[0]))   # <class 'Module'>

``DataList[Module]`` is read at runtime: the member is created as
``DataList(Module)`` on first access, so no ``__init__`` is needed. Assigning it
explicitly still works and is equivalent. A **bare** ``DataList`` annotation
carries no member type and raises ``NodeDefinitionError``.

``type_`` survives ``copy.copy()`` and ``copy.deepcopy()``, but not slicing:
``params.modules[:]`` is a plain ``list``.


load_from(): load a dict hierarchy, e.g. from YAML
--------------------------------------------------

.. code-block:: python

    import yaml

    params = Params()
    params.load_from(yaml.safe_load(...))

    # or, creating the object at the same time
    params = Params.create_from(yaml.safe_load(...))

The rules, per key:

* a key naming an existing ``DataClass`` or ``DataList`` member is **loaded
  into**, recursively — the existing object is kept, not replaced;
* any other known key is assigned verbatim, with **no type coercion**;
* an unknown key raises ``AttributeError`` if ``raise_error=True``;
* otherwise an unknown key is added, and its value converted by shape:

  * a ``dict`` becomes a ``DataClass``;
  * a ``tuple`` is first turned into a ``list``;
  * a list of mappings becomes a ``DataList`` of plain ``DataClass`` objects,
    and a list holding no mapping stays a plain list — so an empty list stays a
    list.

A ``DataList`` holds a single member type, so a list mixing mappings and
scalars is malformed rather than something to guess at: it raises ``TypeError``
naming the member, the index and the offending value. Both orders are rejected.

``raise_error`` reaches every level: a typo nested inside a child node, or
inside an item of a ``DataList``, is caught as well as one at the top.
``create()`` sets it; ``create_from()`` and ``load_from()`` do not.

Which structures are validated
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

You rarely need ``raise_error``, because **@frozen already decides this, per
node**. A frozen class is closed to new member names, so a key the class does
not declare cannot be assigned — by attribute, by item, or by ``load_from()``:

.. code-block:: python

    @frozen
    class Point(DataClass):
        x: int

        def __init__(self):
            self.x = 0

    Point().load_from(dict(x=1))            # ✅
    Point().load_from(dict(nmae=1))         # ❌ FrozenNodeError: nmae

A plain data class is open, so it takes whatever the document carries. That is
what you want for a bag of values whose keys are not known in advance.

Strictness is decided by **each node's own class**, not inherited from its
parent, so the two mix freely in one tree:

.. code-block:: python

    @frozen
    class Context(DataClass):
        args: DataClass                     # the context is closed...

        def __init__(self):
            self.args = DataClass()         # ...but args is an open bag

    ctx = Context()
    ctx.args.anything = 1                   # ✅ the option parser decides these
    ctx.new_field = 1                       # ❌ FrozenNodeError: new_field

So: freeze the classes whose shape you know, and leave open the ones filled
from outside. Validation follows from the declaration, with no flag to pass.


Frozen types
------------

Any data class can be frozen. **Frozen means the set of member names is
closed** — the values stay mutable, and a subclass may still add names by
annotating them. It applies to this class only, not to whatever its members
hold. Closing the names is what makes a frozen class validate the documents
loaded into it, as described above.

A frozen class rejects a name it does not declare, even in its own
``__init__``:

.. code-block:: python

    from dewi_dataclass import DataClass, FrozenNodeError, frozen

    @frozen
    class Point(DataClass):
        x: float
        y: float

        def __init__(self):
            self.z = 42    # ❌ FrozenNodeError -- z is not annotated

    point = Point()
    point.z = 0.0          # ❌ FrozenNodeError
    point['z'] = 0.0       # ❌ FrozenNodeError too -- __setitem__ uses setattr
    point.x = 1.0          # ✅ ok -- values stay mutable

A subclass extends a frozen class by **annotating** the new member. The
annotation is what makes the name acceptable, so it is required whether or not
``__init__`` also assigns it:

.. code-block:: python

    class Point3D(Point):
        z: float           # ✅ required

        def __init__(self):
            super().__init__()
            self.z = 0.0   # ✅ ok, because z is annotated above

    class Point3Dv2(Point):
        def __init__(self):
            super().__init__()
            self.z = 0.0   # ❌ FrozenNodeError -- no annotation for z

Watch out for one thing in particular: an annotated assignment *inside*
``__init__`` looks like a declaration but is not one. Python records no
annotation for an attribute target, so this fails:

.. code-block:: python

    @frozen
    class Amount(DataClass):
        def __init__(self):
            self.price: float = 0.0   # ❌ FrozenNodeError: price

``FrozenNodeError`` subclasses ``AttributeError`` and keeps the member name as
``args[0]``, so existing ``except AttributeError`` handlers keep working; its
message explains which class is frozen and what to declare.

Note this freezes *names*, not values. That is the difference from
``dataclasses.dataclass(frozen=True)``, which makes the values immutable and
the instance hashable.

**A data class is never hashable**, frozen or not: it is a ``MutableMapping``,
and ``MutableMapping`` sets ``__hash__`` to ``None``. So an instance cannot be
a dict key or a set member. Use a value derived from it — a tuple of the fields
you care about, or ``tuple(sorted(as_dict(node).items()))`` for a shallow
tree — when you need one.


fields() and replace()
----------------------

``fields()`` lists the declared members of a class or instance, base classes
included:

.. code-block:: python

    from dewi_dataclass import DataClass, fields

    class Point(DataClass):
        x: int
        y: int

    fields(Point)          # {'x': int, 'y': int}

Only *declared* members appear; a key added at runtime is in the mapping
itself, not in ``fields()``.

``replace()`` returns a deep copy with some members changed, leaving the
original alone. Names are checked as ``create()`` checks them, so a typo raises
rather than quietly adding a member:

.. code-block:: python

    from dewi_dataclass import replace

    updated = replace(config, name='other')
    updated = replace(config, driver=dict(headless=True))   # merges into the child
    replace(config, nmae='typo')                            # ❌ AttributeError

Passing a dict for a child node **merges** into it — only the keys named are
touched — rather than replacing the child wholesale.


as_dict(): convert to core Python types
---------------------------------------

Serializers need plain dicts and lists. ``as_dict()`` converts a tree of
``DataClass`` and ``DataList`` objects into ``dict`` and ``list``:

.. code-block:: python

    from dewi_dataclass import DataClass, as_dict

    print(point.as_dict())
    # or the function form, after attrs.asdict()
    print(as_dict(point))

    # typical use
    yaml.dump(as_dict(config), stream=sys.stdout, default_flow_style=False)

``DataList`` has the corresponding ``as_list()``.

Conversion is recursive and reaches everywhere, not just direct members:

* ``DataClass`` and any mapping (``dict``, ``defaultdict``, ...) become ``dict``
* ``DataList``, ``list`` and ``tuple`` become ``list``
* anything else is returned as it is

So a ``DataClass`` stored inside a plain ``list`` or ``dict`` is converted too.
A ``tuple`` becomes a ``list``, because that is what YAML and JSON can
represent — and because ``load_from()`` already turns tuples into lists, so the
round trip is stable.

Containers are rebuilt, so the result never shares a list or dict with the
original; mutating the result cannot reach back into the tree. Values that are
neither a data class nor a container — an ``Enum``, a ``datetime``, a
``bytearray``, an arbitrary object — are passed through **by reference**, not
copied. Copying them could be wrong or expensive, and a serializer needs the
original anyway.

Such a value is also the one thing that can still put a ``!!python/`` tag in
YAML output — an ``Enum`` member, for instance. That is only a problem if
something other than Python has to read the file. If the same program both
writes and reads it, loading with ``yaml.Loader`` (rather than
``yaml.safe_load``) reconstructs the value and nothing more is needed. For
output meant to be portable, give the dumper a representer for the type, or
convert it before dumping.

The serialization module
~~~~~~~~~~~~~~~~~~~~~~~~

The conversion lives in ``dewi_dataclass.serialization`` as free functions,
because conversion is about a *value*, whatever its type — a method can only
ever see ``self``, which is why it could not reach into plain containers:

.. code-block:: python

    from dewi_dataclass.serialization import as_dict, as_list, convert, load_into

    as_dict(node)              # a Node       -> dict
    as_list(node_list)         # a list       -> list
    convert(anything)          # any value, the general entry point
    load_into(node, data)      # the implementation behind load_from()

The methods on ``DataClass`` and ``DataList`` remain as thin wrappers, so
``node.as_dict()`` and ``node.load_from(...)`` are unaffected.

This package does not depend on PyYAML and registers no YAML representers; the
serializer glue lives in the calling project.


.. _Compared to @dataclass:

Compared to @dataclass
----------------------

.. list-table::
   :header-rows: 1
   :widths: 26 34 40

   * - Aspect
     - ``@dataclass``
     - ``DataClass`` (``Node``)
   * - Mechanism
     - generates methods at class creation
     - resolves at attribute-access time
   * - Storage
     - attributes (or ``__slots__``)
     - ``__dict__``, exposed as a Mapping
   * - ``__init__``
     - generated from the fields
     - write your own; always zero-argument
   * - Construction with values
     - ``Point(1, 2)``
     - ``Point.create(x=1, y=2)``
   * - Field set
     - closed
     - open; ``@frozen`` closes the names
   * - Defaults
     - ``= value`` or ``default_factory``
     - ``= value``, or the annotated type's constructor
   * - Mutable default
     - ``ValueError`` at class creation
     - allowed, and shared between instances
   * - Missing value
     - ``TypeError`` at construction
     - fabricated on first read
   * - ``__eq__``
     - generated, type-sensitive
     - from ``Mapping`` -- equal to a plain dict
   * - Hashable
     - with ``frozen=True``
     - never
   * - Immutability
     - ``frozen=True``: values immutable
     - ``@frozen``: member *names* fixed
   * - Deserialization
     - none
     - ``load_from()`` / ``create_from()``
   * - Serialization
     - ``dataclasses.asdict()``
     - ``as_dict()`` / ``as_list()``
   * - Introspection
     - ``fields()``, ``is_dataclass()``
     - none
   * - Keys
     - identifiers only
     - any string

Use ``@dataclass`` for a record with a fixed set of fields. Use ``DataClass``
when the data is a tree that has to be loaded from, and written back to, dicts
— especially when parts of it are only known at runtime.
