tina4-python 3.12.2__tar.gz → 3.12.4__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.12.2 → tina4_python-3.12.4}/PKG-INFO +1 -1
- {tina4_python-3.12.2 → tina4_python-3.12.4}/pyproject.toml +1 -1
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/__init__.py +1 -1
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/core/server.py +31 -7
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/database/connection.py +9 -1
- tina4_python-3.12.4/tina4_python/debug/__init__.py +406 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/dotenv/__init__.py +6 -2
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/frond/engine.py +20 -5
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/graphql/__init__.py +37 -1
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/mcp/__init__.py +39 -1
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/messenger/__init__.py +33 -5
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/session/__init__.py +27 -3
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/swagger/__init__.py +47 -7
- tina4_python-3.12.2/tina4_python/debug/__init__.py +0 -228
- {tina4_python-3.12.2 → tina4_python-3.12.4}/.gitignore +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/README.md +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/Testing.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/core/events.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/core/request.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/core/response.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/core/router.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/docs.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.12.2 → tina4_python-3.12.4}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -150,8 +150,14 @@ async def _health_handler(request: Request, response: Response) -> Response:
|
|
|
150
150
|
return response.status(code).json(health)
|
|
151
151
|
|
|
152
152
|
|
|
153
|
-
# Register health check
|
|
154
|
-
|
|
153
|
+
# Register health check.
|
|
154
|
+
# TINA4_HEALTH_PATH overrides the URL path. We also keep /health registered
|
|
155
|
+
# under the env path; if the env path differs we register both so existing
|
|
156
|
+
# probes don't break. Default "/__health" matches PHP/Ruby/Node parity.
|
|
157
|
+
_HEALTH_PATH = os.environ.get("TINA4_HEALTH_PATH", "/__health")
|
|
158
|
+
Router.add("GET", _HEALTH_PATH, _health_handler)
|
|
159
|
+
if _HEALTH_PATH != "/health":
|
|
160
|
+
Router.add("GET", "/health", _health_handler)
|
|
155
161
|
|
|
156
162
|
|
|
157
163
|
def _render_error_page(status_code: int, path: str, request_id: str, error_message: str = "") -> str | None:
|
|
@@ -1289,6 +1295,17 @@ async def handle(request: Request) -> Response:
|
|
|
1289
1295
|
from tina4_python.dotenv import is_truthy
|
|
1290
1296
|
_is_dev = is_truthy(os.environ.get("TINA4_DEBUG", ""))
|
|
1291
1297
|
|
|
1298
|
+
# Trailing-slash redirect — when TINA4_TRAILING_SLASH_REDIRECT=true and a
|
|
1299
|
+
# request arrives at `/foo/`, return 301 to `/foo`. Skip the root `/` so
|
|
1300
|
+
# the homepage still works. Cross-framework parity v3.12.4.
|
|
1301
|
+
if (
|
|
1302
|
+
is_truthy(os.environ.get("TINA4_TRAILING_SLASH_REDIRECT", ""))
|
|
1303
|
+
and len(request.path) > 1
|
|
1304
|
+
and request.path.endswith("/")
|
|
1305
|
+
):
|
|
1306
|
+
canonical = request.path.rstrip("/") or "/"
|
|
1307
|
+
return response.status(301).header("location", canonical)
|
|
1308
|
+
|
|
1292
1309
|
# Dev admin — also catches /ai/api/chat (SPA's ollama proxy) and the
|
|
1293
1310
|
# bare /ai /vision /embed /image /rag service-health probes that
|
|
1294
1311
|
# drive the "SERVICES ●●●●●" dots in the dev-admin UI.
|
|
@@ -1629,11 +1646,14 @@ def resolve_config(cli_host: str | None = None, cli_port: int | None = None) ->
|
|
|
1629
1646
|
default_host = "0.0.0.0"
|
|
1630
1647
|
default_port = 7146
|
|
1631
1648
|
|
|
1632
|
-
# Host: CLI flag > HOST env > default
|
|
1649
|
+
# Host: CLI flag > TINA4_HOST env > HOST env > default.
|
|
1650
|
+
# TINA4_HOST takes precedence over the legacy plain HOST so a stray
|
|
1651
|
+
# OS-level HOST (common on shared CI runners) can't silently override
|
|
1652
|
+
# the framework's bind address. See cross-framework v3.12.4 plan.
|
|
1633
1653
|
if cli_host is not None:
|
|
1634
1654
|
host = cli_host
|
|
1635
1655
|
else:
|
|
1636
|
-
host = os.environ.get("HOST", default_host)
|
|
1656
|
+
host = os.environ.get("TINA4_HOST") or os.environ.get("HOST", default_host)
|
|
1637
1657
|
|
|
1638
1658
|
# Port: CLI flag > PORT env > default
|
|
1639
1659
|
if cli_port is not None:
|
|
@@ -1731,7 +1751,7 @@ def _check_legacy_env_vars() -> None:
|
|
|
1731
1751
|
new = _LEGACY_ENV_VARS[old]
|
|
1732
1752
|
msg.append(f" {old:<28} → {new}")
|
|
1733
1753
|
msg.extend(["",
|
|
1734
|
-
"Run `tina4 env
|
|
1754
|
+
"Run `tina4 env --migrate` to rewrite your .env automatically,",
|
|
1735
1755
|
"or rename manually. See https://tina4.com/release/3.12.0",
|
|
1736
1756
|
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
1737
1757
|
"─" * 72, ""])
|
|
@@ -1869,8 +1889,12 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
1869
1889
|
_no_ai_port = os.environ.get("TINA4_NO_AI_PORT", "").lower() in ("true", "1", "yes")
|
|
1870
1890
|
_ai_port = (port + 1000) if (is_debug and not _no_ai_port) else None
|
|
1871
1891
|
|
|
1872
|
-
# Banner — printed directly to stdout, not through the logger
|
|
1873
|
-
|
|
1892
|
+
# Banner — printed directly to stdout, not through the logger.
|
|
1893
|
+
# TINA4_SUPPRESS=true silences the startup banner (useful in CI / Docker
|
|
1894
|
+
# logs where the ASCII art is just noise). Cross-framework parity v3.12.4.
|
|
1895
|
+
from tina4_python.dotenv import is_truthy as _is_truthy
|
|
1896
|
+
if not _is_truthy(os.environ.get("TINA4_SUPPRESS", "")):
|
|
1897
|
+
_print_banner(host, port, server_name, ai_port=_ai_port)
|
|
1874
1898
|
|
|
1875
1899
|
display = "localhost" if host in ("0.0.0.0", "::") else host
|
|
1876
1900
|
Log.info(f"Server started http://{display}:{port} ({server_name})")
|
|
@@ -144,7 +144,15 @@ class Database:
|
|
|
144
144
|
# Priority: constructor params > env vars > empty
|
|
145
145
|
self.username = username or os.environ.get("TINA4_DATABASE_USERNAME", "")
|
|
146
146
|
self.password = password or os.environ.get("TINA4_DATABASE_PASSWORD", "")
|
|
147
|
-
|
|
147
|
+
# Pool size — caller's explicit value wins; otherwise honour
|
|
148
|
+
# TINA4_DB_POOL so deployments can flip pooling on without code
|
|
149
|
+
# changes. 0 = single connection, N>0 = N pooled connections.
|
|
150
|
+
if pool == 0:
|
|
151
|
+
try:
|
|
152
|
+
pool = int(os.environ.get("TINA4_DB_POOL", "0"))
|
|
153
|
+
except (TypeError, ValueError):
|
|
154
|
+
pool = 0
|
|
155
|
+
self.pool_size = pool
|
|
148
156
|
self._connect_kwargs = kwargs # Extra kwargs passed through to adapter.connect()
|
|
149
157
|
self.last_error = None # Last execute() error message
|
|
150
158
|
self._last_id = None # Last insert ID from execute/insert
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
# Tina4 Debug — Structured logging with rotation.
|
|
2
|
+
"""
|
|
3
|
+
Zero-dependency structured logger.
|
|
4
|
+
|
|
5
|
+
from tina4_python.debug import Log
|
|
6
|
+
|
|
7
|
+
Log.info("Request completed", method="GET", path="/api/users", duration_ms=45)
|
|
8
|
+
Log.error("Database failed", error="connection refused")
|
|
9
|
+
|
|
10
|
+
Production: JSON lines → logs/tina4.log (with rotation)
|
|
11
|
+
Development: Human-readable → stdout + logs/tina4.log
|
|
12
|
+
|
|
13
|
+
Environment variables (all optional — defaults match v2 behaviour):
|
|
14
|
+
TINA4_LOG_FILE Log filename. Empty string = stdout only.
|
|
15
|
+
TINA4_LOG_DIR Directory for log files (default: "logs"). Joined
|
|
16
|
+
with TINA4_LOG_FILE unless that is absolute.
|
|
17
|
+
TINA4_LOG_FORMAT "text" (default) or "json".
|
|
18
|
+
TINA4_LOG_OUTPUT "stdout" (default), "file", or "both".
|
|
19
|
+
TINA4_LOG_ROTATE_SIZE Bytes per file before rotation. Default 10 MB.
|
|
20
|
+
Set to 0 to disable rotation.
|
|
21
|
+
TINA4_LOG_ROTATE_KEEP Number of rotated files to keep (default: 5).
|
|
22
|
+
TINA4_LOG_CRITICAL When truthy, Log.critical(...) is accepted and
|
|
23
|
+
mapped to error level. Default: false.
|
|
24
|
+
TINA4_LOG_MAX_SIZE [legacy] Megabytes per file. Used only when
|
|
25
|
+
TINA4_LOG_ROTATE_SIZE is unset (back-compat).
|
|
26
|
+
TINA4_LOG_KEEP [legacy] Alias for TINA4_LOG_ROTATE_KEEP.
|
|
27
|
+
"""
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import threading
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from logging.handlers import RotatingFileHandler
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Request ID context (set per-request by middleware)
|
|
39
|
+
_request_id_var = threading.local()
|
|
40
|
+
|
|
41
|
+
# Regex to strip ANSI escape codes
|
|
42
|
+
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def set_request_id(request_id: str):
|
|
46
|
+
"""Set the current request ID (called by middleware)."""
|
|
47
|
+
_request_id_var.id = request_id
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_request_id() -> str | None:
|
|
51
|
+
"""Get the current request ID."""
|
|
52
|
+
return getattr(_request_id_var, "id", None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _strip_ansi(text: str) -> str:
|
|
56
|
+
"""Remove ANSI escape codes from text."""
|
|
57
|
+
return _ANSI_RE.sub("", text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_truthy(val) -> bool:
|
|
61
|
+
return str(val or "").strip().lower() in ("true", "1", "yes", "on")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _LogWriter:
|
|
65
|
+
"""File writer with numbered rotation support — used as the default
|
|
66
|
+
fallback when TINA4_LOG_FILE is unset (legacy "logs/tina4.log" path).
|
|
67
|
+
|
|
68
|
+
Rotation scheme:
|
|
69
|
+
tina4.log → tina4.log.1 → tina4.log.2 → ... → tina4.log.{keep}
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, log_dir: str = "logs", filename: str = "tina4.log",
|
|
73
|
+
max_size_mb: int = 10, keep: int = 5):
|
|
74
|
+
self.log_dir = Path(log_dir)
|
|
75
|
+
self.filename = filename
|
|
76
|
+
self.max_size = max_size_mb * 1024 * 1024
|
|
77
|
+
self.keep = keep
|
|
78
|
+
self._lock = threading.Lock()
|
|
79
|
+
self._ensure_dir()
|
|
80
|
+
|
|
81
|
+
def _ensure_dir(self):
|
|
82
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
|
|
84
|
+
def _log_path(self) -> Path:
|
|
85
|
+
return self.log_dir / self.filename
|
|
86
|
+
|
|
87
|
+
def _rotate_if_needed(self):
|
|
88
|
+
log_path = self._log_path()
|
|
89
|
+
|
|
90
|
+
if not log_path.exists():
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
if log_path.stat().st_size < self.max_size:
|
|
95
|
+
return
|
|
96
|
+
except OSError:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# Delete the oldest rotated file if it exists
|
|
100
|
+
oldest = self.log_dir / f"{self.filename}.{self.keep}"
|
|
101
|
+
if oldest.exists():
|
|
102
|
+
try:
|
|
103
|
+
oldest.unlink()
|
|
104
|
+
except OSError:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
# Shift existing rotated files: .{n} → .{n+1}
|
|
108
|
+
for n in range(self.keep - 1, 0, -1):
|
|
109
|
+
src = self.log_dir / f"{self.filename}.{n}"
|
|
110
|
+
dst = self.log_dir / f"{self.filename}.{n + 1}"
|
|
111
|
+
if src.exists():
|
|
112
|
+
try:
|
|
113
|
+
src.rename(dst)
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# Rename current log to .1
|
|
118
|
+
try:
|
|
119
|
+
log_path.rename(self.log_dir / f"{self.filename}.1")
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def write(self, line: str):
|
|
124
|
+
"""Write a line to the log file, stripping ANSI codes. Rotates if needed."""
|
|
125
|
+
clean_line = _strip_ansi(line)
|
|
126
|
+
with self._lock:
|
|
127
|
+
self._rotate_if_needed()
|
|
128
|
+
log_path = self._log_path()
|
|
129
|
+
try:
|
|
130
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
131
|
+
f.write(clean_line + "\n")
|
|
132
|
+
except OSError:
|
|
133
|
+
pass # Can't write logs — don't crash the app
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _StdlibFileWriter:
|
|
137
|
+
"""Adapter around stdlib logging.handlers.RotatingFileHandler / FileHandler.
|
|
138
|
+
|
|
139
|
+
Used when TINA4_LOG_FILE is set explicitly. Keeps the same .write(line)
|
|
140
|
+
interface as _LogWriter so the Log class doesn't care which backend
|
|
141
|
+
produced the file output.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(self, path: Path, max_bytes: int, backup_count: int):
|
|
145
|
+
# Resolve dir up-front so callers can fail fast on a bad path.
|
|
146
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
self.path = path
|
|
148
|
+
if max_bytes > 0:
|
|
149
|
+
self._handler = RotatingFileHandler(
|
|
150
|
+
str(path),
|
|
151
|
+
maxBytes=max_bytes,
|
|
152
|
+
backupCount=backup_count,
|
|
153
|
+
encoding="utf-8",
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
# 0 disables rotation — use a plain FileHandler so the file just
|
|
157
|
+
# grows. Matches the documented contract for TINA4_LOG_ROTATE_SIZE=0.
|
|
158
|
+
self._handler = logging.FileHandler(str(path), encoding="utf-8")
|
|
159
|
+
# Bare formatter — Log already builds the full line itself.
|
|
160
|
+
self._handler.setFormatter(logging.Formatter("%(message)s"))
|
|
161
|
+
self._lock = threading.Lock()
|
|
162
|
+
|
|
163
|
+
def write(self, line: str):
|
|
164
|
+
clean = _strip_ansi(line)
|
|
165
|
+
record = logging.LogRecord(
|
|
166
|
+
name="tina4",
|
|
167
|
+
level=logging.INFO,
|
|
168
|
+
pathname="",
|
|
169
|
+
lineno=0,
|
|
170
|
+
msg=clean,
|
|
171
|
+
args=(),
|
|
172
|
+
exc_info=None,
|
|
173
|
+
)
|
|
174
|
+
with self._lock:
|
|
175
|
+
try:
|
|
176
|
+
self._handler.emit(record)
|
|
177
|
+
self._handler.flush()
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def close(self):
|
|
182
|
+
try:
|
|
183
|
+
self._handler.close()
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Log:
|
|
189
|
+
"""Structured logger with request ID tracking and log rotation."""
|
|
190
|
+
|
|
191
|
+
_writer: _LogWriter | _StdlibFileWriter | None = None
|
|
192
|
+
_error_writer: _LogWriter | None = None
|
|
193
|
+
_level: str = "info"
|
|
194
|
+
_is_production: bool = False
|
|
195
|
+
_initialized: bool = False
|
|
196
|
+
# Output toggles — driven by TINA4_LOG_OUTPUT.
|
|
197
|
+
_stdout_enabled: bool = True
|
|
198
|
+
_file_enabled: bool = True
|
|
199
|
+
# Format — "text" or "json". Independent of _is_production so users
|
|
200
|
+
# can opt into JSON in dev too (TINA4_LOG_FORMAT=json). Stored under
|
|
201
|
+
# _format_mode so it doesn't clash with the legacy _format() method
|
|
202
|
+
# name kept below for backward compatibility.
|
|
203
|
+
_format_mode: str = "text"
|
|
204
|
+
# Whether Log.critical() is accepted (TINA4_LOG_CRITICAL).
|
|
205
|
+
_critical_enabled: bool = False
|
|
206
|
+
|
|
207
|
+
LEVELS = {"debug": 0, "info": 1, "warning": 2, "error": 3}
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def configure(cls, log_dir: str = "logs", level: str = "info",
|
|
211
|
+
production: bool = False):
|
|
212
|
+
"""Configure the logger. Called once at startup.
|
|
213
|
+
|
|
214
|
+
Reads the new TINA4_LOG_* env vars (rotation size/keep, format,
|
|
215
|
+
output, file, dir, critical) so individual deployments can tune
|
|
216
|
+
the logger without code changes. Defaults preserve existing
|
|
217
|
+
behaviour: file output to logs/tina4.log + stdout, text format,
|
|
218
|
+
10 MB rotation, keep 5.
|
|
219
|
+
"""
|
|
220
|
+
cls._level = level.lower()
|
|
221
|
+
cls._is_production = production
|
|
222
|
+
|
|
223
|
+
# ── Output channels ──────────────────────────────────────
|
|
224
|
+
output = os.environ.get("TINA4_LOG_OUTPUT", "stdout").lower().strip()
|
|
225
|
+
if output == "file":
|
|
226
|
+
cls._stdout_enabled = False
|
|
227
|
+
cls._file_enabled = True
|
|
228
|
+
elif output == "both":
|
|
229
|
+
cls._stdout_enabled = True
|
|
230
|
+
cls._file_enabled = True
|
|
231
|
+
else:
|
|
232
|
+
# "stdout" (default) — but we still keep file output on for
|
|
233
|
+
# parity with v2 behaviour where logs/tina4.log is always
|
|
234
|
+
# written. Operators who want stdout-only can flip the new
|
|
235
|
+
# explicit "stdout-only" by setting TINA4_LOG_FILE="" AND
|
|
236
|
+
# TINA4_LOG_OUTPUT=stdout — handled below where the file
|
|
237
|
+
# path resolves to empty.
|
|
238
|
+
cls._stdout_enabled = True
|
|
239
|
+
cls._file_enabled = True
|
|
240
|
+
|
|
241
|
+
# ── Format ───────────────────────────────────────────────
|
|
242
|
+
fmt = os.environ.get("TINA4_LOG_FORMAT", "text").lower().strip()
|
|
243
|
+
cls._format_mode = "json" if fmt == "json" else "text"
|
|
244
|
+
|
|
245
|
+
# ── Critical level toggle ────────────────────────────────
|
|
246
|
+
cls._critical_enabled = _is_truthy(os.environ.get("TINA4_LOG_CRITICAL"))
|
|
247
|
+
|
|
248
|
+
# ── Rotation config ──────────────────────────────────────
|
|
249
|
+
# New-style: TINA4_LOG_ROTATE_SIZE in BYTES (0 = disabled).
|
|
250
|
+
# Legacy: TINA4_LOG_MAX_SIZE in MEGABYTES.
|
|
251
|
+
rotate_size_env = os.environ.get("TINA4_LOG_ROTATE_SIZE")
|
|
252
|
+
if rotate_size_env is not None:
|
|
253
|
+
try:
|
|
254
|
+
rotate_bytes = int(rotate_size_env)
|
|
255
|
+
except ValueError:
|
|
256
|
+
rotate_bytes = 10 * 1024 * 1024
|
|
257
|
+
else:
|
|
258
|
+
try:
|
|
259
|
+
rotate_bytes = int(os.environ.get("TINA4_LOG_MAX_SIZE", "10")) * 1024 * 1024
|
|
260
|
+
except ValueError:
|
|
261
|
+
rotate_bytes = 10 * 1024 * 1024
|
|
262
|
+
|
|
263
|
+
keep_env = os.environ.get("TINA4_LOG_ROTATE_KEEP", os.environ.get("TINA4_LOG_KEEP", "5"))
|
|
264
|
+
try:
|
|
265
|
+
keep = int(keep_env)
|
|
266
|
+
except ValueError:
|
|
267
|
+
keep = 5
|
|
268
|
+
|
|
269
|
+
# ── File path resolution ─────────────────────────────────
|
|
270
|
+
log_file = os.environ.get("TINA4_LOG_FILE", "")
|
|
271
|
+
log_dir_env = os.environ.get("TINA4_LOG_DIR", log_dir)
|
|
272
|
+
|
|
273
|
+
# Close any previous writer so reconfigure during tests doesn't
|
|
274
|
+
# leak file handles.
|
|
275
|
+
if isinstance(cls._writer, _StdlibFileWriter):
|
|
276
|
+
cls._writer.close()
|
|
277
|
+
cls._writer = None
|
|
278
|
+
|
|
279
|
+
if log_file:
|
|
280
|
+
# Explicit log file path. Honour absolute paths verbatim;
|
|
281
|
+
# otherwise join with TINA4_LOG_DIR per spec.
|
|
282
|
+
if os.path.isabs(log_file):
|
|
283
|
+
resolved = Path(log_file)
|
|
284
|
+
else:
|
|
285
|
+
resolved = Path(log_dir_env) / log_file
|
|
286
|
+
cls._writer = _StdlibFileWriter(resolved, rotate_bytes, keep)
|
|
287
|
+
elif cls._file_enabled:
|
|
288
|
+
# Default behaviour: keep the existing logs/tina4.log writer
|
|
289
|
+
# so v2 deployments don't need to change a thing.
|
|
290
|
+
mb = max(1, rotate_bytes // (1024 * 1024)) if rotate_bytes > 0 else 10
|
|
291
|
+
cls._writer = _LogWriter(log_dir_env, "tina4.log", mb, keep)
|
|
292
|
+
|
|
293
|
+
# Error mirror — only when no explicit TINA4_LOG_FILE is set.
|
|
294
|
+
# When the operator points at a custom file they almost certainly
|
|
295
|
+
# don't want a second sibling error.log appearing alongside it.
|
|
296
|
+
if not log_file and cls._file_enabled:
|
|
297
|
+
mb = max(1, rotate_bytes // (1024 * 1024)) if rotate_bytes > 0 else 10
|
|
298
|
+
cls._error_writer = _LogWriter(log_dir_env, "error.log", mb, keep)
|
|
299
|
+
else:
|
|
300
|
+
cls._error_writer = None
|
|
301
|
+
|
|
302
|
+
cls._initialized = True
|
|
303
|
+
|
|
304
|
+
@classmethod
|
|
305
|
+
def _should_log(cls, level: str) -> bool:
|
|
306
|
+
return cls.LEVELS.get(level, 0) >= cls.LEVELS.get(cls._level, 0)
|
|
307
|
+
|
|
308
|
+
# ANSI color codes for dev mode (matching PHP reference)
|
|
309
|
+
COLORS = {
|
|
310
|
+
"debug": "\033[36m", # Cyan
|
|
311
|
+
"info": "\033[32m", # Green
|
|
312
|
+
"warning": "\033[33m", # Yellow
|
|
313
|
+
"error": "\033[31m", # Red
|
|
314
|
+
}
|
|
315
|
+
RESET = "\033[0m"
|
|
316
|
+
|
|
317
|
+
@classmethod
|
|
318
|
+
def _timestamp(cls) -> str:
|
|
319
|
+
"""ISO 8601 UTC timestamp with milliseconds: YYYY-MM-DDTHH:MM:SS.mmmZ"""
|
|
320
|
+
now = datetime.now(timezone.utc)
|
|
321
|
+
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def _format_line(cls, level: str, message: str, **kwargs) -> str:
|
|
325
|
+
timestamp = cls._timestamp()
|
|
326
|
+
request_id = get_request_id()
|
|
327
|
+
|
|
328
|
+
# JSON format wins whenever the explicit env opt-in is set,
|
|
329
|
+
# regardless of production flag (so dev devs can ship JSON to
|
|
330
|
+
# `jq` if they prefer).
|
|
331
|
+
if cls._format_mode == "json" or cls._is_production:
|
|
332
|
+
entry = {
|
|
333
|
+
"timestamp": timestamp,
|
|
334
|
+
"level": level.upper(),
|
|
335
|
+
"message": message,
|
|
336
|
+
}
|
|
337
|
+
if request_id:
|
|
338
|
+
entry["request_id"] = request_id
|
|
339
|
+
if kwargs:
|
|
340
|
+
entry["context"] = {k: v for k, v in kwargs.items()}
|
|
341
|
+
return json.dumps(entry, default=str)
|
|
342
|
+
|
|
343
|
+
# Human-readable for development
|
|
344
|
+
level_str = level.upper().ljust(7)
|
|
345
|
+
parts = [timestamp, f"[{level_str}]"]
|
|
346
|
+
if request_id:
|
|
347
|
+
parts.append(f"[{request_id}]")
|
|
348
|
+
parts.append(message)
|
|
349
|
+
if kwargs:
|
|
350
|
+
parts.append(json.dumps(kwargs, default=str))
|
|
351
|
+
return " ".join(parts)
|
|
352
|
+
|
|
353
|
+
# Kept under the old name so any external code calling Log._format
|
|
354
|
+
# (e.g. tests) still works.
|
|
355
|
+
@classmethod
|
|
356
|
+
def _format(cls, level: str, message: str, **kwargs) -> str:
|
|
357
|
+
return cls._format_line(level, message, **kwargs)
|
|
358
|
+
|
|
359
|
+
@classmethod
|
|
360
|
+
def _log(cls, level: str, message: str, **kwargs):
|
|
361
|
+
# File always gets ALL levels (no filtering for file output)
|
|
362
|
+
line = cls._format_line(level, message, **kwargs)
|
|
363
|
+
|
|
364
|
+
# Console output respects TINA4_LOG_LEVEL and the stdout toggle
|
|
365
|
+
if cls._stdout_enabled and not cls._is_production and cls._should_log(level):
|
|
366
|
+
color = cls.COLORS.get(level, "")
|
|
367
|
+
print(f"{color}{line}{cls.RESET}")
|
|
368
|
+
|
|
369
|
+
# Always write ALL levels to the main file (raw log, no filtering)
|
|
370
|
+
if cls._writer:
|
|
371
|
+
cls._writer.write(line)
|
|
372
|
+
|
|
373
|
+
# Mirror WARNING and ERROR into the dedicated error log so
|
|
374
|
+
# `tail -f logs/error.log` gives just the stuff worth looking
|
|
375
|
+
# at, without wading through DEBUG / INFO noise. Parity with
|
|
376
|
+
# tina4-php's Log class.
|
|
377
|
+
if cls._error_writer and cls.LEVELS.get(level, 0) >= cls.LEVELS["warning"]:
|
|
378
|
+
cls._error_writer.write(line)
|
|
379
|
+
|
|
380
|
+
@classmethod
|
|
381
|
+
def debug(cls, message: str, **kwargs):
|
|
382
|
+
cls._log("debug", message, **kwargs)
|
|
383
|
+
|
|
384
|
+
@classmethod
|
|
385
|
+
def info(cls, message: str, **kwargs):
|
|
386
|
+
cls._log("info", message, **kwargs)
|
|
387
|
+
|
|
388
|
+
@classmethod
|
|
389
|
+
def warning(cls, message: str, **kwargs):
|
|
390
|
+
cls._log("warning", message, **kwargs)
|
|
391
|
+
|
|
392
|
+
@classmethod
|
|
393
|
+
def error(cls, message: str, **kwargs):
|
|
394
|
+
cls._log("error", message, **kwargs)
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def critical(cls, message: str, **kwargs):
|
|
398
|
+
"""Critical-level log — accepted only when TINA4_LOG_CRITICAL=true.
|
|
399
|
+
|
|
400
|
+
Maps to error so existing log consumers (alerting, error.log)
|
|
401
|
+
keep working. When the toggle is off the call is a no-op so
|
|
402
|
+
deployments that have standardised on debug/info/warning/error
|
|
403
|
+
don't get surprise log lines.
|
|
404
|
+
"""
|
|
405
|
+
if cls._critical_enabled:
|
|
406
|
+
cls._log("error", message, **kwargs)
|
|
@@ -17,7 +17,7 @@ import os
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def load_env(file_path: str =
|
|
20
|
+
def load_env(file_path: str = None, override: bool = False) -> dict:
|
|
21
21
|
"""Load environment variables from a .env file.
|
|
22
22
|
|
|
23
23
|
Supports:
|
|
@@ -31,12 +31,16 @@ def load_env(file_path: str = ".env", override: bool = False) -> dict:
|
|
|
31
31
|
multi-word unquoted values
|
|
32
32
|
|
|
33
33
|
Args:
|
|
34
|
-
file_path: Path to .env file
|
|
34
|
+
file_path: Path to .env file. When None, falls back to the
|
|
35
|
+
TINA4_ENV_FILE env var, then ".env". This lets ops point at
|
|
36
|
+
an alternate file (e.g. ".env.staging") without code changes.
|
|
35
37
|
override: If True, overwrite existing env vars (default: False)
|
|
36
38
|
|
|
37
39
|
Returns:
|
|
38
40
|
Dict of loaded key-value pairs
|
|
39
41
|
"""
|
|
42
|
+
if file_path is None:
|
|
43
|
+
file_path = os.environ.get("TINA4_ENV_FILE", ".env")
|
|
40
44
|
env_file = Path(file_path)
|
|
41
45
|
loaded = {}
|
|
42
46
|
|
|
@@ -10,6 +10,7 @@ import html
|
|
|
10
10
|
import hashlib
|
|
11
11
|
import json
|
|
12
12
|
import secrets
|
|
13
|
+
import time
|
|
13
14
|
from functools import lru_cache
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
from datetime import datetime
|
|
@@ -1270,10 +1271,20 @@ class Frond:
|
|
|
1270
1271
|
# Fragment cache (key → (html, expires_at))
|
|
1271
1272
|
self._fragment_cache: dict[str, tuple[str, float]] = {}
|
|
1272
1273
|
# Token pre-compilation cache
|
|
1273
|
-
self._compiled: dict[str, tuple[list, float]] = {} # {template_name: (tokens,
|
|
1274
|
+
self._compiled: dict[str, tuple[list, float]] = {} # {template_name: (tokens, expires_at)}
|
|
1274
1275
|
self._compiled_strings: dict[str, list] = {} # {md5_hash: tokens}
|
|
1275
1276
|
# Filter chain cache: expr → (var_name, [(filter_name, [args])])
|
|
1276
1277
|
self._filter_chain_cache: dict[str, tuple[str, list]] = {}
|
|
1278
|
+
# Compile-cache TTL — TINA4_TEMPLATE_CACHE_TTL in seconds.
|
|
1279
|
+
# 0 (default) means "no expiry" so the existing permanent-cache
|
|
1280
|
+
# behaviour is preserved. Anything >0 forces re-tokenisation
|
|
1281
|
+
# after that many seconds, which is handy for long-running
|
|
1282
|
+
# workers that want to pick up template edits without a full
|
|
1283
|
+
# restart but without paying the dev-mode "always re-read" tax.
|
|
1284
|
+
try:
|
|
1285
|
+
self._cache_ttl: int = int(os.environ.get("TINA4_TEMPLATE_CACHE_TTL", "0"))
|
|
1286
|
+
except (TypeError, ValueError):
|
|
1287
|
+
self._cache_ttl = 0
|
|
1277
1288
|
|
|
1278
1289
|
# Built-in global functions
|
|
1279
1290
|
self._globals["form_token"] = _form_token
|
|
@@ -1363,18 +1374,22 @@ class Frond:
|
|
|
1363
1374
|
debug_mode = os.environ.get("TINA4_DEBUG", "").lower() == "true"
|
|
1364
1375
|
|
|
1365
1376
|
if not debug_mode:
|
|
1366
|
-
# Production:
|
|
1377
|
+
# Production: cached. If TINA4_TEMPLATE_CACHE_TTL > 0 we honour
|
|
1378
|
+
# the expiry; otherwise the cache is permanent (legacy behaviour).
|
|
1367
1379
|
cached = self._compiled.get(template)
|
|
1368
1380
|
if cached is not None:
|
|
1369
|
-
|
|
1381
|
+
tokens_cached, expires_at = cached
|
|
1382
|
+
if self._cache_ttl <= 0 or time.time() < expires_at:
|
|
1383
|
+
return self._execute_cached(tokens_cached, context, template)
|
|
1370
1384
|
|
|
1371
|
-
# Dev mode
|
|
1385
|
+
# Dev mode (or expired entry): re-read and re-tokenize.
|
|
1372
1386
|
# mtime-based invalidation doesn't catch changes to included/extended
|
|
1373
1387
|
# templates (parent or partial changes don't update the caller's mtime).
|
|
1374
1388
|
source = path.read_text(encoding="utf-8")
|
|
1375
1389
|
tokens = _tokenize(source)
|
|
1376
1390
|
if not debug_mode:
|
|
1377
|
-
|
|
1391
|
+
expires_at = (time.time() + self._cache_ttl) if self._cache_ttl > 0 else 0
|
|
1392
|
+
self._compiled[template] = (tokens, expires_at)
|
|
1378
1393
|
return self._execute_with_source(source, tokens, context, template)
|
|
1379
1394
|
|
|
1380
1395
|
def render_string(self, source: str, data: dict = None) -> str:
|
|
@@ -23,6 +23,7 @@ Supported:
|
|
|
23
23
|
- Error capture (resolver exceptions become GraphQL errors)
|
|
24
24
|
"""
|
|
25
25
|
import json
|
|
26
|
+
import os
|
|
26
27
|
import re
|
|
27
28
|
from typing import Any
|
|
28
29
|
|
|
@@ -464,10 +465,45 @@ def _make_orm_delete_resolver(orm_class, pk_field):
|
|
|
464
465
|
# ── Executor ──────────────────────────────────────────────────
|
|
465
466
|
|
|
466
467
|
class GraphQL:
|
|
467
|
-
"""GraphQL engine — parse, validate, execute.
|
|
468
|
+
"""GraphQL engine — parse, validate, execute.
|
|
469
|
+
|
|
470
|
+
Env vars (cross-framework parity v3.12.4):
|
|
471
|
+
TINA4_GRAPHQL_AUTO_SCHEMA When truthy (default), discovered ORM
|
|
472
|
+
subclasses passed via auto_register()
|
|
473
|
+
are wired into the schema. Set to false
|
|
474
|
+
to opt out and build the schema manually.
|
|
475
|
+
TINA4_GRAPHQL_ENDPOINT Default URL path for the HTTP endpoint
|
|
476
|
+
(default: "/graphql"). Read by .endpoint.
|
|
477
|
+
"""
|
|
468
478
|
|
|
469
479
|
def __init__(self):
|
|
470
480
|
self.schema = Schema()
|
|
481
|
+
# Default endpoint URL — env-overridable so deployments can mount
|
|
482
|
+
# GraphQL at e.g. /api/graphql without changing app code.
|
|
483
|
+
self.endpoint: str = os.environ.get("TINA4_GRAPHQL_ENDPOINT", "/graphql")
|
|
484
|
+
# Auto-schema toggle. The flag is stored on the instance so the
|
|
485
|
+
# outer app can introspect it (e.g. dev_admin shows "auto-schema:
|
|
486
|
+
# off" in the GraphQL panel) and so test fixtures can flip it
|
|
487
|
+
# without monkey-patching env.
|
|
488
|
+
self.auto_schema: bool = str(
|
|
489
|
+
os.environ.get("TINA4_GRAPHQL_AUTO_SCHEMA", "true")
|
|
490
|
+
).strip().lower() in ("true", "1", "yes", "on")
|
|
491
|
+
|
|
492
|
+
def auto_register(self, *orm_classes) -> int:
|
|
493
|
+
"""Wire each ORM class into the schema via from_orm().
|
|
494
|
+
|
|
495
|
+
No-op when TINA4_GRAPHQL_AUTO_SCHEMA is falsy. Returns the number
|
|
496
|
+
of classes actually registered. Callers (dev_admin, app bootstrap)
|
|
497
|
+
use this instead of looping themselves so the env-var gate is
|
|
498
|
+
honoured in one place.
|
|
499
|
+
"""
|
|
500
|
+
if not self.auto_schema:
|
|
501
|
+
return 0
|
|
502
|
+
count = 0
|
|
503
|
+
for cls in orm_classes:
|
|
504
|
+
self.schema.from_orm(cls)
|
|
505
|
+
count += 1
|
|
506
|
+
return count
|
|
471
507
|
|
|
472
508
|
def execute(self, query: str, variables: dict = None, context: dict = None) -> dict:
|
|
473
509
|
"""Execute a GraphQL query string. Returns {"data": ..., "errors": [...]}."""
|