Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update typing for Flask 3.0 #418

Merged
merged 4 commits into from
Oct 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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