From ae9dbd60fc160e685097b9e896cab7918f067216 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 18 Oct 2023 12:23:33 +0530 Subject: [PATCH 1/4] Update typing for Flask 3.0 --- pyproject.toml | 2 +- src/coaster/app.py | 12 ++++++---- src/coaster/assets.py | 2 ++ src/coaster/logger.py | 9 ++++++-- src/coaster/sqlalchemy/functions.py | 16 ++++++++----- src/coaster/sqlalchemy/mixins.py | 18 ++++++++++----- src/coaster/sqlalchemy/roles.py | 4 ++++ src/coaster/views/classview.py | 35 ++++++++++++++--------------- test_requirements.txt | 2 +- 9 files changed, 62 insertions(+), 38 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bf19319d..1b7646b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ sections = ['FUTURE', 'STDLIB', 'THIRDPARTY', 'FIRSTPARTY', 'LOCALFOLDER'] [tool.pytest.ini_options] pythonpath = 'src' -required_plugins = ['pytest-env', 'pytest-rerunfailures', 'pytest-remotedata'] +required_plugins = ['pytest-env', 'pytest-rerunfailures', 'pytest-socket'] minversion = '6.0' addopts = '--doctest-modules --ignore setup.py --cov-report=term-missing' doctest_optionflags = ['ALLOW_UNICODE', 'ALLOW_BYTES'] diff --git a/src/coaster/app.py b/src/coaster/app.py index 3627f4ce..3efc67a7 100644 --- a/src/coaster/app.py +++ b/src/coaster/app.py @@ -14,10 +14,14 @@ from typing import NamedTuple, cast import itsdangerous -from flask import Flask from flask.json.provider import DefaultJSONProvider from flask.sessions import SecureCookieSessionInterface +try: # Flask >= 3.0 + from flask.sansio.app import App as FlaskApp +except ModuleNotFoundError: # Flask < 3.0 + from flask import Flask as FlaskApp + from . import logger from .auth import current_auth from .views import current_view @@ -162,7 +166,7 @@ class RotatingKeySecureCookieSessionInterface(SecureCookieSessionInterface): """Replaces the serializer with key rotation support.""" def get_signing_serializer( # type: ignore[override] - self, app: Flask + self, app: FlaskApp ) -> t.Optional[KeyRotationWrapper]: """Return serializers wrapped for key rotation.""" if not app.config.get('SECRET_KEYS'): @@ -201,7 +205,7 @@ def default(o: t.Any) -> t.Any: def init_app( - app: Flask, + app: FlaskApp, config: t.Optional[t.List[te.Literal['env', 'py', 'json', 'toml', 'yaml']]] = None, *, env_prefix: t.Optional[t.Union[str, t.Sequence[str]]] = None, @@ -317,7 +321,7 @@ def init_app( def load_config_from_file( - app: Flask, + app: FlaskApp, filepath: str, load: t.Optional[t.Callable] = None, text: t.Optional[bool] = None, diff --git a/src/coaster/assets.py b/src/coaster/assets.py index f7df5281..942028b3 100644 --- a/src/coaster/assets.py +++ b/src/coaster/assets.py @@ -141,6 +141,8 @@ def _require_recursive(self, *namespecs: str) -> t.List[t.Tuple[str, Version, st ) else: asset = self[name][version] + requires: t.Union[t.List[str], t.Tuple[str, ...], str] + provides: t.Union[t.List[str], t.Tuple[str, ...], str] if isinstance(asset, (list, tuple)): # We have (requires, bundle). Get requirements requires = asset[:-1] diff --git a/src/coaster/logger.py b/src/coaster/logger.py index 3f06e0c7..c24e1a02 100644 --- a/src/coaster/logger.py +++ b/src/coaster/logger.py @@ -29,9 +29,14 @@ from logging import _SysExcInfoType import requests -from flask import Flask, g, request, session +from flask import g, request, session from flask.config import Config +try: # Flask >= 3.0 + from flask.sansio.app import App as FlaskApp +except ModuleNotFoundError: + from flask import Flask as FlaskApp + from .auth import current_auth # Regex for credit card numbers @@ -432,7 +437,7 @@ def emit(self, record: logging.LogRecord) -> None: } -def init_app(app: Flask, _warning_stacklevel: int = 2) -> None: +def init_app(app: FlaskApp, _warning_stacklevel: int = 2) -> None: """ Enable logging for an app using :class:`LocalVarFormatter`. diff --git a/src/coaster/sqlalchemy/functions.py b/src/coaster/sqlalchemy/functions.py index c5719878..b7370e44 100644 --- a/src/coaster/sqlalchemy/functions.py +++ b/src/coaster/sqlalchemy/functions.py @@ -6,6 +6,7 @@ from __future__ import annotations import typing as t +from datetime import datetime from typing import cast, overload import sqlalchemy as sa @@ -63,18 +64,18 @@ def _utcnow_mssql( # pragma: no cover def make_timestamp_columns( timezone: bool = False, -) -> t.Iterable[sa.Column[sa.TIMESTAMP]]: +) -> t.Tuple[sa.Column[datetime], sa.Column[datetime]]: """Return two columns, `created_at` and `updated_at`, with appropriate defaults.""" return ( sa.Column( 'created_at', - sa.TIMESTAMP(timezone=timezone), # type: ignore[arg-type] + sa.TIMESTAMP(timezone=timezone), default=sa.func.utcnow(), nullable=False, ), sa.Column( 'updated_at', - sa.TIMESTAMP(timezone=timezone), # type: ignore[arg-type] + sa.TIMESTAMP(timezone=timezone), default=sa.func.utcnow(), onupdate=sa.func.utcnow(), nullable=False, @@ -82,18 +83,21 @@ def make_timestamp_columns( ) +session_type = t.Union[sa.orm.Session, sa.orm.scoped_session] + + @overload -def failsafe_add(__session: sa.orm.Session, __instance: t.Any) -> None: +def failsafe_add(__session: session_type, __instance: t.Any) -> None: ... @overload -def failsafe_add(__session: sa.orm.Session, __instance: T, **filters: t.Any) -> T: +def failsafe_add(__session: session_type, __instance: T, **filters: t.Any) -> T: ... def failsafe_add( - __session: sa.orm.Session, __instance: T, **filters: t.Any + __session: session_type, __instance: T, **filters: t.Any ) -> t.Optional[T]: """ Add and commit a new instance in a nested transaction (using SQL SAVEPOINT). diff --git a/src/coaster/sqlalchemy/mixins.py b/src/coaster/sqlalchemy/mixins.py index 9f659323..0fb10cc1 100644 --- a/src/coaster/sqlalchemy/mixins.py +++ b/src/coaster/sqlalchemy/mixins.py @@ -30,8 +30,14 @@ class MyModel(BaseMixin, db.Model): from typing import cast, overload from uuid import UUID, uuid4 +from flask import current_app, url_for + +try: # Flask >= 3.0 + from flask.sansio.app import App as FlaskApp +except ModuleNotFoundError: # Flask < 3.0 + from flask import Flask as FlaskApp + import sqlalchemy as sa -from flask import Flask, current_app, url_for from sqlalchemy import event from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, declarative_mixin, declared_attr, synonym @@ -385,11 +391,11 @@ class UrlForMixin: #: app may also be None as fallback. Each subclass will get its own dictionary. #: This particular dictionary is only used as an inherited fallback. url_for_endpoints: t.ClassVar[ - t.Dict[t.Optional[Flask], t.Dict[str, UrlEndpointData]] + t.Dict[t.Optional[FlaskApp], t.Dict[str, UrlEndpointData]] ] = {None: {}} #: Mapping of {app: {action: (classview, attr)}} view_for_endpoints: t.ClassVar[ - t.Dict[t.Optional[Flask], t.Dict[str, t.Tuple[t.Any, str]]] + t.Dict[t.Optional[FlaskApp], t.Dict[str, t.Tuple[t.Any, str]]] ] = {} #: Dictionary of URLs available on this object @@ -448,7 +454,7 @@ def is_url_for( cls, _action: str, _endpoint: t.Optional[str] = None, - _app: t.Optional[Flask] = None, + _app: t.Optional[FlaskApp] = None, _external: t.Optional[bool] = None, **paramattrs: t.Union[str, t.Tuple[str, ...], t.Callable[[t.Any], str]], ) -> ReturnDecorator: @@ -479,7 +485,7 @@ def register_endpoint( cls, action: str, endpoint: str, - app: t.Optional[Flask], + app: t.Optional[FlaskApp], paramattrs: t.Mapping[ str, t.Union[str, t.Tuple[str, ...], t.Callable[[t.Any], str]] ], @@ -522,7 +528,7 @@ def register_endpoint( @classmethod def register_view_for( - cls, app: t.Optional[Flask], action: str, classview: t.Any, attr: str + cls, app: t.Optional[FlaskApp], action: str, classview: t.Any, attr: str ) -> None: """Register a classview and viewhandler for a given app and action.""" if 'view_for_endpoints' not in cls.__dict__: diff --git a/src/coaster/sqlalchemy/roles.py b/src/coaster/sqlalchemy/roles.py index 0b504068..b79c00bf 100644 --- a/src/coaster/sqlalchemy/roles.py +++ b/src/coaster/sqlalchemy/roles.py @@ -647,6 +647,8 @@ def __repr__(self) -> str: def __contains__(self, value: t.Any) -> bool: relattr = self.relattr + if t.TYPE_CHECKING: + assert relattr.session is not None # nosec B101 return relattr.session.query( relattr.filter_by(**{self.attr: value}).exists() ).scalar() @@ -660,6 +662,8 @@ def __len__(self) -> int: def __bool__(self) -> bool: relattr = self.relattr + if t.TYPE_CHECKING: + assert relattr.session is not None # nosec B101 return relattr.session.query(relattr.exists()).scalar() def __eq__(self, other: t.Any) -> bool: diff --git a/src/coaster/views/classview.py b/src/coaster/views/classview.py index 234aeb8d..64741f08 100644 --- a/src/coaster/views/classview.py +++ b/src/coaster/views/classview.py @@ -13,18 +13,16 @@ from functools import partial, update_wrapper, wraps from typing import cast, overload -from flask import ( - Blueprint, - Flask, - abort, - g, - has_app_context, - make_response, - redirect, - request, -) -from flask.blueprints import BlueprintSetupState +from flask import abort, g, has_app_context, make_response, redirect, request from flask.typing import ResponseReturnValue + +try: # Flask >= 3.0 + from flask.sansio.app import App as FlaskApp + from flask.sansio.blueprints import Blueprint, BlueprintSetupState +except ModuleNotFoundError: # Flask < 3.0 + from flask import Flask as FlaskApp + from flask.blueprints import BlueprintSetupState + from furl import furl from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.descriptor_props import SynonymProperty @@ -70,7 +68,7 @@ class InitAppCallback(te.Protocol): # pylint: disable=too-few-public-methods def __call__( self, - app: t.Union[Flask, Blueprint], + app: t.Union[FlaskApp, Blueprint], rule: str, endpoint: str, view_func: t.Callable, @@ -257,7 +255,7 @@ def __get__( def init_app( self, - app: t.Union[Flask, Blueprint], + app: t.Union[FlaskApp, Blueprint], cls: t.Type[ClassView], callback: t.Optional[InitAppCallback] = None, ) -> None: @@ -584,7 +582,7 @@ class SubView(BaseView): @classmethod def init_app( cls, - app: t.Union[Flask, Blueprint], + app: t.Union[FlaskApp, Blueprint], callback: t.Optional[InitAppCallback] = None, ) -> None: """ @@ -804,7 +802,7 @@ class UrlForView: # pylint: disable=too-few-public-methods @classmethod def init_app( cls, - app: t.Union[Flask, Blueprint], + app: t.Union[FlaskApp, Blueprint], callback: t.Optional[InitAppCallback] = None, ) -> None: """Register view on an app.""" @@ -812,14 +810,14 @@ def init_app( def register_view_on_model( # pylint: disable=too-many-arguments cls: t.Type[ClassView], callback: t.Optional[InitAppCallback], - app: t.Union[Flask, Blueprint], + app: t.Union[FlaskApp, Blueprint], rule: str, endpoint: str, view_func: t.Callable, **options: t.Any, ) -> None: def register_paths_from_app( - reg_app: Flask, + reg_app: FlaskApp, reg_rule: str, reg_endpoint: str, reg_options: t.Dict[str, t.Any], @@ -872,7 +870,7 @@ def blueprint_postprocess(state: BlueprintSetupState) -> None: ) register_paths_from_app(state.app, reg_rule, reg_endpoint, reg_options) - if isinstance(app, Flask): + if isinstance(app, FlaskApp): register_paths_from_app(app, rule, endpoint, options) elif isinstance(app, Blueprint): app.record(blueprint_postprocess) @@ -881,6 +879,7 @@ def blueprint_postprocess(state: BlueprintSetupState) -> None: if callback: # pragma: no cover callback(app, rule, endpoint, view_func, **options) + assert issubclass(cls, ClassView) # nosec B101 super().init_app( # type: ignore[misc] app, callback=partial(register_view_on_model, cls, callback) ) diff --git a/test_requirements.txt b/test_requirements.txt index ddc5a069..9cc4a25f 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -7,7 +7,7 @@ pytest pytest-cov pytest-env pytest-ignore-flaky -pytest-remotedata pytest-rerunfailures +pytest-socket pyyaml toml From 374d6ab2ae292a6fed2064bba20ce8459947d7f1 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 18 Oct 2023 12:53:58 +0530 Subject: [PATCH 2/4] Drop Python 3.7 and 3.8 support --- .github/workflows/pytest.yml | 2 +- .pre-commit-config.yaml | 3 +-- CHANGES.rst | 6 +++--- README.rst | 2 +- pyproject.toml | 17 +++++++---------- 5 files changed, 13 insertions(+), 17 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 18725b6a..ca46e38b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11'] services: redis: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2d52e1b8..5a18d1e6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,8 +25,7 @@ repos: rev: v3.15.0 hooks: - id: pyupgrade - args: - ['--keep-runtime-typing', '--py3-plus', '--py36-plus', '--py37-plus'] + args: ['--keep-runtime-typing', '--py39-plus'] - repo: https://github.com/asottile/yesqa rev: v1.5.0 hooks: diff --git a/CHANGES.rst b/CHANGES.rst index cf9ee3ab..6762e5df 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,8 +1,8 @@ -0.7.0 - 2022-05-XX +0.7.0 - Unreleased ------------------ * Dropped Python 2.7 support -* Dropped Python 3.6 support as it lacks typing annotations; 3.7+ is now +* Dropped Python 3.6 support as it lacks typing annotations; 3.9+ is now required * Removed deprecated ``docflow`` module. ``StateManager`` replaces it * Removed deprecated ``make_password`` and ``check_password`` functions @@ -30,7 +30,7 @@ * Coaster now uses ``src`` folder layout and has project metadata defined in ``pyproject.toml`` as per PEP 660, requiring setuptools>=61 * Added ``coaster.app.JSONProvider`` that supports the ``__json__`` protocol -* Now compatible with Flask 2.2 and 2.3 +* Now compatible with Flask 2.3 and 3.0 * UgliPyJS is no longer offered as a Webassets filter as the dependency is unmaintained, and usage is shifting from Webassets to Webpack * ``for_tsquery`` has been removed as PostgreSQL>=12 has native functions diff --git a/README.rst b/README.rst index e39acdf1..e4c04a14 100644 --- a/README.rst +++ b/README.rst @@ -5,7 +5,7 @@ Coaster: common patterns for Flask apps Coaster contains functions and db models for recurring patterns in Flask apps. Documentation is at https://coaster.readthedocs.org/. Coaster requires -Python 3.7 or later. +Python 3.9 or later. Run tests diff --git a/pyproject.toml b/pyproject.toml index 1b7646b3..6aa22a4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ build-backend = 'setuptools.build_meta' name = 'coaster' description = 'Coaster for Flask' readme = 'README.rst' -requires-python = '>=3.7' +requires-python = '>=3.9' keywords = ['coaster', 'flask', 'framework', 'web', 'auth', 'sqlalchemy'] license = { file = 'LICENSE.txt' } dynamic = ['version'] @@ -19,8 +19,6 @@ urls = { repository = 'https://github.com/hasgeek/coaster' } classifiers = [ 'Programming Language :: Python', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', @@ -40,7 +38,7 @@ dependencies = [ 'Flask-Assets2', 'Flask-Migrate', 'Flask-SQLAlchemy', - 'Flask>=2.2', + 'Flask>=2.3', 'furl', 'html5lib>=0.999999999', 'isoweek', @@ -66,7 +64,7 @@ where = ['src'] [tool.black] line-length = 88 -target_version = ['py36'] +target_version = ['py39'] skip-string-normalization = true include = '\.pyi?$' exclude = ''' @@ -176,9 +174,8 @@ skips = ['*/*_test.py', '*/test_*.py'] [tool.ruff] # This is a slight customisation of the default rules -# 1. Coaster still supports Python 3.7 pending its EOL -# 2. Rule E402 (module-level import not top-level) is disabled as isort handles it -# 3. Rule E501 (line too long) is left to Black; some strings are worse for wrapping +# 1. Rule E402 (module-level import not top-level) is disabled as isort handles it +# 2. Rule E501 (line too long) is left to Black; some strings are worse for wrapping # Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. select = ["E", "F"] @@ -263,8 +260,8 @@ line-length = 88 # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -# Target Python 3.7 -target-version = "py37" +# Target Python 3.9 +target-version = "py39" [tool.ruff.mccabe] # Unlike Flake8, default to a complexity level of 10. From b67553b66e9e52adcbf15db46e3f453d43465c9f Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 18 Oct 2023 12:55:31 +0530 Subject: [PATCH 3/4] Add Python 3.12 to CI --- .github/workflows/pytest.yml | 2 +- pyproject.toml | 1 + src/coaster/app.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index ca46e38b..f54f78bb 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12'] services: redis: diff --git a/pyproject.toml b/pyproject.toml index 6aa22a4a..7e5f9f3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Framework :: Flask', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', diff --git a/src/coaster/app.py b/src/coaster/app.py index 3efc67a7..ab13c6a2 100644 --- a/src/coaster/app.py +++ b/src/coaster/app.py @@ -244,7 +244,7 @@ def init_app( .. note:: YAML support requires PyYAML_. TOML requires toml_ with Flask 2.2, or tomli_ - with Flask 2.3, or Python's inbuilt tomllib_ with Flask 2.3 and Python 3.11. + with Flask 2.3, or Python's inbuilt tomllib_ with Flask 2.3 and Python 3.11+. tomli_ and tomllib_ are not compatible with Flask 2.2 as they require the file to be opened in binary mode, an optional flag introduced in Flask 2.3. From f25c2f7929a46d1c6c411c7345de27626b37b9d7 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 18 Oct 2023 13:08:11 +0530 Subject: [PATCH 4/4] Python 3.12 doesn't recast as RuntimeError --- tests/coaster_tests/sqlalchemy_registry_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/coaster_tests/sqlalchemy_registry_test.py b/tests/coaster_tests/sqlalchemy_registry_test.py index 9fe6b57d..0b93834b 100644 --- a/tests/coaster_tests/sqlalchemy_registry_test.py +++ b/tests/coaster_tests/sqlalchemy_registry_test.py @@ -174,9 +174,9 @@ class RegistryUser: # pylint: disable=unused-variable def test_registry_reuse_error() -> None: """Registries cannot be reused under different names.""" - # Registry raises AttributeError from __set_name__, but Python recasts as - # RuntimeError - with pytest.raises(RuntimeError): + # Registry raises AttributeError from __set_name__, but Python < 3.12 recasts as + # RuntimeError (3.12 doesn't recast) + with pytest.raises((RuntimeError, AttributeError)): class RegistryUser: # pylint: disable=unused-variable a = b = Registry()