tina4-python 3.13.37__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.37 → tina4_python-3.13.39}/.gitignore +7 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/PKG-INFO +10 -10
- {tina4_python-3.13.37 → tina4_python-3.13.39}/README.md +9 -9
- {tina4_python-3.13.37 → tina4_python-3.13.39}/pyproject.toml +1 -1
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/CLAUDE.md +21 -13
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/HtmlElement.py +24 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/__init__.py +1 -1
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/api/__init__.py +94 -8
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/auth/__init__.py +131 -9
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/cli/__init__.py +91 -2
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/core/events.py +51 -9
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/core/middleware.py +34 -10
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/core/router.py +13 -1
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/core/server.py +209 -37
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/database/connection.py +315 -52
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/database/firebird.py +6 -2
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/database/mssql.py +14 -3
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/database/mysql.py +15 -4
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/database/sqlite.py +7 -2
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/debug/__init__.py +30 -23
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/dev_admin/__init__.py +86 -68
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/dev_admin/metrics.py +178 -48
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/dev_admin/plan.py +1 -1
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/graphql/__init__.py +32 -9
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/mcp/__init__.py +17 -5
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/mcp/tools.py +6 -2
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/messenger/__init__.py +117 -30
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/migration/runner.py +92 -6
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/orm/model.py +83 -11
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/query_builder/__init__.py +22 -3
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/queue_backends/kafka_backend.py +35 -1
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/seeder/__init__.py +354 -44
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/session/__init__.py +84 -9
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/websocket/__init__.py +326 -20
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/wsdl/__init__.py +15 -1
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/env.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.37 → tina4_python-3.13.39}/tina4_python/websocket/backplane.py +0 -0
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
/src/templates/index.twig
|
|
7
7
|
/secrets/
|
|
8
8
|
/.env
|
|
9
|
+
/.env.local
|
|
10
|
+
.env.local
|
|
9
11
|
/public/css/test.css
|
|
10
12
|
/sessions/
|
|
11
13
|
/tests/secrets/domain.cert
|
|
@@ -83,3 +85,8 @@ example/store/src/**/__pycache__/
|
|
|
83
85
|
/data/store.db
|
|
84
86
|
/example/store/
|
|
85
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
|
|
|
@@ -1165,6 +1170,8 @@ queue.purge("completed")
|
|
|
1165
1170
|
|
|
1166
1171
|
Tina4 includes zero-config SOAP 1.1 support with automatic WSDL generation.
|
|
1167
1172
|
|
|
1173
|
+
**Security:** SOAP requests containing a `<!DOCTYPE>` (DTD) are rejected with a `Client` fault before parsing — SOAP 1.1 forbids DTDs, and this closes the XML entity-expansion (billion-laughs) and external-entity (XXE) attack surface. An operation that raises returns a `Server` fault whose `<faultstring>` is the real cause **only** in debug mode (`TINA4_DEBUG`); in production it is a generic "Internal server error" and the real cause is written to the log — so a resolver exception never leaks internal state to a SOAP client.
|
|
1174
|
+
|
|
1168
1175
|
```python
|
|
1169
1176
|
from typing import List, Optional
|
|
1170
1177
|
from tina4_python.wsdl import WSDL, wsdl_operation
|
|
@@ -1249,7 +1256,7 @@ result = gql.execute('{ users(limit: 3) { id name } }', variables={}, context={}
|
|
|
1249
1256
|
# {"data": {"users": [...]}}
|
|
1250
1257
|
```
|
|
1251
1258
|
|
|
1252
|
-
Supports: queries, mutations, variables, fragments, aliases, `@skip`/`@include` directives, nested selections, list types, inline fragments. Resolver exceptions are captured as GraphQL errors.
|
|
1259
|
+
Supports: queries, mutations, variables, fragments, aliases, `@skip`/`@include` directives, nested selections, list types, inline fragments. Resolver exceptions are captured as GraphQL errors — the message is the real cause only in debug mode (`TINA4_DEBUG`); in production it is a generic "Internal server error" (the real cause is logged) so a resolver exception never leaks internal state. **Depth guard:** selection-set nesting is bounded by `TINA4_GRAPHQL_MAX_DEPTH` (default `50`; set `<= 0` to disable). An over-deep query or a circular fragment fails with a `"Query exceeds maximum depth of N"` error instead of overflowing the stack.
|
|
1253
1260
|
|
|
1254
1261
|
| ORM Field | GraphQL Type |
|
|
1255
1262
|
|-----------|-------------|
|
|
@@ -1604,7 +1611,7 @@ Key `.env` settings:
|
|
|
1604
1611
|
|
|
1605
1612
|
```bash
|
|
1606
1613
|
# Authentication
|
|
1607
|
-
TINA4_SECRET=your-jwt-secret # JWT signing (
|
|
1614
|
+
TINA4_SECRET=your-jwt-secret # JWT signing. In DEV (TINA4_DEBUG truthy, not CI/prod) a blank value auto-generates a per-machine secret saved to gitignored .env.local; in CI/prod a blank value warns actionably (set it with `openssl rand -hex 32`)
|
|
1608
1615
|
TINA4_API_KEY=your-api-key # Static bearer token for API auth (API_KEY fallback supported)
|
|
1609
1616
|
TINA4_TOKEN_LIMIT=60 # Token lifetime in minutes (default: 60)
|
|
1610
1617
|
|
|
@@ -1615,7 +1622,7 @@ TINA4_DATABASE_PASSWORD= # DB password
|
|
|
1615
1622
|
|
|
1616
1623
|
# Framework
|
|
1617
1624
|
TINA4_DEBUG=true # Enable dev mode (toolbar, live reload, error overlay)
|
|
1618
|
-
TINA4_LOG_LEVEL=
|
|
1625
|
+
TINA4_LOG_LEVEL=INFO # Log verbosity: ALL, DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO)
|
|
1619
1626
|
TINA4_LOCALE=en # Language for framework messages (en, fr, af, zh, ja, es)
|
|
1620
1627
|
TINA4_DEFAULT_WEBSERVER=FALSE # Set to TRUE to use Tina4's built-in webserver instead of ASGI
|
|
1621
1628
|
TINA4_OVERRIDE_CLIENT=false # Set to true to allow running without tina4 CLI (e.g. Docker)
|
|
@@ -1636,10 +1643,11 @@ SWAGGER_DEV_URL=http://localhost:7145 # Dev server URL for Swagger
|
|
|
1636
1643
|
```
|
|
1637
1644
|
|
|
1638
1645
|
### Debug levels
|
|
1639
|
-
- `ALL` / `DEBUG` —
|
|
1640
|
-
- `INFO` — standard logging
|
|
1641
|
-
- `WARNING` — warnings and
|
|
1642
|
-
- `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)
|
|
1643
1651
|
|
|
1644
1652
|
## CORS
|
|
1645
1653
|
|
|
@@ -1853,7 +1861,7 @@ async def dashboard(request, response):
|
|
|
1853
1861
|
- **2,899 tests** passing across all modules
|
|
1854
1862
|
- **Production server auto-detect**: `tina4python serve --production` auto-installs uvicorn
|
|
1855
1863
|
- **`tina4python generate`**: model, route, migration, middleware scaffolding
|
|
1856
|
-
- **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **
|
|
1864
|
+
- **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **off by default — opt-in via `TINA4_AUTO_CACHING=true`** (TTL `TINA4_AUTO_CACHING_TTL=5`s) dedupes identical reads within a request and flushes on writes; it ships OFF because a request-scoped cache can return pre-write state in a read-after-write (`SELECT MAX(id)` before an `INSERT` → duplicate keys), so opt in per read-heavy endpoint. Persistent cross-request cache also opt-in via `TINA4_DB_CACHE=true` (TTL `TINA4_DB_CACHE_TTL=30`s) routed through the unified backend set via `TINA4_DB_CACHE_BACKEND` (memory/file/redis/valkey/memcached/mongodb/database) + `TINA4_DB_CACHE_URL` so instances share one cache with global write-invalidation; `cache_stats()` reports `mode` (request/persistent/off) and `backend`, `cache_clear()`
|
|
1857
1865
|
- **Sessions**: 4 backends (file, Redis/Valkey, MongoDB, database)
|
|
1858
1866
|
- **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
|
|
1859
1867
|
- **Cache**: unified backend set — memory (default), file, redis, valkey, memcached, mongodb, database — via `TINA4_CACHE_BACKEND` (+ `TINA4_CACHE_URL`/credentials); file-backend fallback if a backend is unreachable
|
|
@@ -17,6 +17,25 @@ Usage:
|
|
|
17
17
|
import html as _html
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
class Raw(str):
|
|
21
|
+
"""Marker for trusted, pre-sanitised HTML that must render UNESCAPED.
|
|
22
|
+
|
|
23
|
+
String/scalar children of an HTMLElement are HTML-escaped by default to
|
|
24
|
+
prevent stored/reflected XSS. Wrap a value in Raw() to opt out of escaping
|
|
25
|
+
when (and only when) you have already sanitised it yourself.
|
|
26
|
+
|
|
27
|
+
HTMLElement("div")("<b>x</b>") # <b>x</b> (escaped)
|
|
28
|
+
HTMLElement("div")(Raw("<b>x</b>")) # <b>x</b> (raw)
|
|
29
|
+
|
|
30
|
+
Alias: SafeString.
|
|
31
|
+
"""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Alias — some callers/frameworks prefer the SafeString name (matches Frond).
|
|
36
|
+
SafeString = Raw
|
|
37
|
+
|
|
38
|
+
|
|
20
39
|
class HTMLElement:
|
|
21
40
|
"""A single HTML element that renders itself and its children to a string."""
|
|
22
41
|
|
|
@@ -65,8 +84,13 @@ class HTMLElement:
|
|
|
65
84
|
|
|
66
85
|
for child in self.children:
|
|
67
86
|
if isinstance(child, HTMLElement):
|
|
87
|
+
# Nested elements render themselves (already escape their own children)
|
|
88
|
+
parts.append(str(child))
|
|
89
|
+
elif isinstance(child, Raw):
|
|
90
|
+
# Explicitly trusted markup — emit unescaped
|
|
68
91
|
parts.append(str(child))
|
|
69
92
|
else:
|
|
93
|
+
# Plain string/scalar child — escape to defeat XSS
|
|
70
94
|
parts.append(_html.escape(str(child), quote=True))
|
|
71
95
|
|
|
72
96
|
parts.append(f"</{self.tag}>")
|
|
@@ -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:
|
|
@@ -49,24 +49,145 @@ class _DualMethod:
|
|
|
49
49
|
return functools.partial(self._func, obj)
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
def _is_ci() -> bool:
|
|
53
|
+
"""True when running under a CI system.
|
|
54
|
+
|
|
55
|
+
Honours the de-facto ``CI`` env var that GitHub Actions, GitLab CI,
|
|
56
|
+
CircleCI, Travis, etc. all set to a truthy value. We never generate or
|
|
57
|
+
persist a dev secret in CI — a CI run with a blank secret must surface
|
|
58
|
+
the actionable warning, not silently mint one.
|
|
59
|
+
"""
|
|
60
|
+
from tina4_python.dotenv import is_truthy
|
|
61
|
+
return is_truthy(os.environ.get("CI"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_dev() -> bool:
|
|
65
|
+
"""True when the framework is in development mode (TINA4_DEBUG truthy)."""
|
|
66
|
+
from tina4_python.dotenv import is_truthy
|
|
67
|
+
return is_truthy(os.environ.get("TINA4_DEBUG"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_production() -> bool:
|
|
71
|
+
"""True when running in production (TINA4_ENV=production)."""
|
|
72
|
+
return os.environ.get("TINA4_ENV", "development") == "production"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Actionable message shown when TINA4_SECRET is blank in CI/prod — tells the
|
|
76
|
+
# operator exactly what to set and how. Kept as a constant so the bootstrap
|
|
77
|
+
# and the lazy resolver emit the identical guidance.
|
|
78
|
+
_BLANK_SECRET_WARNING = (
|
|
79
|
+
"Auth: TINA4_SECRET is not set — JWT signing is insecure. "
|
|
80
|
+
"Set TINA4_SECRET to a random value (e.g. `openssl rand -hex 32`) "
|
|
81
|
+
"in your environment or .env before serving traffic."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def ensure_dev_secret(cwd: str = None) -> str | None:
|
|
86
|
+
"""Generate a per-machine development JWT secret once, at server boot.
|
|
87
|
+
|
|
88
|
+
Fail-safe default for local dev: a blank ``TINA4_SECRET`` used to log a
|
|
89
|
+
loud warning on every boot even though the developer was never told what
|
|
90
|
+
to set. Instead, in DEV (and only in dev), we mint a cryptographically
|
|
91
|
+
random secret, persist it to a gitignored ``.env.local`` so it survives
|
|
92
|
+
restarts, and set it in the process env for this run.
|
|
93
|
+
|
|
94
|
+
Generation happens ONLY when ALL of these hold:
|
|
95
|
+
* ``TINA4_SECRET`` is currently blank, AND
|
|
96
|
+
* we are in DEV (``TINA4_DEBUG`` truthy), AND
|
|
97
|
+
* we are NOT in CI (``CI`` env var not truthy), AND
|
|
98
|
+
* we are NOT in production (``TINA4_ENV`` != "production").
|
|
99
|
+
|
|
100
|
+
SECURITY: never generate or persist a secret in CI or production, and
|
|
101
|
+
only ever write to ``.env.local`` (gitignored) — never ``.env``. The
|
|
102
|
+
signing secret must never become a guessable built-in default.
|
|
103
|
+
|
|
104
|
+
In CI/prod with a blank secret, this emits the actionable warning instead
|
|
105
|
+
of generating anything.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
cwd: Directory in which to create/append ``.env.local`` (defaults to
|
|
109
|
+
the current working directory). Used by tests to point at a temp
|
|
110
|
+
dir without chdir'ing the whole process.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The generated secret string when one was minted, else ``None``.
|
|
114
|
+
"""
|
|
115
|
+
if os.environ.get("TINA4_SECRET"):
|
|
116
|
+
return None # already configured — nothing to do
|
|
117
|
+
|
|
118
|
+
if not _is_dev() or _is_ci() or _is_production():
|
|
119
|
+
# CI / prod / non-dev with a blank secret: warn actionably, never mint.
|
|
120
|
+
_warn_blank_secret()
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
# Dev, not CI, not prod, blank secret → mint a per-machine dev secret.
|
|
124
|
+
new_secret = secrets.token_hex(32)
|
|
125
|
+
os.environ["TINA4_SECRET"] = new_secret # available for THIS run immediately
|
|
126
|
+
|
|
127
|
+
from pathlib import Path
|
|
128
|
+
base = Path(cwd) if cwd else Path.cwd()
|
|
129
|
+
env_local = base / ".env.local"
|
|
130
|
+
try:
|
|
131
|
+
# Append (create if missing). A trailing newline keeps the file
|
|
132
|
+
# parseable if it already held entries without a final newline.
|
|
133
|
+
prefix = ""
|
|
134
|
+
if env_local.exists():
|
|
135
|
+
existing = env_local.read_text(encoding="utf-8")
|
|
136
|
+
if existing and not existing.endswith("\n"):
|
|
137
|
+
prefix = "\n"
|
|
138
|
+
with env_local.open("a", encoding="utf-8") as fh:
|
|
139
|
+
fh.write(f"{prefix}TINA4_SECRET={new_secret}\n")
|
|
140
|
+
_log_info(
|
|
141
|
+
"Auth: generated a development secret, saved to .env.local (gitignored)"
|
|
142
|
+
)
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
# Never crash boot over a file write — keep the in-memory secret for
|
|
145
|
+
# this run and warn that it won't persist.
|
|
146
|
+
_log_warning(
|
|
147
|
+
"Auth: generated a development secret but could not write "
|
|
148
|
+
f".env.local ({exc}); using it for this run only"
|
|
149
|
+
)
|
|
150
|
+
return new_secret
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _log_info(message: str) -> None:
|
|
154
|
+
try:
|
|
155
|
+
from tina4_python.debug import Log
|
|
156
|
+
Log.info(message)
|
|
157
|
+
except Exception:
|
|
158
|
+
import sys
|
|
159
|
+
print(message, file=sys.stderr)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _log_warning(message: str) -> None:
|
|
163
|
+
try:
|
|
164
|
+
from tina4_python.debug import Log
|
|
165
|
+
Log.warning(message)
|
|
166
|
+
except Exception:
|
|
167
|
+
import sys
|
|
168
|
+
print(message, file=sys.stderr)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _warn_blank_secret() -> None:
|
|
172
|
+
_log_warning(_BLANK_SECRET_WARNING)
|
|
173
|
+
|
|
174
|
+
|
|
52
175
|
def _resolve_secret(secret: str = None) -> str:
|
|
53
176
|
"""Resolve the JWT signing secret.
|
|
54
177
|
|
|
55
178
|
Reads ``TINA4_SECRET`` from the environment. When neither an explicit
|
|
56
|
-
secret nor ``TINA4_SECRET`` is set, warns loudly
|
|
57
|
-
secret — parity with the PHP/Node frameworks.
|
|
58
|
-
with a guessable built-in default, which would
|
|
179
|
+
secret nor ``TINA4_SECRET`` is set, warns loudly with an actionable
|
|
180
|
+
message and returns a blank secret — parity with the PHP/Node frameworks.
|
|
181
|
+
Tina4 never silently signs with a guessable built-in default, which would
|
|
182
|
+
make tokens forgeable. (In dev, ``ensure_dev_secret()`` runs at boot and
|
|
183
|
+
mints + persists a secret so this blank path is hit only in CI/prod or
|
|
184
|
+
when auth is used before boot.)
|
|
59
185
|
"""
|
|
60
186
|
if secret:
|
|
61
187
|
return secret
|
|
62
188
|
env_secret = os.environ.get("TINA4_SECRET", "")
|
|
63
189
|
if not env_secret:
|
|
64
|
-
|
|
65
|
-
from tina4_python.debug import Log
|
|
66
|
-
Log.warning("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)")
|
|
67
|
-
except Exception:
|
|
68
|
-
import sys
|
|
69
|
-
print("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)", file=sys.stderr)
|
|
190
|
+
_warn_blank_secret()
|
|
70
191
|
return ""
|
|
71
192
|
return env_secret
|
|
72
193
|
|
|
@@ -403,4 +524,5 @@ class AuthMiddleware:
|
|
|
403
524
|
__all__ = [
|
|
404
525
|
"Auth", "AuthMiddleware", "get_token", "valid_token", "get_payload",
|
|
405
526
|
"refresh_token", "authenticate_request", "validate_api_key",
|
|
527
|
+
"ensure_dev_secret",
|
|
406
528
|
]
|