mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-06 16:44:08 +00:00
feat: use async_lru for caching
This commit is contained in:
@@ -31,6 +31,7 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
|
uv add "fastapi-toolsets[security]"
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
|
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
|
||||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
|
uv add "fastapi-toolsets[security]"
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
|
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
|
||||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
+6
-1
@@ -50,13 +50,17 @@ cli = [
|
|||||||
metrics = [
|
metrics = [
|
||||||
"prometheus_client>=0.20.0",
|
"prometheus_client>=0.20.0",
|
||||||
]
|
]
|
||||||
|
security = [
|
||||||
|
"async-lru>=1.0",
|
||||||
|
"httpx>=0.25.0",
|
||||||
|
]
|
||||||
pytest = [
|
pytest = [
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"pytest-xdist>=3.0.0",
|
"pytest-xdist>=3.0.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
"fastapi-toolsets[cli,metrics,pytest]",
|
"fastapi-toolsets[cli,metrics,pytest,security]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -73,6 +77,7 @@ dev = [
|
|||||||
"ty>=0.0.1a0",
|
"ty>=0.0.1a0",
|
||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
|
"async-lru>=1.0",
|
||||||
"coverage>=7.0.0",
|
"coverage>=7.0.0",
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"pytest-anyio>=0.0.0",
|
"pytest-anyio>=0.0.0",
|
||||||
|
|||||||
@@ -5,18 +5,15 @@ import binascii
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import time as _time
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from async_lru import alru_cache
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
_discovery_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
|
||||||
_DISCOVERY_TTL_SECONDS = 3600 # 1 hour
|
|
||||||
_DISCOVERY_CACHE_MAX = 32
|
|
||||||
|
|
||||||
|
|
||||||
|
@alru_cache(maxsize=32)
|
||||||
async def oauth_resolve_provider_urls(
|
async def oauth_resolve_provider_urls(
|
||||||
discovery_url: str,
|
discovery_url: str,
|
||||||
) -> tuple[str, str, str | None]:
|
) -> tuple[str, str, str | None]:
|
||||||
@@ -29,17 +26,10 @@ async def oauth_resolve_provider_urls(
|
|||||||
A ``(authorization_url, token_url, userinfo_url)`` tuple.
|
A ``(authorization_url, token_url, userinfo_url)`` tuple.
|
||||||
*userinfo_url* is ``None`` when the provider does not advertise one.
|
*userinfo_url* is ``None`` when the provider does not advertise one.
|
||||||
"""
|
"""
|
||||||
now = _time.time()
|
async with httpx.AsyncClient() as client:
|
||||||
cached = _discovery_cache.get(discovery_url)
|
resp = await client.get(discovery_url)
|
||||||
if cached is None or now - cached[1] > _DISCOVERY_TTL_SECONDS:
|
resp.raise_for_status()
|
||||||
async with httpx.AsyncClient() as client:
|
cfg = resp.json()
|
||||||
resp = await client.get(discovery_url)
|
|
||||||
resp.raise_for_status()
|
|
||||||
if len(_discovery_cache) >= _DISCOVERY_CACHE_MAX:
|
|
||||||
oldest = min(_discovery_cache, key=lambda k: _discovery_cache[k][1])
|
|
||||||
del _discovery_cache[oldest]
|
|
||||||
_discovery_cache[discovery_url] = (resp.json(), now)
|
|
||||||
cfg = _discovery_cache[discovery_url][0]
|
|
||||||
return (
|
return (
|
||||||
cfg["authorization_endpoint"],
|
cfg["authorization_endpoint"],
|
||||||
cfg["token_endpoint"],
|
cfg["token_endpoint"],
|
||||||
|
|||||||
+14
-14
@@ -1123,11 +1123,11 @@ class TestResolveProviderUrls:
|
|||||||
mock_resp.json.return_value = self._discovery()
|
mock_resp.json.return_value = self._discovery()
|
||||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||||
|
|
||||||
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
oauth_resolve_provider_urls.cache_clear()
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
with patch("httpx.AsyncClient", return_value=cm):
|
||||||
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
|
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
|
||||||
"https://auth.example.com/.well-known/openid-configuration"
|
"https://auth.example.com/.well-known/openid-configuration"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert auth_url == "https://auth.example.com/authorize"
|
assert auth_url == "https://auth.example.com/authorize"
|
||||||
assert token_url == "https://auth.example.com/token"
|
assert token_url == "https://auth.example.com/token"
|
||||||
@@ -1140,11 +1140,11 @@ class TestResolveProviderUrls:
|
|||||||
mock_resp.json.return_value = self._discovery(userinfo=False)
|
mock_resp.json.return_value = self._discovery(userinfo=False)
|
||||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||||
|
|
||||||
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
oauth_resolve_provider_urls.cache_clear()
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
with patch("httpx.AsyncClient", return_value=cm):
|
||||||
_, _, userinfo_url = await oauth_resolve_provider_urls(
|
_, _, userinfo_url = await oauth_resolve_provider_urls(
|
||||||
"https://auth.example.com/.well-known/openid-configuration"
|
"https://auth.example.com/.well-known/openid-configuration"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert userinfo_url is None
|
assert userinfo_url is None
|
||||||
|
|
||||||
@@ -1156,10 +1156,10 @@ class TestResolveProviderUrls:
|
|||||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||||
|
|
||||||
url = "https://auth.example.com/.well-known/openid-configuration"
|
url = "https://auth.example.com/.well-known/openid-configuration"
|
||||||
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
oauth_resolve_provider_urls.cache_clear()
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
with patch("httpx.AsyncClient", return_value=cm):
|
||||||
await oauth_resolve_provider_urls(url)
|
await oauth_resolve_provider_urls(url)
|
||||||
await oauth_resolve_provider_urls(url)
|
await oauth_resolve_provider_urls(url)
|
||||||
|
|
||||||
assert mock_client.get.call_count == 1
|
assert mock_client.get.call_count == 1
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-lru"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asyncpg"
|
name = "asyncpg"
|
||||||
version = "0.31.0"
|
version = "0.31.0"
|
||||||
@@ -332,6 +341,7 @@ dependencies = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
all = [
|
all = [
|
||||||
|
{ name = "async-lru" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "prometheus-client" },
|
{ name = "prometheus-client" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -349,9 +359,14 @@ pytest = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-xdist" },
|
{ name = "pytest-xdist" },
|
||||||
]
|
]
|
||||||
|
security = [
|
||||||
|
{ name = "async-lru" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "async-lru" },
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
{ name = "fastapi-toolsets", extra = ["all"] },
|
{ name = "fastapi-toolsets", extra = ["all"] },
|
||||||
@@ -376,6 +391,7 @@ docs-src = [
|
|||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
|
{ name = "async-lru" },
|
||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -386,10 +402,12 @@ tests = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" },
|
||||||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.100.0" },
|
{ name = "fastapi", specifier = ">=0.100.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" },
|
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest", "security"], marker = "extra == 'all'" },
|
||||||
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
||||||
|
{ name = "httpx", marker = "extra == 'security'", specifier = ">=0.25.0" },
|
||||||
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
||||||
@@ -397,10 +415,11 @@ requires-dist = [
|
|||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["cli", "metrics", "pytest", "all"]
|
provides-extras = ["cli", "metrics", "security", "pytest", "all"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "async-lru", specifier = ">=1.0" },
|
||||||
{ name = "bcrypt", specifier = ">=4.0.0" },
|
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["all"] },
|
{ name = "fastapi-toolsets", extras = ["all"] },
|
||||||
@@ -423,6 +442,7 @@ docs = [
|
|||||||
]
|
]
|
||||||
docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }]
|
docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }]
|
||||||
tests = [
|
tests = [
|
||||||
|
{ name = "async-lru", specifier = ">=1.0" },
|
||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
{ name = "httpx", specifier = ">=0.25.0" },
|
{ name = "httpx", specifier = ">=0.25.0" },
|
||||||
{ name = "pytest", specifier = ">=8.0.0" },
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user