tina4-python 3.11.35__tar.gz → 3.12.0__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.11.35 → tina4_python-3.12.0}/.gitignore +4 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/PKG-INFO +2 -2
- {tina4_python-3.11.35 → tina4_python-3.12.0}/pyproject.toml +5 -2
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/__init__.py +1 -1
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/auth/__init__.py +7 -7
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/cli/__init__.py +53 -5
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/middleware.py +2 -2
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/server.py +240 -21
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/connection.py +38 -7
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/firebird.py +16 -90
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/postgres.py +16 -2
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/debug/__init__.py +6 -3
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/dev_admin/__init__.py +675 -109
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/dev_admin/plan.py +108 -0
- tina4_python-3.12.0/tina4_python/docs.py +821 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/dotenv/__init__.py +1 -1
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/frond/engine.py +77 -1
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/mcp/__init__.py +1 -1
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/mcp/tools.py +39 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/messenger/__init__.py +9 -9
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/orm/model.py +1011 -1017
- tina4_python-3.12.0/tina4_python/public/js/frond.js +600 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/js/frond.min.js +1 -1
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/js/tina4-dev-admin.js +274 -140
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/js/tina4-dev-admin.min.js +274 -140
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/swagger/__init__.py +3 -3
- {tina4_python-3.11.35 → tina4_python-3.12.0}/README.md +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/Testing.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/events.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/request.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/response.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/router.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tina4-python
|
|
3
|
-
Version: 3.
|
|
4
|
-
Summary: Tina4 Python
|
|
3
|
+
Version: 3.12.0
|
|
4
|
+
Summary: Tina4 for Python — 54 built-in features, zero dependencies
|
|
5
5
|
Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
|
|
6
6
|
License: MIT
|
|
7
7
|
Requires-Python: >=3.12
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tina4-python"
|
|
3
|
-
|
|
4
|
-
description = "Tina4 Python
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Tina4 for Python — 54 built-in features, zero dependencies"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
|
|
7
7
|
]
|
|
@@ -55,6 +55,9 @@ dev = [
|
|
|
55
55
|
requires = ["hatchling"]
|
|
56
56
|
build-backend = "hatchling.build"
|
|
57
57
|
|
|
58
|
+
[tool.hatch.version]
|
|
59
|
+
path = "tina4_python/__init__.py"
|
|
60
|
+
|
|
58
61
|
[tool.hatch.build]
|
|
59
62
|
include = ["tina4_python/**/*"]
|
|
60
63
|
|
|
@@ -60,7 +60,7 @@ class Auth:
|
|
|
60
60
|
algorithm: JWT algorithm (default HS256).
|
|
61
61
|
expires_in: Token lifetime in seconds (default 3600).
|
|
62
62
|
"""
|
|
63
|
-
self.secret = secret or os.environ.get("
|
|
63
|
+
self.secret = secret or os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
64
64
|
self.algorithm = algorithm
|
|
65
65
|
self.expires_in = expires_in or int(
|
|
66
66
|
os.environ.get("TINA4_TOKEN_LIMIT", "60")
|
|
@@ -161,28 +161,28 @@ class Auth:
|
|
|
161
161
|
@classmethod
|
|
162
162
|
def get_token_static(cls, payload: dict, expires_in: int = 60) -> str:
|
|
163
163
|
"""Create a JWT without instantiating Auth — reads SECRET from env."""
|
|
164
|
-
secret = os.environ.get("
|
|
164
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
165
165
|
auth = cls(secret=secret, expires_in=expires_in)
|
|
166
166
|
return auth.get_token(payload)
|
|
167
167
|
|
|
168
168
|
@classmethod
|
|
169
169
|
def valid_token_static(cls, token: str) -> bool:
|
|
170
170
|
"""Validate a JWT without instantiating Auth — reads SECRET from env."""
|
|
171
|
-
secret = os.environ.get("
|
|
171
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
172
172
|
auth = cls(secret=secret)
|
|
173
173
|
return auth.valid_token(token)
|
|
174
174
|
|
|
175
175
|
@classmethod
|
|
176
176
|
def get_payload_static(cls, token: str) -> dict | None:
|
|
177
177
|
"""Decode payload (no validation) without instantiating Auth."""
|
|
178
|
-
secret = os.environ.get("
|
|
178
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
179
179
|
auth = cls(secret=secret)
|
|
180
180
|
return auth.get_payload(token)
|
|
181
181
|
|
|
182
182
|
@classmethod
|
|
183
183
|
def refresh_token_static(cls, token: str, expires_in: int = 60) -> str | None:
|
|
184
184
|
"""Refresh a JWT without instantiating Auth — reads SECRET from env."""
|
|
185
|
-
secret = os.environ.get("
|
|
185
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
186
186
|
auth = cls(secret=secret, expires_in=expires_in)
|
|
187
187
|
return auth.refresh_token(token)
|
|
188
188
|
|
|
@@ -193,7 +193,7 @@ class Auth:
|
|
|
193
193
|
Reads SECRET from env. Checks: Bearer JWT, Bearer API key, Basic auth.
|
|
194
194
|
Returns payload dict on success, None on failure.
|
|
195
195
|
"""
|
|
196
|
-
secret = os.environ.get("
|
|
196
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
197
197
|
auth = cls(secret=secret)
|
|
198
198
|
return auth.authenticate_request(headers)
|
|
199
199
|
|
|
@@ -246,7 +246,7 @@ class Auth:
|
|
|
246
246
|
Returns: True if the provided key matches.
|
|
247
247
|
"""
|
|
248
248
|
if expected is None:
|
|
249
|
-
expected = os.environ.get("TINA4_API_KEY",
|
|
249
|
+
expected = os.environ.get("TINA4_API_KEY", "")
|
|
250
250
|
if not expected:
|
|
251
251
|
return False
|
|
252
252
|
return hmac.compare_digest(provided, expected)
|
|
@@ -137,6 +137,7 @@ def main():
|
|
|
137
137
|
"migrate:create": _migrate_create,
|
|
138
138
|
"migrate:rollback": _migrate_rollback,
|
|
139
139
|
"migrate:status": _migrate_status,
|
|
140
|
+
"env-migrate": _env_migrate,
|
|
140
141
|
"seed": _seed,
|
|
141
142
|
"routes": _routes,
|
|
142
143
|
"test": _test,
|
|
@@ -155,6 +156,52 @@ def main():
|
|
|
155
156
|
_help([])
|
|
156
157
|
|
|
157
158
|
|
|
159
|
+
def _env_migrate(args):
|
|
160
|
+
"""Rewrite a .env file in place, renaming pre-3.12 names to TINA4_ form.
|
|
161
|
+
|
|
162
|
+
Usage: tina4python env-migrate [path] (default path: .env)
|
|
163
|
+
|
|
164
|
+
Backs the original up to <path>.bak before rewriting. Prints a diff of
|
|
165
|
+
each rename. Idempotent — running twice is a no-op on the second run.
|
|
166
|
+
"""
|
|
167
|
+
from tina4_python.core.server import _LEGACY_ENV_VARS
|
|
168
|
+
target = Path(args[0]) if args else Path(".env")
|
|
169
|
+
if not target.is_file():
|
|
170
|
+
print(f" no .env at {target}")
|
|
171
|
+
return
|
|
172
|
+
text = target.read_text(encoding="utf-8")
|
|
173
|
+
backup = target.with_suffix(target.suffix + ".bak")
|
|
174
|
+
backup.write_text(text, encoding="utf-8")
|
|
175
|
+
print(f" backup written: {backup}")
|
|
176
|
+
|
|
177
|
+
renamed = 0
|
|
178
|
+
new_lines = []
|
|
179
|
+
for line in text.splitlines(keepends=True):
|
|
180
|
+
stripped = line.lstrip()
|
|
181
|
+
if not stripped or stripped.startswith("#"):
|
|
182
|
+
new_lines.append(line); continue
|
|
183
|
+
if "=" not in stripped:
|
|
184
|
+
new_lines.append(line); continue
|
|
185
|
+
key = stripped.split("=", 1)[0].strip()
|
|
186
|
+
if key in _LEGACY_ENV_VARS:
|
|
187
|
+
new_key = _LEGACY_ENV_VARS[key]
|
|
188
|
+
new_line = line.replace(key, new_key, 1)
|
|
189
|
+
print(f" {key:<28} → {new_key}")
|
|
190
|
+
new_lines.append(new_line)
|
|
191
|
+
renamed += 1
|
|
192
|
+
else:
|
|
193
|
+
new_lines.append(line)
|
|
194
|
+
|
|
195
|
+
if renamed == 0:
|
|
196
|
+
print(" nothing to rename — your .env is already on the new convention")
|
|
197
|
+
backup.unlink() # don't leave a noise backup
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
target.write_text("".join(new_lines), encoding="utf-8")
|
|
201
|
+
print(f"\n done: {renamed} rename(s) applied to {target}")
|
|
202
|
+
print(f" original kept at {backup} (delete once you've verified)")
|
|
203
|
+
|
|
204
|
+
|
|
158
205
|
def _help(args=None):
|
|
159
206
|
print("""
|
|
160
207
|
Tina4 Python — CLI
|
|
@@ -168,6 +215,7 @@ Commands:
|
|
|
168
215
|
migrate:create <desc> Create a new migration file
|
|
169
216
|
migrate:rollback Rollback last migration batch
|
|
170
217
|
migrate:status Show migration status
|
|
218
|
+
env-migrate [path] Rewrite .env to TINA4_-prefixed names (v3.12 migration)
|
|
171
219
|
seed Run database seeders
|
|
172
220
|
routes List all registered routes
|
|
173
221
|
test Run test suite
|
|
@@ -212,7 +260,7 @@ def _console(args=None):
|
|
|
212
260
|
|
|
213
261
|
# Try to connect database from DATABASE_URL
|
|
214
262
|
db = None
|
|
215
|
-
db_url = os.environ.get("
|
|
263
|
+
db_url = os.environ.get("TINA4_DATABASE_URL")
|
|
216
264
|
if db_url:
|
|
217
265
|
try:
|
|
218
266
|
db = Database(db_url)
|
|
@@ -404,7 +452,7 @@ def _migrate(args):
|
|
|
404
452
|
from tina4_python.database import Database
|
|
405
453
|
from tina4_python.migration import Migration
|
|
406
454
|
|
|
407
|
-
db_url = os.environ.get("
|
|
455
|
+
db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
|
|
408
456
|
db = Database(db_url)
|
|
409
457
|
mig_dir = args[0] if args else "migrations"
|
|
410
458
|
ran = Migration(db, mig_dir).migrate()
|
|
@@ -434,7 +482,7 @@ def _migrate_rollback(args):
|
|
|
434
482
|
from tina4_python.database import Database
|
|
435
483
|
from tina4_python.migration import Migration
|
|
436
484
|
|
|
437
|
-
db_url = os.environ.get("
|
|
485
|
+
db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
|
|
438
486
|
db = Database(db_url)
|
|
439
487
|
mig_dir = args[0] if args else "migrations"
|
|
440
488
|
rolled = Migration(db, mig_dir).rollback()
|
|
@@ -453,7 +501,7 @@ def _migrate_status(args):
|
|
|
453
501
|
from tina4_python.database import Database
|
|
454
502
|
from tina4_python.migration import Migration
|
|
455
503
|
|
|
456
|
-
db_url = os.environ.get("
|
|
504
|
+
db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
|
|
457
505
|
db = Database(db_url)
|
|
458
506
|
result = Migration(db, args[0] if args else "migrations").status()
|
|
459
507
|
completed, pending = result["completed"], result["pending"]
|
|
@@ -489,7 +537,7 @@ def _seed(args):
|
|
|
489
537
|
import importlib.util
|
|
490
538
|
from tina4_python.database import Database
|
|
491
539
|
|
|
492
|
-
db_url = os.environ.get("
|
|
540
|
+
db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
|
|
493
541
|
db = Database(db_url)
|
|
494
542
|
sys.path.insert(0, str(Path.cwd()))
|
|
495
543
|
|
|
@@ -287,7 +287,7 @@ class CsrfMiddleware:
|
|
|
287
287
|
bearer_token = auth_header[7:].strip()
|
|
288
288
|
if bearer_token:
|
|
289
289
|
from tina4_python.auth import Auth as _CsrfAuth
|
|
290
|
-
secret = os.environ.get("
|
|
290
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
291
291
|
auth = _CsrfAuth(secret=secret)
|
|
292
292
|
if auth.valid_token(bearer_token):
|
|
293
293
|
return request, response
|
|
@@ -326,7 +326,7 @@ class CsrfMiddleware:
|
|
|
326
326
|
|
|
327
327
|
# Validate the token
|
|
328
328
|
from tina4_python.auth import Auth as _CsrfAuth
|
|
329
|
-
secret = os.environ.get("
|
|
329
|
+
secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
|
|
330
330
|
auth = _CsrfAuth(secret=secret)
|
|
331
331
|
if not auth.valid_token(token):
|
|
332
332
|
return request, response.error(
|
|
@@ -193,20 +193,93 @@ def _render_error_page(status_code: int, path: str, request_id: str, error_messa
|
|
|
193
193
|
_template_cache: dict[str, str] | None = None
|
|
194
194
|
|
|
195
195
|
|
|
196
|
+
# Auto-routing scans this single subdirectory of src/templates/. Only files
|
|
197
|
+
# in src/templates/pages/ become URLs — everything else (partials, layouts,
|
|
198
|
+
# base.twig, errors, components, macros) is never URL-exposed and remains
|
|
199
|
+
# renderable only via {% include %} / {% extends %} / response.render().
|
|
200
|
+
#
|
|
201
|
+
# Convention adapted from Next.js' pages/ directory and Nuxt's pages/ folder.
|
|
202
|
+
# Explicit, secure by default, no skip lists to maintain.
|
|
203
|
+
_TEMPLATE_PAGES_DIR = "pages"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_dev_mode() -> bool:
|
|
207
|
+
"""True when ``TINA4_DEBUG`` is one of the truthy values (true|1|yes).
|
|
208
|
+
|
|
209
|
+
Centralised so every dev-mode gate (landing page, dev toolbar, error
|
|
210
|
+
overlay, dev admin) reads the same flag the same way.
|
|
211
|
+
"""
|
|
212
|
+
return os.environ.get("TINA4_DEBUG", "false").strip().lower() in ("true", "1", "yes")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# RFC 7231 / RFC 9110 status reason phrases. We use this to write a correct
|
|
216
|
+
# HTTP status line in the dev server's HTTP/1.1 → ASGI bridge — previously
|
|
217
|
+
# the bridge wrote "HTTP/1.1 404 OK" regardless of code, which is malformed.
|
|
218
|
+
_HTTP_REASON_PHRASES: dict[int, str] = {
|
|
219
|
+
100: "Continue", 101: "Switching Protocols",
|
|
220
|
+
200: "OK", 201: "Created", 202: "Accepted", 204: "No Content",
|
|
221
|
+
206: "Partial Content",
|
|
222
|
+
301: "Moved Permanently", 302: "Found", 303: "See Other",
|
|
223
|
+
304: "Not Modified", 307: "Temporary Redirect", 308: "Permanent Redirect",
|
|
224
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
|
|
225
|
+
404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable",
|
|
226
|
+
409: "Conflict", 410: "Gone", 413: "Content Too Large",
|
|
227
|
+
415: "Unsupported Media Type", 422: "Unprocessable Content",
|
|
228
|
+
429: "Too Many Requests",
|
|
229
|
+
500: "Internal Server Error", 501: "Not Implemented",
|
|
230
|
+
502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _http_reason(status: int) -> str:
|
|
235
|
+
"""Return the canonical HTTP reason phrase for ``status``.
|
|
236
|
+
|
|
237
|
+
Falls back to a sensible label when an exotic status is used. Never
|
|
238
|
+
returns an empty string — the HTTP/1.1 status line requires a phrase.
|
|
239
|
+
"""
|
|
240
|
+
return _HTTP_REASON_PHRASES.get(int(status), "OK" if 200 <= status < 300 else "Error")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _template_auto_routing_enabled() -> bool:
|
|
244
|
+
"""Honour TINA4_TEMPLATE_ROUTING=off|false|0|no as an explicit kill switch.
|
|
245
|
+
|
|
246
|
+
Default: enabled. Drop a file in src/templates/pages/ and it serves at
|
|
247
|
+
the matching URL — the zero-config Tina4 convention. Operators who want
|
|
248
|
+
explicit-only routing can set TINA4_TEMPLATE_ROUTING=off and every URL
|
|
249
|
+
must be registered via @get / @post (or be a static file).
|
|
250
|
+
"""
|
|
251
|
+
val = os.environ.get("TINA4_TEMPLATE_ROUTING", "on").strip().lower()
|
|
252
|
+
return val not in ("off", "false", "0", "no", "disabled")
|
|
253
|
+
|
|
254
|
+
|
|
196
255
|
def _resolve_template(path: str) -> str | None:
|
|
197
|
-
"""Resolve a URL path to a template file in src/templates/.
|
|
256
|
+
"""Resolve a URL path to a template file in src/templates/pages/.
|
|
257
|
+
|
|
258
|
+
Only files inside ``src/templates/pages/`` auto-route from a URL.
|
|
259
|
+
Anything in ``src/templates/`` outside ``pages/`` (partials, layouts,
|
|
260
|
+
base.twig, errors, components) is never served standalone.
|
|
261
|
+
|
|
198
262
|
Dev mode: checks filesystem every time for live changes.
|
|
199
263
|
Production: uses a cached lookup built once at startup.
|
|
264
|
+
|
|
265
|
+
The whole feature can be turned off with ``TINA4_TEMPLATE_ROUTING=off``.
|
|
200
266
|
"""
|
|
267
|
+
if not _template_auto_routing_enabled():
|
|
268
|
+
return None
|
|
269
|
+
|
|
201
270
|
clean_path = path.strip("/") or "index"
|
|
202
271
|
is_dev = os.environ.get("TINA4_DEBUG", "false").lower() in ("true", "1", "yes")
|
|
203
272
|
|
|
204
273
|
if is_dev:
|
|
205
|
-
|
|
274
|
+
# Skip underscore-prefixed files even within pages/ — they're private
|
|
275
|
+
# by Hugo/Jekyll convention (helpers, fragments) and shouldn't auto-serve.
|
|
276
|
+
if any(seg.startswith("_") for seg in clean_path.split("/")):
|
|
277
|
+
return None
|
|
278
|
+
pages_dir = Path("src/templates") / _TEMPLATE_PAGES_DIR
|
|
206
279
|
for ext in (".twig", ".html"):
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
return
|
|
280
|
+
candidate_rel = f"{_TEMPLATE_PAGES_DIR}/{clean_path}{ext}"
|
|
281
|
+
if (pages_dir / (clean_path + ext)).is_file():
|
|
282
|
+
return candidate_rel
|
|
210
283
|
return None
|
|
211
284
|
|
|
212
285
|
global _template_cache
|
|
@@ -216,17 +289,25 @@ def _resolve_template(path: str) -> str | None:
|
|
|
216
289
|
|
|
217
290
|
|
|
218
291
|
def _build_template_cache() -> None:
|
|
219
|
-
"""Scan src/templates/ once and build url_path -> template_file lookup.
|
|
292
|
+
"""Scan src/templates/pages/ once and build url_path -> template_file lookup.
|
|
293
|
+
Only files under ``pages/`` are eligible — partials, layouts, base.twig,
|
|
294
|
+
errors etc remain renderable via explicit response.render() but never
|
|
295
|
+
auto-serve from a URL.
|
|
296
|
+
"""
|
|
220
297
|
global _template_cache
|
|
221
298
|
_template_cache = {}
|
|
222
|
-
|
|
223
|
-
if not
|
|
299
|
+
pages_dir = Path("src/templates") / _TEMPLATE_PAGES_DIR
|
|
300
|
+
if not pages_dir.is_dir():
|
|
224
301
|
return
|
|
225
|
-
for f in
|
|
302
|
+
for f in pages_dir.rglob("*"):
|
|
226
303
|
if not f.is_file() or f.suffix not in (".twig", ".html"):
|
|
227
304
|
continue
|
|
228
|
-
|
|
229
|
-
|
|
305
|
+
# Skip private files even within pages/ (e.g. pages/_helper.twig)
|
|
306
|
+
rel_inside_pages = f.relative_to(pages_dir)
|
|
307
|
+
if any(p.startswith("_") for p in rel_inside_pages.parts):
|
|
308
|
+
continue
|
|
309
|
+
rel = str(f.relative_to(Path("src/templates"))).replace("\\", "/")
|
|
310
|
+
url_path = str(rel_inside_pages).replace("\\", "/").rsplit(".", 1)[0]
|
|
230
311
|
if url_path not in _template_cache:
|
|
231
312
|
_template_cache[url_path] = rel
|
|
232
313
|
|
|
@@ -837,7 +918,10 @@ async def _handle_dev_admin(request: Request, response: Response) -> Response:
|
|
|
837
918
|
"""Serve the /__dev dashboard and API routes."""
|
|
838
919
|
from tina4_python.dev_admin import get_api_handlers
|
|
839
920
|
if request.path in ("/__dev/", "/__dev", "/__dev/v2", "/__dev/v2/"):
|
|
840
|
-
# Unified SPA dev admin
|
|
921
|
+
# Unified SPA dev admin. The bundle derives its WS URL from
|
|
922
|
+
# `location.host` directly, so no environment shim is needed —
|
|
923
|
+
# the framework serves /__dev_reload on its own port and the
|
|
924
|
+
# SPA reaches it as `ws://<page-host>/__dev_reload`.
|
|
841
925
|
response.html("""<!DOCTYPE html>
|
|
842
926
|
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev Admin</title></head>
|
|
843
927
|
<body><div id="app" data-framework="python" data-color="#3b82f6"></div>
|
|
@@ -847,8 +931,19 @@ async def _handle_dev_admin(request: Request, response: Response) -> Response:
|
|
|
847
931
|
handler_info = handlers.get(request.path)
|
|
848
932
|
if handler_info and request.method == handler_info[0]:
|
|
849
933
|
try:
|
|
850
|
-
def _resp(data, code=200):
|
|
851
|
-
|
|
934
|
+
def _resp(data, code=200, content_type=None):
|
|
935
|
+
# content_type overrides the auto-detected MIME —
|
|
936
|
+
# lets handlers stream binary with an explicit
|
|
937
|
+
# Content-Type (e.g. /__dev/api/file/raw).
|
|
938
|
+
if content_type is not None:
|
|
939
|
+
response.status(code)
|
|
940
|
+
response.content_type = content_type
|
|
941
|
+
response.content = data if isinstance(data, (bytes, bytearray)) else str(data).encode("utf-8")
|
|
942
|
+
elif isinstance(data, (bytes, bytearray)):
|
|
943
|
+
response.status(code)
|
|
944
|
+
response.content_type = "application/octet-stream"
|
|
945
|
+
response.content = data
|
|
946
|
+
elif isinstance(data, str):
|
|
852
947
|
response.status(code).html(data)
|
|
853
948
|
else:
|
|
854
949
|
response.status(code).json(data)
|
|
@@ -906,7 +1001,7 @@ def _check_auth(request: Request, response: Response, route: dict) -> bool:
|
|
|
906
1001
|
if not route.get("auth_required"):
|
|
907
1002
|
return False
|
|
908
1003
|
_auth_header = request.headers.get("authorization", "")
|
|
909
|
-
_api_key = os.environ.get("TINA4_API_KEY",
|
|
1004
|
+
_api_key = os.environ.get("TINA4_API_KEY", "")
|
|
910
1005
|
_auth_ok = False
|
|
911
1006
|
if _auth_header and _auth_header.startswith("Bearer "):
|
|
912
1007
|
_token = _auth_header[7:]
|
|
@@ -1062,7 +1157,19 @@ def _handle_route_error(
|
|
|
1062
1157
|
|
|
1063
1158
|
|
|
1064
1159
|
def _handle_no_route(request: Request, response: Response, request_id: str) -> Response:
|
|
1065
|
-
"""Serve static files, templates, landing page, or 404.
|
|
1160
|
+
"""Serve static files, templates, landing page, or 404.
|
|
1161
|
+
|
|
1162
|
+
Lookup order at any URL with no registered route:
|
|
1163
|
+
1. Static file (public/, src/public/, framework public/, with /
|
|
1164
|
+
resolving to index.html so SPAs Just Work)
|
|
1165
|
+
2. Auto-routed template from src/templates/pages/ (gated by
|
|
1166
|
+
TINA4_TEMPLATE_ROUTING)
|
|
1167
|
+
3. Framework landing page — only at "/", and only in dev
|
|
1168
|
+
(``TINA4_DEBUG=true``). Production never shows it, so the
|
|
1169
|
+
framework version, dev-admin link, and gallery never leak
|
|
1170
|
+
to real users.
|
|
1171
|
+
4. 404
|
|
1172
|
+
"""
|
|
1066
1173
|
static = _try_static(request.path)
|
|
1067
1174
|
if static:
|
|
1068
1175
|
return static
|
|
@@ -1071,7 +1178,7 @@ def _handle_no_route(request: Request, response: Response, request_id: str) -> R
|
|
|
1071
1178
|
from tina4_python.core.response import get_frond
|
|
1072
1179
|
html = get_frond().render(tpl_file, {})
|
|
1073
1180
|
response.html(html)
|
|
1074
|
-
elif request.path == "/":
|
|
1181
|
+
elif request.path == "/" and _is_dev_mode():
|
|
1075
1182
|
response.html(_render_landing_page())
|
|
1076
1183
|
else:
|
|
1077
1184
|
html = _render_error_page(404, request.path, request_id)
|
|
@@ -1182,8 +1289,14 @@ async def handle(request: Request) -> Response:
|
|
|
1182
1289
|
from tina4_python.dotenv import is_truthy
|
|
1183
1290
|
_is_dev = is_truthy(os.environ.get("TINA4_DEBUG", ""))
|
|
1184
1291
|
|
|
1185
|
-
# Dev admin
|
|
1186
|
-
|
|
1292
|
+
# Dev admin — also catches /ai/api/chat (SPA's ollama proxy) and the
|
|
1293
|
+
# bare /ai /vision /embed /image /rag service-health probes that
|
|
1294
|
+
# drive the "SERVICES ●●●●●" dots in the dev-admin UI.
|
|
1295
|
+
_dev_extra_paths = {"/ai/api/chat", "/ai", "/vision", "/embed", "/image", "/rag"}
|
|
1296
|
+
if _is_dev and (
|
|
1297
|
+
request.path.startswith("/__dev")
|
|
1298
|
+
or request.path in _dev_extra_paths
|
|
1299
|
+
):
|
|
1187
1300
|
return await _handle_dev_admin(request, response)
|
|
1188
1301
|
|
|
1189
1302
|
# Swagger
|
|
@@ -1304,8 +1417,16 @@ def _try_static(path: str) -> Response | None:
|
|
|
1304
1417
|
2. public/ (simple, IDE-friendly)
|
|
1305
1418
|
3. src/public/ (nested convention)
|
|
1306
1419
|
4. tina4_python/public/ (framework built-in assets)
|
|
1420
|
+
|
|
1421
|
+
Index resolution: when ``path`` is ``/`` or ends with ``/``, the lookup
|
|
1422
|
+
appends ``index.html`` so a Vite/SPA build with ``src/public/index.html``
|
|
1423
|
+
serves at the matching URL — no custom ``@get("/")`` route needed.
|
|
1307
1424
|
"""
|
|
1308
1425
|
clean = path.lstrip("/")
|
|
1426
|
+
# Index resolution: '/' or '/foo/' -> append 'index.html' so SPA builds
|
|
1427
|
+
# in src/public/ Just Work without a custom root route.
|
|
1428
|
+
if clean == "" or clean.endswith("/"):
|
|
1429
|
+
clean = clean + "index.html"
|
|
1309
1430
|
custom = os.environ.get("TINA4_PUBLIC_DIR")
|
|
1310
1431
|
candidates = []
|
|
1311
1432
|
if custom:
|
|
@@ -1555,6 +1676,69 @@ def _print_banner(host: str, port: int, server_name: str = "asyncio", ai_port: i
|
|
|
1555
1676
|
print(banner)
|
|
1556
1677
|
|
|
1557
1678
|
|
|
1679
|
+
# Legacy env var names that v3.12 has retired. If any of these are set in
|
|
1680
|
+
# the environment we refuse to boot — silently ignoring them would cause
|
|
1681
|
+
# auth/db/mail to fall back to defaults with no warning. Each maps to its
|
|
1682
|
+
# new TINA4_-prefixed canonical name (or DROPPED for deleted features).
|
|
1683
|
+
_LEGACY_ENV_VARS: dict[str, str] = {
|
|
1684
|
+
"DATABASE_URL": "TINA4_DATABASE_URL",
|
|
1685
|
+
"DATABASE_USERNAME": "TINA4_DATABASE_USERNAME",
|
|
1686
|
+
"DATABASE_PASSWORD": "TINA4_DATABASE_PASSWORD",
|
|
1687
|
+
"DB_URL": "TINA4_DATABASE_URL",
|
|
1688
|
+
"SECRET": "TINA4_SECRET",
|
|
1689
|
+
"API_KEY": "TINA4_API_KEY",
|
|
1690
|
+
"JWT_ALGORITHM": "TINA4_JWT_ALGORITHM",
|
|
1691
|
+
"SMTP_HOST": "TINA4_MAIL_HOST",
|
|
1692
|
+
"SMTP_PORT": "TINA4_MAIL_PORT",
|
|
1693
|
+
"SMTP_USERNAME": "TINA4_MAIL_USERNAME",
|
|
1694
|
+
"SMTP_PASSWORD": "TINA4_MAIL_PASSWORD",
|
|
1695
|
+
"SMTP_FROM": "TINA4_MAIL_FROM",
|
|
1696
|
+
"SMTP_FROM_NAME": "TINA4_MAIL_FROM_NAME",
|
|
1697
|
+
"IMAP_HOST": "TINA4_MAIL_IMAP_HOST",
|
|
1698
|
+
"IMAP_PORT": "TINA4_MAIL_IMAP_PORT",
|
|
1699
|
+
"IMAP_USER": "TINA4_MAIL_IMAP_USERNAME",
|
|
1700
|
+
"IMAP_PASS": "TINA4_MAIL_IMAP_PASSWORD",
|
|
1701
|
+
"HOST_NAME": "TINA4_HOST_NAME",
|
|
1702
|
+
"SWAGGER_TITLE": "TINA4_SWAGGER_TITLE",
|
|
1703
|
+
"SWAGGER_DESCRIPTION": "TINA4_SWAGGER_DESCRIPTION",
|
|
1704
|
+
"SWAGGER_VERSION": "TINA4_SWAGGER_VERSION",
|
|
1705
|
+
"ORM_PLURAL_TABLE_NAMES": "TINA4_ORM_PLURAL_TABLE_NAMES",
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
def _check_legacy_env_vars() -> None:
|
|
1710
|
+
"""Refuse to boot if pre-3.12 un-prefixed env vars are still set.
|
|
1711
|
+
|
|
1712
|
+
Tina4 v3.12 hard-renamed every framework-specific env var to use the
|
|
1713
|
+
``TINA4_`` prefix. Booting silently with a legacy ``DATABASE_URL`` or
|
|
1714
|
+
``SECRET`` would let auth, DB, or mail fall back to insecure defaults
|
|
1715
|
+
while the user thought their config was being read. Better to die
|
|
1716
|
+
loudly with a list of names to fix.
|
|
1717
|
+
|
|
1718
|
+
Bypass with ``TINA4_ALLOW_LEGACY_ENV=true`` in CI / migration scripts
|
|
1719
|
+
that genuinely need both names set during a transition window.
|
|
1720
|
+
"""
|
|
1721
|
+
if os.environ.get("TINA4_ALLOW_LEGACY_ENV", "").lower() in ("true", "1", "yes"):
|
|
1722
|
+
return
|
|
1723
|
+
found = sorted(name for name in _LEGACY_ENV_VARS if name in os.environ)
|
|
1724
|
+
if not found:
|
|
1725
|
+
return
|
|
1726
|
+
msg = ["", "─" * 72,
|
|
1727
|
+
"Tina4 v3.12 requires TINA4_ prefix on all framework env vars.",
|
|
1728
|
+
"Your environment still has these legacy names:",
|
|
1729
|
+
""]
|
|
1730
|
+
for old in found:
|
|
1731
|
+
new = _LEGACY_ENV_VARS[old]
|
|
1732
|
+
msg.append(f" {old:<28} → {new}")
|
|
1733
|
+
msg.extend(["",
|
|
1734
|
+
"Run `tina4 env-migrate` to rewrite your .env automatically,",
|
|
1735
|
+
"or rename manually. See https://tina4.com/release/3.12.0",
|
|
1736
|
+
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
1737
|
+
"─" * 72, ""])
|
|
1738
|
+
print("\n".join(msg), file=sys.stderr)
|
|
1739
|
+
sys.exit(2)
|
|
1740
|
+
|
|
1741
|
+
|
|
1558
1742
|
def run(host: str | None = None, port: int | None = None, no_browser: bool = False, no_reload: bool = False):
|
|
1559
1743
|
"""Start the Tina4 dev server.
|
|
1560
1744
|
|
|
@@ -1570,6 +1754,9 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
1570
1754
|
global _start_time
|
|
1571
1755
|
_start_time = time.time()
|
|
1572
1756
|
|
|
1757
|
+
# Refuse to boot with v3.11 / v2 era un-prefixed env vars set.
|
|
1758
|
+
_check_legacy_env_vars()
|
|
1759
|
+
|
|
1573
1760
|
# ── Require tina4 CLI ─────────────────────────────────────────
|
|
1574
1761
|
# The framework must be launched via `tina4 serve`, not `python app.py`.
|
|
1575
1762
|
# The tina4 CLI passes --managed when spawning the server process.
|
|
@@ -1615,6 +1802,38 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
1615
1802
|
log_level = os.environ.get("TINA4_LOG_LEVEL", "error" if not is_production else "error")
|
|
1616
1803
|
Log.configure(level=log_level, production=is_production)
|
|
1617
1804
|
|
|
1805
|
+
# Install a top-level exception hook so uncaught exceptions bubbling
|
|
1806
|
+
# out of anything (a route handler, a background task, the event
|
|
1807
|
+
# loop itself on startup) land in logs/error.log. Without this,
|
|
1808
|
+
# an uncaught exception surfaces only via Python's default stderr
|
|
1809
|
+
# writer and never touches Log — the same gap PHP had before its
|
|
1810
|
+
# set_exception_handler fix. Chains to the previous hook so any
|
|
1811
|
+
# debugger / IDE hook already in place still fires.
|
|
1812
|
+
import sys as _sys
|
|
1813
|
+
import traceback as _traceback
|
|
1814
|
+
_prior_excepthook = _sys.excepthook
|
|
1815
|
+
|
|
1816
|
+
def _tina4_excepthook(exc_type, exc_value, exc_tb):
|
|
1817
|
+
# KeyboardInterrupt is a user-initiated Ctrl+C, not an error —
|
|
1818
|
+
# defer to the prior hook (which prints a clean traceback).
|
|
1819
|
+
if issubclass(exc_type, KeyboardInterrupt):
|
|
1820
|
+
_prior_excepthook(exc_type, exc_value, exc_tb)
|
|
1821
|
+
return
|
|
1822
|
+
try:
|
|
1823
|
+
trace_text = "".join(_traceback.format_exception(exc_type, exc_value, exc_tb))
|
|
1824
|
+
Log.error(
|
|
1825
|
+
f"Uncaught {exc_type.__name__}: {exc_value}",
|
|
1826
|
+
trace=trace_text,
|
|
1827
|
+
)
|
|
1828
|
+
except Exception:
|
|
1829
|
+
# If logging itself fails (disk full, permissions, logger
|
|
1830
|
+
# not initialised yet), fall through to the prior hook so
|
|
1831
|
+
# the user still sees something in stderr.
|
|
1832
|
+
pass
|
|
1833
|
+
_prior_excepthook(exc_type, exc_value, exc_tb)
|
|
1834
|
+
|
|
1835
|
+
_sys.excepthook = _tina4_excepthook
|
|
1836
|
+
|
|
1618
1837
|
# Ensure folders
|
|
1619
1838
|
_ensure_folders()
|
|
1620
1839
|
|
|
@@ -1777,7 +1996,7 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
1777
1996
|
# Streaming mode — flush headers on first chunk, then write each chunk immediately
|
|
1778
1997
|
if not _headers_sent:
|
|
1779
1998
|
_headers_sent = True
|
|
1780
|
-
writer.write(f"HTTP/1.1 {resp_status}
|
|
1999
|
+
writer.write(f"HTTP/1.1 {resp_status} {_http_reason(resp_status)}\r\n".encode())
|
|
1781
2000
|
for name, value in resp_headers:
|
|
1782
2001
|
writer.write(name + b": " + value + b"\r\n")
|
|
1783
2002
|
writer.write(b"\r\n")
|
|
@@ -1797,7 +2016,7 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
1797
2016
|
|
|
1798
2017
|
# Write HTTP/1.1 response (only if headers weren't already sent by streaming)
|
|
1799
2018
|
if not _headers_sent:
|
|
1800
|
-
status_line = f"HTTP/1.1 {resp_status}
|
|
2019
|
+
status_line = f"HTTP/1.1 {resp_status} {_http_reason(resp_status)}\r\n"
|
|
1801
2020
|
writer.write(status_line.encode())
|
|
1802
2021
|
for name, value in resp_headers:
|
|
1803
2022
|
writer.write(name + b": " + value + b"\r\n")
|