diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 85350472c..d714d038f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,7 +17,7 @@ jobs: - uses: "actions/checkout@v3" - uses: "actions/setup-python@v4" with: - python-version: 3.7 + python-version: 3.10 - name: "Install dependencies" run: "scripts/install" - name: "Build package & docs" diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index f5ad75a4e..eb1cc7e22 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] steps: - uses: "actions/checkout@v3" diff --git a/README.md b/README.md index 6aff26b3e..d03eec16e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ It is production-ready, and gives you the following: ## Requirements -Python 3.7+ (For Python 3.6 support, install version 0.19.1) +Python 3.8+ ## Installation diff --git a/docs/index.md b/docs/index.md index 20435d262..e395800f5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,7 +38,7 @@ It is production-ready, and gives you the following: ## Requirements -Python 3.7+ (For Python 3.6 support, install version 0.19.1) +Python 3.8+ ## Installation diff --git a/pyproject.toml b/pyproject.toml index 3ff3ca71b..0b8e6cb50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "The little ASGI library that shines." readme = "README.md" license = "BSD-3-Clause" -requires-python = ">=3.7" +requires-python = ">=3.8" authors = [ { name = "Tom Christie", email = "tom@tomchristie.com" }, ] @@ -20,7 +20,6 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/starlette/_utils.py b/starlette/_utils.py index d781647ff..5a6e6965b 100644 --- a/starlette/_utils.py +++ b/starlette/_utils.py @@ -1,13 +1,6 @@ import asyncio import functools -import sys import typing -from types import TracebackType - -if sys.version_info < (3, 8): # pragma: no cover - from typing_extensions import Protocol -else: # pragma: no cover - from typing import Protocol def is_async_callable(obj: typing.Any) -> bool: @@ -22,31 +15,13 @@ def is_async_callable(obj: typing.Any) -> bool: T_co = typing.TypeVar("T_co", covariant=True) -# TODO: once 3.8 is the minimum supported version (27 Jun 2023) -# this can just become -# class AwaitableOrContextManager( -# typing.Awaitable[T_co], -# typing.AsyncContextManager[T_co], -# typing.Protocol[T_co], -# ): -# pass -class AwaitableOrContextManager(Protocol[T_co]): - def __await__(self) -> typing.Generator[typing.Any, None, T_co]: - ... # pragma: no cover - - async def __aenter__(self) -> T_co: - ... # pragma: no cover - - async def __aexit__( - self, - __exc_type: typing.Optional[typing.Type[BaseException]], - __exc_value: typing.Optional[BaseException], - __traceback: typing.Optional[TracebackType], - ) -> typing.Union[bool, None]: - ... # pragma: no cover +class AwaitableOrContextManager( + typing.Awaitable[T_co], typing.AsyncContextManager[T_co], typing.Protocol[T_co] +): + ... -class SupportsAsyncClose(Protocol): +class SupportsAsyncClose(typing.Protocol): async def close(self) -> None: ... # pragma: no cover diff --git a/starlette/applications.py b/starlette/applications.py index 5fc11f955..344a4a37f 100644 --- a/starlette/applications.py +++ b/starlette/applications.py @@ -111,9 +111,8 @@ def build_middleware_stack(self) -> ASGIApp: def routes(self) -> typing.List[BaseRoute]: return self.router.routes - # TODO: Make `__name` a positional-only argument when we drop Python 3.7 support. - def url_path_for(self, __name: str, **path_params: typing.Any) -> URLPath: - return self.router.url_path_for(__name, **path_params) + def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath: + return self.router.url_path_for(name, **path_params) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: scope["app"] = self diff --git a/starlette/middleware/sessions.py b/starlette/middleware/sessions.py index b1e32ec16..74e57352f 100644 --- a/starlette/middleware/sessions.py +++ b/starlette/middleware/sessions.py @@ -1,5 +1,4 @@ import json -import sys import typing from base64 import b64decode, b64encode @@ -10,11 +9,6 @@ from starlette.requests import HTTPConnection from starlette.types import ASGIApp, Message, Receive, Scope, Send -if sys.version_info >= (3, 8): # pragma: no cover - from typing import Literal -else: # pragma: no cover - from typing_extensions import Literal - class SessionMiddleware: def __init__( @@ -24,7 +18,7 @@ def __init__( session_cookie: str = "session", max_age: typing.Optional[int] = 14 * 24 * 60 * 60, # 14 days, in seconds path: str = "/", - same_site: Literal["lax", "strict", "none"] = "lax", + same_site: typing.Literal["lax", "strict", "none"] = "lax", https_only: bool = False, ) -> None: self.app = app diff --git a/starlette/requests.py b/starlette/requests.py index dbcaad875..fff451e23 100644 --- a/starlette/requests.py +++ b/starlette/requests.py @@ -173,9 +173,9 @@ def state(self) -> State: self._state = State(self.scope["state"]) return self._state - def url_for(self, __name: str, **path_params: typing.Any) -> URL: + def url_for(self, name: str, /, **path_params: typing.Any) -> URL: router: Router = self.scope["router"] - url_path = router.url_path_for(__name, **path_params) + url_path = router.url_path_for(name, **path_params) return url_path.make_absolute_url(base_url=self.base_url) diff --git a/starlette/responses.py b/starlette/responses.py index 24fece1d4..575caf655 100644 --- a/starlette/responses.py +++ b/starlette/responses.py @@ -2,12 +2,11 @@ import json import os import stat -import sys import typing from datetime import datetime from email.utils import format_datetime, formatdate from functools import partial -from mimetypes import guess_type as mimetypes_guess_type +from mimetypes import guess_type from urllib.parse import quote import anyio @@ -18,23 +17,6 @@ from starlette.datastructures import URL, MutableHeaders from starlette.types import Receive, Scope, Send -if sys.version_info >= (3, 8): # pragma: no cover - from typing import Literal -else: # pragma: no cover - from typing_extensions import Literal - -# Workaround for adding samesite support to pre 3.8 python -http.cookies.Morsel._reserved["samesite"] = "SameSite" # type: ignore[attr-defined] - - -# Compatibility wrapper for `mimetypes.guess_type` to support `os.PathLike` on typing.Tuple[typing.Optional[str], typing.Optional[str]]: - if sys.version_info < (3, 8): # pragma: no cover - url = os.fspath(url) - return mimetypes_guess_type(url, strict) - class Response: media_type = None @@ -111,7 +93,7 @@ def set_cookie( domain: typing.Optional[str] = None, secure: bool = False, httponly: bool = False, - samesite: typing.Optional[Literal["lax", "strict", "none"]] = "lax", + samesite: typing.Optional[typing.Literal["lax", "strict", "none"]] = "lax", ) -> None: cookie: "http.cookies.BaseCookie[str]" = http.cookies.SimpleCookie() cookie[key] = value @@ -147,7 +129,7 @@ def delete_cookie( domain: typing.Optional[str] = None, secure: bool = False, httponly: bool = False, - samesite: typing.Optional[Literal["lax", "strict", "none"]] = "lax", + samesite: typing.Optional[typing.Literal["lax", "strict", "none"]] = "lax", ) -> None: self.set_cookie( key, diff --git a/starlette/routing.py b/starlette/routing.py index 132600be6..b50d32a1f 100644 --- a/starlette/routing.py +++ b/starlette/routing.py @@ -179,7 +179,7 @@ class BaseRoute: def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]: raise NotImplementedError() # pragma: no cover - def url_path_for(self, __name: str, **path_params: typing.Any) -> URLPath: + def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath: raise NotImplementedError() # pragma: no cover async def handle(self, scope: Scope, receive: Receive, send: Send) -> None: @@ -258,12 +258,12 @@ def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]: return Match.FULL, child_scope return Match.NONE, {} - def url_path_for(self, __name: str, **path_params: typing.Any) -> URLPath: + def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath: seen_params = set(path_params.keys()) expected_params = set(self.param_convertors.keys()) - if __name != self.name or seen_params != expected_params: - raise NoMatchFound(__name, path_params) + if name != self.name or seen_params != expected_params: + raise NoMatchFound(name, path_params) path, remaining_params = replace_params( self.path_format, self.param_convertors, path_params @@ -333,12 +333,12 @@ def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]: return Match.FULL, child_scope return Match.NONE, {} - def url_path_for(self, __name: str, **path_params: typing.Any) -> URLPath: + def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath: seen_params = set(path_params.keys()) expected_params = set(self.param_convertors.keys()) - if __name != self.name or seen_params != expected_params: - raise NoMatchFound(__name, path_params) + if name != self.name or seen_params != expected_params: + raise NoMatchFound(name, path_params) path, remaining_params = replace_params( self.path_format, self.param_convertors, path_params @@ -415,8 +415,8 @@ def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]: return Match.FULL, child_scope return Match.NONE, {} - def url_path_for(self, __name: str, **path_params: typing.Any) -> URLPath: - if self.name is not None and __name == self.name and "path" in path_params: + def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath: + if self.name is not None and name == self.name and "path" in path_params: # 'name' matches "". path_params["path"] = path_params["path"].lstrip("/") path, remaining_params = replace_params( @@ -424,13 +424,13 @@ def url_path_for(self, __name: str, **path_params: typing.Any) -> URLPath: ) if not remaining_params: return URLPath(path=path) - elif self.name is None or __name.startswith(self.name + ":"): + elif self.name is None or name.startswith(self.name + ":"): if self.name is None: # No mount name. - remaining_name = __name + remaining_name = name else: # 'name' matches ":". - remaining_name = __name[len(self.name) + 1 :] + remaining_name = name[len(self.name) + 1 :] path_kwarg = path_params.get("path") path_params["path"] = "" path_prefix, remaining_params = replace_params( @@ -446,7 +446,7 @@ def url_path_for(self, __name: str, **path_params: typing.Any) -> URLPath: ) except NoMatchFound: pass - raise NoMatchFound(__name, path_params) + raise NoMatchFound(name, path_params) async def handle(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) @@ -493,8 +493,8 @@ def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]: return Match.FULL, child_scope return Match.NONE, {} - def url_path_for(self, __name: str, **path_params: typing.Any) -> URLPath: - if self.name is not None and __name == self.name and "path" in path_params: + def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath: + if self.name is not None and name == self.name and "path" in path_params: # 'name' matches "". path = path_params.pop("path") host, remaining_params = replace_params( @@ -502,13 +502,13 @@ def url_path_for(self, __name: str, **path_params: typing.Any) -> URLPath: ) if not remaining_params: return URLPath(path=path, host=host) - elif self.name is None or __name.startswith(self.name + ":"): + elif self.name is None or name.startswith(self.name + ":"): if self.name is None: # No mount name. - remaining_name = __name + remaining_name = name else: # 'name' matches ":". - remaining_name = __name[len(self.name) + 1 :] + remaining_name = name[len(self.name) + 1 :] host, remaining_params = replace_params( self.host_format, self.param_convertors, path_params ) @@ -518,7 +518,7 @@ def url_path_for(self, __name: str, **path_params: typing.Any) -> URLPath: return URLPath(path=str(url), protocol=url.protocol, host=host) except NoMatchFound: pass - raise NoMatchFound(__name, path_params) + raise NoMatchFound(name, path_params) async def handle(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) @@ -652,13 +652,13 @@ async def not_found(self, scope: Scope, receive: Receive, send: Send) -> None: response = PlainTextResponse("Not Found", status_code=404) await response(scope, receive, send) - def url_path_for(self, __name: str, **path_params: typing.Any) -> URLPath: + def url_path_for(self, name: str, /, **path_params: typing.Any) -> URLPath: for route in self.routes: try: - return route.url_path_for(__name, **path_params) + return route.url_path_for(name, **path_params) except NoMatchFound: pass - raise NoMatchFound(__name, path_params) + raise NoMatchFound(name, path_params) async def startup(self) -> None: """ diff --git a/starlette/templating.py b/starlette/templating.py index abc845fed..ffa4133b8 100644 --- a/starlette/templating.py +++ b/starlette/templating.py @@ -123,11 +123,9 @@ def _create_env( **env_options: typing.Any, ) -> "jinja2.Environment": @pass_context - # TODO: Make `__name` a positional-only argument when we drop Python 3.7 - # support. - def url_for(context: dict, __name: str, **path_params: typing.Any) -> URL: + def url_for(context: dict, name: str, /, **path_params: typing.Any) -> URL: request = context["request"] - return request.url_for(__name, **path_params) + return request.url_for(name, **path_params) loader = jinja2.FileSystemLoader(directory) env_options.setdefault("loader", loader) diff --git a/starlette/testclient.py b/starlette/testclient.py index 099edc3ff..1b4f1303f 100644 --- a/starlette/testclient.py +++ b/starlette/testclient.py @@ -4,7 +4,6 @@ import json import math import queue -import sys import typing import warnings from concurrent.futures import Future @@ -27,12 +26,6 @@ "You can install this with:\n" " $ pip install httpx\n" ) - -if sys.version_info >= (3, 8): # pragma: no cover - from typing import TypedDict -else: # pragma: no cover - from typing_extensions import TypedDict - _PortalFactoryType = typing.Callable[ [], typing.ContextManager[anyio.abc.BlockingPortal] ] @@ -64,7 +57,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await instance(receive, send) -class _AsyncBackend(TypedDict): +class _AsyncBackend(typing.TypedDict): backend: str backend_options: typing.Dict[str, typing.Any] diff --git a/tests/test_routing.py b/tests/test_routing.py index 04f425cad..24f2bf7d7 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -1,14 +1,8 @@ import contextlib import functools -import sys import typing import uuid -if sys.version_info < (3, 8): - from typing_extensions import TypedDict # pragma: no cover -else: - from typing import TypedDict # pragma: no cover - import pytest from starlette.applications import Starlette @@ -751,7 +745,7 @@ def test_lifespan_state_async_cm(test_client_factory): startup_complete = False shutdown_complete = False - class State(TypedDict): + class State(typing.TypedDict): count: int items: typing.List[int]