Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/marshmallow/experimental/meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from __future__ import annotations

import typing

if typing.TYPE_CHECKING:
from marshmallow.fields import Field
from marshmallow.schema import SchemaMeta
from marshmallow.types import UnknownOption


@typing.overload
def meta(
*bases: SchemaMeta,
Copy link
Member

@sloria sloria Feb 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be

Suggested change
*bases: SchemaMeta,
*bases: Schema.Meta,

? Surprised mypy doesn't raise an error in the tests tho 🤔

fields: tuple[str, ...] | list[str] | None,
additional: tuple[str, ...] | list[str] | None,
include: dict[str, Field] | None,
exclude: tuple[str, ...] | list[str] | None,
many: bool | None,
dateformat: str | None,
datetimeformat: str | None,
timeformat: str | None,
render_module: typing.Any | None,
index_errors: bool | None,
load_only: tuple[str, ...] | list[str] | None,
dump_only: tuple[str, ...] | list[str] | None,
unknown: UnknownOption | None,
register: bool | None,
**kwargs,
):
"""
:param *bases: The meta classes to inherit from. Inherits from the decorated schema's
Meta class by default. Pass `None` to prevent inheritance.
:param fields: Fields to include in the (de)serialized result
:param additional: Fields to include in addition to the explicitly declared fields.
`additional <marshmallow.Schema.Meta.additional>` and `fields <marshmallow.Schema.Meta.fields>`
are mutually-exclusive options.
:param include: Dictionary of additional fields to include in the schema. It is
usually better to define fields as class variables, but you may need to
use this option, e.g., if your fields are Python keywords.
:param exclude: Fields to exclude in the serialized result.
Nested fields can be represented with dot delimiters.
:param many: Whether data should be (de)serialized as a collection by default.
:param dateformat: Default format for `Date <marshmallow.fields.Date>` fields.
:param datetimeformat: Default format for `DateTime <marshmallow.fields.DateTime>` fields.
:param timeformat: Default format for `Time <marshmallow.fields.Time>` fields.
:param render_module: Module to use for `loads <marshmallow.Schema.loads>` and `dumps <marshmallow.Schema.dumps>`.
Defaults to `json` from the standard library.
:param index_errors: If `True`, errors dictionaries will include the index of invalid items in a collection.
:param load_only: Fields to exclude from serialized results
:param dump_only: Fields to exclude from serialized results
:param unknown: Whether to exclude, include, or raise an error for unknown fields in the data.
Use `EXCLUDE`, `INCLUDE` or `RAISE`.
:param register: Whether to register the `Schema <marshmallow.Schema>` with marshmallow's internal
class registry. Must be `True` if you intend to refer to this `Schema <marshmallow.Schema>`
by class name in `Nested` fields. Only set this to `False` when memory
usage is critical. Defaults to `True`.
"""


@typing.overload
def meta(*bases, **kwargs): ...


def meta(*bases, **kwargs):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the @overload usage seems a bit odd...does the approach with TypedDict and total=False not work? like what we do here?

class _BaseFieldKwargs(typing.TypedDict, total=False):
load_default: typing.Any
dump_default: typing.Any
data_key: str | None
attribute: str | None
validate: types.Validator | typing.Iterable[types.Validator] | None
required: bool
allow_none: bool | None
load_only: bool
dump_only: bool
error_messages: dict[str, str] | None
metadata: typing.Mapping[str, typing.Any] | None

or perhaps

def meta(
    *bases: SchemaMeta,
    fields: tuple[str, ...] | list[str] | None,
    additional: tuple[str, ...] | list[str] | None,
    # ...
    **kwargs,
):
    def wrapper(schema):
        mro = bases if bases else (schema.Meta,)
        meta = type(schema.Meta.__name__, mro, {
            "fields": fields,
            "additional": additional,
             # ...
             **kwargs
        })
        return type(schema.__name__, (schema,), {"Meta": meta})

    return wrapper

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unpack[TypedDict] does not allow accepting extra kwargs, but we allow custom Meta attributes.

Explicitly copying the args into a dictionary defines missing attributes as None, which breaks inheritance.

One @overloadis all that is needed (and is valid), but mypy requires a sacrificial overload.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see. In that case, maybe just add comments about why this approach is necessary. Could even include a link to that gh comment

def wrapper(schema):
mro = bases if bases else (schema.Meta,)
meta = type(schema.Meta.__name__, mro, kwargs)
return type(schema.__name__, (schema,), {"Meta": meta})

return wrapper
37 changes: 37 additions & 0 deletions tests/test_meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from marshmallow import Schema
from marshmallow.experimental.meta import meta


class Base(Schema):
class Meta:
foo = True


class TestMeta:
def test_default_inheritance(self):
@meta(bar=True)
class Test(Base):
pass

assert getattr(Test.Meta, "foo", None)
assert getattr(Test.Meta, "bar", None)

def test_explicit_inheritance(self):
class Parent(Schema):
class Meta:
bar = True

@meta(Base.Meta, Parent.Meta, baz=True)
class Test(Schema):
pass

assert getattr(Test.Meta, "foo", None)
assert getattr(Test.Meta, "bar", None)
assert getattr(Test.Meta, "baz", None)

def test_clear_inheritance(self):
@meta(Schema.Meta, bar=True)
class Test(Base):
pass

assert not hasattr(Test.Meta, "foo")