Skip to content

Commit

Permalink
Update typing for Flask 3.0; drop Python 3.7 and 3.8 (#418)
Browse files Browse the repository at this point in the history
  • Loading branch information
jace committed Oct 18, 2023
1 parent 7d2414b commit 74b27cf
Show file tree
Hide file tree
Showing 14 changed files with 80 additions and 59 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 9 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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',
Expand All @@ -40,7 +39,7 @@ dependencies = [
'Flask-Assets2',
'Flask-Migrate',
'Flask-SQLAlchemy',
'Flask>=2.2',
'Flask>=2.3',
'furl',
'html5lib>=0.999999999',
'isoweek',
Expand All @@ -66,7 +65,7 @@ where = ['src']

[tool.black]
line-length = 88
target_version = ['py36']
target_version = ['py39']
skip-string-normalization = true
include = '\.pyi?$'
exclude = '''
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 9 additions & 5 deletions src/coaster/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/coaster/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
9 changes: 7 additions & 2 deletions src/coaster/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
16 changes: 10 additions & 6 deletions src/coaster/sqlalchemy/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,37 +64,40 @@ 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,
),
)


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).
Expand Down
18 changes: 12 additions & 6 deletions src/coaster/sqlalchemy/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]]
],
Expand Down Expand Up @@ -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__:
Expand Down
4 changes: 4 additions & 0 deletions src/coaster/sqlalchemy/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand Down
Loading

0 comments on commit 74b27cf

Please sign in to comment.