diff --git a/README.md b/README.md index d12b709..49ec9bc 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,13 @@ uv add "fastapi-multiauth[jwt,oauth]" ```python from fastapi import FastAPI, Security -from fastapi_multiauth import HTTPBearerAuth, APIKeyCookieAuth, MultiAuth, UnauthorizedError +from fastapi_multiauth import ( + HTTPBearerAuth, + APIKeyCookieAuth, + MultiAuth, + UnauthorizedError, +) + async def validate_token(token: str) -> dict: user = await lookup_user_by_token(token) @@ -46,9 +52,11 @@ async def validate_token(token: str) -> dict: raise UnauthorizedError() return user + async def validate_session(value: str) -> dict: return await lookup_user_by_session(value) + bearer = HTTPBearerAuth(validate_token, prefix="user_") session = APIKeyCookieAuth("session", validate_session, secret_key="...") @@ -57,6 +65,7 @@ auth = MultiAuth(bearer, session) app = FastAPI() + @app.get("/me") async def me(user=Security(auth)): return user diff --git a/docs/index.md b/docs/index.md index 2d087e0..a331eb0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,7 +38,13 @@ uv add "fastapi-multiauth[jwt,oauth]" ```python from fastapi import FastAPI, Security -from fastapi_multiauth import HTTPBearerAuth, APIKeyCookieAuth, MultiAuth, UnauthorizedError +from fastapi_multiauth import ( + HTTPBearerAuth, + APIKeyCookieAuth, + MultiAuth, + UnauthorizedError, +) + async def validate_token(token: str) -> dict: user = await lookup_user_by_token(token) @@ -46,9 +52,11 @@ async def validate_token(token: str) -> dict: raise UnauthorizedError() return user + async def validate_session(value: str) -> dict: return await lookup_user_by_session(value) + bearer = HTTPBearerAuth(validate_token, prefix="user_") session = APIKeyCookieAuth("session", validate_session, secret_key="...") @@ -57,6 +65,7 @@ auth = MultiAuth(bearer, session) app = FastAPI() + @app.get("/me") async def me(user=Security(auth)): return user diff --git a/docs/oauth.md b/docs/oauth.md index e2e9626..750c469 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -22,6 +22,7 @@ from fastapi_multiauth.oauth import ( oauth_resolve_provider_urls, ) + @app.get("/oauth/login") async def oauth_login(request: Request, next: str = "/"): endpoints = await oauth_resolve_provider_urls(settings.OIDC_DISCOVERY_URL) @@ -40,15 +41,14 @@ async def oauth_login(request: Request, next: str = "/"): code_challenge=code_challenge, ) + @app.get("/oauth/callback") async def oauth_callback(request: Request, code: str, state: str | None = None): endpoints = await oauth_resolve_provider_urls(settings.OIDC_DISCOVERY_URL) expected = request.session.pop("oauth_state", "") # single-use code_verifier = request.session.pop("oauth_code_verifier", None) # Open-redirect guard: by default only relative paths are accepted. - destination = oauth_decode_state( - state, expected_state_token=expected, fallback="/" - ) + destination = oauth_decode_state(state, expected_state_token=expected, fallback="/") try: token = await oauth_exchange_code( token_url=endpoints.token_endpoint, diff --git a/docs/recipes.md b/docs/recipes.md index cb83d45..dc2f359 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -9,27 +9,35 @@ The flagship pattern: browser users authenticate with a signed session cookie, a ```python from fastapi import FastAPI, Response, Security from fastapi_multiauth import ( - HTTPBearerAuth, APIKeyCookieAuth, MultiAuth, UnauthorizedError, hash_token, + HTTPBearerAuth, + APIKeyCookieAuth, + MultiAuth, + UnauthorizedError, + hash_token, ) + async def validate_session(user_id: str) -> User: user = await db.get_user(user_id) if user is None: raise UnauthorizedError() return user + async def validate_api_token(token: str) -> User: row = await db.get_api_token(token_hash=hash_token(token)) if row is None or row.revoked: raise UnauthorizedError() return row.user + session = APIKeyCookieAuth("session", validate_session, secret_key=settings.SECRET_KEY) api = HTTPBearerAuth(validate_api_token, prefix="user_") auth = MultiAuth(api, session) # bearer first: API clients never hit cookie parsing app = FastAPI() + @app.get("/me") async def me(user: User = Security(auth)): return user @@ -44,10 +52,11 @@ user_tokens = HTTPBearerAuth(validate_user_token, prefix="user_") org_tokens = HTTPBearerAuth(validate_org_token, prefix="org_") auth = MultiAuth(user_tokens, org_tokens) + # Issuing: store the hash, hand out the token once: @app.post("/tokens") async def create_token(user: User = Security(session)): - token = user_tokens.generate_token() # "user_Xk3..." + token = user_tokens.generate_token() # "user_Xk3..." await db.save_api_token(user.id, hash_token(token)) return {"token": token} # the only time it is ever visible ``` @@ -82,6 +91,7 @@ from fastapi_multiauth.oauth import ( oauth_resolve_provider_urls, ) + @app.get("/oauth/login") async def oauth_login(request: Request, next: str = "/"): endpoints = await oauth_resolve_provider_urls(settings.OIDC_DISCOVERY_URL) @@ -99,6 +109,7 @@ async def oauth_login(request: Request, next: str = "/"): code_challenge=code_challenge, ) + @app.get("/oauth/callback") async def oauth_callback(request: Request, code: str, state: str | None = None): endpoints = await oauth_resolve_provider_urls(settings.OIDC_DISCOVERY_URL) @@ -153,13 +164,14 @@ async def validate_session(user_id: str, *, role: str | None = None) -> User: if user is None: raise UnauthorizedError() if role is not None and user.role != role: - raise ForbiddenError() # authenticated, but not allowed → 403 + raise ForbiddenError() # authenticated, but not allowed → 403 return user + session = APIKeyCookieAuth("session", validate_session, secret_key=settings.SECRET_KEY) admin_session = session.require(role="admin") + @app.get("/admin/stats") -async def stats(user: User = Security(admin_session)): - ... +async def stats(user: User = Security(admin_session)): ... ``` diff --git a/docs/usage.md b/docs/usage.md index 6cd46c3..61be9eb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,6 +7,7 @@ Every source wraps a callable you provide. It can be sync or async, receives the ```python from fastapi_multiauth import UnauthorizedError + async def validate_token(token: str) -> User: user = await db.get_user_by_token(token) if user is None: @@ -25,6 +26,7 @@ The same applies per route: `.require(**kwargs)` returns a copy of the source wi ```python bearer = HTTPBearerAuth(validate_token) + @app.get("/admin") async def admin(user=Security(bearer.require(role=Role.ADMIN))): return user @@ -46,6 +48,7 @@ bearer = HTTPBearerAuth(validate_token) app = FastAPI() + @app.get("/me") async def me(user=Security(bearer)): return user @@ -67,9 +70,10 @@ org_bearer = HTTPBearerAuth(validate_org_token, prefix="org_") ```python from fastapi_multiauth import hash_token, verify_token_hash -token = user_bearer.generate_token() # "user_Xk3...": show it to the user once +token = user_bearer.generate_token() # "user_Xk3...": show it to the user once await db.save_api_token(user_id, token_hash=hash_token(token)) + async def validate_user_token(token: str) -> User: row = await db.get_api_token(token_hash=hash_token(token)) if row is None: @@ -91,15 +95,17 @@ session = APIKeyCookieAuth( validate_session, secret_key=settings.SECRET_KEY, # ≥ 32 bytes, enforced at startup ttl=86400, - samesite="lax", # default; also: domain=..., path=... + samesite="lax", # default; also: domain=..., path=... ) + @app.post("/login") async def login(response: Response, credentials: LoginForm): user = await check_password(credentials) session.set_cookie(response, str(user.id)) return {"ok": True} + @app.post("/logout") async def logout(response: Response): session.delete_cookie(response) @@ -151,6 +157,7 @@ api_key = APIKeyQueryAuth("api_key", validate_api_key) import secrets from fastapi_multiauth import HTTPBasicAuth, UnauthorizedError + async def validate_basic(username: str, password: str) -> dict: user = await db.get_user(username) if user is None or not secrets.compare_digest( @@ -159,6 +166,7 @@ async def validate_basic(username: str, password: str) -> dict: raise UnauthorizedError() return user + basic = HTTPBasicAuth(validate_basic, realm="api") ``` @@ -173,6 +181,7 @@ from fastapi_multiauth import MultiAuth auth = MultiAuth(bearer, session) + @app.get("/me") async def me(user=Security(auth)): return user @@ -189,11 +198,12 @@ async def validate_token(token: str, scopes: list[str]) -> User: raise UnauthorizedError() return user + bearer = HTTPBearerAuth(validate_token) + @app.post("/challenges") -async def create(user=Security(bearer, scopes=["challenges:write"])): - ... +async def create(user=Security(bearer, scopes=["challenges:write"])): ... ``` !!! note "Scopes and OpenAPI" @@ -220,6 +230,7 @@ bearer = HTTPBearerAuth( ) ) + @app.get("/me") async def me(claims=Security(bearer)): return claims diff --git a/pyproject.toml b/pyproject.toml index 5660cc1..0020ae9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ build-backend = "uv_build" extend-select = ["S"] [tool.ruff.lint.per-file-ignores] -"tests/**" = ["S"] +"tests/**" = ["S", "B008"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/fastapi_multiauth/_http.py b/src/fastapi_multiauth/_http.py index 388e3bd..3173a2c 100644 --- a/src/fastapi_multiauth/_http.py +++ b/src/fastapi_multiauth/_http.py @@ -15,7 +15,7 @@ def _require_https(url: str, description: str) -> str: """Reject non-string URLs and URLs that would send credentials over plaintext HTTP.""" if not isinstance(url, str): - raise ValueError(f"{description} must be a string URL (got {url!r})") + raise ValueError(f"{description} must be a string URL (got {url!r})") # noqa: TRY004 parsed = urlsplit(url) if parsed.scheme == "https": return url diff --git a/src/fastapi_multiauth/abc.py b/src/fastapi_multiauth/abc.py index cd8346d..7dce996 100644 --- a/src/fastapi_multiauth/abc.py +++ b/src/fastapi_multiauth/abc.py @@ -3,7 +3,8 @@ import copy import inspect from abc import ABC, abstractmethod -from typing import Annotated, Any, Callable, TypeVar +from collections.abc import Callable +from typing import TYPE_CHECKING, Annotated, Any from fastapi import Depends, HTTPException, Request from fastapi.security import SecurityScopes @@ -12,7 +13,8 @@ from fastapi_multiauth.exceptions import UnauthorizedError from fastapi_multiauth.utils import add_challenge, challenge_headers, ensure_async -_V = TypeVar("_V", bound="ValidatedAuthSource") +if TYPE_CHECKING: + from typing_extensions import Self def _reject_scopes_kwarg(kwargs: dict[str, Any]) -> None: @@ -71,7 +73,7 @@ def __init__(self, scheme: Any) -> None: self.model = scheme.model self.scheme_name = scheme.scheme_name - async def __call__(self, request: Request) -> None: # noqa: ARG002 + async def __call__(self, request: Request) -> None: return None @@ -212,7 +214,7 @@ async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any: """Validate a credential, forwarding route-declared scopes to the validator.""" return await self._call_validator(credential, scopes=scopes) - def require(self: _V, **kwargs: Any) -> _V: + def require(self, **kwargs: Any) -> "Self": """Return a copy of this source with additional (or overriding) validator kwargs.""" _reject_scopes_kwarg(kwargs) clone = copy.copy(self) diff --git a/src/fastapi_multiauth/jwt.py b/src/fastapi_multiauth/jwt.py index ae0e9a3..ed7d060 100644 --- a/src/fastapi_multiauth/jwt.py +++ b/src/fastapi_multiauth/jwt.py @@ -2,8 +2,8 @@ import logging import time -from collections.abc import Sequence -from typing import Any, Callable +from collections.abc import Callable, Sequence +from typing import Any import anyio from fastapi import HTTPException, status diff --git a/src/fastapi_multiauth/sources/basic.py b/src/fastapi_multiauth/sources/basic.py index f944aa4..c0d797d 100644 --- a/src/fastapi_multiauth/sources/basic.py +++ b/src/fastapi_multiauth/sources/basic.py @@ -1,7 +1,8 @@ """HTTP Basic authentication source.""" import base64 -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from fastapi import Request from fastapi.security import HTTPBasic diff --git a/src/fastapi_multiauth/sources/bearer.py b/src/fastapi_multiauth/sources/bearer.py index 537b2b5..ffca6e0 100644 --- a/src/fastapi_multiauth/sources/bearer.py +++ b/src/fastapi_multiauth/sources/bearer.py @@ -1,7 +1,8 @@ """Bearer token authentication source.""" import secrets -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from fastapi import Request from fastapi.security import HTTPBearer diff --git a/src/fastapi_multiauth/sources/cookie.py b/src/fastapi_multiauth/sources/cookie.py index b3bbcf9..f0527fa 100644 --- a/src/fastapi_multiauth/sources/cookie.py +++ b/src/fastapi_multiauth/sources/cookie.py @@ -1,8 +1,8 @@ """Cookie-based authentication source.""" import hashlib -from collections.abc import Sequence -from typing import Any, Callable, Literal +from collections.abc import Callable, Sequence +from typing import Any, Literal from fastapi import Request, Response from fastapi.security import APIKeyCookie diff --git a/src/fastapi_multiauth/sources/header.py b/src/fastapi_multiauth/sources/header.py index e4b1d00..39e586b 100644 --- a/src/fastapi_multiauth/sources/header.py +++ b/src/fastapi_multiauth/sources/header.py @@ -1,6 +1,7 @@ """API key header authentication source.""" -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from fastapi import Request from fastapi.security import APIKeyHeader diff --git a/src/fastapi_multiauth/sources/query.py b/src/fastapi_multiauth/sources/query.py index ca339a4..cd3a82b 100644 --- a/src/fastapi_multiauth/sources/query.py +++ b/src/fastapi_multiauth/sources/query.py @@ -1,6 +1,7 @@ """API key query-string authentication source.""" -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from fastapi import Request from fastapi.security import APIKeyQuery diff --git a/src/fastapi_multiauth/utils.py b/src/fastapi_multiauth/utils.py index adcc34f..464772d 100644 --- a/src/fastapi_multiauth/utils.py +++ b/src/fastapi_multiauth/utils.py @@ -4,7 +4,8 @@ import hashlib import hmac import inspect -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import anyio.to_thread from fastapi import HTTPException, Request diff --git a/tests/test_multiauth.py b/tests/test_multiauth.py index e97442c..7329f3f 100644 --- a/tests/test_multiauth.py +++ b/tests/test_multiauth.py @@ -14,23 +14,23 @@ import respx from cryptography.hazmat.primitives.asymmetric import rsa from fastapi import FastAPI, HTTPException, Security -from jwt.algorithms import RSAAlgorithm from fastapi.testclient import TestClient +from jwt.algorithms import RSAAlgorithm -from fastapi_multiauth.exceptions import UnauthorizedError from fastapi_multiauth import ( + APIKeyCookieAuth, APIKeyHeaderAuth, APIKeyQueryAuth, AuthSource, + ForbiddenError, HTTPBasicAuth, HTTPBearerAuth, - APIKeyCookieAuth, - ForbiddenError, JWTValidator, MultiAuth, hash_token, verify_token_hash, ) +from fastapi_multiauth.exceptions import UnauthorizedError from fastapi_multiauth.oauth import ( OAuthDiscoveryError, OAuthExchangeError, @@ -1402,7 +1402,8 @@ def test_scopes_kwarg_rejected_via_require(self): def test_accepts_scopes_non_introspectable_callable(self): """Callables without an inspectable signature are treated as scope-less.""" - from typing import Any, Callable, cast + from collections.abc import Callable + from typing import Any, cast from fastapi_multiauth.abc import _accepts_scopes diff --git a/uv.lock b/uv.lock index 7382edb..b5da924 100644 --- a/uv.lock +++ b/uv.lock @@ -1121,27 +1121,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.21" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, - { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, - { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, - { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, - { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, - { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, - { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, - { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, - { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]]