tina4-python 3.12.10__tar.gz → 3.12.13__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tina4_python-3.12.10 → tina4_python-3.12.13}/PKG-INFO +3 -3
- {tina4_python-3.12.10 → tina4_python-3.12.13}/README.md +2 -2
- {tina4_python-3.12.10 → tina4_python-3.12.13}/pyproject.toml +1 -1
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/CLAUDE.md +6 -6
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/__init__.py +1 -1
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/cli/__init__.py +6 -4
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/request.py +20 -1
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/router.py +93 -3
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/server.py +156 -7
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/debug/error_overlay.py +36 -3
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/dev_admin/__init__.py +351 -127
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/dev_admin/plan.py +39 -12
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/mcp/tools.py +246 -11
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/migration/runner.py +192 -67
- tina4_python-3.12.13/tina4_python/public/__feedback/widget.js +96 -0
- tina4_python-3.12.13/tina4_python/public/js/tina4-dev-admin.js +1089 -0
- tina4_python-3.12.13/tina4_python/public/js/tina4-dev-admin.min.js +1089 -0
- tina4_python-3.12.10/tina4_python/public/js/tina4-dev-admin.js +0 -1413
- tina4_python-3.12.10/tina4_python/public/js/tina4-dev-admin.min.js +0 -1413
- {tina4_python-3.12.10 → tina4_python-3.12.13}/.gitignore +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/Testing.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/events.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/core/response.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/docs.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.12.10 → tina4_python-3.12.13}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tina4-python
|
|
3
|
-
Version: 3.12.
|
|
3
|
+
Version: 3.12.13
|
|
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
|
|
@@ -208,7 +208,7 @@ Visit `http://localhost:7146/api/hello` -- routes are auto-discovered, no import
|
|
|
208
208
|
Edit `.env`:
|
|
209
209
|
|
|
210
210
|
```bash
|
|
211
|
-
|
|
211
|
+
TINA4_DATABASE_URL=sqlite:///data/app.db
|
|
212
212
|
```
|
|
213
213
|
|
|
214
214
|
Create and run a migration:
|
|
@@ -695,7 +695,7 @@ Frond.clear_cache()
|
|
|
695
695
|
|
|
696
696
|
```bash
|
|
697
697
|
SECRET=your-jwt-secret
|
|
698
|
-
|
|
698
|
+
TINA4_DATABASE_URL=sqlite:///data/app.db
|
|
699
699
|
TINA4_DEBUG=true # Enable dev toolbar, error overlay
|
|
700
700
|
TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
|
|
701
701
|
TINA4_LOCALE=en # en, fr, af, zh, ja, es
|
|
@@ -176,7 +176,7 @@ Visit `http://localhost:7146/api/hello` -- routes are auto-discovered, no import
|
|
|
176
176
|
Edit `.env`:
|
|
177
177
|
|
|
178
178
|
```bash
|
|
179
|
-
|
|
179
|
+
TINA4_DATABASE_URL=sqlite:///data/app.db
|
|
180
180
|
```
|
|
181
181
|
|
|
182
182
|
Create and run a migration:
|
|
@@ -663,7 +663,7 @@ Frond.clear_cache()
|
|
|
663
663
|
|
|
664
664
|
```bash
|
|
665
665
|
SECRET=your-jwt-secret
|
|
666
|
-
|
|
666
|
+
TINA4_DATABASE_URL=sqlite:///data/app.db
|
|
667
667
|
TINA4_DEBUG=true # Enable dev toolbar, error overlay
|
|
668
668
|
TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
|
|
669
669
|
TINA4_LOCALE=en # en, fr, af, zh, ja, es
|
|
@@ -287,7 +287,7 @@ Queue(topic="tasks").push({"action": "send_email"})
|
|
|
287
287
|
- Other methods: `.to_json()`, `.to_array()`, `.to_csv()`, `.to_paginate()`
|
|
288
288
|
4. **fetch_one()**: Returns a plain dict (or None), NOT a DatabaseResult
|
|
289
289
|
5. **Dict access**: All query results use dict access `row["column"]` not attribute access `row.column`
|
|
290
|
-
6. **Connection strings**: v3 uses standard URL format: `driver://host:port/database` with separate `username` and `password` parameters. Example: `Database("firebird://localhost:3050//path/to/db", "SYSDBA", "masterkey")`. Environment variable: `
|
|
290
|
+
6. **Connection strings**: v3 uses standard URL format: `driver://host:port/database` with separate `username` and `password` parameters. Example: `Database("firebird://localhost:3050//path/to/db", "SYSDBA", "masterkey")`. Environment variable: `TINA4_DATABASE_URL`.
|
|
291
291
|
7. **Running the app**: `uv run python app.py <port> <name>` — port and name are CLI args handled by tina4_python
|
|
292
292
|
8. **SCSS**: Files in `src/scss/` are auto-compiled to `src/public/css/` on startup
|
|
293
293
|
12. **Background tasks**: Use `background(fn, interval)` from `tina4_python.core.server` — never use `threading.Thread` for periodic work. The `background()` function runs tasks cooperatively in the asyncio event loop with proper shutdown handling.
|
|
@@ -408,7 +408,7 @@ Set `TINA4_DEBUG=true` in `.env` to enable development features:
|
|
|
408
408
|
- **CSS hot-reload** — SCSS/CSS changes refresh stylesheets without full page reload
|
|
409
409
|
- **SCSS auto-compile** — `.scss` files in `src/scss/` are compiled to `src/public/css/` on save
|
|
410
410
|
- **Error overlay** — Runtime errors display a rich, syntax-highlighted overlay in the browser
|
|
411
|
-
- **
|
|
411
|
+
- **Route re-discovery** — `POST /__dev/api/reload` re-runs auto-discover, so new files in `src/routes/`, `src/orm/`, or `src/app/` register without a server restart. Existing modules are NOT re-imported — for code changes inside an already-loaded file, restart the server.
|
|
412
412
|
|
|
413
413
|
DevReload connects via WebSocket at `/__dev_reload`. No configuration needed.
|
|
414
414
|
|
|
@@ -741,7 +741,7 @@ db = Database("postgresql://localhost:5432/mydb", "user", "password") # Post
|
|
|
741
741
|
db = Database("mysql://localhost:3306/mydb", "user", "password") # MySQL
|
|
742
742
|
db = Database("firebird://localhost:3050//path/to/db", "SYSDBA", "masterkey") # Firebird
|
|
743
743
|
db = Database("mssql://localhost:1433/mydb", "sa", "password") # MSSQL
|
|
744
|
-
db = Database() # Uses
|
|
744
|
+
db = Database() # Uses TINA4_DATABASE_URL env var
|
|
745
745
|
```
|
|
746
746
|
|
|
747
747
|
### MongoDB support
|
|
@@ -1565,9 +1565,9 @@ TINA4_API_KEY=your-api-key # Static bearer token for API auth (API_KEY fa
|
|
|
1565
1565
|
TINA4_TOKEN_LIMIT=60 # Token lifetime in minutes (default: 60)
|
|
1566
1566
|
|
|
1567
1567
|
# Database
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1568
|
+
TINA4_DATABASE_URL=sqlite:///app.db # Connection URL (driver://host:port/database)
|
|
1569
|
+
TINA4_DATABASE_USERNAME= # DB username (for PostgreSQL, MySQL, etc.)
|
|
1570
|
+
TINA4_DATABASE_PASSWORD= # DB password
|
|
1571
1571
|
|
|
1572
1572
|
# Framework
|
|
1573
1573
|
TINA4_DEBUG=true # Enable dev mode (toolbar, live reload, error overlay)
|
|
@@ -258,7 +258,7 @@ def _console(args=None):
|
|
|
258
258
|
from tina4_python.api import Api
|
|
259
259
|
from tina4_python.core.events import on, emit
|
|
260
260
|
|
|
261
|
-
# Try to connect database from
|
|
261
|
+
# Try to connect database from TINA4_DATABASE_URL
|
|
262
262
|
db = None
|
|
263
263
|
db_url = os.environ.get("TINA4_DATABASE_URL")
|
|
264
264
|
if db_url:
|
|
@@ -345,15 +345,17 @@ def _init(args):
|
|
|
345
345
|
encoding="utf-8",
|
|
346
346
|
)
|
|
347
347
|
|
|
348
|
-
# Create .env
|
|
348
|
+
# Create .env — every framework env var is TINA4_-prefixed since v3.12.
|
|
349
|
+
# The boot guard refuses to start with bare DATABASE_URL / SECRET set,
|
|
350
|
+
# so a fresh project MUST get the prefixed names.
|
|
349
351
|
env_file = target / ".env"
|
|
350
352
|
if not env_file.exists():
|
|
351
353
|
env_file.write_text(
|
|
352
354
|
"# Tina4 Configuration\n"
|
|
353
355
|
"TINA4_DEBUG=true\n"
|
|
354
356
|
"TINA4_LOG_LEVEL=ALL\n"
|
|
355
|
-
"
|
|
356
|
-
'
|
|
357
|
+
"TINA4_DATABASE_URL=sqlite:///data/app.db\n"
|
|
358
|
+
'TINA4_SECRET=change-me-in-production\n',
|
|
357
359
|
encoding="utf-8",
|
|
358
360
|
)
|
|
359
361
|
|
|
@@ -19,7 +19,7 @@ class Request:
|
|
|
19
19
|
"""Parsed HTTP request — everything a route handler needs."""
|
|
20
20
|
|
|
21
21
|
__slots__ = (
|
|
22
|
-
"method", "path", "query_string", "params", "query", "headers",
|
|
22
|
+
"method", "path", "url", "query_string", "params", "query", "headers",
|
|
23
23
|
"body", "raw_body", "cookies", "files", "ip",
|
|
24
24
|
"content_type", "session", "_route_params",
|
|
25
25
|
)
|
|
@@ -27,6 +27,7 @@ class Request:
|
|
|
27
27
|
def __init__(self):
|
|
28
28
|
self.method: str = "GET"
|
|
29
29
|
self.path: str = "/"
|
|
30
|
+
self.url: str = "/"
|
|
30
31
|
self.query_string: str = ""
|
|
31
32
|
self.params: dict = {} # Query string + route params merged
|
|
32
33
|
self.query: dict = {} # Query string params only (separate from route params)
|
|
@@ -56,6 +57,24 @@ class Request:
|
|
|
56
57
|
req.content_type = req.headers.get("content-type", "")
|
|
57
58
|
req.ip = _extract_ip(scope, req.headers)
|
|
58
59
|
|
|
60
|
+
# Reconstruct the full absolute URL — scheme://host[:port]/path[?query].
|
|
61
|
+
# Honours x-forwarded-proto and x-forwarded-host so apps behind a proxy
|
|
62
|
+
# still see the URL the client used. Matches PHP/Ruby/Node parity.
|
|
63
|
+
scheme = (
|
|
64
|
+
req.headers.get("x-forwarded-proto")
|
|
65
|
+
or scope.get("scheme")
|
|
66
|
+
or "http"
|
|
67
|
+
)
|
|
68
|
+
host = (
|
|
69
|
+
req.headers.get("x-forwarded-host")
|
|
70
|
+
or req.headers.get("host")
|
|
71
|
+
or "localhost"
|
|
72
|
+
)
|
|
73
|
+
url = f"{scheme}://{host}{req.path}"
|
|
74
|
+
if req.query_string:
|
|
75
|
+
url = f"{url}?{req.query_string}"
|
|
76
|
+
req.url = url
|
|
77
|
+
|
|
59
78
|
# Check upload size limit
|
|
60
79
|
content_length = int(req.headers.get("content-length", 0) or 0)
|
|
61
80
|
if content_length > TINA4_MAX_UPLOAD_SIZE or len(body) > TINA4_MAX_UPLOAD_SIZE:
|
|
@@ -246,6 +246,33 @@ class Router:
|
|
|
246
246
|
"""Register a DELETE route (imperative, non-decorator style)."""
|
|
247
247
|
return cls.add("DELETE", path, handler, middleware=middleware, swagger_meta=swagger_meta, template=template, **options)
|
|
248
248
|
|
|
249
|
+
@classmethod
|
|
250
|
+
def head(cls, path: str, handler, middleware: list = None, swagger_meta: dict = None, template: str = None, **options) -> "RouteRef":
|
|
251
|
+
"""Register an explicit HEAD route.
|
|
252
|
+
|
|
253
|
+
By default the framework auto-handles HEAD by falling back to the GET
|
|
254
|
+
route and stripping the body (RFC 9110 §9.3.2). Use this method only
|
|
255
|
+
when you need a HEAD handler that does something different from GET —
|
|
256
|
+
e.g. cheaper existence-check logic, custom validator headers without
|
|
257
|
+
the cost of building the body.
|
|
258
|
+
|
|
259
|
+
The framework still strips the response body for you on the way out —
|
|
260
|
+
HEAD MUST NOT return content, even if your handler does, so we
|
|
261
|
+
enforce that unconditionally rather than relying on developer care.
|
|
262
|
+
"""
|
|
263
|
+
return cls.add("HEAD", path, handler, middleware=middleware, swagger_meta=swagger_meta, template=template, **options)
|
|
264
|
+
|
|
265
|
+
@classmethod
|
|
266
|
+
def options(cls, path: str, handler, middleware: list = None, swagger_meta: dict = None, template: str = None, **options) -> "RouteRef":
|
|
267
|
+
"""Register an explicit OPTIONS route.
|
|
268
|
+
|
|
269
|
+
By default the framework auto-handles OPTIONS by building an Allow
|
|
270
|
+
header from every method registered for the path and returning 204
|
|
271
|
+
(RFC 9110 §9.3.7). Use this method to take over that behaviour —
|
|
272
|
+
e.g. to return a richer OPTIONS payload describing the resource.
|
|
273
|
+
"""
|
|
274
|
+
return cls.add("OPTIONS", path, handler, middleware=middleware, swagger_meta=swagger_meta, template=template, **options)
|
|
275
|
+
|
|
249
276
|
@classmethod
|
|
250
277
|
def any(cls, path: str, handler, middleware: list = None, swagger_meta: dict = None, template: str = None, **options) -> "RouteRef":
|
|
251
278
|
"""Register a route for any HTTP method (imperative, non-decorator style)."""
|
|
@@ -291,7 +318,11 @@ class Router:
|
|
|
291
318
|
# Route has custom middleware — developer handles auth themselves
|
|
292
319
|
auth_required = False
|
|
293
320
|
else:
|
|
294
|
-
|
|
321
|
+
# GET, HEAD, OPTIONS, and ANY are public by default. HEAD and
|
|
322
|
+
# OPTIONS are safe/idempotent introspection methods (RFC 9110
|
|
323
|
+
# §9.2.1) — requiring auth on them breaks cache validators
|
|
324
|
+
# and CORS preflight probes.
|
|
325
|
+
auth_required = m not in ("GET", "HEAD", "OPTIONS", "ANY")
|
|
295
326
|
|
|
296
327
|
route = {
|
|
297
328
|
"method": m,
|
|
@@ -312,9 +343,18 @@ class Router:
|
|
|
312
343
|
|
|
313
344
|
@staticmethod
|
|
314
345
|
def match(method: str, path: str) -> tuple[dict | None, dict]:
|
|
315
|
-
"""Find a route matching method + path. Returns (route, params).
|
|
346
|
+
"""Find a route matching method + path. Returns (route, params).
|
|
347
|
+
|
|
348
|
+
RFC 9110 §9.3.2: HEAD is identical to GET except the response carries
|
|
349
|
+
no body. If the app didn't register a dedicated HEAD route, we
|
|
350
|
+
transparently match the GET route; the dispatcher strips the body on
|
|
351
|
+
the way out, so the handler doesn't need to know HEAD even happened.
|
|
352
|
+
"""
|
|
353
|
+
method_upper = method.upper()
|
|
354
|
+
|
|
355
|
+
# First pass: exact method match (covers HEAD → explicit HEAD route too)
|
|
316
356
|
for route in _routes:
|
|
317
|
-
if route["method"] not in (
|
|
357
|
+
if route["method"] not in (method_upper, "ANY"):
|
|
318
358
|
continue
|
|
319
359
|
m = route["pattern"].match(path)
|
|
320
360
|
if m:
|
|
@@ -323,8 +363,58 @@ class Router:
|
|
|
323
363
|
params[name] = m.group(i + 1)
|
|
324
364
|
return route, params
|
|
325
365
|
|
|
366
|
+
# Second pass: HEAD auto-fallback to GET when no HEAD route registered
|
|
367
|
+
if method_upper == "HEAD":
|
|
368
|
+
for route in _routes:
|
|
369
|
+
if route["method"] not in ("GET", "ANY"):
|
|
370
|
+
continue
|
|
371
|
+
m = route["pattern"].match(path)
|
|
372
|
+
if m:
|
|
373
|
+
params = {}
|
|
374
|
+
for i, name in enumerate(route["param_names"]):
|
|
375
|
+
params[name] = m.group(i + 1)
|
|
376
|
+
return route, params
|
|
377
|
+
|
|
326
378
|
return None, {}
|
|
327
379
|
|
|
380
|
+
@staticmethod
|
|
381
|
+
def methods_allowed_for_path(path: str) -> list[str]:
|
|
382
|
+
"""Return the list of HTTP methods registered for ``path``, in the
|
|
383
|
+
order GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS. Used by
|
|
384
|
+
the dispatcher to build the ``Allow:`` header on 405 / OPTIONS
|
|
385
|
+
responses (RFC 9110 §10.2.1, §9.3.7).
|
|
386
|
+
|
|
387
|
+
If GET is registered, HEAD is appended implicitly (the framework
|
|
388
|
+
auto-falls-back HEAD to GET). OPTIONS is appended whenever the
|
|
389
|
+
path has any registered method (the framework auto-handles OPTIONS).
|
|
390
|
+
"""
|
|
391
|
+
# ANY routes count for every method but we don't enumerate them
|
|
392
|
+
# individually — flag whether ANY matched and union it with the
|
|
393
|
+
# concrete-method matches.
|
|
394
|
+
method_order = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
|
395
|
+
seen: set[str] = set()
|
|
396
|
+
any_matched = False
|
|
397
|
+
|
|
398
|
+
for route in _routes:
|
|
399
|
+
if not route["pattern"].match(path):
|
|
400
|
+
continue
|
|
401
|
+
m = route["method"]
|
|
402
|
+
if m == "ANY":
|
|
403
|
+
any_matched = True
|
|
404
|
+
elif m in method_order:
|
|
405
|
+
seen.add(m)
|
|
406
|
+
|
|
407
|
+
if any_matched:
|
|
408
|
+
seen.update(method_order)
|
|
409
|
+
|
|
410
|
+
# GET implies HEAD; any registered method implies OPTIONS.
|
|
411
|
+
if seen:
|
|
412
|
+
if "GET" in seen:
|
|
413
|
+
seen.add("HEAD")
|
|
414
|
+
seen.add("OPTIONS")
|
|
415
|
+
|
|
416
|
+
return [m for m in method_order if m in seen]
|
|
417
|
+
|
|
328
418
|
@staticmethod
|
|
329
419
|
def get_routes() -> list[dict]:
|
|
330
420
|
"""Return all registered routes."""
|
|
@@ -49,19 +49,45 @@ def background(callback, interval: float = 1.0):
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def _auto_discover(root_dir: str = "src"):
|
|
52
|
-
"""Auto-import all .py files in
|
|
52
|
+
"""Auto-import all .py files in ``root_dir`` to trigger route decorators.
|
|
53
|
+
|
|
54
|
+
Idempotent and re-runnable: skips modules already in ``sys.modules`` so
|
|
55
|
+
re-discovery on /__dev/api/reload is cheap. New files added after server
|
|
56
|
+
boot get picked up on the next reload signal.
|
|
57
|
+
|
|
58
|
+
Import failures are recorded to ``data/.broken/`` so /health surfaces them
|
|
59
|
+
instead of swallowing them into a console line nobody reads.
|
|
60
|
+
"""
|
|
53
61
|
root = Path(root_dir).resolve()
|
|
54
62
|
if not root.is_dir():
|
|
55
63
|
return
|
|
56
64
|
|
|
65
|
+
# Folders to skip — non-Python sub-trees inside src/.
|
|
57
66
|
skip = {"public", "templates", "scss", "locales", "icons"}
|
|
67
|
+
# Routes folder is special-cased so the user gets a clear warning when
|
|
68
|
+
# it exists but contains zero discoverable Python files.
|
|
69
|
+
routes_dir = root / "routes"
|
|
70
|
+
found_route_files = 0
|
|
58
71
|
|
|
59
72
|
for py_file in sorted(root.rglob("*.py")):
|
|
60
|
-
|
|
73
|
+
# Only filter on parts INSIDE src/, not the absolute path. Previously
|
|
74
|
+
# `py_file.parts` included every ancestor, so a project living under
|
|
75
|
+
# something like /Users/me/_archive/myapp would silently skip every
|
|
76
|
+
# file. Compute the relative parts first and filter on those.
|
|
77
|
+
try:
|
|
78
|
+
rel_to_root = py_file.relative_to(root)
|
|
79
|
+
except ValueError:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
rel_parts = rel_to_root.parts
|
|
83
|
+
if any(part.startswith("_") for part in rel_parts):
|
|
61
84
|
continue
|
|
62
|
-
if any(s in
|
|
85
|
+
if any(s in rel_parts for s in skip):
|
|
63
86
|
continue
|
|
64
87
|
|
|
88
|
+
if routes_dir in py_file.parents:
|
|
89
|
+
found_route_files += 1
|
|
90
|
+
|
|
65
91
|
try:
|
|
66
92
|
rel = py_file.relative_to(Path.cwd()).with_suffix("")
|
|
67
93
|
module_name = ".".join(rel.parts)
|
|
@@ -70,6 +96,39 @@ def _auto_discover(root_dir: str = "src"):
|
|
|
70
96
|
Log.debug(f"Loaded: {module_name}")
|
|
71
97
|
except Exception as e:
|
|
72
98
|
Log.error(f"Failed to load {py_file}: {e}")
|
|
99
|
+
_record_broken_import(py_file, e)
|
|
100
|
+
|
|
101
|
+
# User-friendly hint: routes folder has Python files but the router is
|
|
102
|
+
# still empty. They almost certainly forgot the @get/@post decorator.
|
|
103
|
+
if found_route_files > 0:
|
|
104
|
+
try:
|
|
105
|
+
from tina4_python.core.router import Router
|
|
106
|
+
if not Router.get_routes():
|
|
107
|
+
Log.warning(
|
|
108
|
+
f"Auto-discover found {found_route_files} .py file(s) in "
|
|
109
|
+
f"{routes_dir} but no routes registered. Did you forget "
|
|
110
|
+
f"@get / @post / @put / @delete on your handler?"
|
|
111
|
+
)
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _record_broken_import(py_file: Path, error: Exception) -> None:
|
|
117
|
+
"""Write a .broken sentinel so /health and the dev dashboard surface
|
|
118
|
+
auto-discover failures instead of burying them in the console."""
|
|
119
|
+
try:
|
|
120
|
+
broken_dir = Path("data/.broken")
|
|
121
|
+
broken_dir.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
import json
|
|
123
|
+
slug = str(py_file).replace("/", "_").replace("\\", "_")
|
|
124
|
+
(broken_dir / f"discover_{slug}.broken").write_text(json.dumps({
|
|
125
|
+
"type": "auto_discover_failure",
|
|
126
|
+
"file": str(py_file),
|
|
127
|
+
"error": f"{type(error).__name__}: {error}",
|
|
128
|
+
}))
|
|
129
|
+
except Exception:
|
|
130
|
+
# If even the .broken write fails we already logged the original error.
|
|
131
|
+
pass
|
|
73
132
|
|
|
74
133
|
|
|
75
134
|
def _ensure_folders():
|
|
@@ -932,10 +991,35 @@ async def _handle_dev_admin(request: Request, response: Response) -> Response:
|
|
|
932
991
|
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev Admin</title></head>
|
|
933
992
|
<body><div id="app" data-framework="python" data-color="#3b82f6"></div>
|
|
934
993
|
<script src="/js/tina4-dev-admin.min.js"></script></body></html>""")
|
|
994
|
+
# Never cache the dev admin shell — otherwise old HTML can
|
|
995
|
+
# keep loading stale widget references / outdated bundle URLs
|
|
996
|
+
# after we ship UX changes, leaving the user staring at
|
|
997
|
+
# phantoms that no longer exist server-side.
|
|
998
|
+
response.header("cache-control", "no-store, must-revalidate")
|
|
999
|
+
response.header("pragma", "no-cache")
|
|
935
1000
|
else:
|
|
936
1001
|
handlers = get_api_handlers()
|
|
937
1002
|
handler_info = handlers.get(request.path)
|
|
938
|
-
if
|
|
1003
|
+
if not handler_info:
|
|
1004
|
+
# Fallback: longest-prefix wildcard match. Routes registered
|
|
1005
|
+
# with a trailing "/*" (e.g. "/__dev/api/threads/*") catch
|
|
1006
|
+
# everything under that namespace — used for parameterised
|
|
1007
|
+
# resources like /threads/{id} that don't fit exact-match.
|
|
1008
|
+
best_prefix = ""
|
|
1009
|
+
for key, info in handlers.items():
|
|
1010
|
+
if key.endswith("/*"):
|
|
1011
|
+
prefix = key[:-1] # keep the trailing slash
|
|
1012
|
+
if request.path.startswith(prefix) and len(prefix) > len(best_prefix):
|
|
1013
|
+
best_prefix = prefix
|
|
1014
|
+
handler_info = info
|
|
1015
|
+
# Allow "*" as a method wildcard for handlers that switch on
|
|
1016
|
+
# request.method themselves (REST resources with GET+POST+PATCH
|
|
1017
|
+
# on the same path).
|
|
1018
|
+
method_ok = (
|
|
1019
|
+
handler_info is not None
|
|
1020
|
+
and (handler_info[0] == "*" or request.method == handler_info[0])
|
|
1021
|
+
)
|
|
1022
|
+
if method_ok:
|
|
939
1023
|
try:
|
|
940
1024
|
def _resp(data, code=200, content_type=None):
|
|
941
1025
|
# content_type overrides the auto-detected MIME —
|
|
@@ -955,6 +1039,14 @@ async def _handle_dev_admin(request: Request, response: Response) -> Response:
|
|
|
955
1039
|
response.status(code).json(data)
|
|
956
1040
|
return data
|
|
957
1041
|
_resp.render = response.render
|
|
1042
|
+
# Expose .stream() so handlers can return an SSE/chunked
|
|
1043
|
+
# response — used by the dev_admin supervisor proxy to
|
|
1044
|
+
# forward the agent server's text/event-stream live
|
|
1045
|
+
# (instead of buffering the whole multi-agent run).
|
|
1046
|
+
_resp.stream = response.stream
|
|
1047
|
+
# Expose .header() so handlers can set custom headers
|
|
1048
|
+
# (e.g. Cache-Control on the feedback widget bundle).
|
|
1049
|
+
_resp.header = response.header
|
|
958
1050
|
import inspect
|
|
959
1051
|
_tsig = inspect.signature(handler_info[1])
|
|
960
1052
|
_tpcount = len(_tsig.parameters)
|
|
@@ -1306,12 +1398,17 @@ async def handle(request: Request) -> Response:
|
|
|
1306
1398
|
canonical = request.path.rstrip("/") or "/"
|
|
1307
1399
|
return response.status(301).header("location", canonical)
|
|
1308
1400
|
|
|
1309
|
-
# Dev admin — also catches /ai/api/chat (SPA's ollama proxy)
|
|
1401
|
+
# Dev admin — also catches /ai/api/chat (SPA's ollama proxy), the
|
|
1310
1402
|
# bare /ai /vision /embed /image /rag service-health probes that
|
|
1311
|
-
# drive the "SERVICES ●●●●●" dots in the dev-admin UI
|
|
1403
|
+
# drive the "SERVICES ●●●●●" dots in the dev-admin UI, and the
|
|
1404
|
+
# /__feedback/* routes used by the customer feedback widget. The
|
|
1405
|
+
# feedback paths live OUTSIDE /__dev because the widget is for
|
|
1406
|
+
# whitelisted END USERS of the shipped app — they shouldn't see
|
|
1407
|
+
# any /__dev URL in their network tab.
|
|
1312
1408
|
_dev_extra_paths = {"/ai/api/chat", "/ai", "/vision", "/embed", "/image", "/rag"}
|
|
1313
1409
|
if _is_dev and (
|
|
1314
1410
|
request.path.startswith("/__dev")
|
|
1411
|
+
or request.path.startswith("/__feedback")
|
|
1315
1412
|
or request.path in _dev_extra_paths
|
|
1316
1413
|
):
|
|
1317
1414
|
return await _handle_dev_admin(request, response)
|
|
@@ -1338,7 +1435,43 @@ async def handle(request: Request) -> Response:
|
|
|
1338
1435
|
except Exception as e:
|
|
1339
1436
|
response = _handle_route_error(e, request, response, request_id, _is_dev)
|
|
1340
1437
|
else:
|
|
1341
|
-
|
|
1438
|
+
# RFC 9110 conformance — before falling through to 404 / static / template,
|
|
1439
|
+
# check whether the PATH is known to the router under any OTHER method.
|
|
1440
|
+
# If yes:
|
|
1441
|
+
# - OPTIONS request → 204 No Content with Allow listing the methods
|
|
1442
|
+
# (RFC 9110 §9.3.7). Generic OPTIONS handler.
|
|
1443
|
+
# - Any other method (PUT on GET-only route, TRACE, CONNECT, etc.)
|
|
1444
|
+
# → 405 Method Not Allowed with Allow header (§15.5.6 + §10.2.1).
|
|
1445
|
+
allowed = Router.methods_allowed_for_path(request.path)
|
|
1446
|
+
if allowed:
|
|
1447
|
+
allow_header = ", ".join(allowed)
|
|
1448
|
+
if request.method.upper() == "OPTIONS":
|
|
1449
|
+
response.header("Allow", allow_header)
|
|
1450
|
+
response.status(204)
|
|
1451
|
+
else:
|
|
1452
|
+
response.header("Allow", allow_header)
|
|
1453
|
+
response.status(405).json({
|
|
1454
|
+
"error": "Method Not Allowed",
|
|
1455
|
+
"path": request.path,
|
|
1456
|
+
"method": request.method,
|
|
1457
|
+
"allow": allowed,
|
|
1458
|
+
"status": 405,
|
|
1459
|
+
})
|
|
1460
|
+
else:
|
|
1461
|
+
response = _handle_no_route(request, response, request_id)
|
|
1462
|
+
|
|
1463
|
+
# RFC 9110 §9.3.2: a HEAD response MUST NOT include content. Strip the
|
|
1464
|
+
# body unconditionally (even for explicit Router.head() handlers that
|
|
1465
|
+
# accidentally returned one) and record what Content-Length the GET
|
|
1466
|
+
# would have sent — cache validators / link checkers / monitoring
|
|
1467
|
+
# probes rely on that header to size estimates.
|
|
1468
|
+
if request.method.upper() == "HEAD":
|
|
1469
|
+
body = response.content if response.content is not None else b""
|
|
1470
|
+
if isinstance(body, str):
|
|
1471
|
+
body = body.encode("utf-8")
|
|
1472
|
+
if body:
|
|
1473
|
+
response.header("Content-Length", str(len(body)))
|
|
1474
|
+
response.content = b""
|
|
1342
1475
|
|
|
1343
1476
|
return _finalize_response(request, response, route, request_id, _is_dev, _req_start)
|
|
1344
1477
|
|
|
@@ -1407,6 +1540,22 @@ async def app(scope: dict, receive, send):
|
|
|
1407
1540
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
1408
1541
|
return
|
|
1409
1542
|
|
|
1543
|
+
# Customer feedback widget injection — adds <script src="/__feedback/widget.js">
|
|
1544
|
+
# to HTML responses for whitelisted users. No-op if the feature is
|
|
1545
|
+
# off (TINA4_FEEDBACK_WHITELIST empty) or the user isn't whitelisted
|
|
1546
|
+
# or the body isn't HTML. Done BEFORE ETag/header build so the
|
|
1547
|
+
# injected bytes are included in the ETag hash + Content-Length.
|
|
1548
|
+
try:
|
|
1549
|
+
if (
|
|
1550
|
+
response.content
|
|
1551
|
+
and isinstance(response.content, (bytes, bytearray))
|
|
1552
|
+
and "text/html" in (response.content_type or "").lower()
|
|
1553
|
+
):
|
|
1554
|
+
from tina4_python.dev_admin import inject_feedback_widget
|
|
1555
|
+
response.content = inject_feedback_widget(request, bytes(response.content))
|
|
1556
|
+
except Exception:
|
|
1557
|
+
pass # Injection is best-effort — never break the response.
|
|
1558
|
+
|
|
1410
1559
|
# ETag check — 304 Not Modified
|
|
1411
1560
|
if_none_match = request.headers.get("if-none-match", "")
|
|
1412
1561
|
accept_encoding = request.headers.get("accept-encoding", "")
|
|
@@ -79,9 +79,31 @@ def _format_source_block(filename: str, lineno: int) -> str:
|
|
|
79
79
|
)
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
def _format_frame(frame: traceback.FrameSummary) -> str:
|
|
83
|
-
"""Render one stack frame.
|
|
82
|
+
def _format_frame(frame: traceback.FrameSummary, captured_at: float = 0.0) -> str:
|
|
83
|
+
"""Render one stack frame.
|
|
84
|
+
|
|
85
|
+
When the file was modified AFTER `captured_at`, append a
|
|
86
|
+
"(file modified since)" badge so a stale browser-cached overlay
|
|
87
|
+
can't lie about what the source looks like now. The AI coder
|
|
88
|
+
often rewrites files in place between page loads, leaving the
|
|
89
|
+
overlay's source view showing different code than what raised
|
|
90
|
+
the error.
|
|
91
|
+
"""
|
|
84
92
|
source = _format_source_block(frame.filename, frame.lineno) if frame.filename and frame.lineno else ""
|
|
93
|
+
stale_badge = ""
|
|
94
|
+
if captured_at and frame.filename:
|
|
95
|
+
try:
|
|
96
|
+
mtime = os.path.getmtime(frame.filename)
|
|
97
|
+
if mtime > captured_at + 0.5: # 0.5s margin for fs noise
|
|
98
|
+
from datetime import datetime, timezone
|
|
99
|
+
mtime_iso = datetime.fromtimestamp(mtime, tz=timezone.utc).strftime("%H:%M:%S")
|
|
100
|
+
stale_badge = (
|
|
101
|
+
f' <span style="background:{_PEACH};color:{_BG};padding:1px 8px;'
|
|
102
|
+
f'border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;">'
|
|
103
|
+
f'FILE MODIFIED @ {mtime_iso} — source may not match what failed</span>'
|
|
104
|
+
)
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
85
107
|
return (
|
|
86
108
|
f'<div style="margin-bottom:16px;">'
|
|
87
109
|
f'<div style="margin-bottom:4px;">'
|
|
@@ -90,6 +112,7 @@ def _format_frame(frame: traceback.FrameSummary) -> str:
|
|
|
90
112
|
f'<span style="color:{_YELLOW};">{frame.lineno}</span>'
|
|
91
113
|
f'<span style="color:{_SUBTEXT};"> in </span>'
|
|
92
114
|
f'<span style="color:{_GREEN};">{_escape(frame.name)}</span>'
|
|
115
|
+
f"{stale_badge}"
|
|
93
116
|
f"</div>"
|
|
94
117
|
f"{source}"
|
|
95
118
|
f"</div>"
|
|
@@ -131,14 +154,23 @@ def render_error_overlay(exception: BaseException, request: Any = None) -> str:
|
|
|
131
154
|
Returns:
|
|
132
155
|
A complete HTML page string.
|
|
133
156
|
"""
|
|
157
|
+
import time as _time
|
|
158
|
+
captured_at = _time.time()
|
|
159
|
+
captured_iso = _time.strftime("%H:%M:%S UTC", _time.gmtime(captured_at))
|
|
160
|
+
|
|
134
161
|
exc_type = type(exception).__qualname__
|
|
135
162
|
exc_msg = str(exception)
|
|
136
163
|
tb = traceback.extract_tb(exception.__traceback__)
|
|
137
164
|
|
|
138
165
|
# ── Stack trace ──
|
|
166
|
+
# Each frame compares its source file's mtime to captured_at and
|
|
167
|
+
# flags itself if the file has been modified since — protects
|
|
168
|
+
# against the "browser cached an old overlay, then the AI rewrote
|
|
169
|
+
# the file" confusion where displayed source no longer matches
|
|
170
|
+
# what actually raised the error.
|
|
139
171
|
frames_html = ""
|
|
140
172
|
for frame in reversed(tb):
|
|
141
|
-
frames_html += _format_frame(frame)
|
|
173
|
+
frames_html += _format_frame(frame, captured_at=captured_at)
|
|
142
174
|
|
|
143
175
|
# ── Request info ──
|
|
144
176
|
request_pairs: list[tuple[str, str]] = []
|
|
@@ -192,6 +224,7 @@ body{{background:{_BG};color:{_TEXT};font-family:-apple-system,BlinkMacSystemFon
|
|
|
192
224
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
|
193
225
|
<span style="background:{_RED};color:{_BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;">Error</span>
|
|
194
226
|
<span style="color:{_SUBTEXT};font-size:14px;">Tina4 Debug Overlay</span>
|
|
227
|
+
<span style="color:{_SUBTEXT};font-size:12px;margin-left:auto;font-family:'SF Mono',Menlo,monospace;">captured {captured_iso}</span>
|
|
195
228
|
</div>
|
|
196
229
|
<h1 style="color:{_RED};font-size:28px;font-weight:700;margin-bottom:8px;">{_escape(exc_type)}</h1>
|
|
197
230
|
<p style="color:{_TEXT};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:{_SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid {_RED};">{_escape(exc_msg)}</p>
|