tina4-python 3.10.90__tar.gz → 3.10.93__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tina4_python-3.10.90 → tina4_python-3.10.93}/PKG-INFO +1 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/Testing.py +9 -4
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/__init__.py +1 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/api/__init__.py +2 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/auth/__init__.py +16 -12
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/cache/__init__.py +10 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/cli/__init__.py +6 -6
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/__init__.py +2 -2
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/middleware.py +157 -76
- tina4_python-3.10.93/tina4_python/core/rate_limiter.py +130 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/request.py +14 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/response.py +17 -3
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/router.py +65 -26
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/server.py +86 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/crud/__init__.py +19 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/adapter.py +36 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/connection.py +103 -4
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/debug/__init__.py +3 -3
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/dev_admin/__init__.py +54 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/frond/engine.py +223 -17
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/graphql/__init__.py +50 -37
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/i18n/__init__.py +46 -2
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/mcp/__init__.py +21 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/mcp/tools.py +2 -2
- tina4_python-3.10.93/tina4_python/migration/__init__.py +15 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/migration/runner.py +177 -56
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/orm/model.py +65 -3
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/__init__.py +92 -23
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/kafka_backend.py +1 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/lite_backend.py +49 -4
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/mongo_backend.py +1 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/rabbitmq_backend.py +1 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue_backends/__init__.py +9 -4
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue_backends/kafka_backend.py +4 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue_backends/mongo_backend.py +4 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue_backends/rabbitmq_backend.py +4 -1
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/__init__.py +79 -1
- tina4_python-3.10.93/tina4_python/seeder/__init__.py +459 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/service/__init__.py +66 -5
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/session/__init__.py +10 -6
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/websocket/__init__.py +32 -13
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/wsdl/__init__.py +3 -1
- tina4_python-3.10.90/tina4_python/migration/__init__.py +0 -13
- tina4_python-3.10.90/tina4_python/seeder/__init__.py +0 -182
- {tina4_python-3.10.90 → tina4_python-3.10.93}/.gitignore +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/README.md +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/pyproject.toml +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/core/events.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/dev_reload.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.10.90 → tina4_python-3.10.93}/tina4_python/websocket/backplane.py +0 -0
|
@@ -18,8 +18,8 @@ Usage:
|
|
|
18
18
|
return a + b
|
|
19
19
|
|
|
20
20
|
Run all tests:
|
|
21
|
-
from tina4_python.Testing import
|
|
22
|
-
|
|
21
|
+
from tina4_python.Testing import run_all
|
|
22
|
+
run_all()
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
import sys
|
|
@@ -58,7 +58,7 @@ def tests(*assertions):
|
|
|
58
58
|
"""Decorator that attaches inline test assertions to a function.
|
|
59
59
|
|
|
60
60
|
The decorated function is returned unchanged; the assertions are
|
|
61
|
-
stored in a global registry and executed by ``
|
|
61
|
+
stored in a global registry and executed by ``run_all()``.
|
|
62
62
|
"""
|
|
63
63
|
def decorator(fn):
|
|
64
64
|
_registry.append({
|
|
@@ -73,7 +73,7 @@ def tests(*assertions):
|
|
|
73
73
|
|
|
74
74
|
# ── Runner ──────────────────────────────────────────────────────────
|
|
75
75
|
|
|
76
|
-
def
|
|
76
|
+
def run_all(quiet: bool = False, failfast: bool = False) -> dict:
|
|
77
77
|
"""Discover and run every ``@tests``-decorated function.
|
|
78
78
|
|
|
79
79
|
Returns a dict with keys ``passed``, ``failed``, ``errors``, ``details``.
|
|
@@ -171,6 +171,11 @@ def _assertion_label(assertion: dict, fn_name: str) -> str:
|
|
|
171
171
|
return f"{fn_name} [{atype}]"
|
|
172
172
|
|
|
173
173
|
|
|
174
|
+
def reset():
|
|
175
|
+
"""Reset the test registry (useful between test runs)."""
|
|
176
|
+
_registry.clear()
|
|
177
|
+
|
|
178
|
+
|
|
174
179
|
def _print_summary(results: dict, quiet: bool):
|
|
175
180
|
if quiet:
|
|
176
181
|
return
|
|
@@ -35,6 +35,7 @@ class Api:
|
|
|
35
35
|
"""Add custom headers to all requests."""
|
|
36
36
|
self._headers.update(headers)
|
|
37
37
|
|
|
38
|
+
|
|
38
39
|
def set_basic_auth(self, username: str, password: str):
|
|
39
40
|
"""Set Basic authentication."""
|
|
40
41
|
creds = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
@@ -67,7 +68,7 @@ class Api:
|
|
|
67
68
|
"""HTTP DELETE request."""
|
|
68
69
|
return self._request("DELETE", self._url(path), body)
|
|
69
70
|
|
|
70
|
-
def
|
|
71
|
+
def send_request(self, method: str, path: str = "", body=None,
|
|
71
72
|
content_type: str = "application/json") -> dict:
|
|
72
73
|
"""Generic request method."""
|
|
73
74
|
return self._request(method.upper(), self._url(path), body, content_type)
|
|
@@ -69,14 +69,18 @@ class Auth:
|
|
|
69
69
|
# ── JWT ────────────────────────────────────────────────────────
|
|
70
70
|
|
|
71
71
|
@_DualMethod
|
|
72
|
-
def get_token(self, payload: dict, expires_in: int = None) -> str:
|
|
72
|
+
def get_token(self, payload: dict, expires_in: int = None, secret: str = None) -> str:
|
|
73
73
|
"""Create a signed JWT token.
|
|
74
74
|
|
|
75
75
|
Args:
|
|
76
76
|
expires_in: Lifetime in minutes (default: self.expires_in).
|
|
77
|
+
secret: Override signing secret (default: self.secret / SECRET env var).
|
|
77
78
|
|
|
78
79
|
Returns: header.payload.signature
|
|
79
80
|
"""
|
|
81
|
+
if secret is not None:
|
|
82
|
+
return Auth(secret=secret, algorithm=self.algorithm,
|
|
83
|
+
expires_in=self.expires_in).get_token(payload, expires_in=expires_in)
|
|
80
84
|
exp_minutes = expires_in if expires_in is not None else self.expires_in
|
|
81
85
|
exp_seconds = exp_minutes * 60
|
|
82
86
|
|
|
@@ -193,14 +197,6 @@ class Auth:
|
|
|
193
197
|
auth = cls(secret=secret)
|
|
194
198
|
return auth.authenticate_request(headers)
|
|
195
199
|
|
|
196
|
-
@staticmethod
|
|
197
|
-
def validate_api_key_static(provided: str, expected: str = None) -> bool:
|
|
198
|
-
"""Validate an API key without instantiating Auth.
|
|
199
|
-
|
|
200
|
-
Alias for validate_api_key (already a staticmethod).
|
|
201
|
-
"""
|
|
202
|
-
return Auth.validate_api_key(provided, expected)
|
|
203
|
-
|
|
204
200
|
# ── Password Hashing ──────────────────────────────────────────
|
|
205
201
|
|
|
206
202
|
@staticmethod
|
|
@@ -258,9 +254,13 @@ class Auth:
|
|
|
258
254
|
# ── Request Auth Helper ───────────────────────────────────────
|
|
259
255
|
|
|
260
256
|
@_DualMethod
|
|
261
|
-
def authenticate_request(self, headers: dict) -> dict | None:
|
|
257
|
+
def authenticate_request(self, headers: dict, secret: str = None, algorithm: str = "HS256") -> dict | None:
|
|
262
258
|
"""Extract and validate auth from request headers.
|
|
263
259
|
|
|
260
|
+
Args:
|
|
261
|
+
secret: Override signing secret (default: self.secret / SECRET env var).
|
|
262
|
+
algorithm: JWT algorithm override (default: "HS256").
|
|
263
|
+
|
|
264
264
|
Checks: Bearer JWT, Bearer API key, Basic auth.
|
|
265
265
|
Returns payload dict on success, None on failure.
|
|
266
266
|
"""
|
|
@@ -300,8 +300,10 @@ def _b64url_decode(s: str) -> bytes:
|
|
|
300
300
|
|
|
301
301
|
# ── Module-level convenience functions (use static methods) ────
|
|
302
302
|
|
|
303
|
-
def get_token(payload: dict, expires_in: int = 60) -> str:
|
|
303
|
+
def get_token(payload: dict, expires_in: int = 60, secret: str = None) -> str:
|
|
304
304
|
"""Create a JWT — reads SECRET from env. Shortcut for Auth.get_token_static()."""
|
|
305
|
+
if secret is not None:
|
|
306
|
+
return Auth(secret=secret).get_token(payload, expires_in=expires_in)
|
|
305
307
|
return Auth.get_token_static(payload, expires_in=expires_in)
|
|
306
308
|
|
|
307
309
|
|
|
@@ -320,8 +322,10 @@ def refresh_token(token: str, expires_in: int = 60) -> str | None:
|
|
|
320
322
|
return Auth.refresh_token_static(token, expires_in=expires_in)
|
|
321
323
|
|
|
322
324
|
|
|
323
|
-
def authenticate_request(headers: dict) -> dict | None:
|
|
325
|
+
def authenticate_request(headers: dict, secret: str = None, algorithm: str = "HS256") -> dict | None:
|
|
324
326
|
"""Validate auth from request headers — reads SECRET from env."""
|
|
327
|
+
if secret is not None:
|
|
328
|
+
return Auth(secret=secret).authenticate_request(headers, algorithm=algorithm)
|
|
325
329
|
return Auth.authenticate_request_static(headers)
|
|
326
330
|
|
|
327
331
|
|
|
@@ -591,6 +591,11 @@ class ResponseCache:
|
|
|
591
591
|
"backend": self._backend.name(),
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
+
def sweep(self) -> int:
|
|
595
|
+
"""Remove all expired entries. Returns count removed."""
|
|
596
|
+
self._cleanup_expired()
|
|
597
|
+
return 0
|
|
598
|
+
|
|
594
599
|
def clear_cache(self) -> None:
|
|
595
600
|
"""Flush all cached entries and reset stats."""
|
|
596
601
|
with self._lock:
|
|
@@ -709,6 +714,11 @@ def cache_delete(key: str) -> bool:
|
|
|
709
714
|
return _get_backend().delete(key)
|
|
710
715
|
|
|
711
716
|
|
|
717
|
+
def sweep() -> int:
|
|
718
|
+
"""Remove expired entries from the cache. Returns count removed."""
|
|
719
|
+
return _get_backend().sweep() if hasattr(_get_backend(), "sweep") else 0
|
|
720
|
+
|
|
721
|
+
|
|
712
722
|
def cache_clear():
|
|
713
723
|
"""Clear all entries from the cache."""
|
|
714
724
|
_get_backend().clear()
|
|
@@ -396,12 +396,12 @@ def _migrate(args):
|
|
|
396
396
|
"""Run pending migrations."""
|
|
397
397
|
_load_env()
|
|
398
398
|
from tina4_python.database import Database
|
|
399
|
-
from tina4_python.migration import
|
|
399
|
+
from tina4_python.migration import Migration
|
|
400
400
|
|
|
401
401
|
db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
|
|
402
402
|
db = Database(db_url)
|
|
403
403
|
mig_dir = args[0] if args else "migrations"
|
|
404
|
-
ran =
|
|
404
|
+
ran = Migration(db, mig_dir).migrate()
|
|
405
405
|
if ran:
|
|
406
406
|
for f in ran:
|
|
407
407
|
print(f" Migrated: {f}")
|
|
@@ -426,12 +426,12 @@ def _migrate_rollback(args):
|
|
|
426
426
|
"""Rollback the last migration batch."""
|
|
427
427
|
_load_env()
|
|
428
428
|
from tina4_python.database import Database
|
|
429
|
-
from tina4_python.migration import
|
|
429
|
+
from tina4_python.migration import Migration
|
|
430
430
|
|
|
431
431
|
db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
|
|
432
432
|
db = Database(db_url)
|
|
433
433
|
mig_dir = args[0] if args else "migrations"
|
|
434
|
-
rolled =
|
|
434
|
+
rolled = Migration(db, mig_dir).rollback()
|
|
435
435
|
if rolled:
|
|
436
436
|
for f in rolled:
|
|
437
437
|
print(f" Rolled back: {f}")
|
|
@@ -445,11 +445,11 @@ def _migrate_status(args):
|
|
|
445
445
|
"""Show migration status."""
|
|
446
446
|
_load_env()
|
|
447
447
|
from tina4_python.database import Database
|
|
448
|
-
from tina4_python.migration import
|
|
448
|
+
from tina4_python.migration import Migration
|
|
449
449
|
|
|
450
450
|
db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
|
|
451
451
|
db = Database(db_url)
|
|
452
|
-
result =
|
|
452
|
+
result = Migration(db, args[0] if args else "migrations").status()
|
|
453
453
|
completed, pending = result["completed"], result["pending"]
|
|
454
454
|
|
|
455
455
|
if completed:
|
|
@@ -19,7 +19,7 @@ from tina4_python.core.router import (
|
|
|
19
19
|
from tina4_python.core.middleware import CorsMiddleware, RateLimiter
|
|
20
20
|
from tina4_python.core.cache import Cache
|
|
21
21
|
from tina4_python.core.events import on, off, emit, emit_async, once, listeners, events, clear as clear_events
|
|
22
|
-
from tina4_python.core.server import run, resolve_config, handle
|
|
22
|
+
from tina4_python.core.server import run, resolve_config, handle, start, stop
|
|
23
23
|
|
|
24
24
|
__all__ = [
|
|
25
25
|
"Request", "Response", "Router",
|
|
@@ -28,5 +28,5 @@ __all__ = [
|
|
|
28
28
|
"CorsMiddleware", "RateLimiter",
|
|
29
29
|
"Cache",
|
|
30
30
|
"on", "off", "emit", "emit_async", "once", "listeners", "events", "clear_events",
|
|
31
|
-
"run", "resolve_config", "handle",
|
|
31
|
+
"run", "resolve_config", "handle", "start", "stop",
|
|
32
32
|
]
|
|
@@ -1,32 +1,108 @@
|
|
|
1
|
-
# Tina4 Middleware —
|
|
1
|
+
# Tina4 Middleware — orchestrator plus built-in middleware classes.
|
|
2
2
|
"""
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Standardized middleware orchestrator and built-in middleware.
|
|
4
|
+
|
|
5
|
+
Middleware classes follow a simple convention:
|
|
6
|
+
- Static methods named ``before_*`` run BEFORE the route handler
|
|
7
|
+
- Static methods named ``after_*`` run AFTER the route handler
|
|
8
|
+
- Each method receives ``(request, response)`` and returns ``(request, response)``
|
|
9
|
+
- If a before method sets the response status to >= 400, the chain short-circuits
|
|
5
10
|
|
|
6
|
-
|
|
11
|
+
Usage::
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
TINA4_CORS_ORIGINS=* # Allowed origins (* = all)
|
|
10
|
-
TINA4_CORS_METHODS=GET,POST,PUT,DELETE # Allowed methods
|
|
11
|
-
TINA4_CORS_HEADERS=Content-Type,Authorization
|
|
12
|
-
TINA4_CORS_MAX_AGE=86400 # Preflight cache (seconds)
|
|
13
|
+
from tina4_python.core.middleware import Middleware, CorsMiddleware
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
Middleware.use(CorsMiddleware)
|
|
16
|
+
request, response = Middleware.run_before([CorsMiddleware], request, response)
|
|
17
|
+
request, response = Middleware.run_after([RequestLoggerMiddleware], request, response)
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
TINA4_CSRF=true # Enable CSRF token validation
|
|
19
|
+
Zero dependencies — stdlib only.
|
|
20
20
|
"""
|
|
21
21
|
import os
|
|
22
22
|
import time
|
|
23
23
|
import logging
|
|
24
24
|
import threading
|
|
25
25
|
|
|
26
|
+
from tina4_python.core.rate_limiter import RateLimiter # noqa: F401 — re-export for backward compat
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Middleware:
|
|
30
|
+
"""Standardized middleware orchestrator.
|
|
31
|
+
|
|
32
|
+
Registers middleware classes globally and runs their ``before_*`` /
|
|
33
|
+
``after_*`` static methods in alphabetical order. Mirrors the PHP,
|
|
34
|
+
Ruby and Node.js orchestrators.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_global_middleware: list = []
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def use(cls, middleware_class) -> None:
|
|
41
|
+
"""Register a middleware class to run on every request."""
|
|
42
|
+
if middleware_class not in cls._global_middleware:
|
|
43
|
+
cls._global_middleware.append(middleware_class)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def get_global(cls) -> list:
|
|
47
|
+
"""Return the list of globally registered middleware classes."""
|
|
48
|
+
return list(cls._global_middleware)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def reset(cls) -> None:
|
|
52
|
+
"""Clear all globally registered middleware (primarily for tests)."""
|
|
53
|
+
cls._global_middleware = []
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def run_before(cls, middleware_classes, request, response):
|
|
57
|
+
"""Run every ``before_*`` static method on the given classes.
|
|
58
|
+
|
|
59
|
+
Short-circuits if the response status becomes >= 400.
|
|
60
|
+
Returns ``(request, response)``.
|
|
61
|
+
"""
|
|
62
|
+
for mw_class in middleware_classes:
|
|
63
|
+
for method_name in cls._discover_methods(mw_class, "before_"):
|
|
64
|
+
result = getattr(mw_class, method_name)(request, response)
|
|
65
|
+
if isinstance(result, tuple) and len(result) >= 2:
|
|
66
|
+
request, response = result[0], result[1]
|
|
67
|
+
status = getattr(response, "status_code", None) or getattr(response, "status", 0)
|
|
68
|
+
if isinstance(status, int) and status >= 400:
|
|
69
|
+
return request, response
|
|
70
|
+
return request, response
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def run_after(cls, middleware_classes, request, response):
|
|
74
|
+
"""Run every ``after_*`` static method on the given classes."""
|
|
75
|
+
for mw_class in middleware_classes:
|
|
76
|
+
for method_name in cls._discover_methods(mw_class, "after_"):
|
|
77
|
+
result = getattr(mw_class, method_name)(request, response)
|
|
78
|
+
if isinstance(result, tuple) and len(result) >= 2:
|
|
79
|
+
request, response = result[0], result[1]
|
|
80
|
+
return request, response
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _discover_methods(mw_class, prefix: str) -> list:
|
|
84
|
+
"""Return sorted list of public static method names with ``prefix``."""
|
|
85
|
+
names = [
|
|
86
|
+
name
|
|
87
|
+
for name in dir(mw_class)
|
|
88
|
+
if name.startswith(prefix) and callable(getattr(mw_class, name, None))
|
|
89
|
+
]
|
|
90
|
+
return sorted(names)
|
|
91
|
+
|
|
26
92
|
|
|
27
93
|
class CorsMiddleware:
|
|
28
94
|
"""CORS handler — reads config from env, injects headers."""
|
|
29
95
|
|
|
96
|
+
@staticmethod
|
|
97
|
+
def before_cors(request, response):
|
|
98
|
+
"""Inject CORS headers on every request (class-based middleware convention)."""
|
|
99
|
+
instance = CorsMiddleware()
|
|
100
|
+
if instance.is_preflight(request):
|
|
101
|
+
instance.apply(request, response)
|
|
102
|
+
return request, response
|
|
103
|
+
instance.apply(request, response)
|
|
104
|
+
return request, response
|
|
105
|
+
|
|
30
106
|
def __init__(self):
|
|
31
107
|
self.origins = os.environ.get("TINA4_CORS_ORIGINS", "*")
|
|
32
108
|
self.methods = os.environ.get(
|
|
@@ -74,73 +150,44 @@ class CorsMiddleware:
|
|
|
74
150
|
)
|
|
75
151
|
|
|
76
152
|
|
|
77
|
-
class
|
|
78
|
-
"""
|
|
153
|
+
class RateLimiterMiddleware:
|
|
154
|
+
"""Static rate limiter middleware — tracks requests per IP, returns 429 when exceeded.
|
|
79
155
|
|
|
80
|
-
|
|
156
|
+
Config via env: TINA4_RATE_LIMIT (default 100), TINA4_RATE_WINDOW (default 60s).
|
|
157
|
+
Delegates to the RateLimiter class for the actual sliding window logic.
|
|
81
158
|
"""
|
|
82
159
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
self.window = int(os.environ.get("TINA4_RATE_WINDOW", "60"))
|
|
86
|
-
self._requests: dict[str, list[float]] = {}
|
|
87
|
-
self._lock = threading.Lock()
|
|
88
|
-
self._last_cleanup = time.monotonic()
|
|
160
|
+
_limiter = None
|
|
161
|
+
_lock = threading.Lock()
|
|
89
162
|
|
|
90
|
-
|
|
91
|
-
|
|
163
|
+
@classmethod
|
|
164
|
+
def _get_limiter(cls):
|
|
165
|
+
with cls._lock:
|
|
166
|
+
if cls._limiter is None:
|
|
167
|
+
cls._limiter = RateLimiter()
|
|
168
|
+
return cls._limiter
|
|
92
169
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if len(timestamps) >= self.limit:
|
|
116
|
-
return False, {
|
|
117
|
-
"limit": self.limit,
|
|
118
|
-
"remaining": 0,
|
|
119
|
-
"reset": reset,
|
|
120
|
-
"window": self.window,
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
timestamps.append(now)
|
|
124
|
-
return True, {
|
|
125
|
-
"limit": self.limit,
|
|
126
|
-
"remaining": remaining - 1,
|
|
127
|
-
"reset": self.window,
|
|
128
|
-
"window": self.window,
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
def _cleanup(self, now: float):
|
|
132
|
-
"""Remove IPs with no recent requests."""
|
|
133
|
-
cutoff = now - self.window
|
|
134
|
-
expired = [ip for ip, ts in self._requests.items() if not ts or ts[-1] < cutoff]
|
|
135
|
-
for ip in expired:
|
|
136
|
-
del self._requests[ip]
|
|
137
|
-
|
|
138
|
-
def apply_headers(self, response, info: dict):
|
|
139
|
-
"""Add rate limit headers to response."""
|
|
140
|
-
response.header("x-ratelimit-limit", str(info["limit"]))
|
|
141
|
-
response.header("x-ratelimit-remaining", str(info["remaining"]))
|
|
142
|
-
response.header("x-ratelimit-reset", str(info["reset"]))
|
|
143
|
-
return response
|
|
170
|
+
@staticmethod
|
|
171
|
+
def before_rate_limit(request, response):
|
|
172
|
+
"""Middleware hook — enforces rate limiting before the route handler."""
|
|
173
|
+
limiter = RateLimiterMiddleware._get_limiter()
|
|
174
|
+
ip = getattr(request, "ip", None) or "unknown"
|
|
175
|
+
allowed, info = limiter.check(ip)
|
|
176
|
+
limiter.apply_headers(response, info)
|
|
177
|
+
if not allowed:
|
|
178
|
+
retry_after = max(1, int(info.get("reset", limiter.window)))
|
|
179
|
+
response.header("retry-after", str(retry_after))
|
|
180
|
+
if hasattr(response, "error"):
|
|
181
|
+
response.error("Too Many Requests", f"Rate limit exceeded. Retry in {retry_after}s.", 429)
|
|
182
|
+
else:
|
|
183
|
+
setattr(response, "status_code", 429)
|
|
184
|
+
return request, response
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def check(ip: str):
|
|
188
|
+
"""Check if an IP is within rate limits. Returns (allowed, info)."""
|
|
189
|
+
limiter = RateLimiterMiddleware._get_limiter()
|
|
190
|
+
return limiter.check(ip)
|
|
144
191
|
|
|
145
192
|
|
|
146
193
|
class SecurityHeadersMiddleware:
|
|
@@ -308,3 +355,37 @@ class CsrfMiddleware:
|
|
|
308
355
|
)
|
|
309
356
|
|
|
310
357
|
return request, response
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class RequestLoggerMiddleware:
|
|
361
|
+
"""Request logger — stamps start time before the handler and logs elapsed time after.
|
|
362
|
+
|
|
363
|
+
Mirrors the PHP, Ruby and Node.js RequestLogger classes.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
_logger = logging.getLogger("tina4.request")
|
|
367
|
+
_start_times: dict = {}
|
|
368
|
+
_lock = threading.Lock()
|
|
369
|
+
|
|
370
|
+
@staticmethod
|
|
371
|
+
def before_log(request, response):
|
|
372
|
+
"""Record the request start time."""
|
|
373
|
+
key = id(request)
|
|
374
|
+
with RequestLoggerMiddleware._lock:
|
|
375
|
+
RequestLoggerMiddleware._start_times[key] = time.monotonic()
|
|
376
|
+
return request, response
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def after_log(request, response):
|
|
380
|
+
"""Log the request method, path, status code, and elapsed time."""
|
|
381
|
+
key = id(request)
|
|
382
|
+
with RequestLoggerMiddleware._lock:
|
|
383
|
+
start = RequestLoggerMiddleware._start_times.pop(key, None)
|
|
384
|
+
elapsed_ms = round((time.monotonic() - start) * 1000, 3) if start is not None else 0.0
|
|
385
|
+
method = getattr(request, "method", "?")
|
|
386
|
+
path = getattr(request, "url", None) or getattr(request, "path", "/")
|
|
387
|
+
status = getattr(response, "status_code", None) or getattr(response, "status", 0)
|
|
388
|
+
RequestLoggerMiddleware._logger.info(
|
|
389
|
+
"[RequestLogger] %s %s -> %s (%sms)", method, path, status, elapsed_ms
|
|
390
|
+
)
|
|
391
|
+
return request, response
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Tina4 RateLimiter — sliding window per-IP rate limiter.
|
|
2
|
+
"""
|
|
3
|
+
In-memory sliding window rate limiter.
|
|
4
|
+
|
|
5
|
+
Thread-safe. Automatically cleans up expired entries.
|
|
6
|
+
Reads configuration from environment variables:
|
|
7
|
+
TINA4_RATE_LIMIT — max requests per window (default: 100)
|
|
8
|
+
TINA4_RATE_WINDOW — window duration in seconds (default: 60)
|
|
9
|
+
"""
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RateLimiter:
|
|
16
|
+
"""Sliding window rate limiter — per-IP, in-memory.
|
|
17
|
+
|
|
18
|
+
Thread-safe. Automatically cleans up expired entries.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
_shared_instance = None
|
|
22
|
+
_shared_lock = threading.Lock()
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def _shared(cls) -> "RateLimiter":
|
|
26
|
+
with cls._shared_lock:
|
|
27
|
+
if cls._shared_instance is None:
|
|
28
|
+
cls._shared_instance = cls()
|
|
29
|
+
return cls._shared_instance
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def before_rate_limit(request, response):
|
|
33
|
+
"""Class-based middleware entry point — enforces the shared rate limit."""
|
|
34
|
+
limiter = RateLimiter._shared()
|
|
35
|
+
ip = getattr(request, "ip", None) or "unknown"
|
|
36
|
+
allowed, info = limiter.check(ip)
|
|
37
|
+
limiter.apply_headers(response, info)
|
|
38
|
+
if not allowed:
|
|
39
|
+
retry_after = max(1, int(info.get("reset", limiter.window)))
|
|
40
|
+
response.header("retry-after", str(retry_after))
|
|
41
|
+
if hasattr(response, "error"):
|
|
42
|
+
response.error("Too Many Requests", f"Rate limit exceeded. Retry in {retry_after}s.", 429)
|
|
43
|
+
else:
|
|
44
|
+
setattr(response, "status_code", 429)
|
|
45
|
+
return request, response
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self.limit = int(os.environ.get("TINA4_RATE_LIMIT", "100"))
|
|
49
|
+
self.window = int(os.environ.get("TINA4_RATE_WINDOW", "60"))
|
|
50
|
+
self._requests: dict[str, list[float]] = {}
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
self._last_cleanup = time.monotonic()
|
|
53
|
+
|
|
54
|
+
def check(self, ip: str) -> tuple[bool, dict]:
|
|
55
|
+
"""Check if request is allowed.
|
|
56
|
+
|
|
57
|
+
Returns (allowed, info) where info has remaining/limit/reset fields.
|
|
58
|
+
"""
|
|
59
|
+
now = time.monotonic()
|
|
60
|
+
|
|
61
|
+
with self._lock:
|
|
62
|
+
# Periodic cleanup every 60 seconds
|
|
63
|
+
if now - self._last_cleanup > 60:
|
|
64
|
+
self._cleanup(now)
|
|
65
|
+
self._last_cleanup = now
|
|
66
|
+
|
|
67
|
+
if ip not in self._requests:
|
|
68
|
+
self._requests[ip] = []
|
|
69
|
+
|
|
70
|
+
# Remove expired timestamps
|
|
71
|
+
cutoff = now - self.window
|
|
72
|
+
timestamps = self._requests[ip]
|
|
73
|
+
self._requests[ip] = [t for t in timestamps if t > cutoff]
|
|
74
|
+
timestamps = self._requests[ip]
|
|
75
|
+
|
|
76
|
+
remaining = max(0, self.limit - len(timestamps))
|
|
77
|
+
reset = int(self.window - (now - timestamps[0])) if timestamps else self.window
|
|
78
|
+
|
|
79
|
+
if len(timestamps) >= self.limit:
|
|
80
|
+
return False, {
|
|
81
|
+
"limit": self.limit,
|
|
82
|
+
"remaining": 0,
|
|
83
|
+
"reset": reset,
|
|
84
|
+
"window": self.window,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
timestamps.append(now)
|
|
88
|
+
return True, {
|
|
89
|
+
"limit": self.limit,
|
|
90
|
+
"remaining": remaining - 1,
|
|
91
|
+
"reset": self.window,
|
|
92
|
+
"window": self.window,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def _cleanup(self, now: float):
|
|
96
|
+
"""Remove IPs with no recent requests."""
|
|
97
|
+
cutoff = now - self.window
|
|
98
|
+
expired = [ip for ip, ts in self._requests.items() if not ts or ts[-1] < cutoff]
|
|
99
|
+
for ip in expired:
|
|
100
|
+
del self._requests[ip]
|
|
101
|
+
|
|
102
|
+
def apply(self, request, response):
|
|
103
|
+
"""Apply rate limiting to a request/response pair.
|
|
104
|
+
|
|
105
|
+
Checks the IP, sets headers, and returns 429 if over limit.
|
|
106
|
+
Returns (request, response).
|
|
107
|
+
"""
|
|
108
|
+
ip = getattr(request, "ip", None) or "unknown"
|
|
109
|
+
allowed, info = self.check(ip)
|
|
110
|
+
self.apply_headers(response, info)
|
|
111
|
+
if not allowed:
|
|
112
|
+
retry_after = max(1, int(info.get("reset", self.window)))
|
|
113
|
+
response.header("retry-after", str(retry_after))
|
|
114
|
+
if hasattr(response, "error"):
|
|
115
|
+
response.error("Too Many Requests", f"Rate limit exceeded. Retry in {retry_after}s.", 429)
|
|
116
|
+
else:
|
|
117
|
+
setattr(response, "status_code", 429)
|
|
118
|
+
return request, response
|
|
119
|
+
|
|
120
|
+
def reset(self):
|
|
121
|
+
"""Clear all tracked request data."""
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._requests.clear()
|
|
124
|
+
|
|
125
|
+
def apply_headers(self, response, info: dict):
|
|
126
|
+
"""Add rate limit headers to response."""
|
|
127
|
+
response.header("x-ratelimit-limit", str(info["limit"]))
|
|
128
|
+
response.header("x-ratelimit-remaining", str(info["remaining"]))
|
|
129
|
+
response.header("x-ratelimit-reset", str(info["reset"]))
|
|
130
|
+
return response
|
|
@@ -106,6 +106,20 @@ class Request:
|
|
|
106
106
|
"""Get a route parameter (from URL path). Alias for params[key]."""
|
|
107
107
|
return self.params.get(key, self._route_params.get(key, default))
|
|
108
108
|
|
|
109
|
+
def header(self, name: str) -> str | None:
|
|
110
|
+
"""Get a specific header value by name (case-insensitive)."""
|
|
111
|
+
return self.headers.get(name.lower().replace("-", "_"), self.headers.get(name.lower(), None))
|
|
112
|
+
|
|
113
|
+
def bearer_token(self) -> str | None:
|
|
114
|
+
"""Extract the Bearer token from the Authorization header."""
|
|
115
|
+
auth = self.headers.get("authorization", "")
|
|
116
|
+
if auth.lower().startswith("bearer "):
|
|
117
|
+
return auth[7:]
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def parse_body(self) -> dict | str | None:
|
|
121
|
+
"""Parse the raw body based on content type. Returns the parsed result."""
|
|
122
|
+
return _parse_body(self.raw_body, self.content_type)
|
|
109
123
|
|
|
110
124
|
|
|
111
125
|
def _extract_ip(scope: dict, headers: dict) -> str:
|