From e488a3a3dcf96253706208a16d718e631f8d4b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CTomas?= Date: Sat, 28 Mar 2026 20:20:20 -0300 Subject: [PATCH 1/2] Upgrade to marshmallow 4+ - Replace Schema.context with contextvars.ContextVar (cornice_request) - Remove removed Meta options (strict, ordered) - Move EXCLUDE import from marshmallow.utils to marshmallow - Replace metadata={"load_from": ...} with data_key parameter - Replace missing= with load_default= in test schemas - Remove pre-2.10 _message_normalizer compat code Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 +- src/cornice/validators/__init__.py | 2 ++ src/cornice/validators/_marshmallow.py | 33 +++++++------------------- tests/test_validation.py | 16 ------------- tests/validationapp.py | 30 +++++++---------------- 5 files changed, 20 insertions(+), 63 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c01ba907..6aa04b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dev = [ "pytest-cache<2", "pytest-cov<7", "WebTest<4", - "marshmallow<4", + "marshmallow>=4", "colander<3", ] diff --git a/src/cornice/validators/__init__.py b/src/cornice/validators/__init__.py index fc66736d..fb2e5289 100755 --- a/src/cornice/validators/__init__.py +++ b/src/cornice/validators/__init__.py @@ -11,6 +11,7 @@ from cornice.validators._colander import querystring_validator as colander_querystring_validator from cornice.validators._colander import validator as colander_validator from cornice.validators._marshmallow import body_validator as marshmallow_body_validator +from cornice.validators._marshmallow import cornice_request from cornice.validators._marshmallow import headers_validator as marshmallow_headers_validator from cornice.validators._marshmallow import path_validator as marshmallow_path_validator from cornice.validators._marshmallow import ( @@ -30,6 +31,7 @@ "marshmallow_headers_validator", "marshmallow_path_validator", "marshmallow_querystring_validator", + "cornice_request", "extract_cstruct", "DEFAULT_VALIDATORS", "DEFAULT_FILTERS", diff --git a/src/cornice/validators/_marshmallow.py b/src/cornice/validators/_marshmallow.py index 61bfa49d..3e3567ba 100644 --- a/src/cornice/validators/_marshmallow.py +++ b/src/cornice/validators/_marshmallow.py @@ -3,6 +3,10 @@ # You can obtain one at http://mozilla.org/MPL/2.0/. import inspect +from contextvars import ContextVar + + +cornice_request: ContextVar = ContextVar("cornice_request") def _generate_marshmallow_validator(location): @@ -40,7 +44,7 @@ def _validator(request, schema=None, deserializer=None, **kwargs): """ import marshmallow import marshmallow.schema - from marshmallow.utils import EXCLUDE + from marshmallow import EXCLUDE if schema is None: return @@ -51,13 +55,11 @@ def _validator(request, schema=None, deserializer=None, **kwargs): class ValidatedField(marshmallow.fields.Field): def _deserialize(self, value, attr, data, **kwargs): - schema.context.setdefault("request", request) + cornice_request.set(request) deserialized = schema.load(value) return deserialized class Meta(object): - strict = True - ordered = True unknown = EXCLUDE class RequestSchemaMeta(marshmallow.schema.SchemaMeta): @@ -80,7 +82,7 @@ def __new__(cls, name, bases, class_attrs): """ class_attrs[location] = ValidatedField( - required=True, metadata={"load_from": location} + required=True, data_key=location ) class_attrs["Meta"] = Meta return type(name, bases, class_attrs) @@ -102,22 +104,6 @@ class RequestSchema(marshmallow.Schema, metaclass=RequestSchemaMeta): # noqa querystring_validator = _generate_marshmallow_validator("querystring") -def _message_normalizer(exc, no_field_name="_schema"): - """ - Normally `normalize_messages` will exist on `ValidationError` but pre 2.10 - versions don't have it - :param exc: - :param no_field_name: - :return: - """ - if isinstance(exc.messages, dict): - return exc.messages - field_names = exc.kwargs.get("field_names", []) - if len(field_names) == 0: - return {no_field_name: exc.messages} - return dict((name, exc.messages) for name in field_names) - - def validator(request, schema=None, deserializer=None, **kwargs): """ Validate the full request against the schema defined on the service. @@ -148,14 +134,13 @@ def validator(request, schema=None, deserializer=None, **kwargs): return schema = _instantiate_schema(schema) - schema.context.setdefault("request", request) + cornice_request.set(request) cstruct = deserializer(request) try: deserialized = schema.load(cstruct) except marshmallow.ValidationError as err: - # translate = request.localizer.translate - normalized_errors = _message_normalizer(err) + normalized_errors = err.messages for location, details in normalized_errors.items(): location = location if location != "_schema" else "" if hasattr(details, "items"): diff --git a/tests/test_validation.py b/tests/test_validation.py index 63ae0ecb..3219dd5a 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -783,22 +783,6 @@ def test_no_body_schema(self): self.assertEqual(request.validated, mock.sentinel.validated) self.assertEqual(len(request.errors), 0) - def test_message_normalizer_no_field_names(self): - from marshmallow.exceptions import ValidationError - - from cornice.validators._marshmallow import _message_normalizer - - parsed = _message_normalizer(ValidationError("Test message")) - self.assertEqual({"_schema": ["Test message"]}, parsed) - - def test_message_normalizer_field_names(self): - from marshmallow.exceptions import ValidationError - - from cornice.validators._marshmallow import _message_normalizer - - parsed = _message_normalizer(ValidationError("Test message", field_names=["test"])) - self.assertEqual({"test": ["Test message"]}, parsed) - def test_instantiated_schema(self): app = TestApp(main({})) with self.assertRaises(ValueError): diff --git a/tests/validationapp.py b/tests/validationapp.py index e3abfc80..a31b1f2d 100644 --- a/tests/validationapp.py +++ b/tests/validationapp.py @@ -330,12 +330,9 @@ def form(request): try: import marshmallow + from marshmallow import EXCLUDE - try: - from marshmallow.utils import EXCLUDE - except ImportError: - EXCLUDE = "exclude" - from cornice.validators import marshmallow_body_validator, marshmallow_validator + from cornice.validators import cornice_request, marshmallow_body_validator, marshmallow_validator MARSHMALLOW = True except ImportError: @@ -355,7 +352,6 @@ def form(request): class MSignupSchema(marshmallow.Schema): class Meta: - strict = True unknown = EXCLUDE username = marshmallow.fields.String() @@ -364,16 +360,15 @@ class Meta: class MNeedsContextSchema(marshmallow.Schema): class Meta: - strict = True unknown = EXCLUDE - somefield = marshmallow.fields.Float(missing=lambda: random.random()) + somefield = marshmallow.fields.Float(load_default=lambda: random.random()) csrf_secret = marshmallow.fields.String() @marshmallow.validates_schema def validate_csrf_secret(self, data, **kwargs): # simulate validation of session variables - if self.context["request"].get_csrf() != data.get("csrf_secret"): + if cornice_request.get().get_csrf() != data.get("csrf_secret"): raise marshmallow.ValidationError("Wrong token") @m_bound.post(schema=MNeedsContextSchema, validators=(marshmallow_body_validator,)) @@ -402,26 +397,23 @@ def m_validate_bar(node, value): class MBodySchema(marshmallow.Schema): class Meta: - strict = True unknown = EXCLUDE # foo and bar are required, baz is optional foo = marshmallow.fields.String() bar = SchemaNode(String(), validator=m_validate_bar) - baz = marshmallow.fields.String(missing=None) - ipsum = marshmallow.fields.Integer(missing=1, validate=marshmallow.validate.Range(0, 3)) + baz = marshmallow.fields.String(load_default=None) + ipsum = marshmallow.fields.Integer(load_default=1, validate=marshmallow.validate.Range(0, 3)) integers = marshmallow.fields.List(marshmallow.fields.Integer()) class MQuery(marshmallow.Schema): class Meta: - strict = True unknown = EXCLUDE yeah = marshmallow.fields.String() class MRequestSchema(marshmallow.Schema): class Meta: - strict = True unknown = EXCLUDE body = marshmallow.fields.Nested(MBodySchema) @@ -432,7 +424,7 @@ def m_foobar_post(request): return {"test": "succeeded"} class MListQuerystringSequenced(marshmallow.Schema): - field = marshmallow.fields.List(marshmallow.fields.String(), many=True) + field = marshmallow.fields.List(marshmallow.fields.String()) @marshmallow.pre_load() def normalize_field(self, data, **kwargs): @@ -442,7 +434,6 @@ def normalize_field(self, data, **kwargs): class MQSSchema(marshmallow.Schema): class Meta: - strict = True unknown = EXCLUDE querystring = marshmallow.fields.Nested(MListQuerystringSequenced) @@ -453,14 +444,12 @@ def m_foobaz_get(request): class MNewsletterSchema(marshmallow.Schema): class Meta: - strict = True unknown = EXCLUDE email = marshmallow.fields.String(validate=marshmallow.validate.Email()) class MRefererSchema(marshmallow.Schema): class Meta: - strict = True unknown = EXCLUDE ref = marshmallow.fields.Integer() @@ -485,14 +474,12 @@ def m_newsletter(request): class MItemPathSchema(marshmallow.Schema): class Meta: - strict = True unknown = EXCLUDE - item_id = marshmallow.fields.Integer(missing=None) + item_id = marshmallow.fields.Integer(load_default=None) class MItemSchema(marshmallow.Schema): class Meta: - strict = True unknown = EXCLUDE path = marshmallow.fields.Nested(MItemPathSchema) @@ -507,7 +494,6 @@ def m_item_fails(request): class MFormSchema(marshmallow.Schema): class Meta: - strict = True unknown = EXCLUDE field1 = marshmallow.fields.String() From d92187169828aa4a4adf5d4946abc43070cc4b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CTomas?= Date: Sat, 28 Mar 2026 20:55:18 -0300 Subject: [PATCH 2/2] Support both marshmallow 3 (>=3.13) and 4 - Add _marshmallow_compat.py with version detection and shims for EXCLUDE import and Schema.context differences - Use contextvars.ContextVar alongside Schema.context for v3 compat - Add tox.ini with marshmallow3 and marshmallow4 test environments - Add marshmallow-version matrix dimension to CI workflow Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/test.yml | 4 +++ pyproject.toml | 2 +- src/cornice/validators/_marshmallow.py | 5 +++- src/cornice/validators/_marshmallow_compat.py | 22 ++++++++++++++++ tests/validationapp.py | 3 ++- tox.ini | 25 +++++++++++++++++++ 6 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 src/cornice/validators/_marshmallow_compat.py create mode 100644 tox.ini diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75193f86..09a8a222 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,7 @@ jobs: strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12"] + marshmallow-version: ["marshmallow>=3.13,<4", "marshmallow>=4"] steps: - uses: actions/checkout@v4 @@ -31,6 +32,9 @@ jobs: - name: Install dependencies run: make install + - name: Install marshmallow ${{ matrix.marshmallow-version }} + run: .venv/bin/pip install "${{ matrix.marshmallow-version }}" + - name: Run unit tests run: make test diff --git a/pyproject.toml b/pyproject.toml index 6aa04b56..de8c43d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dev = [ "pytest-cache<2", "pytest-cov<7", "WebTest<4", - "marshmallow>=4", + "marshmallow>=3.13", "colander<3", ] diff --git a/src/cornice/validators/_marshmallow.py b/src/cornice/validators/_marshmallow.py index 3e3567ba..df88d7ec 100644 --- a/src/cornice/validators/_marshmallow.py +++ b/src/cornice/validators/_marshmallow.py @@ -5,6 +5,8 @@ import inspect from contextvars import ContextVar +from cornice.validators._marshmallow_compat import EXCLUDE, set_schema_context + cornice_request: ContextVar = ContextVar("cornice_request") @@ -44,7 +46,6 @@ def _validator(request, schema=None, deserializer=None, **kwargs): """ import marshmallow import marshmallow.schema - from marshmallow import EXCLUDE if schema is None: return @@ -56,6 +57,7 @@ def _validator(request, schema=None, deserializer=None, **kwargs): class ValidatedField(marshmallow.fields.Field): def _deserialize(self, value, attr, data, **kwargs): cornice_request.set(request) + set_schema_context(schema, "request", request) deserialized = schema.load(value) return deserialized @@ -135,6 +137,7 @@ def validator(request, schema=None, deserializer=None, **kwargs): schema = _instantiate_schema(schema) cornice_request.set(request) + set_schema_context(schema, "request", request) cstruct = deserializer(request) try: diff --git a/src/cornice/validators/_marshmallow_compat.py b/src/cornice/validators/_marshmallow_compat.py new file mode 100644 index 00000000..0ca5c0a0 --- /dev/null +++ b/src/cornice/validators/_marshmallow_compat.py @@ -0,0 +1,22 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +from importlib.metadata import version + +import marshmallow + + +MARSHMALLOW_V4 = int(version("marshmallow").split(".")[0]) >= 4 + +# EXCLUDE moved from marshmallow.utils to marshmallow in v4 +if MARSHMALLOW_V4: + from marshmallow import EXCLUDE +else: + from marshmallow.utils import EXCLUDE + + +def set_schema_context(schema, key, value): + """Set context on schema. Uses Schema.context on v3, no-op on v4.""" + if not MARSHMALLOW_V4: + schema.context.setdefault(key, value) diff --git a/tests/validationapp.py b/tests/validationapp.py index a31b1f2d..848b9ac3 100644 --- a/tests/validationapp.py +++ b/tests/validationapp.py @@ -330,9 +330,9 @@ def form(request): try: import marshmallow - from marshmallow import EXCLUDE from cornice.validators import cornice_request, marshmallow_body_validator, marshmallow_validator + from cornice.validators._marshmallow_compat import EXCLUDE MARSHMALLOW = True except ImportError: @@ -368,6 +368,7 @@ class Meta: @marshmallow.validates_schema def validate_csrf_secret(self, data, **kwargs): # simulate validation of session variables + # cornice_request contextvar works on both v3 and v4 if cornice_request.get().get_csrf() != data.get("csrf_secret"): raise marshmallow.ValidationError("Wrong token") diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..39ac1707 --- /dev/null +++ b/tox.ini @@ -0,0 +1,25 @@ +[tox] +envlist = marshmallow3, marshmallow4 + +[testenv] +pip_pre = false +deps = + pytest<9 + pytest-cache<2 + pytest-cov<7 + WebTest<4 + colander<3 + +[testenv:marshmallow3] +deps = + {[testenv]deps} + marshmallow>=3.13,<4 +commands = + pytest tests/ {posargs:-v} + +[testenv:marshmallow4] +deps = + {[testenv]deps} + marshmallow>=4 +commands = + pytest tests/ {posargs:-v}