Skip to content

Commit

Permalink
Merge pull request #2707 from marshmallow-code/context
Browse files Browse the repository at this point in the history
Use a context variable to pass Schema context
  • Loading branch information
lafrech authored Jan 5, 2025
2 parents 68c68e3 + 5edd8b5 commit e28da9a
Show file tree
Hide file tree
Showing 17 changed files with 480 additions and 305 deletions.
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", "fred@queen.com")
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": "keith@stones.com"}}
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

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))

@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

0 comments on commit e28da9a

Please sign in to comment.