Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use a context variable to pass Schema context #2707

Merged
merged 25 commits into from
Jan 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
07c9c18
Add CONTEXT context variable
lafrech Dec 30, 2024
4f4e048
Expose Context context manager
lafrech Dec 30, 2024
a5003b8
Remove Schema.context and Field.context
lafrech Dec 30, 2024
7bb901f
Expose context as field/schema property
lafrech Dec 31, 2024
a454a88
Make current_context a Context class attribute
lafrech Jan 1, 2025
0f285ed
Don't provide None as default context
lafrech Jan 1, 2025
feffc2e
Fix Function field docstring about context
lafrech Jan 1, 2025
6fac57d
Allow passing a default to Context.get
lafrech Jan 1, 2025
1a4eec7
Never pass context to functions in Function field
lafrech Jan 1, 2025
4447c07
Remove utils.get_func_args
lafrech Jan 2, 2025
72de755
Make _CURRENT_CONTEXT a module-level attribute
lafrech Jan 2, 2025
17bd038
Move Context into experimental
lafrech Jan 2, 2025
63abfc1
Add typing to context.py
sloria Jan 2, 2025
c6c4e88
Add tests for decorated processors with context
lafrech Jan 3, 2025
c7d0bca
Merge branch '4.0' into context
lafrech Jan 3, 2025
947de51
Update documentation about removal of context
lafrech Jan 4, 2025
5b10b84
Update versionchanged in docstrings
lafrech Jan 4, 2025
318cae0
Update changelog about Context
lafrech Jan 4, 2025
e638af3
Context: initialize token at __init__
lafrech Jan 4, 2025
5e485cb
Merge branch '4.0' into context
sloria Jan 5, 2025
5604959
Minor edit to upgrading guide
sloria Jan 5, 2025
c89c15a
Add more documentation for Context
sloria Jan 5, 2025
f1cbe27
More complete examples
sloria Jan 5, 2025
63d46aa
Exemplify using type aliases for Context
sloria Jan 5, 2025
5edd8b5
Merge branch '4.0' into context
lafrech Jan 5, 2025
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
29 changes: 28 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Other changes:
As a consequence of this change:
- Time with time offsets are now supported.
- YYYY-MM-DD is now accepted as a datetime and deserialized as naive 00:00 AM.
- `from_iso_date`, `from_iso_time` and `from_iso_datetime` are removed from `marshmallow.utils`
- `from_iso_date`, `from_iso_time` and `from_iso_datetime` are removed from `marshmallow.utils`.

- *Backwards-incompatible*: Custom validators must raise a `ValidationError <marshmallow.exceptions.ValidationError>` for invalid values.
Returning `False` is no longer supported (:issue:`1775`).
Expand Down Expand Up @@ -56,6 +56,33 @@ As a consequence of this change:

Thanks :user:`ddelange` for the PR.

- *Backwards-incompatible*: Remove `Schema <marshmallow.schema.Schema>`'s ``context`` attribute. Passing a context
should be done using `contextvars.ContextVar` (:issue:`1826`).
marshmallow 4 provides an experimental `Context <marshmallow.experimental.context.Context>`
manager class that can be used to both set and retrieve context.

.. code-block:: python

import typing

from marshmallow import Schema, fields
from marshmallow.experimental.context import Context


class UserContext(typing.TypedDict):
suffix: str


class UserSchema(Schema):
name_suffixed = fields.Function(
lambda obj: obj["name"] + Context[UserContext].get()["suffix"]
)


with Context[UserContext]({"suffix": "bar"}):
UserSchema().dump({"name": "foo"})
# {'name_suffixed': 'foobar'}

Deprecations/Removals:

- *Backwards-incompatible*: Remove implicit field creation, i.e. using the ``fields`` or ``additional`` class Meta options with undeclared fields (:issue:`1356`).
Expand Down
1 change: 1 addition & 0 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ API Reference
marshmallow.decorators
marshmallow.validate
marshmallow.utils
marshmallow.experimental.context
marshmallow.error_store
marshmallow.class_registry
marshmallow.exceptions
64 changes: 49 additions & 15 deletions docs/custom_fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,38 +95,72 @@ Both :class:`Function <marshmallow.fields.Function>` and :class:`Method <marshma
result = schema.load({"balance": "100.00"})
result["balance"] # => 100.0

.. _adding-context:
.. _using_context:

Adding context to `Method` and `Function` fields
------------------------------------------------
Using context
-------------

A :class:`Function <marshmallow.fields.Function>` or :class:`Method <marshmallow.fields.Method>` field may need information about its environment to know how to serialize a value.
A field may need information about its environment to know how to (de)serialize a value.

In these cases, you can set the ``context`` attribute (a dictionary) of a `Schema`. :class:`Function <marshmallow.fields.Function>` and :class:`Method <marshmallow.fields.Method>` fields will have access to this dictionary.
You can use the experimental `Context <marshmallow.experimental.context.Context>` class
to set and retrieve context.

As an example, you might want your ``UserSchema`` to output whether or not a ``User`` is the author of a ``Blog`` or whether a certain word appears in a ``Blog's`` title.
Let's say your ``UserSchema`` needs to output
whether or not a ``User`` is the author of a ``Blog`` or
whether a certain word appears in a ``Blog's`` title.

.. code-block:: python

import typing
from dataclasses import dataclass

from marshmallow import Schema, fields
from marshmallow.experimental.context import Context


@dataclass
class User:
name: str


@dataclass
class Blog:
title: str
author: User


class ContextDict(typing.TypedDict):
blog: Blog


class UserSchema(Schema):
name = fields.String()
# Function fields optionally receive context argument
is_author = fields.Function(lambda user, context: user == context["blog"].author)

is_author = fields.Function(
lambda user: user == Context[ContextDict].get()["blog"].author
)
likes_bikes = fields.Method("writes_about_bikes")

def writes_about_bikes(self, user):
return "bicycle" in self.context["blog"].title.lower()
def writes_about_bikes(self, user: User) -> bool:
return "bicycle" in Context[ContextDict].get()["blog"].title.lower()

.. note::
You can use `Context.get <marshmallow.experimental.context.Context.get>`
within custom fields, pre-/post-processing methods, and validators.

When (de)serializing, set the context by using `Context <marshmallow.experimental.context.Context>` as a context manager.

.. code-block:: python

schema = UserSchema()

user = User("Freddie Mercury", "[email protected]")
blog = Blog("Bicycle Blog", author=user)

schema.context = {"blog": blog}
result = schema.dump(user)
result["is_author"] # => True
result["likes_bikes"] # => True
schema = UserSchema()
with Context({"blog": blog}):
result = schema.dump(user)
print(result["is_author"]) # => True
print(result["likes_bikes"]) # => True


Customizing error messages
Expand Down
13 changes: 0 additions & 13 deletions docs/extending.rst
Original file line number Diff line number Diff line change
Expand Up @@ -454,19 +454,6 @@ Our application schemas can now inherit from our custom schema class.
result = ser.dump(user)
result # {"user": {"name": "Keith", "email": "[email protected]"}}

Using context
-------------

The ``context`` attribute of a `Schema` is a general-purpose store for extra information that may be needed for (de)serialization. It may be used in both ``Schema`` and ``Field`` methods.

.. code-block:: python

schema = UserSchema()
# Make current HTTP request available to
# custom fields, schema methods, schema validators, etc.
schema.context["request"] = request
schema.dump(user)

Custom error messages
---------------------

Expand Down
5 changes: 5 additions & 0 deletions docs/marshmallow.experimental.context.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Context (experimental)
======================

.. automodule:: marshmallow.experimental.context
:members:
59 changes: 56 additions & 3 deletions docs/upgrading.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,58 @@ If you want to use anonymous functions, you can use this helper function.
class UserSchema(Schema):
password = fields.String(validate=predicate(lambda x: x == "password"))

New context API
***************

Passing context to `Schema <marshmallow.schema.Schema>` classes is no longer supported. Use `contextvars.ContextVar` for passing context to
fields, pre-/post-processing methods, and validators instead.

marshmallow 4 provides an experimental `Context <marshmallow.experimental.context.Context>`
manager class that can be used to both set and retrieve context.

.. code-block:: python

# 3.x
from marshmallow import Schema, fields


class UserSchema(Schema):
name_suffixed = fields.Function(
lambda obj, context: obj["name"] + context["suffix"]
)


user_schema = UserSchema()
user_schema.context = {"suffix": "bar"}
user_schema.dump({"name": "foo"})
# {'name_suffixed': 'foobar'}

# 4.x
import typing

from marshmallow import Schema, fields
from marshmallow.experimental.context import Context


class UserContext(typing.TypedDict):
suffix: str


UserSchemaContext = Context[UserContext]


class UserSchema(Schema):
name_suffixed = fields.Function(
lambda obj: obj["name"] + UserSchemaContext.get()["suffix"]
)


with UserSchemaContext({"suffix": "bar"}):
UserSchema().dump({"name": "foo"})
# {'name_suffixed': 'foobar'}

See :ref:`using_context` for more information.

Implicit field creation is removed
**********************************

Expand Down Expand Up @@ -237,8 +289,8 @@ if you need to change the final output type.
``pass_many`` is renamed to ``pass_collection`` in decorators
*************************************************************

The ``pass_many`` argument to `pre_load <marshmallow.decorators.pre_load>`,
`post_load <marshmallow.decorators.post_load>`, `pre_dump <marshmallow.decorators.pre_dump>`,
The ``pass_many`` argument to `pre_load <marshmallow.decorators.pre_load>`,
`post_load <marshmallow.decorators.post_load>`, `pre_dump <marshmallow.decorators.pre_dump>`,
and `post_dump <marshmallow.decorators.post_dump>` is renamed to ``pass_collection``.

The behavior is unchanged.
Expand Down Expand Up @@ -309,7 +361,7 @@ Upgrading to 3.13
``load_default`` and ``dump_default``
+++++++++++++++++++++++++++++++++++++

The ``missing`` and ``default`` parameters of fields are renamed to
The ``missing`` and ``default`` parameters of fields are renamed to
``load_default`` and ``dump_default``, respectively.

.. code-block:: python
Expand All @@ -330,6 +382,7 @@ The ``missing`` and ``default`` parameters of fields are renamed to

``load_default`` and ``dump_default`` are passed to the field constructor as keyword arguments.


Upgrading to 3.3
++++++++++++++++

Expand Down
33 changes: 0 additions & 33 deletions docs/why.rst
Original file line number Diff line number Diff line change
Expand Up @@ -55,39 +55,6 @@ In this example, a single schema produced three different outputs! The dynamic n
.. _Django REST Framework: https://www.django-rest-framework.org/
.. _Flask-RESTful: https://flask-restful.readthedocs.io/


Context-aware serialization
---------------------------

marshmallow schemas can modify their output based on the context in which they are used. Field objects have access to a ``context`` dictionary that can be changed at runtime.

Here's a simple example that shows how a `Schema <marshmallow.Schema>` can anonymize a person's name when a boolean is set on the context.

.. code-block:: python

class PersonSchema(Schema):
id = fields.Integer()
name = fields.Method("get_name")

def get_name(self, person, context):
if context.get("anonymize"):
return "<anonymized>"
return person.name


person = Person(name="Monty")
schema = PersonSchema()
schema.dump(person) # {'id': 143, 'name': 'Monty'}

# In a different context, anonymize the name
schema.context["anonymize"] = True
schema.dump(person) # {'id': 143, 'name': '<anonymized>'}


.. seealso::

See the relevant section of the :ref:`usage guide <adding-context>` to learn more about context-aware serialization.

Advanced schema nesting
-----------------------

Expand Down
5 changes: 5 additions & 0 deletions src/marshmallow/experimental/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Experimental features.

The features in this subpackage are experimental. Breaking changes may be
introduced in minor marshmallow versions.
"""
61 changes: 61 additions & 0 deletions src/marshmallow/experimental/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Helper API for setting serialization/deserialization context.

Example usage:

.. code-block:: python

import typing

from marshmallow import Schema, fields
from marshmallow.experimental.context import Context


class UserContext(typing.TypedDict):
suffix: str


UserSchemaContext = Context[UserContext]


class UserSchema(Schema):
name_suffixed = fields.Function(
lambda user: user["name"] + UserSchemaContext.get()["suffix"]
)


with UserSchemaContext({"suffix": "bar"}):
print(UserSchema().dump({"name": "foo"}))
# {'name_suffixed': 'foobar'}
"""

import contextlib
import contextvars
import typing

_T = typing.TypeVar("_T")
_CURRENT_CONTEXT: contextvars.ContextVar = contextvars.ContextVar("context")


class Context(contextlib.AbstractContextManager, typing.Generic[_T]):
"""Context manager for setting and retrieving context."""

def __init__(self, context: _T) -> None:
self.context = context
self.token: contextvars.Token | None = None

lafrech marked this conversation as resolved.
Show resolved Hide resolved
def __enter__(self) -> None:
self.token = _CURRENT_CONTEXT.set(self.context)

def __exit__(self, *args, **kwargs) -> None:
_CURRENT_CONTEXT.reset(typing.cast(contextvars.Token, self.token))
sloria marked this conversation as resolved.
Show resolved Hide resolved

@classmethod
def get(cls, default=...) -> _T:
"""Get the current context.

:param default: Default value to return if no context is set.
If not provided and no context is set, a :exc:`LookupError` is raised.
"""
if default is not ...:
return _CURRENT_CONTEXT.get(default)
return _CURRENT_CONTEXT.get()
Loading
Loading