tina4-python 3.8.0__tar.gz → 3.8.2__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.8.0 → tina4_python-3.8.2}/.gitignore +5 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/PKG-INFO +1 -1
- {tina4_python-3.8.0 → tina4_python-3.8.2}/pyproject.toml +1 -1
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/__init__.py +1 -1
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/middleware.py +48 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/request.py +17 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/response.py +22 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/connection.py +156 -27
- tina4_python-3.8.2/tina4_python/test_client/__init__.py +177 -0
- tina4_python-3.8.2/tina4_python/validator/__init__.py +169 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/README.md +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/Testing.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/events.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/router.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/core/server.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/dev_reload.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.8.0 → tina4_python-3.8.2}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -137,3 +137,51 @@ class RateLimiter:
|
|
|
137
137
|
response.header("x-ratelimit-remaining", str(info["remaining"]))
|
|
138
138
|
response.header("x-ratelimit-reset", str(info["reset"]))
|
|
139
139
|
return response
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SecurityHeadersMiddleware:
|
|
143
|
+
"""Injects security headers on every response.
|
|
144
|
+
|
|
145
|
+
Configurable via environment variables:
|
|
146
|
+
TINA4_FRAME_OPTIONS — X-Frame-Options (default: SAMEORIGIN)
|
|
147
|
+
TINA4_HSTS — Strict-Transport-Security max-age value
|
|
148
|
+
(default: "" = off; set to "31536000" to enable)
|
|
149
|
+
TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
|
|
150
|
+
TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
|
|
151
|
+
TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def before_security(request, response):
|
|
156
|
+
"""Set security headers before the route handler runs."""
|
|
157
|
+
response.header(
|
|
158
|
+
"x-frame-options",
|
|
159
|
+
os.environ.get("TINA4_FRAME_OPTIONS", "SAMEORIGIN"),
|
|
160
|
+
)
|
|
161
|
+
response.header("x-content-type-options", "nosniff")
|
|
162
|
+
|
|
163
|
+
hsts = os.environ.get("TINA4_HSTS", "")
|
|
164
|
+
if hsts:
|
|
165
|
+
response.header(
|
|
166
|
+
"strict-transport-security",
|
|
167
|
+
f"max-age={hsts}; includeSubDomains",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
response.header(
|
|
171
|
+
"content-security-policy",
|
|
172
|
+
os.environ.get("TINA4_CSP", "default-src 'self'"),
|
|
173
|
+
)
|
|
174
|
+
response.header(
|
|
175
|
+
"referrer-policy",
|
|
176
|
+
os.environ.get("TINA4_REFERRER_POLICY", "strict-origin-when-cross-origin"),
|
|
177
|
+
)
|
|
178
|
+
response.header("x-xss-protection", "0")
|
|
179
|
+
response.header(
|
|
180
|
+
"permissions-policy",
|
|
181
|
+
os.environ.get(
|
|
182
|
+
"TINA4_PERMISSIONS_POLICY",
|
|
183
|
+
"camera=(), microphone=(), geolocation=()",
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return request, response
|
|
@@ -3,8 +3,17 @@
|
|
|
3
3
|
Clean request object with parsed body, params, headers, and cookies.
|
|
4
4
|
"""
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
from urllib.parse import parse_qs, unquote
|
|
7
8
|
|
|
9
|
+
# Maximum upload size in bytes (default 10 MB). Override via TINA4_MAX_UPLOAD_SIZE env var.
|
|
10
|
+
TINA4_MAX_UPLOAD_SIZE = int(os.environ.get("TINA4_MAX_UPLOAD_SIZE", 10_485_760))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PayloadTooLarge(Exception):
|
|
14
|
+
"""Raised when request body exceeds TINA4_MAX_UPLOAD_SIZE."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
8
17
|
|
|
9
18
|
class Request:
|
|
10
19
|
"""Parsed HTTP request — everything a route handler needs."""
|
|
@@ -46,6 +55,14 @@ class Request:
|
|
|
46
55
|
req.content_type = req.headers.get("content-type", "")
|
|
47
56
|
req.ip = _extract_ip(scope, req.headers)
|
|
48
57
|
|
|
58
|
+
# Check upload size limit
|
|
59
|
+
content_length = int(req.headers.get("content-length", 0) or 0)
|
|
60
|
+
if content_length > TINA4_MAX_UPLOAD_SIZE or len(body) > TINA4_MAX_UPLOAD_SIZE:
|
|
61
|
+
raise PayloadTooLarge(
|
|
62
|
+
f"Request body ({max(content_length, len(body))} bytes) exceeds "
|
|
63
|
+
f"TINA4_MAX_UPLOAD_SIZE ({TINA4_MAX_UPLOAD_SIZE} bytes)"
|
|
64
|
+
)
|
|
65
|
+
|
|
49
66
|
# Parse query params
|
|
50
67
|
if req.query_string:
|
|
51
68
|
parsed = parse_qs(req.query_string, keep_blank_values=True)
|
|
@@ -132,6 +132,14 @@ class Response:
|
|
|
132
132
|
self.content = content.encode() if isinstance(content, str) else content
|
|
133
133
|
return self
|
|
134
134
|
|
|
135
|
+
def error(self, code: str, message: str, status_code: int = 400) -> "Response":
|
|
136
|
+
"""Standard error response envelope.
|
|
137
|
+
|
|
138
|
+
Usage:
|
|
139
|
+
return response.error("VALIDATION_FAILED", "Email is required", 400)
|
|
140
|
+
"""
|
|
141
|
+
return self.json(error_response(code, message, status_code), status_code)
|
|
142
|
+
|
|
135
143
|
def xml(self, content: str, status_code: int = None) -> "Response":
|
|
136
144
|
"""XML response."""
|
|
137
145
|
if status_code:
|
|
@@ -229,6 +237,20 @@ class Response:
|
|
|
229
237
|
return headers
|
|
230
238
|
|
|
231
239
|
|
|
240
|
+
def error_response(code: str, message: str, status: int = 400) -> dict:
|
|
241
|
+
"""Build a standard error response envelope.
|
|
242
|
+
|
|
243
|
+
Usage:
|
|
244
|
+
return response(error_response("VALIDATION_FAILED", "Email is required", 400), 400)
|
|
245
|
+
"""
|
|
246
|
+
return {
|
|
247
|
+
"error": True,
|
|
248
|
+
"code": code,
|
|
249
|
+
"message": message,
|
|
250
|
+
"status": status,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
232
254
|
def _is_compressible(content_type: str) -> bool:
|
|
233
255
|
"""Check if content type benefits from compression."""
|
|
234
256
|
compressible = (
|
|
@@ -5,6 +5,9 @@ The Database class parses a connection URL and creates the right adapter.
|
|
|
5
5
|
db = Database("sqlite:///data/app.db")
|
|
6
6
|
db = Database("postgresql://user:pass@host:5432/dbname")
|
|
7
7
|
db = Database() # Reads DATABASE_URL from environment
|
|
8
|
+
|
|
9
|
+
Connection pooling:
|
|
10
|
+
db = Database("sqlite:///data/app.db", pool=4) # 4 connections, round-robin
|
|
8
11
|
"""
|
|
9
12
|
import hashlib
|
|
10
13
|
import os
|
|
@@ -14,6 +17,73 @@ from urllib.parse import urlparse
|
|
|
14
17
|
from tina4_python.database.adapter import DatabaseAdapter, DatabaseResult
|
|
15
18
|
|
|
16
19
|
|
|
20
|
+
class ConnectionPool:
|
|
21
|
+
"""Thread-safe connection pool using round-robin rotation.
|
|
22
|
+
|
|
23
|
+
When pool_size > 0, maintains multiple adapter instances and rotates
|
|
24
|
+
through them for each operation. Connections are created lazily on
|
|
25
|
+
first use.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
pool = ConnectionPool(pool_size=4, factory=create_adapter,
|
|
29
|
+
connect_args=("path", {"username": "u", "password": "p"}))
|
|
30
|
+
adapter = pool.checkout()
|
|
31
|
+
try:
|
|
32
|
+
result = adapter.fetch(sql, params, limit, offset)
|
|
33
|
+
finally:
|
|
34
|
+
pool.checkin(adapter)
|
|
35
|
+
pool.close_all()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, pool_size: int, factory: callable, connect_path: str,
|
|
39
|
+
username: str = "", password: str = ""):
|
|
40
|
+
self._pool_size = pool_size
|
|
41
|
+
self._factory = factory
|
|
42
|
+
self._connect_path = connect_path
|
|
43
|
+
self._username = username
|
|
44
|
+
self._password = password
|
|
45
|
+
self._adapters: list[DatabaseAdapter | None] = [None] * pool_size
|
|
46
|
+
self._index = 0
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
|
|
49
|
+
def _ensure_adapter(self, idx: int) -> DatabaseAdapter:
|
|
50
|
+
"""Lazily create an adapter at the given index."""
|
|
51
|
+
if self._adapters[idx] is None:
|
|
52
|
+
adapter = self._factory()
|
|
53
|
+
adapter.connect(self._connect_path, username=self._username, password=self._password)
|
|
54
|
+
self._adapters[idx] = adapter
|
|
55
|
+
return self._adapters[idx]
|
|
56
|
+
|
|
57
|
+
def checkout(self) -> DatabaseAdapter:
|
|
58
|
+
"""Get the next adapter via round-robin. Thread-safe."""
|
|
59
|
+
with self._lock:
|
|
60
|
+
idx = self._index
|
|
61
|
+
self._index = (self._index + 1) % self._pool_size
|
|
62
|
+
return self._ensure_adapter(idx)
|
|
63
|
+
|
|
64
|
+
def checkin(self, adapter: DatabaseAdapter) -> None:
|
|
65
|
+
"""Return an adapter to the pool. Currently a no-op for round-robin."""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def close_all(self) -> None:
|
|
69
|
+
"""Close all active connections in the pool."""
|
|
70
|
+
with self._lock:
|
|
71
|
+
for i, adapter in enumerate(self._adapters):
|
|
72
|
+
if adapter is not None:
|
|
73
|
+
adapter.close()
|
|
74
|
+
self._adapters[i] = None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def size(self) -> int:
|
|
78
|
+
return self._pool_size
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def active_count(self) -> int:
|
|
82
|
+
"""Number of connections that have been created."""
|
|
83
|
+
with self._lock:
|
|
84
|
+
return sum(1 for a in self._adapters if a is not None)
|
|
85
|
+
|
|
86
|
+
|
|
17
87
|
# Driver registry — maps URL scheme to adapter class
|
|
18
88
|
_DRIVERS: dict[str, type] = {}
|
|
19
89
|
|
|
@@ -65,9 +135,23 @@ class Database:
|
|
|
65
135
|
# Priority: constructor params > env vars > empty
|
|
66
136
|
self.username = username or os.environ.get("DATABASE_USERNAME", "")
|
|
67
137
|
self.password = password or os.environ.get("DATABASE_PASSWORD", "")
|
|
68
|
-
self.pool_size = pool #
|
|
69
|
-
|
|
70
|
-
|
|
138
|
+
self.pool_size = pool # 0 = single connection, N>0 = N pooled connections
|
|
139
|
+
|
|
140
|
+
if self.pool_size > 0:
|
|
141
|
+
# Pooled mode — create a ConnectionPool with lazy adapter creation
|
|
142
|
+
self._pool = ConnectionPool(
|
|
143
|
+
pool_size=self.pool_size,
|
|
144
|
+
factory=self._create_adapter,
|
|
145
|
+
connect_path=self._connection_path(),
|
|
146
|
+
username=self.username,
|
|
147
|
+
password=self.password,
|
|
148
|
+
)
|
|
149
|
+
self._adapter: DatabaseAdapter | None = None
|
|
150
|
+
else:
|
|
151
|
+
# Single-connection mode — current behavior
|
|
152
|
+
self._pool: ConnectionPool | None = None
|
|
153
|
+
self._adapter: DatabaseAdapter = self._create_adapter()
|
|
154
|
+
self._adapter.connect(self._connection_path(), username=self.username, password=self.password)
|
|
71
155
|
|
|
72
156
|
# Query cache — off by default, opt-in via TINA4_DB_CACHE=true
|
|
73
157
|
from tina4_python.dotenv import is_truthy
|
|
@@ -166,20 +250,34 @@ class Database:
|
|
|
166
250
|
self._cache_hits = 0
|
|
167
251
|
self._cache_misses = 0
|
|
168
252
|
|
|
253
|
+
# ── Pool-aware adapter access ─────────────────────────────
|
|
254
|
+
|
|
255
|
+
def _get_adapter(self) -> DatabaseAdapter:
|
|
256
|
+
"""Get an adapter — from pool (round-robin) or single connection."""
|
|
257
|
+
if self._pool is not None:
|
|
258
|
+
return self._pool.checkout()
|
|
259
|
+
return self._adapter
|
|
260
|
+
|
|
169
261
|
# ── Delegate to adapter — with cache integration ─────────
|
|
170
262
|
|
|
171
263
|
def close(self):
|
|
172
|
-
|
|
264
|
+
"""Close all connections (pool or single)."""
|
|
265
|
+
if self._pool is not None:
|
|
266
|
+
self._pool.close_all()
|
|
267
|
+
elif self._adapter is not None:
|
|
268
|
+
self._adapter.close()
|
|
173
269
|
|
|
174
270
|
def execute(self, sql: str, params: list = None) -> DatabaseResult:
|
|
175
271
|
if self._cache_enabled:
|
|
176
272
|
self._cache_invalidate()
|
|
177
|
-
|
|
273
|
+
adapter = self._get_adapter()
|
|
274
|
+
return adapter.execute(sql, params)
|
|
178
275
|
|
|
179
276
|
def execute_many(self, sql: str, params_list: list[list] = None) -> DatabaseResult:
|
|
180
277
|
if self._cache_enabled:
|
|
181
278
|
self._cache_invalidate()
|
|
182
|
-
|
|
279
|
+
adapter = self._get_adapter()
|
|
280
|
+
return adapter.execute_many(sql, params_list)
|
|
183
281
|
|
|
184
282
|
def fetch(self, sql: str, params: list = None,
|
|
185
283
|
limit: int = 20, offset: int = 0) -> DatabaseResult:
|
|
@@ -191,12 +289,14 @@ class Database:
|
|
|
191
289
|
with self._cache_lock:
|
|
192
290
|
self._cache_hits += 1
|
|
193
291
|
return cached
|
|
194
|
-
|
|
292
|
+
adapter = self._get_adapter()
|
|
293
|
+
result = adapter.fetch(sql, params, limit, offset)
|
|
195
294
|
self._cache_set(key, result)
|
|
196
295
|
with self._cache_lock:
|
|
197
296
|
self._cache_misses += 1
|
|
198
297
|
return result
|
|
199
|
-
|
|
298
|
+
adapter = self._get_adapter()
|
|
299
|
+
return adapter.fetch(sql, params, limit, offset)
|
|
200
300
|
|
|
201
301
|
def fetch_one(self, sql: str, params: list = None) -> dict | None:
|
|
202
302
|
if self._cache_enabled:
|
|
@@ -206,59 +306,79 @@ class Database:
|
|
|
206
306
|
with self._cache_lock:
|
|
207
307
|
self._cache_hits += 1
|
|
208
308
|
return cached
|
|
209
|
-
|
|
309
|
+
adapter = self._get_adapter()
|
|
310
|
+
result = adapter.fetch_one(sql, params)
|
|
210
311
|
self._cache_set(key, result)
|
|
211
312
|
with self._cache_lock:
|
|
212
313
|
self._cache_misses += 1
|
|
213
314
|
return result
|
|
214
|
-
|
|
315
|
+
adapter = self._get_adapter()
|
|
316
|
+
return adapter.fetch_one(sql, params)
|
|
215
317
|
|
|
216
318
|
def insert(self, table: str, data: dict | list) -> DatabaseResult:
|
|
217
319
|
if self._cache_enabled:
|
|
218
320
|
self._cache_invalidate()
|
|
219
|
-
|
|
321
|
+
adapter = self._get_adapter()
|
|
322
|
+
return adapter.insert(table, data)
|
|
220
323
|
|
|
221
324
|
def update(self, table: str, data: dict,
|
|
222
325
|
filter_sql: str = "", params: list = None) -> DatabaseResult:
|
|
223
326
|
if self._cache_enabled:
|
|
224
327
|
self._cache_invalidate()
|
|
225
|
-
|
|
328
|
+
adapter = self._get_adapter()
|
|
329
|
+
return adapter.update(table, data, filter_sql, params)
|
|
226
330
|
|
|
227
331
|
def delete(self, table: str,
|
|
228
332
|
filter_sql: str | dict | list = "", params: list = None) -> DatabaseResult:
|
|
229
333
|
if self._cache_enabled:
|
|
230
334
|
self._cache_invalidate()
|
|
231
|
-
|
|
335
|
+
adapter = self._get_adapter()
|
|
336
|
+
return adapter.delete(table, filter_sql, params)
|
|
232
337
|
|
|
233
338
|
def start_transaction(self):
|
|
234
|
-
self.
|
|
339
|
+
adapter = self._get_adapter()
|
|
340
|
+
adapter.start_transaction()
|
|
235
341
|
|
|
236
342
|
def commit(self):
|
|
237
|
-
self.
|
|
343
|
+
adapter = self._get_adapter()
|
|
344
|
+
adapter.commit()
|
|
238
345
|
|
|
239
346
|
def rollback(self):
|
|
240
|
-
self.
|
|
347
|
+
adapter = self._get_adapter()
|
|
348
|
+
adapter.rollback()
|
|
241
349
|
|
|
242
350
|
def table_exists(self, name: str) -> bool:
|
|
243
|
-
|
|
351
|
+
adapter = self._get_adapter()
|
|
352
|
+
return adapter.table_exists(name)
|
|
244
353
|
|
|
245
354
|
def get_tables(self) -> list[str]:
|
|
246
|
-
|
|
355
|
+
adapter = self._get_adapter()
|
|
356
|
+
return adapter.get_tables()
|
|
247
357
|
|
|
248
358
|
def get_columns(self, table: str) -> list[dict]:
|
|
249
|
-
|
|
359
|
+
adapter = self._get_adapter()
|
|
360
|
+
return adapter.get_columns(table)
|
|
250
361
|
|
|
251
362
|
def get_database_type(self) -> str:
|
|
252
|
-
|
|
363
|
+
adapter = self._get_adapter()
|
|
364
|
+
return adapter.get_database_type()
|
|
253
365
|
|
|
254
366
|
@property
|
|
255
367
|
def autocommit(self) -> bool:
|
|
256
368
|
"""Whether writes auto-commit. Off by default, set TINA4_AUTOCOMMIT=true to enable."""
|
|
257
|
-
|
|
369
|
+
adapter = self._get_adapter()
|
|
370
|
+
return adapter.autocommit
|
|
258
371
|
|
|
259
372
|
@autocommit.setter
|
|
260
373
|
def autocommit(self, value: bool):
|
|
261
|
-
self.
|
|
374
|
+
if self._pool is not None:
|
|
375
|
+
# Set autocommit on all active pool connections
|
|
376
|
+
with self._pool._lock:
|
|
377
|
+
for a in self._pool._adapters:
|
|
378
|
+
if a is not None:
|
|
379
|
+
a.autocommit = value
|
|
380
|
+
elif self._adapter is not None:
|
|
381
|
+
self._adapter.autocommit = value
|
|
262
382
|
|
|
263
383
|
def register_function(self, name: str, num_params: int, func: callable, deterministic: bool = True):
|
|
264
384
|
"""Register a custom SQL function (SQLite only).
|
|
@@ -267,14 +387,23 @@ class Database:
|
|
|
267
387
|
db.register_function("double", 1, lambda x: x * 2)
|
|
268
388
|
db.fetch_one("SELECT double(5) as result") # {"result": 10}
|
|
269
389
|
"""
|
|
270
|
-
|
|
271
|
-
|
|
390
|
+
adapter = self._get_adapter()
|
|
391
|
+
if hasattr(adapter, "register_function"):
|
|
392
|
+
adapter.register_function(name, num_params, func, deterministic)
|
|
272
393
|
else:
|
|
273
394
|
raise NotImplementedError(
|
|
274
|
-
f"{
|
|
395
|
+
f"{adapter.get_database_type()} does not support custom function registration"
|
|
275
396
|
)
|
|
276
397
|
|
|
277
398
|
@property
|
|
278
399
|
def adapter(self) -> DatabaseAdapter:
|
|
279
|
-
"""Access the underlying adapter directly (for driver-specific ops).
|
|
280
|
-
|
|
400
|
+
"""Access the underlying adapter directly (for driver-specific ops).
|
|
401
|
+
|
|
402
|
+
With pooling enabled, returns the next adapter from the pool via round-robin.
|
|
403
|
+
"""
|
|
404
|
+
return self._get_adapter()
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def pool(self) -> ConnectionPool | None:
|
|
408
|
+
"""Access the connection pool (None if pooling is disabled)."""
|
|
409
|
+
return self._pool
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Tina4 Test Client — Test routes without starting a server.
|
|
2
|
+
"""
|
|
3
|
+
Simple test client that creates mock requests, matches routes,
|
|
4
|
+
executes handlers, and returns a TestResponse.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from tina4_python.test_client import TestClient
|
|
9
|
+
|
|
10
|
+
client = TestClient()
|
|
11
|
+
|
|
12
|
+
response = client.get("/api/users")
|
|
13
|
+
assert response.status == 200
|
|
14
|
+
assert response.json()["users"] is not None
|
|
15
|
+
|
|
16
|
+
response = client.post("/api/users", json={"name": "Alice"})
|
|
17
|
+
assert response.status == 201
|
|
18
|
+
|
|
19
|
+
response = client.get("/api/users/1", headers={"Authorization": "Bearer token123"})
|
|
20
|
+
"""
|
|
21
|
+
import json as _json
|
|
22
|
+
import asyncio
|
|
23
|
+
from tina4_python.core.request import Request
|
|
24
|
+
from tina4_python.core.response import Response
|
|
25
|
+
from tina4_python.core.router import Router
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestResponse:
|
|
29
|
+
"""Wraps a Response object with a clean test-friendly API."""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("status", "body", "headers", "content_type")
|
|
32
|
+
|
|
33
|
+
def __init__(self, response: Response):
|
|
34
|
+
self.status: int = response.status_code
|
|
35
|
+
self.body: bytes = response.content
|
|
36
|
+
self.content_type: str = response.content_type
|
|
37
|
+
self.headers: dict = {}
|
|
38
|
+
for name, value in response._headers:
|
|
39
|
+
self.headers[name.lower()] = value
|
|
40
|
+
|
|
41
|
+
def json(self) -> dict | list | None:
|
|
42
|
+
"""Parse body as JSON."""
|
|
43
|
+
if not self.body:
|
|
44
|
+
return None
|
|
45
|
+
return _json.loads(self.body.decode())
|
|
46
|
+
|
|
47
|
+
def text(self) -> str:
|
|
48
|
+
"""Return body as a string."""
|
|
49
|
+
return self.body.decode(errors="replace")
|
|
50
|
+
|
|
51
|
+
def __repr__(self) -> str:
|
|
52
|
+
return f"<TestResponse status={self.status} content_type={self.content_type!r}>"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestClient:
|
|
56
|
+
"""Test routes directly without starting a server.
|
|
57
|
+
|
|
58
|
+
Creates a mock Request, finds the matching route via Router.match(),
|
|
59
|
+
executes the handler, and returns a TestResponse.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def get(self, path: str, *, headers: dict | None = None) -> TestResponse:
|
|
63
|
+
"""Send a GET request to the given path."""
|
|
64
|
+
return self._request("GET", path, headers=headers)
|
|
65
|
+
|
|
66
|
+
def post(self, path: str, *, json: dict | list | None = None,
|
|
67
|
+
body: str | bytes | None = None, headers: dict | None = None) -> TestResponse:
|
|
68
|
+
"""Send a POST request to the given path."""
|
|
69
|
+
return self._request("POST", path, json=json, body=body, headers=headers)
|
|
70
|
+
|
|
71
|
+
def put(self, path: str, *, json: dict | list | None = None,
|
|
72
|
+
body: str | bytes | None = None, headers: dict | None = None) -> TestResponse:
|
|
73
|
+
"""Send a PUT request to the given path."""
|
|
74
|
+
return self._request("PUT", path, json=json, body=body, headers=headers)
|
|
75
|
+
|
|
76
|
+
def patch(self, path: str, *, json: dict | list | None = None,
|
|
77
|
+
body: str | bytes | None = None, headers: dict | None = None) -> TestResponse:
|
|
78
|
+
"""Send a PATCH request to the given path."""
|
|
79
|
+
return self._request("PATCH", path, json=json, body=body, headers=headers)
|
|
80
|
+
|
|
81
|
+
def delete(self, path: str, *, headers: dict | None = None) -> TestResponse:
|
|
82
|
+
"""Send a DELETE request to the given path."""
|
|
83
|
+
return self._request("DELETE", path, headers=headers)
|
|
84
|
+
|
|
85
|
+
def _request(self, method: str, path: str, *,
|
|
86
|
+
json: dict | list | None = None,
|
|
87
|
+
body: str | bytes | None = None,
|
|
88
|
+
headers: dict | None = None) -> TestResponse:
|
|
89
|
+
"""Build a mock request, match the route, execute the handler."""
|
|
90
|
+
|
|
91
|
+
# Build raw body bytes
|
|
92
|
+
raw_body = b""
|
|
93
|
+
content_type = ""
|
|
94
|
+
|
|
95
|
+
if json is not None:
|
|
96
|
+
raw_body = _json.dumps(json).encode()
|
|
97
|
+
content_type = "application/json"
|
|
98
|
+
elif body is not None:
|
|
99
|
+
if isinstance(body, str):
|
|
100
|
+
raw_body = body.encode()
|
|
101
|
+
else:
|
|
102
|
+
raw_body = body
|
|
103
|
+
|
|
104
|
+
# Build ASGI-style headers
|
|
105
|
+
header_list: list[tuple[bytes, bytes]] = []
|
|
106
|
+
if headers:
|
|
107
|
+
for k, v in headers.items():
|
|
108
|
+
header_list.append((k.lower().encode(), v.encode()))
|
|
109
|
+
|
|
110
|
+
if content_type and not any(h[0] == b"content-type" for h in header_list):
|
|
111
|
+
header_list.append((b"content-type", content_type.encode()))
|
|
112
|
+
|
|
113
|
+
if raw_body and not any(h[0] == b"content-length" for h in header_list):
|
|
114
|
+
header_list.append((b"content-length", str(len(raw_body)).encode()))
|
|
115
|
+
|
|
116
|
+
# Split path and query string
|
|
117
|
+
query_string = ""
|
|
118
|
+
clean_path = path
|
|
119
|
+
if "?" in path:
|
|
120
|
+
clean_path, query_string = path.split("?", 1)
|
|
121
|
+
|
|
122
|
+
# Build ASGI scope
|
|
123
|
+
scope = {
|
|
124
|
+
"type": "http",
|
|
125
|
+
"method": method,
|
|
126
|
+
"path": clean_path,
|
|
127
|
+
"query_string": query_string.encode(),
|
|
128
|
+
"headers": header_list,
|
|
129
|
+
"client": ("127.0.0.1", 0),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Create Request from scope
|
|
133
|
+
request = Request.from_scope(scope, raw_body)
|
|
134
|
+
|
|
135
|
+
# Match route
|
|
136
|
+
route, params = Router.match(method, clean_path)
|
|
137
|
+
|
|
138
|
+
if route is None:
|
|
139
|
+
# No route found — return 404
|
|
140
|
+
resp = Response()
|
|
141
|
+
resp.status_code = 404
|
|
142
|
+
resp.content = b'{"error":"Not found"}'
|
|
143
|
+
resp.content_type = "application/json"
|
|
144
|
+
return TestResponse(resp)
|
|
145
|
+
|
|
146
|
+
# Inject route params
|
|
147
|
+
request._route_params = params
|
|
148
|
+
request.merge_route_params()
|
|
149
|
+
|
|
150
|
+
# Create response callable
|
|
151
|
+
response = Response()
|
|
152
|
+
|
|
153
|
+
# Execute handler (sync or async)
|
|
154
|
+
handler = route["handler"]
|
|
155
|
+
result = handler(request, response)
|
|
156
|
+
|
|
157
|
+
# If handler is async, run it in an event loop
|
|
158
|
+
if asyncio.iscoroutine(result):
|
|
159
|
+
try:
|
|
160
|
+
loop = asyncio.get_running_loop()
|
|
161
|
+
except RuntimeError:
|
|
162
|
+
loop = None
|
|
163
|
+
|
|
164
|
+
if loop and loop.is_running():
|
|
165
|
+
# Already in an async context — create a task
|
|
166
|
+
import concurrent.futures
|
|
167
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
168
|
+
result = pool.submit(asyncio.run, result).result()
|
|
169
|
+
else:
|
|
170
|
+
result = asyncio.run(result)
|
|
171
|
+
|
|
172
|
+
# The handler should have returned the response via response(...)
|
|
173
|
+
# If the handler returned a Response, use that
|
|
174
|
+
if isinstance(result, Response):
|
|
175
|
+
return TestResponse(result)
|
|
176
|
+
|
|
177
|
+
return TestResponse(response)
|