tina4-python 3.13.38__tar.gz → 3.13.39__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.13.38 → tina4_python-3.13.39}/.gitignore +5 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/PKG-INFO +10 -10
- {tina4_python-3.13.38 → tina4_python-3.13.39}/README.md +9 -9
- {tina4_python-3.13.38 → tina4_python-3.13.39}/pyproject.toml +1 -1
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/CLAUDE.md +16 -10
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/__init__.py +1 -1
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/api/__init__.py +94 -8
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/core/middleware.py +1 -1
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/core/router.py +13 -1
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/core/server.py +68 -4
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/debug/__init__.py +30 -23
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/dev_admin/__init__.py +6 -6
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/dev_admin/metrics.py +9 -3
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/dev_admin/plan.py +1 -1
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/mcp/__init__.py +17 -5
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/migration/runner.py +92 -6
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/orm/model.py +73 -3
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/query_builder/__init__.py +22 -3
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/websocket/__init__.py +42 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/env.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.38 → tina4_python-3.13.39}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -85,3 +85,8 @@ example/store/src/**/__pycache__/
|
|
|
85
85
|
/data/store.db
|
|
86
86
|
/example/store/
|
|
87
87
|
/example/uv.lock
|
|
88
|
+
|
|
89
|
+
# macOS $TMPDIR leak guard: tests that mis-resolve an absolute tmp path
|
|
90
|
+
# relative to cwd would create these under the repo. Never commit them.
|
|
91
|
+
/private/
|
|
92
|
+
/var/folders/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tina4-python
|
|
3
|
-
Version: 3.13.
|
|
3
|
+
Version: 3.13.39
|
|
4
4
|
Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
|
|
5
5
|
Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -72,14 +72,14 @@ tina4 init python ./my-app
|
|
|
72
72
|
cd my-app && tina4 serve
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
Open http://localhost:7146
|
|
75
|
+
Open http://localhost:7146. Your app is running.
|
|
76
76
|
|
|
77
|
-
> **Two CLIs:** `tina4` is the cross-language Rust CLI
|
|
77
|
+
> **Two CLIs:** `tina4` is the cross-language Rust CLI that scaffolds projects, runs the dev server, and watches files. `tina4python` is the Python package's own CLI for project tasks (`migrate`, `seed`, `generate`, `test`). This guide uses `tina4` to scaffold and run, and `tina4python` for those tasks.
|
|
78
78
|
|
|
79
79
|
<details>
|
|
80
80
|
<summary><strong>Without the Tina4 CLI (Docker / CI only)</strong></summary>
|
|
81
81
|
|
|
82
|
-
The framework normally refuses to start without the `tina4` Rust CLI (it owns file watching and SCSS compilation). To bypass
|
|
82
|
+
The framework normally refuses to start without the `tina4` Rust CLI (it owns file watching and SCSS compilation). To bypass (e.g. inside a Docker image where you've already built the assets), set `TINA4_OVERRIDE_CLIENT=true` in `.env`:
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
85
|
# 1. Create project
|
|
@@ -114,12 +114,12 @@ Every feature is built from scratch -- no pip install, no node_modules, no third
|
|
|
114
114
|
| Category | Features |
|
|
115
115
|
|----------|----------|
|
|
116
116
|
| **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
|
|
117
|
-
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird
|
|
117
|
+
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
|
|
118
118
|
| **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
|
|
119
119
|
| **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
120
120
|
| **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8x faster), SCSS auto-compilation, built-in CSS (~24 KB) |
|
|
121
121
|
| **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
|
|
122
|
-
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters
|
|
122
|
+
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
|
|
123
123
|
| **Data & Storage** (4) | Session (File/Redis/Valkey/MongoDB/DB), response cache (LRU, TTL), seeder + 50+ fake data generators, messenger (SMTP/IMAP) |
|
|
124
124
|
| **Developer Tools** (7) | Dev dashboard (11 tabs), dev toolbar, error overlay (Catppuccin Mocha), dev mailbox, hot reload + CSS hot-reload, code metrics (complexity, coupling, maintainability), AI context installer (7 tools) |
|
|
125
125
|
| **Utilities** (7) | DI container (transient + singleton), HtmlElement builder, inline testing (`@tests` decorator), i18n (6 languages), Swagger/OpenAPI auto-generation, CLI scaffolding (`generate model/route/migration/middleware`), structured logging |
|
|
@@ -691,7 +691,7 @@ Frond.clear_cache()
|
|
|
691
691
|
|
|
692
692
|
### Gallery
|
|
693
693
|
|
|
694
|
-
7 interactive examples with **Try It** deploy
|
|
694
|
+
7 interactive examples with **Try It** deploy. Visit the dev admin at `/__dev/` to explore.
|
|
695
695
|
|
|
696
696
|
## Environment
|
|
697
697
|
|
|
@@ -716,7 +716,7 @@ Supported: Claude Code, Cursor, GitHub Copilot, Windsurf, Aider, Cline, OpenAI C
|
|
|
716
716
|
|
|
717
717
|
## Performance
|
|
718
718
|
|
|
719
|
-
Benchmarked with `wrk
|
|
719
|
+
Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
|
|
720
720
|
|
|
721
721
|
| Framework | JSON req/s | Deps | Features |
|
|
722
722
|
|-----------|-----------|------|----------|
|
|
@@ -726,7 +726,7 @@ Benchmarked with `wrk` — 5,000 requests, 50 concurrent, median of 3 runs:
|
|
|
726
726
|
| Bottle | 4,355 | 0 | ~5 |
|
|
727
727
|
| Django | 4,050 | 20+ | ~22 |
|
|
728
728
|
|
|
729
|
-
Tina4 Python delivers competitive throughput with **zero dependencies and 55 features
|
|
729
|
+
Tina4 Python delivers competitive throughput with **zero dependencies and 55 features**. Frameworks with higher req/s have a fraction of the functionality and require dozens of third-party packages.
|
|
730
730
|
|
|
731
731
|
**Across all 4 Tina4 implementations:**
|
|
732
732
|
|
|
@@ -742,7 +742,7 @@ Run benchmarks locally: `python benchmarks/benchmark.py --python`
|
|
|
742
742
|
|
|
743
743
|
## Cross-Framework Parity
|
|
744
744
|
|
|
745
|
-
Tina4 ships identical features across four languages
|
|
745
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, same 55 features:
|
|
746
746
|
|
|
747
747
|
| | Python | PHP | Ruby | Node.js |
|
|
748
748
|
|---|--------|-----|------|---------|
|
|
@@ -40,14 +40,14 @@ tina4 init python ./my-app
|
|
|
40
40
|
cd my-app && tina4 serve
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
Open http://localhost:7146
|
|
43
|
+
Open http://localhost:7146. Your app is running.
|
|
44
44
|
|
|
45
|
-
> **Two CLIs:** `tina4` is the cross-language Rust CLI
|
|
45
|
+
> **Two CLIs:** `tina4` is the cross-language Rust CLI that scaffolds projects, runs the dev server, and watches files. `tina4python` is the Python package's own CLI for project tasks (`migrate`, `seed`, `generate`, `test`). This guide uses `tina4` to scaffold and run, and `tina4python` for those tasks.
|
|
46
46
|
|
|
47
47
|
<details>
|
|
48
48
|
<summary><strong>Without the Tina4 CLI (Docker / CI only)</strong></summary>
|
|
49
49
|
|
|
50
|
-
The framework normally refuses to start without the `tina4` Rust CLI (it owns file watching and SCSS compilation). To bypass
|
|
50
|
+
The framework normally refuses to start without the `tina4` Rust CLI (it owns file watching and SCSS compilation). To bypass (e.g. inside a Docker image where you've already built the assets), set `TINA4_OVERRIDE_CLIENT=true` in `.env`:
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
53
|
# 1. Create project
|
|
@@ -82,12 +82,12 @@ Every feature is built from scratch -- no pip install, no node_modules, no third
|
|
|
82
82
|
| Category | Features |
|
|
83
83
|
|----------|----------|
|
|
84
84
|
| **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
|
|
85
|
-
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird
|
|
85
|
+
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
|
|
86
86
|
| **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
|
|
87
87
|
| **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
88
88
|
| **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8x faster), SCSS auto-compilation, built-in CSS (~24 KB) |
|
|
89
89
|
| **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
|
|
90
|
-
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters
|
|
90
|
+
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
|
|
91
91
|
| **Data & Storage** (4) | Session (File/Redis/Valkey/MongoDB/DB), response cache (LRU, TTL), seeder + 50+ fake data generators, messenger (SMTP/IMAP) |
|
|
92
92
|
| **Developer Tools** (7) | Dev dashboard (11 tabs), dev toolbar, error overlay (Catppuccin Mocha), dev mailbox, hot reload + CSS hot-reload, code metrics (complexity, coupling, maintainability), AI context installer (7 tools) |
|
|
93
93
|
| **Utilities** (7) | DI container (transient + singleton), HtmlElement builder, inline testing (`@tests` decorator), i18n (6 languages), Swagger/OpenAPI auto-generation, CLI scaffolding (`generate model/route/migration/middleware`), structured logging |
|
|
@@ -659,7 +659,7 @@ Frond.clear_cache()
|
|
|
659
659
|
|
|
660
660
|
### Gallery
|
|
661
661
|
|
|
662
|
-
7 interactive examples with **Try It** deploy
|
|
662
|
+
7 interactive examples with **Try It** deploy. Visit the dev admin at `/__dev/` to explore.
|
|
663
663
|
|
|
664
664
|
## Environment
|
|
665
665
|
|
|
@@ -684,7 +684,7 @@ Supported: Claude Code, Cursor, GitHub Copilot, Windsurf, Aider, Cline, OpenAI C
|
|
|
684
684
|
|
|
685
685
|
## Performance
|
|
686
686
|
|
|
687
|
-
Benchmarked with `wrk
|
|
687
|
+
Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
|
|
688
688
|
|
|
689
689
|
| Framework | JSON req/s | Deps | Features |
|
|
690
690
|
|-----------|-----------|------|----------|
|
|
@@ -694,7 +694,7 @@ Benchmarked with `wrk` — 5,000 requests, 50 concurrent, median of 3 runs:
|
|
|
694
694
|
| Bottle | 4,355 | 0 | ~5 |
|
|
695
695
|
| Django | 4,050 | 20+ | ~22 |
|
|
696
696
|
|
|
697
|
-
Tina4 Python delivers competitive throughput with **zero dependencies and 55 features
|
|
697
|
+
Tina4 Python delivers competitive throughput with **zero dependencies and 55 features**. Frameworks with higher req/s have a fraction of the functionality and require dozens of third-party packages.
|
|
698
698
|
|
|
699
699
|
**Across all 4 Tina4 implementations:**
|
|
700
700
|
|
|
@@ -710,7 +710,7 @@ Run benchmarks locally: `python benchmarks/benchmark.py --python`
|
|
|
710
710
|
|
|
711
711
|
## Cross-Framework Parity
|
|
712
712
|
|
|
713
|
-
Tina4 ships identical features across four languages
|
|
713
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, same 55 features:
|
|
714
714
|
|
|
715
715
|
| | Python | PHP | Ruby | Node.js |
|
|
716
716
|
|---|--------|-----|------|---------|
|
|
@@ -725,8 +725,14 @@ api.set_basic_auth("client_id", "client_secret")
|
|
|
725
725
|
|
|
726
726
|
# Disable SSL verification (dev only)
|
|
727
727
|
api = Api("https://self-signed.local", ignore_ssl=True)
|
|
728
|
+
|
|
729
|
+
# Opt-in automatic retry with exponential backoff (default off: max_retries=0).
|
|
730
|
+
# Retries a transport error or a retryable status (429/5xx); 4xx is never retried.
|
|
731
|
+
api = Api("https://api.example.com", max_retries=3, retry_backoff=0.5)
|
|
728
732
|
```
|
|
729
733
|
|
|
734
|
+
**Redirect safety:** the client follows redirects, but the `Authorization` header is **stripped on a cross-origin hop** (different scheme/host/port) — so a bearer token is never leaked to a host you didn't authenticate against. Same-origin redirects keep the header.
|
|
735
|
+
|
|
730
736
|
### Return format
|
|
731
737
|
Every request method (`get()`, `post()`, `put()`, `patch()`, `delete()`, `send()`) returns:
|
|
732
738
|
```python
|
|
@@ -952,11 +958,10 @@ uv run tina4python migrate
|
|
|
952
958
|
### How migrations work internally
|
|
953
959
|
|
|
954
960
|
- SQL files live in `migrations/` folder, named `NNNNNN_description.sql` (6-digit sequence)
|
|
955
|
-
- Files are executed **
|
|
956
|
-
- State is tracked in the `tina4_migration` table (auto-created per engine)
|
|
957
|
-
-
|
|
958
|
-
-
|
|
959
|
-
- On **any** error, the migration rolls back and the process exits with `sys.exit(1)` — fix the error before re-running
|
|
961
|
+
- Files are executed in **numeric-prefix order** (`9_` before `10_`) and split on the `;` delimiter. A file without a numeric/timestamp prefix logs a warning — its order is undefined
|
|
962
|
+
- State is tracked (row-existence) in the `tina4_migration` table (auto-created per engine): a migration runs once — if a row for it exists, it is skipped. (A vestigial `passed` column exists for back-compat; only applied = `passed=1` rows are ever written — failures are never recorded as `passed=0`.)
|
|
963
|
+
- **Each migration FILE is wrapped in its own transaction**: on a failure the file rolls back and `migrate()` **raises** (it does not write `passed=0`, delete anything, or `sys.exit`). Already-applied files stay applied — fix the bad file and re-run. The explicit `tina4 migrate` CLI surfaces the raise as a non-zero exit; startup auto-migration logs it and the service still boots (see TINA4_AUTO_MIGRATE above).
|
|
964
|
+
- **Atomicity caveat:** per-file transactions are truly atomic only on engines with **transactional DDL (PostgreSQL)**. MySQL, Firebird, and SQLite auto-commit DDL, so a multi-statement migration that fails midway on those engines leaves earlier statements applied — keep one logical change per file. CREATE TABLE / ALTER-ADD are made idempotent on Firebird/MSSQL (existence-checked) so a re-run doesn't error.
|
|
960
965
|
|
|
961
966
|
### Engine-specific DDL patterns
|
|
962
967
|
|
|
@@ -1617,7 +1622,7 @@ TINA4_DATABASE_PASSWORD= # DB password
|
|
|
1617
1622
|
|
|
1618
1623
|
# Framework
|
|
1619
1624
|
TINA4_DEBUG=true # Enable dev mode (toolbar, live reload, error overlay)
|
|
1620
|
-
TINA4_LOG_LEVEL=
|
|
1625
|
+
TINA4_LOG_LEVEL=INFO # Log verbosity: ALL, DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO)
|
|
1621
1626
|
TINA4_LOCALE=en # Language for framework messages (en, fr, af, zh, ja, es)
|
|
1622
1627
|
TINA4_DEFAULT_WEBSERVER=FALSE # Set to TRUE to use Tina4's built-in webserver instead of ASGI
|
|
1623
1628
|
TINA4_OVERRIDE_CLIENT=false # Set to true to allow running without tina4 CLI (e.g. Docker)
|
|
@@ -1638,10 +1643,11 @@ SWAGGER_DEV_URL=http://localhost:7145 # Dev server URL for Swagger
|
|
|
1638
1643
|
```
|
|
1639
1644
|
|
|
1640
1645
|
### Debug levels
|
|
1641
|
-
- `ALL` / `DEBUG` —
|
|
1642
|
-
- `INFO` — standard logging
|
|
1643
|
-
- `WARNING` — warnings and
|
|
1644
|
-
- `ERROR` — errors
|
|
1646
|
+
- `ALL` / `DEBUG` — most verbose; every level on the console
|
|
1647
|
+
- `INFO` — standard logging (default)
|
|
1648
|
+
- `WARNING` — warnings, errors, and critical
|
|
1649
|
+
- `ERROR` — errors and critical
|
|
1650
|
+
- `CRITICAL` — critical only (highest severity; `Log.critical()` always logs)
|
|
1645
1651
|
|
|
1646
1652
|
## CORS
|
|
1647
1653
|
|
|
@@ -10,12 +10,57 @@ Make HTTP requests without requests/httpx/aiohttp.
|
|
|
10
10
|
"""
|
|
11
11
|
import json
|
|
12
12
|
import ssl
|
|
13
|
+
import time
|
|
13
14
|
import base64
|
|
14
|
-
from urllib.
|
|
15
|
-
from urllib.
|
|
15
|
+
from urllib.parse import urlencode, urlparse
|
|
16
|
+
from urllib.request import Request, HTTPRedirectHandler, HTTPSHandler, build_opener
|
|
16
17
|
from urllib.error import HTTPError, URLError
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
# Statuses that warrant an automatic retry when ``max_retries`` > 0: rate-limit
|
|
21
|
+
# (429) plus the transient server-side 5xx family. 4xx client errors (401,
|
|
22
|
+
# 404, …) are NOT retried — a repeat won't succeed.
|
|
23
|
+
_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _same_origin(url_a: str, url_b: str) -> bool:
|
|
27
|
+
"""True when two URLs share scheme + host + (effective) port."""
|
|
28
|
+
a, b = urlparse(url_a), urlparse(url_b)
|
|
29
|
+
default = {"http": 80, "https": 443}
|
|
30
|
+
pa = a.port if a.port is not None else default.get(a.scheme)
|
|
31
|
+
pb = b.port if b.port is not None else default.get(b.scheme)
|
|
32
|
+
return (a.scheme, a.hostname, pa) == (b.scheme, b.hostname, pb)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _AuthStripRedirectHandler(HTTPRedirectHandler):
|
|
36
|
+
"""Follow redirects, but drop the Authorization header on a cross-origin hop.
|
|
37
|
+
|
|
38
|
+
Plain urllib forwards the Authorization header to ANY redirect target,
|
|
39
|
+
including a different host — so an ``api.get("/login")`` that 302s to
|
|
40
|
+
``https://attacker.example/`` would hand the bearer token to the attacker.
|
|
41
|
+
Stripping it when the target origin (scheme/host/port) differs matches
|
|
42
|
+
requests/httpx and closes that leak, while same-origin redirects keep auth.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
46
|
+
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
47
|
+
if new_req is not None and not _same_origin(req.full_url, newurl):
|
|
48
|
+
new_req.headers = {
|
|
49
|
+
k: v for k, v in new_req.headers.items() if k.lower() != "authorization"
|
|
50
|
+
}
|
|
51
|
+
new_req.unredirected_hdrs = {
|
|
52
|
+
k: v for k, v in getattr(new_req, "unredirected_hdrs", {}).items()
|
|
53
|
+
if k.lower() != "authorization"
|
|
54
|
+
}
|
|
55
|
+
return new_req
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _open(req, timeout, opener):
|
|
59
|
+
"""The single network-call indirection point (keeps the call site easy to
|
|
60
|
+
patch in tests). ``req`` stays the first positional arg on purpose."""
|
|
61
|
+
return opener.open(req, timeout=timeout)
|
|
62
|
+
|
|
63
|
+
|
|
19
64
|
class Api:
|
|
20
65
|
"""HTTP client using urllib — zero external dependencies."""
|
|
21
66
|
|
|
@@ -25,7 +70,9 @@ class Api:
|
|
|
25
70
|
username: str | None = None,
|
|
26
71
|
password: str | None = None,
|
|
27
72
|
headers: dict[str, str] | None = None,
|
|
28
|
-
verify_ssl: bool | None = None
|
|
73
|
+
verify_ssl: bool | None = None,
|
|
74
|
+
max_retries: int = 0,
|
|
75
|
+
retry_backoff: float = 0.5):
|
|
29
76
|
"""HTTP client.
|
|
30
77
|
|
|
31
78
|
Constructor accepts ergonomic kwargs the documentation has long
|
|
@@ -44,12 +91,21 @@ class Api:
|
|
|
44
91
|
``verify_ssl`` is the docs-friendly inverse of ``ignore_ssl`` —
|
|
45
92
|
``verify_ssl=False`` is equivalent to ``ignore_ssl=True``. If
|
|
46
93
|
both are supplied, ``ignore_ssl`` wins (legacy precedence).
|
|
94
|
+
|
|
95
|
+
``max_retries`` (default 0 = off) enables automatic retry with
|
|
96
|
+
exponential backoff (``retry_backoff`` seconds base, doubling each
|
|
97
|
+
attempt) on a transport error or a retryable status (429/5xx). A
|
|
98
|
+
retried non-idempotent request (POST/…) may be re-sent — retries are
|
|
99
|
+
opt-in for that reason.
|
|
47
100
|
"""
|
|
48
101
|
self.base_url = base_url.rstrip("/")
|
|
49
102
|
self.auth_header = auth_header
|
|
50
103
|
self.timeout = timeout
|
|
104
|
+
self.max_retries = max(0, int(max_retries))
|
|
105
|
+
self.retry_backoff = retry_backoff
|
|
51
106
|
self._headers: dict[str, str] = {}
|
|
52
107
|
self._ssl_context = None
|
|
108
|
+
self._opener_cache = None
|
|
53
109
|
|
|
54
110
|
# ── kwarg sugar ────────────────────────────────────────────────
|
|
55
111
|
# Bearer token wins over basic auth if both are passed.
|
|
@@ -120,9 +176,17 @@ class Api:
|
|
|
120
176
|
return path
|
|
121
177
|
return f"{self.base_url}/{path.lstrip('/')}" if path else self.base_url
|
|
122
178
|
|
|
123
|
-
def
|
|
124
|
-
|
|
125
|
-
|
|
179
|
+
def _opener(self):
|
|
180
|
+
"""Build (once) an opener that follows redirects but strips the
|
|
181
|
+
Authorization header on a cross-origin hop, honouring the SSL context."""
|
|
182
|
+
if self._opener_cache is None:
|
|
183
|
+
handlers = [_AuthStripRedirectHandler()]
|
|
184
|
+
if self._ssl_context is not None:
|
|
185
|
+
handlers.append(HTTPSHandler(context=self._ssl_context))
|
|
186
|
+
self._opener_cache = build_opener(*handlers)
|
|
187
|
+
return self._opener_cache
|
|
188
|
+
|
|
189
|
+
def _build_request(self, method: str, url: str, body, content_type: str) -> Request:
|
|
126
190
|
headers = dict(self._headers)
|
|
127
191
|
if self.auth_header:
|
|
128
192
|
headers["Authorization"] = self.auth_header
|
|
@@ -139,10 +203,32 @@ class Api:
|
|
|
139
203
|
data = body
|
|
140
204
|
headers["Content-Type"] = content_type
|
|
141
205
|
|
|
142
|
-
|
|
206
|
+
return Request(url, data=data, headers=headers, method=method)
|
|
207
|
+
|
|
208
|
+
def _request(self, method: str, url: str, body=None,
|
|
209
|
+
content_type: str = "application/json") -> dict:
|
|
210
|
+
"""Execute the request with opt-in retry/backoff. Returns a result dict.
|
|
211
|
+
|
|
212
|
+
With ``max_retries`` > 0, a transport failure (``http_code`` None) or a
|
|
213
|
+
retryable status (429/5xx) is retried up to ``max_retries`` times with
|
|
214
|
+
exponential backoff; any other outcome (2xx, 4xx, 3xx) returns at once.
|
|
215
|
+
"""
|
|
216
|
+
req = self._build_request(method, url, body, content_type)
|
|
217
|
+
attempts = self.max_retries + 1
|
|
218
|
+
result = None
|
|
219
|
+
for attempt in range(attempts):
|
|
220
|
+
result = self._attempt(req)
|
|
221
|
+
code = result.get("http_code")
|
|
222
|
+
retryable = code is None or code in _RETRY_STATUSES
|
|
223
|
+
if not retryable or attempt == attempts - 1:
|
|
224
|
+
return result
|
|
225
|
+
time.sleep(self.retry_backoff * (2 ** attempt))
|
|
226
|
+
return result
|
|
143
227
|
|
|
228
|
+
def _attempt(self, req: Request) -> dict:
|
|
229
|
+
"""A single HTTP attempt. Returns the standardized result dict."""
|
|
144
230
|
try:
|
|
145
|
-
resp =
|
|
231
|
+
resp = _open(req, self.timeout, self._opener())
|
|
146
232
|
raw = resp.read().decode("utf-8", errors="replace")
|
|
147
233
|
resp_headers = dict(resp.headers)
|
|
148
234
|
try:
|
|
@@ -138,7 +138,7 @@ class CorsMiddleware:
|
|
|
138
138
|
)
|
|
139
139
|
self.max_age = os.environ.get("TINA4_CORS_MAX_AGE", "86400")
|
|
140
140
|
self.credentials = os.environ.get(
|
|
141
|
-
"TINA4_CORS_CREDENTIALS", "
|
|
141
|
+
"TINA4_CORS_CREDENTIALS", "false"
|
|
142
142
|
).lower() in ("true", "1", "yes")
|
|
143
143
|
|
|
144
144
|
def allowed_origin(self, request_origin: str) -> str:
|
|
@@ -178,9 +178,18 @@ class Router:
|
|
|
178
178
|
"param_names": param_names,
|
|
179
179
|
"param_types": param_types,
|
|
180
180
|
"handler": handler,
|
|
181
|
+
# A WS route is public by default (like GET). @secured() requires a
|
|
182
|
+
# valid JWT on the upgrade. Read the flag here AND keep a back-ref so
|
|
183
|
+
# @secured() applied AFTER @websocket() (the other decorator order)
|
|
184
|
+
# can still flip it — mirrors the HTTP _route_ref pattern.
|
|
185
|
+
"auth_required": bool(getattr(handler, "_secured", False)),
|
|
181
186
|
}
|
|
182
187
|
_ws_routes.append(route)
|
|
183
|
-
|
|
188
|
+
try:
|
|
189
|
+
handler._ws_route_ref = route
|
|
190
|
+
except (AttributeError, TypeError):
|
|
191
|
+
pass
|
|
192
|
+
Log.debug(f"WebSocket route registered: {path} (auth={'required' if route['auth_required'] else 'public'})")
|
|
184
193
|
|
|
185
194
|
@staticmethod
|
|
186
195
|
def match_ws(path: str) -> tuple[dict | None, dict]:
|
|
@@ -735,6 +744,9 @@ def secured():
|
|
|
735
744
|
# update the route dict directly.
|
|
736
745
|
if hasattr(fn, "_route_ref"):
|
|
737
746
|
fn._route_ref._route["auth_required"] = True
|
|
747
|
+
# Same for a WebSocket route registered by @websocket() below this one.
|
|
748
|
+
if hasattr(fn, "_ws_route_ref"):
|
|
749
|
+
fn._ws_route_ref["auth_required"] = True
|
|
738
750
|
return fn
|
|
739
751
|
return decorator
|
|
740
752
|
|
|
@@ -720,23 +720,36 @@ async def _handle_asgi_websocket(scope: dict, receive, send):
|
|
|
720
720
|
# Origin allow-list (opt-in via TINA4_WS_ALLOWED_ORIGINS). Unset = allow all
|
|
721
721
|
# so existing deployments are unaffected. Shared with the standalone server
|
|
722
722
|
# via websocket.origin_allowed().
|
|
723
|
-
from tina4_python.websocket import origin_allowed
|
|
723
|
+
from tina4_python.websocket import origin_allowed, ws_authorized
|
|
724
724
|
_ws_headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
|
725
725
|
if not origin_allowed(_ws_headers):
|
|
726
726
|
# 1008 = policy violation (per ASGI/RFC 6455 close codes)
|
|
727
727
|
await send({"type": "websocket.close", "code": 1008})
|
|
728
728
|
return
|
|
729
729
|
|
|
730
|
-
#
|
|
730
|
+
# Per-route auth: a @secured() WS route requires a valid JWT on the upgrade
|
|
731
|
+
# (Authorization header, "bearer" subprotocol, or ?token=). Public by default.
|
|
732
|
+
_ws_subproto = _ws_headers.get("sec-websocket-protocol", "")
|
|
733
|
+
_ws_payload, _ws_ok = ws_authorized(
|
|
734
|
+
route, _ws_headers, scope.get("query_string", b"").decode(), _ws_subproto)
|
|
735
|
+
if not _ws_ok:
|
|
736
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
737
|
+
return
|
|
738
|
+
|
|
739
|
+
# Accept the connection (echo the bearer subprotocol if the client offered it)
|
|
731
740
|
msg = await receive()
|
|
732
741
|
if msg["type"] != "websocket.connect":
|
|
733
742
|
return
|
|
734
|
-
|
|
743
|
+
_accept = {"type": "websocket.accept"}
|
|
744
|
+
if any(p.strip().lower() == "bearer" for p in _ws_subproto.split(",")):
|
|
745
|
+
_accept["subprotocol"] = "bearer"
|
|
746
|
+
await send(_accept)
|
|
735
747
|
|
|
736
748
|
handler = route["handler"]
|
|
737
749
|
|
|
738
750
|
# Create a lightweight connection wrapper for ASGI WebSocket
|
|
739
751
|
conn = _AsgiWebSocketConnection(scope, receive, send, path, params, _ws_manager)
|
|
752
|
+
conn.auth = _ws_payload
|
|
740
753
|
_ws_manager.add(conn)
|
|
741
754
|
|
|
742
755
|
# Fire "open" event — this may set conn._on_message / conn._on_close
|
|
@@ -794,6 +807,7 @@ class _AsgiWebSocketConnection:
|
|
|
794
807
|
self.id = str(uuid.uuid4())[:8]
|
|
795
808
|
self.path = path
|
|
796
809
|
self.params = params
|
|
810
|
+
self.auth = None # verified JWT payload on a @secured WS route, else None
|
|
797
811
|
self.headers = {
|
|
798
812
|
k.decode(): v.decode()
|
|
799
813
|
for k, v in scope.get("headers", [])
|
|
@@ -899,7 +913,7 @@ async def _handle_dev_websocket(reader, writer, headers, path):
|
|
|
899
913
|
writer.close()
|
|
900
914
|
return
|
|
901
915
|
|
|
902
|
-
from tina4_python.websocket import compute_accept_key, origin_allowed
|
|
916
|
+
from tina4_python.websocket import compute_accept_key, origin_allowed, ws_authorized
|
|
903
917
|
|
|
904
918
|
ws_key = headers.get("sec-websocket-key")
|
|
905
919
|
if not ws_key:
|
|
@@ -2221,6 +2235,53 @@ def _check_legacy_env_vars() -> None:
|
|
|
2221
2235
|
sys.exit(2)
|
|
2222
2236
|
|
|
2223
2237
|
|
|
2238
|
+
def _auto_migrate_on_startup(migration_folder: str = "migrations") -> None:
|
|
2239
|
+
"""Apply pending DB migrations on startup — NON-BREAKING.
|
|
2240
|
+
|
|
2241
|
+
When a ``migrations/`` folder exists (with at least one ``.sql`` file) and
|
|
2242
|
+
``TINA4_AUTO_MIGRATE`` is not disabled, pending migrations are applied during
|
|
2243
|
+
boot so the schema is current with no manual ``tina4 migrate`` step. A
|
|
2244
|
+
failure here is logged LOUD and the service STILL starts — a bad migration
|
|
2245
|
+
must never take the backend down. (The explicit ``tina4 migrate`` CLI stays
|
|
2246
|
+
fail-fast so CI still gets a non-zero exit.)
|
|
2247
|
+
|
|
2248
|
+
Disable with ``TINA4_AUTO_MIGRATE=false`` — e.g. multi-instance production
|
|
2249
|
+
that migrates as a separate deploy step (concurrent first-apply can race).
|
|
2250
|
+
"""
|
|
2251
|
+
from pathlib import Path
|
|
2252
|
+
from tina4_python.dotenv import is_truthy
|
|
2253
|
+
|
|
2254
|
+
folder = Path(migration_folder)
|
|
2255
|
+
if not folder.is_dir() or not any(folder.glob("*.sql")):
|
|
2256
|
+
return # no migrations → nothing to do (silent)
|
|
2257
|
+
if not is_truthy(os.environ.get("TINA4_AUTO_MIGRATE", "true")):
|
|
2258
|
+
Log.debug("TINA4_AUTO_MIGRATE is off — skipping startup migrations")
|
|
2259
|
+
return
|
|
2260
|
+
|
|
2261
|
+
try:
|
|
2262
|
+
from tina4_python.database import Database
|
|
2263
|
+
db = Database() # resolves TINA4_DATABASE_URL (framework default if unset)
|
|
2264
|
+
except Exception as exc:
|
|
2265
|
+
Log.debug(f"Startup migrations skipped (no database configured): {exc}")
|
|
2266
|
+
return
|
|
2267
|
+
|
|
2268
|
+
try:
|
|
2269
|
+
from tina4_python.migration import migrate
|
|
2270
|
+
applied = migrate(db)
|
|
2271
|
+
if applied:
|
|
2272
|
+
Log.info(f"Applied {len(applied)} pending migration(s) on startup")
|
|
2273
|
+
except Exception as exc:
|
|
2274
|
+
Log.error(
|
|
2275
|
+
f"Startup auto-migration failed: {exc} — the service is starting "
|
|
2276
|
+
"anyway. Run `tina4 migrate` to retry."
|
|
2277
|
+
)
|
|
2278
|
+
finally:
|
|
2279
|
+
try:
|
|
2280
|
+
db.close() # transient migration connection — don't leak it at boot
|
|
2281
|
+
except Exception:
|
|
2282
|
+
pass
|
|
2283
|
+
|
|
2284
|
+
|
|
2224
2285
|
def run(host: str | None = None, port: int | None = None, no_browser: bool = False, no_reload: bool = False):
|
|
2225
2286
|
"""Start the Tina4 dev server.
|
|
2226
2287
|
|
|
@@ -2353,6 +2414,9 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
2353
2414
|
route_count = len(Router.get_routes())
|
|
2354
2415
|
Log.info(f"Discovered {route_count} routes")
|
|
2355
2416
|
|
|
2417
|
+
# Apply pending DB migrations on startup (non-breaking — see helper).
|
|
2418
|
+
_auto_migrate_on_startup()
|
|
2419
|
+
|
|
2356
2420
|
# Resolve host/port (CLI arg > ENV > default)
|
|
2357
2421
|
host, port = resolve_config(cli_host=host, cli_port=port)
|
|
2358
2422
|
|
|
@@ -19,8 +19,6 @@ Environment variables (all optional — defaults match v2 behaviour):
|
|
|
19
19
|
TINA4_LOG_ROTATE_SIZE Bytes per file before rotation. Default 10 MB.
|
|
20
20
|
Set to 0 to disable rotation.
|
|
21
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
22
|
TINA4_LOG_MAX_SIZE [legacy] Megabytes per file. Used only when
|
|
25
23
|
TINA4_LOG_ROTATE_SIZE is unset (back-compat).
|
|
26
24
|
TINA4_LOG_KEEP [legacy] Alias for TINA4_LOG_ROTATE_KEEP.
|
|
@@ -201,10 +199,7 @@ class Log:
|
|
|
201
199
|
# _format_mode so it doesn't clash with the legacy _format() method
|
|
202
200
|
# name kept below for backward compatibility.
|
|
203
201
|
_format_mode: str = "text"
|
|
204
|
-
|
|
205
|
-
_critical_enabled: bool = False
|
|
206
|
-
|
|
207
|
-
LEVELS = {"debug": 0, "info": 1, "warning": 2, "error": 3}
|
|
202
|
+
LEVELS = {"debug": 0, "info": 1, "warning": 2, "error": 3, "critical": 4}
|
|
208
203
|
|
|
209
204
|
@classmethod
|
|
210
205
|
def configure(cls, log_dir: str = "logs", level: str = "info",
|
|
@@ -229,22 +224,19 @@ class Log:
|
|
|
229
224
|
cls._stdout_enabled = True
|
|
230
225
|
cls._file_enabled = True
|
|
231
226
|
else:
|
|
232
|
-
# "stdout" (default)
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
# path
|
|
227
|
+
# "stdout" (default): stdout is ALWAYS on. The log FILE is written
|
|
228
|
+
# only in development (TINA4_DEBUG truthy). In production /
|
|
229
|
+
# containers a logs/tina4.log + error.log just bloat the writable
|
|
230
|
+
# layer and disk, and 12-factor wants logs on stdout for the
|
|
231
|
+
# platform to capture. Explicit TINA4_LOG_OUTPUT=file/both (or an
|
|
232
|
+
# explicit TINA4_LOG_FILE path) overrides this and writes a file.
|
|
238
233
|
cls._stdout_enabled = True
|
|
239
|
-
cls._file_enabled =
|
|
234
|
+
cls._file_enabled = _is_truthy(os.environ.get("TINA4_DEBUG"))
|
|
240
235
|
|
|
241
236
|
# ── Format ───────────────────────────────────────────────
|
|
242
237
|
fmt = os.environ.get("TINA4_LOG_FORMAT", "text").lower().strip()
|
|
243
238
|
cls._format_mode = "json" if fmt == "json" else "text"
|
|
244
239
|
|
|
245
|
-
# ── Critical level toggle ────────────────────────────────
|
|
246
|
-
cls._critical_enabled = _is_truthy(os.environ.get("TINA4_LOG_CRITICAL"))
|
|
247
|
-
|
|
248
240
|
# ── Rotation config ──────────────────────────────────────
|
|
249
241
|
# New-style: TINA4_LOG_ROTATE_SIZE in BYTES (0 = disabled).
|
|
250
242
|
# Legacy: TINA4_LOG_MAX_SIZE in MEGABYTES.
|
|
@@ -311,6 +303,7 @@ class Log:
|
|
|
311
303
|
"info": "\033[32m", # Green
|
|
312
304
|
"warning": "\033[33m", # Yellow
|
|
313
305
|
"error": "\033[31m", # Red
|
|
306
|
+
"critical": "\033[35m", # Magenta
|
|
314
307
|
}
|
|
315
308
|
RESET = "\033[0m"
|
|
316
309
|
|
|
@@ -454,12 +447,26 @@ class Log:
|
|
|
454
447
|
|
|
455
448
|
@classmethod
|
|
456
449
|
def critical(cls, message: str, **kwargs):
|
|
457
|
-
"""Critical-level log —
|
|
450
|
+
"""Critical-level log — the highest severity (above error).
|
|
451
|
+
|
|
452
|
+
Always emitted (like every other level) and written to error.log.
|
|
453
|
+
Use it for unrecoverable, alert-worthy failures.
|
|
454
|
+
"""
|
|
455
|
+
cls._log("critical", message, **kwargs)
|
|
456
|
+
|
|
457
|
+
@classmethod
|
|
458
|
+
def is_enabled(cls, level: str) -> bool:
|
|
459
|
+
"""Return True if a message at ``level`` would pass the configured
|
|
460
|
+
minimum console level.
|
|
461
|
+
|
|
462
|
+
This reflects console (stdout) visibility — the log file always
|
|
463
|
+
records every level regardless of this threshold. Use it to skip
|
|
464
|
+
building an expensive log payload that would not be shown::
|
|
465
|
+
|
|
466
|
+
if Log.is_enabled("debug"):
|
|
467
|
+
Log.debug("state", snapshot=expensive_dump())
|
|
458
468
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
deployments that have standardised on debug/info/warning/error
|
|
462
|
-
don't get surprise log lines.
|
|
469
|
+
``level`` is case-insensitive (``debug`` / ``info`` / ``warning`` /
|
|
470
|
+
``error`` / ``critical``).
|
|
463
471
|
"""
|
|
464
|
-
|
|
465
|
-
cls._log("error", message, **kwargs)
|
|
472
|
+
return cls._should_log((level or "").lower())
|