diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 18725b6a..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.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12'] 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 bf19319d..7e5f9f3c 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,11 +19,10 @@ 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', + 'Programming Language :: Python :: 3.12', 'Framework :: Flask', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', @@ -40,7 +39,7 @@ dependencies = [ 'Flask-Assets2', 'Flask-Migrate', 'Flask-SQLAlchemy', - 'Flask>=2.2', + 'Flask>=2.3', 'furl', 'html5lib>=0.999999999', 'isoweek', @@ -66,7 +65,7 @@ where = ['src'] [tool.black] line-length = 88 -target_version = ['py36'] +target_version = ['py39'] skip-string-normalization = true include = '\.pyi?$' exclude = ''' @@ -104,7 +103,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'] @@ -176,9 +175,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 +261,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. diff --git a/src/coaster/app.py b/src/coaster/app.py index 3627f4ce..ab13c6a2 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, @@ -240,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. @@ -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 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()