From d4c087e0186e179f38817006bec4b2a8f277302d Mon Sep 17 00:00:00 2001 From: Steven Loria Date: Mon, 6 Jan 2025 23:01:41 -0500 Subject: [PATCH] Support Python 3.9-3.13 and marshmallow 4 (#347) * Drop Python 3.8 and support 3.13 * Support marshmallow 4.0 * Update tox config * Update annotations * Attempt to fix test on GHA * Update changelog * Update doc --- .github/workflows/build-release.yml | 12 ++++++------ .pre-commit-config.yaml | 2 +- .readthedocs.yml | 2 +- CHANGELOG.rst | 8 ++++++++ README.rst | 11 +++++------ docs/index.rst | 5 ++--- pyproject.toml | 4 ++-- src/flask_marshmallow/__init__.py | 18 +++++++++++++----- src/flask_marshmallow/fields.py | 18 +++++++++--------- src/flask_marshmallow/schema.py | 6 ++++-- src/flask_marshmallow/sqla.py | 2 ++ src/flask_marshmallow/validate.py | 10 ++++++---- tests/conftest.py | 10 ++++------ tests/test_sqla.py | 5 ++++- tox.ini | 6 +++--- 15 files changed, 70 insertions(+), 49 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index c1ee90b..eebfd2a 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -13,10 +13,10 @@ jobs: fail-fast: false matrix: include: - - { name: "3.8", python: "3.8", tox: py38 } - - { name: "3.12", python: "3.12", tox: py312 } - - { name: "lowest", python: "3.8", tox: py38-lowest } - - { name: "dev", python: "3.12", tox: py312-marshmallowdev } + - { name: "3.9", python: "3.9", tox: py39 } + - { name: "3.13", python: "3.13", tox: py313 } + - { name: "lowest", python: "3.9", tox: py39-lowest } + - { name: "dev", python: "3.13", tox: py313-marshmallowdev } steps: - uses: actions/checkout@v4.0.0 - uses: actions/setup-python@v5 @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.13" - name: Install pypa/build run: python -m pip install build - name: Build a binary wheel and a source tarball @@ -54,7 +54,7 @@ jobs: - uses: actions/checkout@v4.0.0 - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.13" - run: python -m pip install tox - run: python -m tox -elint publish-to-pypi: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 81120c1..c9d48de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: rev: 1.19.1 hooks: - id: blacken-docs - additional_dependencies: [black==23.12.1] + additional_dependencies: [black==24.10.0] - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.13.0 hooks: diff --git a/.readthedocs.yml b/.readthedocs.yml index 4bab202..fd42639 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -6,7 +6,7 @@ formats: build: os: ubuntu-22.04 tools: - python: "3.11" + python: "3.13" python: install: - method: pip diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7b58915..37db44e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,14 @@ Changelog ========= +1.3.0 (unreleased) +****************** + +Support: + +* Support Python 3.9-3.13 (:pr:`347`). +* Support marshmallow 4.0.0 (:pr:`347`). + 1.2.1 (2024-03-18) ****************** diff --git a/README.rst b/README.rst index 53febb5..17ec427 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ Flask-Marshmallow ***************** -|pypi-package| |build-status| |docs| |marshmallow3| +|pypi-package| |build-status| |docs| |marshmallow-support| Flask + marshmallow for beautiful APIs ====================================== @@ -45,9 +45,8 @@ Define your output format with marshmallow. class UserSchema(ma.Schema): - class Meta: - # Fields to expose - fields = ("email", "date_created", "_links") + email = ma.Email() + date_created = ma.DateTime() # Smart hyperlinking _links = ma.Hyperlinks( @@ -127,6 +126,6 @@ MIT licensed. See the bundled `LICENSE =2.2", "marshmallow>=3.0.0"] [project.urls] diff --git a/src/flask_marshmallow/__init__.py b/src/flask_marshmallow/__init__.py index 23969d5..8d29ac1 100755 --- a/src/flask_marshmallow/__init__.py +++ b/src/flask_marshmallow/__init__.py @@ -9,7 +9,15 @@ import typing import warnings -from marshmallow import exceptions, pprint +from marshmallow import exceptions + +try: + # Available in marshmallow 3 only + from marshmallow import pprint # noqa: F401 +except ImportError: + _has_pprint = False +else: + _has_pprint = True from marshmallow import fields as base_fields from . import fields @@ -41,8 +49,9 @@ "Schema", "fields", "exceptions", - "pprint", ] +if _has_pprint: + __all__.append("pprint") EXTENSION_NAME = "flask-marshmallow" @@ -75,9 +84,8 @@ class Marshmallow: You can declare schema like so:: class BookSchema(ma.Schema): - class Meta: - fields = ("id", "title", "author", "links") - + id = ma.Integer(dump_only=True) + title = ma.String(required=True) author = ma.Nested(AuthorSchema) links = ma.Hyperlinks( diff --git a/src/flask_marshmallow/fields.py b/src/flask_marshmallow/fields.py index 83d433f..6d127de 100755 --- a/src/flask_marshmallow/fields.py +++ b/src/flask_marshmallow/fields.py @@ -8,6 +8,8 @@ marshmallow library. """ +from __future__ import annotations + import re import typing from collections.abc import Sequence @@ -29,7 +31,7 @@ _tpl_pattern = re.compile(r"\s*<\s*(\S*)\s*>\s*") -def _tpl(val: str) -> typing.Optional[str]: +def _tpl(val: str) -> str | None: """Return value within ``< >`` if possible, else return ``None``.""" match = _tpl_pattern.match(val) if match: @@ -95,7 +97,7 @@ class URLFor(fields.Field): def __init__( self, endpoint: str, - values: typing.Optional[typing.Dict[str, typing.Any]] = None, + values: dict[str, typing.Any] | None = None, **kwargs, ): self.endpoint = endpoint @@ -133,7 +135,7 @@ class AbsoluteURLFor(URLFor): def __init__( self, endpoint: str, - values: typing.Optional[typing.Dict[str, typing.Any]] = None, + values: dict[str, typing.Any] | None = None, **kwargs, ): if values: @@ -146,9 +148,7 @@ def __init__( AbsoluteUrlFor = AbsoluteURLFor -def _rapply( - d: typing.Union[dict, typing.Iterable], func: typing.Callable, *args, **kwargs -): +def _rapply(d: dict | typing.Iterable, func: typing.Callable, *args, **kwargs): """Apply a function to all values in a dictionary or list of dictionaries, recursively. """ @@ -201,7 +201,7 @@ class Hyperlinks(fields.Field): _CHECK_ATTRIBUTE = False - def __init__(self, schema: typing.Dict[str, typing.Union[URLFor, str]], **kwargs): + def __init__(self, schema: dict[str, URLFor | str], **kwargs): self.schema = schema fields.Field.__init__(self, **kwargs) @@ -229,8 +229,8 @@ def __init__(self, *args, **kwargs): def deserialize( self, value: typing.Any, - attr: typing.Optional[str] = None, - data: typing.Optional[typing.Mapping[str, typing.Any]] = None, + attr: str | None = None, + data: typing.Mapping[str, typing.Any] | None = None, **kwargs, ): if isinstance(value, Sequence) and len(value) == 0: diff --git a/src/flask_marshmallow/schema.py b/src/flask_marshmallow/schema.py index 119a08e..69c0680 100644 --- a/src/flask_marshmallow/schema.py +++ b/src/flask_marshmallow/schema.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import typing import flask @@ -14,8 +16,8 @@ class Schema(ma.Schema): """ def jsonify( - self, obj: typing.Any, many: typing.Optional[bool] = None, *args, **kwargs - ) -> "Response": + self, obj: typing.Any, many: bool | None = None, *args, **kwargs + ) -> Response: """Return a JSON response containing the serialized data. diff --git a/src/flask_marshmallow/sqla.py b/src/flask_marshmallow/sqla.py index 3e012ed..9e8f874 100644 --- a/src/flask_marshmallow/sqla.py +++ b/src/flask_marshmallow/sqla.py @@ -8,6 +8,8 @@ that use the scoped session from Flask-SQLAlchemy. """ +from __future__ import annotations + from urllib import parse import marshmallow_sqlalchemy as msqla diff --git a/src/flask_marshmallow/validate.py b/src/flask_marshmallow/validate.py index a5b222d..eee586c 100644 --- a/src/flask_marshmallow/validate.py +++ b/src/flask_marshmallow/validate.py @@ -5,6 +5,8 @@ Custom validation classes for various types of data. """ +from __future__ import annotations + import io import os import re @@ -91,11 +93,11 @@ class ImageSchema(Schema): def __init__( self, - min: typing.Optional[str] = None, - max: typing.Optional[str] = None, + min: str | None = None, + max: str | None = None, min_inclusive: bool = True, max_inclusive: bool = True, - error: typing.Optional[str] = None, + error: str | None = None, ): self.min = min self.max = max @@ -171,7 +173,7 @@ class ImageSchema(Schema): def __init__( self, accept: typing.Iterable[str], - error: typing.Optional[str] = None, + error: str | None = None, ): self.allowed_types = {ext.lower() for ext in accept} self.error = error or self.default_message diff --git a/tests/conftest.py b/tests/conftest.py index ee18699..47fda2c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,9 +84,8 @@ def ma(app): @pytest.fixture def schemas(ma): class AuthorSchema(ma.Schema): - class Meta: - fields = ("id", "name", "absolute_url", "links") - + id = ma.Integer() + name = ma.String() absolute_url = ma.AbsoluteURLFor("author", values={"id": ""}) links = ma.Hyperlinks( @@ -97,9 +96,8 @@ class Meta: ) class BookSchema(ma.Schema): - class Meta: - fields = ("id", "title", "author", "links") - + id = ma.Integer() + title = ma.String() author = ma.Nested(AuthorSchema) links = ma.Hyperlinks( diff --git a/tests/test_sqla.py b/tests/test_sqla.py index 5d958c3..68c1504 100644 --- a/tests/test_sqla.py +++ b/tests/test_sqla.py @@ -48,7 +48,10 @@ def book(id): @pytest.fixture def db(self, extapp): - return extapp.extensions["sqlalchemy"] + db = extapp.extensions["sqlalchemy"] + yield db + db.session.close() + db.engine.dispose() @pytest.fixture def extma(self, extapp): diff --git a/tox.ini b/tox.ini index cf20ead..857a68e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] envlist= lint - py{38,39,310,311,312} - py312-marshmallowdev - py38-lowest + py{39,310,311,312,313} + py313-marshmallowdev + py39-lowest docs [testenv]