tina4-python 3.1.3__tar.gz → 3.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tina4_python-3.1.3 → tina4_python-3.3.0}/PKG-INFO +2 -2
- {tina4_python-3.1.3 → tina4_python-3.3.0}/README.md +1 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/pyproject.toml +1 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/CLAUDE.md +27 -33
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/__init__.py +1 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/ai/__init__.py +1 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/auth/__init__.py +14 -5
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/cli/__init__.py +37 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/core/__init__.py +2 -2
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/core/request.py +14 -6
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/core/router.py +175 -3
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/core/server.py +298 -5
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/database/adapter.py +186 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/database/connection.py +2 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/database/firebird.py +3 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/database/mssql.py +3 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/database/mysql.py +3 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/database/odbc.py +3 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/database/postgres.py +3 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/database/sqlite.py +3 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/debug/error_overlay.py +10 -6
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/dev_admin/__init__.py +44 -14
- tina4_python-3.3.0/tina4_python/dev_reload.py +206 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/frond/engine.py +128 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +3 -6
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/migration/__init__.py +2 -2
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/migration/runner.py +66 -20
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/orm/model.py +49 -15
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/public/js/tina4-dev-admin.min.js +1 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/queue/__init__.py +151 -94
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/queue_backends/__init__.py +4 -3
- tina4_python-3.3.0/tina4_python/queue_backends/mongo_backend.py +210 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/session/__init__.py +21 -1
- {tina4_python-3.1.3 → tina4_python-3.3.0}/.gitignore +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/Testing.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/core/events.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/core/response.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.1.3 → tina4_python-3.3.0}/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.
|
|
3
|
+
Version: 3.3.0
|
|
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
|
|
@@ -115,7 +115,7 @@ Every feature is built from scratch -- no pip install, no node_modules, no third
|
|
|
115
115
|
| **Database** | SQLite, PostgreSQL, MySQL, MSSQL, Firebird -- unified adapter interface, query caching (TINA4_DB_CACHE=true for 4x speedup) |
|
|
116
116
|
| **Auth** | Zero-dep JWT (HS256), sessions (file/Redis/Valkey/MongoDB/database), password hashing, form tokens |
|
|
117
117
|
| **API** | Swagger/OpenAPI auto-generation, GraphQL with ORM auto-schema and GraphiQL IDE, WSDL/SOAP with auto WSDL |
|
|
118
|
-
| **Background** | Queue (SQLite/RabbitMQ/Kafka) with priority, delayed jobs, retry, batch processing |
|
|
118
|
+
| **Background** | Queue (SQLite/RabbitMQ/Kafka/MongoDB) with priority, delayed jobs, retry, batch processing |
|
|
119
119
|
| **Real-time** | Native asyncio WebSocket (RFC 6455), per-path routing, connection manager |
|
|
120
120
|
| **Frontend** | tina4-css (~24 KB), frond.js helper, SCSS compiler, live reload, CSS hot-reload |
|
|
121
121
|
| **DX** | Dev admin dashboard (11 tabs), error overlay, request inspector, AI tool integration, Carbonah green benchmarks |
|
|
@@ -83,7 +83,7 @@ Every feature is built from scratch -- no pip install, no node_modules, no third
|
|
|
83
83
|
| **Database** | SQLite, PostgreSQL, MySQL, MSSQL, Firebird -- unified adapter interface, query caching (TINA4_DB_CACHE=true for 4x speedup) |
|
|
84
84
|
| **Auth** | Zero-dep JWT (HS256), sessions (file/Redis/Valkey/MongoDB/database), password hashing, form tokens |
|
|
85
85
|
| **API** | Swagger/OpenAPI auto-generation, GraphQL with ORM auto-schema and GraphiQL IDE, WSDL/SOAP with auto WSDL |
|
|
86
|
-
| **Background** | Queue (SQLite/RabbitMQ/Kafka) with priority, delayed jobs, retry, batch processing |
|
|
86
|
+
| **Background** | Queue (SQLite/RabbitMQ/Kafka/MongoDB) with priority, delayed jobs, retry, batch processing |
|
|
87
87
|
| **Real-time** | Native asyncio WebSocket (RFC 6455), per-path routing, connection manager |
|
|
88
88
|
| **Frontend** | tina4-css (~24 KB), frond.js helper, SCSS compiler, live reload, CSS hot-reload |
|
|
89
89
|
| **DX** | Dev admin dashboard (11 tabs), error overlay, request inspector, AI tool integration, Carbonah green benchmarks |
|
|
@@ -154,7 +154,7 @@ Never use raw `requests` or `urllib` directly. Use the built-in `Api` class —
|
|
|
154
154
|
|
|
155
155
|
### 7. Use Queues for Long-Running Work
|
|
156
156
|
|
|
157
|
-
Route handlers must respond fast. Any operation that takes more than a second (sending emails, generating reports, calling slow external APIs, processing files) must be pushed to a Queue and
|
|
157
|
+
Route handlers must respond fast. Any operation that takes more than a second (sending emails, generating reports, calling slow external APIs, processing files) must be pushed to a Queue and consumed via `queue.consume()`.
|
|
158
158
|
|
|
159
159
|
**Bad — blocking the request:**
|
|
160
160
|
```python
|
|
@@ -170,8 +170,8 @@ async def generate_report(request, response):
|
|
|
170
170
|
```python
|
|
171
171
|
@post("/api/reports")
|
|
172
172
|
async def generate_report(request, response):
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
queue = Queue(topic="reports")
|
|
174
|
+
queue.push({"user_id": request.body["user_id"], "type": "monthly"})
|
|
175
175
|
return response({"status": "queued"})
|
|
176
176
|
```
|
|
177
177
|
|
|
@@ -219,7 +219,7 @@ Tina4 provides a full toolkit. Before writing custom code, check if the framewor
|
|
|
219
219
|
|
|
220
220
|
| Need | Use this — don't build your own |
|
|
221
221
|
|------|--------------------------------|
|
|
222
|
-
| Background jobs / async work | `Queue
|
|
222
|
+
| Background jobs / async work | `Queue` from `tina4_python.queue` (use `queue.push()`, `queue.consume()`) |
|
|
223
223
|
| HTTP calls to external APIs | `Api` from `tina4_python.api` |
|
|
224
224
|
| JWT tokens & auth | `Auth` from `tina4_python.auth` (create_token, validate_token, get_payload) |
|
|
225
225
|
| Password hashing | `Auth.hash_password()` / `Auth.check_password()` from `tina4_python.auth` |
|
|
@@ -254,8 +254,8 @@ task_queue = queue.Queue() # Don't do this!
|
|
|
254
254
|
|
|
255
255
|
**Good — use Tina4's Queue:**
|
|
256
256
|
```python
|
|
257
|
-
from tina4_python.queue import Queue
|
|
258
|
-
|
|
257
|
+
from tina4_python.queue import Queue
|
|
258
|
+
Queue(topic="tasks").push({"action": "send_email"})
|
|
259
259
|
```
|
|
260
260
|
|
|
261
261
|
### 11. Key tina4_python Gotchas
|
|
@@ -331,7 +331,7 @@ tina4_python/ # Core framework package (v3.0.0)
|
|
|
331
331
|
├── frond/ # Template engine (Frond — Jinja2/Twig-compatible)
|
|
332
332
|
│ └── engine.py # Frond class (render, add_filter, add_global, add_test)
|
|
333
333
|
├── api/ # HTTP client (Api — urllib, zero deps)
|
|
334
|
-
├── queue/ # Database-backed job queue (Queue,
|
|
334
|
+
├── queue/ # Database-backed job queue (Queue, Job)
|
|
335
335
|
├── swagger/ # OpenAPI 3.0.3 generator (Swagger, description, tags, example)
|
|
336
336
|
├── migration/ # SQL-file migrations (migrate, create_migration, rollback)
|
|
337
337
|
│ └── runner.py # Migration runner
|
|
@@ -1017,48 +1017,43 @@ async def admin_dashboard(request, response):
|
|
|
1017
1017
|
|
|
1018
1018
|
Supports: litequeue (default/SQLite, zero-config), RabbitMQ, Kafka, MongoDB.
|
|
1019
1019
|
|
|
1020
|
-
###
|
|
1020
|
+
### Producing — enqueue work from a route
|
|
1021
1021
|
|
|
1022
1022
|
```python
|
|
1023
|
-
from tina4_python.queue import Queue
|
|
1023
|
+
from tina4_python.queue import Queue
|
|
1024
1024
|
|
|
1025
1025
|
@post("/api/reports/generate")
|
|
1026
1026
|
async def request_report(request, response):
|
|
1027
|
-
queue = Queue(
|
|
1028
|
-
|
|
1029
|
-
producer.push({
|
|
1027
|
+
queue = Queue(topic="reports")
|
|
1028
|
+
queue.push({
|
|
1030
1029
|
"user_id": request.body["user_id"],
|
|
1031
1030
|
"report_type": "monthly",
|
|
1032
1031
|
})
|
|
1033
1032
|
return response({"status": "queued"})
|
|
1034
1033
|
```
|
|
1035
1034
|
|
|
1036
|
-
###
|
|
1035
|
+
### Consuming — process work in a background worker
|
|
1037
1036
|
|
|
1038
1037
|
```python
|
|
1039
1038
|
# worker.py (run separately: python worker.py)
|
|
1040
|
-
from tina4_python.queue import Queue
|
|
1039
|
+
from tina4_python.queue import Queue
|
|
1041
1040
|
from tina4_python.database import Database
|
|
1042
1041
|
|
|
1043
1042
|
db = Database("sqlite:///app.db")
|
|
1043
|
+
queue = Queue(topic="reports")
|
|
1044
1044
|
|
|
1045
|
-
|
|
1045
|
+
for job in queue.consume("reports"):
|
|
1046
1046
|
data = job.data
|
|
1047
1047
|
report = generate_report(data["user_id"], data["report_type"])
|
|
1048
1048
|
send_email(data["user_id"], report)
|
|
1049
|
-
|
|
1050
|
-
queue = Queue(db, topic="reports")
|
|
1051
|
-
consumer = Consumer(queue, callback=handle_report)
|
|
1052
|
-
consumer.run_forever()
|
|
1049
|
+
job.complete()
|
|
1053
1050
|
```
|
|
1054
1051
|
|
|
1055
1052
|
### Poll once for available jobs
|
|
1056
1053
|
|
|
1057
1054
|
```python
|
|
1058
|
-
queue = Queue(
|
|
1059
|
-
|
|
1060
|
-
jobs = consumer.poll() # Returns list of Job objects
|
|
1061
|
-
for job in jobs:
|
|
1055
|
+
queue = Queue(topic="logs")
|
|
1056
|
+
for job in queue.consume():
|
|
1062
1057
|
process(job.data)
|
|
1063
1058
|
job.complete()
|
|
1064
1059
|
```
|
|
@@ -1066,7 +1061,7 @@ for job in jobs:
|
|
|
1066
1061
|
### Queue management
|
|
1067
1062
|
|
|
1068
1063
|
```python
|
|
1069
|
-
queue = Queue(
|
|
1064
|
+
queue = Queue(topic="tasks", max_retries=3)
|
|
1070
1065
|
|
|
1071
1066
|
# Check queue size
|
|
1072
1067
|
queue.size() # pending jobs
|
|
@@ -1681,7 +1676,7 @@ async def upload(request, response):
|
|
|
1681
1676
|
file_list = uploaded if isinstance(uploaded, list) else [uploaded]
|
|
1682
1677
|
for f in file_list:
|
|
1683
1678
|
content = base64.b64decode(f["content"])
|
|
1684
|
-
with open(os.path.join("src/public/uploads", f["
|
|
1679
|
+
with open(os.path.join("src/public/uploads", f["filename"]), "wb") as fh:
|
|
1685
1680
|
fh.write(content)
|
|
1686
1681
|
return response({"uploaded": len(file_list)})
|
|
1687
1682
|
```
|
|
@@ -1715,8 +1710,8 @@ async def charge(request, response):
|
|
|
1715
1710
|
# In route — fast response
|
|
1716
1711
|
@post("/api/invite")
|
|
1717
1712
|
async def invite(request, response):
|
|
1718
|
-
|
|
1719
|
-
|
|
1713
|
+
queue = Queue(topic="emails")
|
|
1714
|
+
queue.push({
|
|
1720
1715
|
"to": request.body["email"],
|
|
1721
1716
|
"template": "invite",
|
|
1722
1717
|
"data": {"name": request.body["name"]}
|
|
@@ -1724,13 +1719,12 @@ async def invite(request, response):
|
|
|
1724
1719
|
return response({"sent": True})
|
|
1725
1720
|
|
|
1726
1721
|
# In worker — separate process
|
|
1727
|
-
|
|
1728
|
-
|
|
1722
|
+
queue = Queue(topic="emails")
|
|
1723
|
+
for job in queue.consume("emails"):
|
|
1724
|
+
email = job.data
|
|
1729
1725
|
html = Template.render(f"emails/{email['template']}.twig", email["data"])
|
|
1730
1726
|
# ... send via SMTP
|
|
1731
|
-
|
|
1732
|
-
queue = Queue(topic="emails", callback=send_email)
|
|
1733
|
-
Consumer(queue).run_forever()
|
|
1727
|
+
job.complete()
|
|
1734
1728
|
```
|
|
1735
1729
|
|
|
1736
1730
|
### Full page with template inheritance
|
|
@@ -1770,7 +1764,7 @@ async def dashboard(request, response):
|
|
|
1770
1764
|
- **`tina4python generate`**: model, route, migration, middleware scaffolding
|
|
1771
1765
|
- **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), query caching (`TINA4_DB_CACHE=true`, `cache_stats()`, `cache_clear()`)
|
|
1772
1766
|
- **Sessions**: 4 backends (file, Redis/Valkey, MongoDB, database)
|
|
1773
|
-
- **Queue**: SQLite/RabbitMQ/Kafka backends, configured via env vars
|
|
1767
|
+
- **Queue**: SQLite/RabbitMQ/Kafka/MongoDB backends, configured via env vars
|
|
1774
1768
|
- **Cache**: memory/Redis/file backends
|
|
1775
1769
|
- **Messenger**: .env driven SMTP/IMAP
|
|
1776
1770
|
- **ORM relationships**: `has_many`, `has_one`, `belongs_to` with eager loading (`include=`)
|
|
@@ -169,7 +169,7 @@ frontend/ — Frontend framework source (builds to public/)
|
|
|
169
169
|
| WebSocket | websocket | `from tina4_python.websocket import WebSocketServer` |
|
|
170
170
|
| SOAP/WSDL | wsdl | `from tina4_python.wsdl import WSDL, wsdl_operation` |
|
|
171
171
|
| Email (SMTP+IMAP) | messenger | `from tina4_python.messenger import Messenger` |
|
|
172
|
-
| Background Queue | queue | `from tina4_python.queue import Queue
|
|
172
|
+
| Background Queue | queue | `from tina4_python.queue import Queue` |
|
|
173
173
|
| SCSS Compilation | scss | Auto-compiled from src/scss/ |
|
|
174
174
|
| Migrations | migration | `tina4python migrate` CLI command |
|
|
175
175
|
| Seeder | seeder | `from tina4_python.seeder import FakeData, seed_table` |
|
|
@@ -6,6 +6,10 @@ No PyJWT, no cryptography package.
|
|
|
6
6
|
from tina4_python.auth import Auth
|
|
7
7
|
|
|
8
8
|
auth = Auth(secret="my-secret")
|
|
9
|
+
token = auth.get_token({"user_id": 1, "role": "admin"})
|
|
10
|
+
payload = auth.valid_token(token)
|
|
11
|
+
|
|
12
|
+
# Legacy aliases also work:
|
|
9
13
|
token = auth.create_token({"user_id": 1, "role": "admin"})
|
|
10
14
|
payload = auth.validate_token(token)
|
|
11
15
|
|
|
@@ -34,7 +38,7 @@ class Auth:
|
|
|
34
38
|
|
|
35
39
|
# ── JWT ────────────────────────────────────────────────────────
|
|
36
40
|
|
|
37
|
-
def
|
|
41
|
+
def get_token(self, payload: dict, expiry_minutes: int = None) -> str:
|
|
38
42
|
"""Create a signed JWT token.
|
|
39
43
|
|
|
40
44
|
Returns: header.payload.signature
|
|
@@ -53,7 +57,7 @@ class Auth:
|
|
|
53
57
|
|
|
54
58
|
return f"{h}.{p}.{signature}"
|
|
55
59
|
|
|
56
|
-
def
|
|
60
|
+
def valid_token(self, token: str) -> dict | None:
|
|
57
61
|
"""Validate a JWT and return the payload. None if invalid/expired."""
|
|
58
62
|
try:
|
|
59
63
|
parts = token.split(".")
|
|
@@ -86,12 +90,12 @@ class Auth:
|
|
|
86
90
|
|
|
87
91
|
def refresh_token(self, token: str, expiry_minutes: int = None) -> str | None:
|
|
88
92
|
"""Validate and issue a fresh token with the same claims."""
|
|
89
|
-
payload = self.
|
|
93
|
+
payload = self.valid_token(token)
|
|
90
94
|
if payload is None:
|
|
91
95
|
return None
|
|
92
96
|
payload.pop("iat", None)
|
|
93
97
|
payload.pop("exp", None)
|
|
94
|
-
return self.
|
|
98
|
+
return self.get_token(payload, expiry_minutes)
|
|
95
99
|
|
|
96
100
|
def _sign(self, message: str) -> str:
|
|
97
101
|
sig = hmac.new(
|
|
@@ -99,6 +103,11 @@ class Auth:
|
|
|
99
103
|
).digest()
|
|
100
104
|
return _b64url_encode(sig)
|
|
101
105
|
|
|
106
|
+
# ── Legacy aliases ─────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
create_token = get_token
|
|
109
|
+
validate_token = valid_token
|
|
110
|
+
|
|
102
111
|
# ── Password Hashing ──────────────────────────────────────────
|
|
103
112
|
|
|
104
113
|
@staticmethod
|
|
@@ -149,7 +158,7 @@ class Auth:
|
|
|
149
158
|
|
|
150
159
|
if auth_header.startswith("Bearer "):
|
|
151
160
|
token = auth_header[7:]
|
|
152
|
-
payload = self.
|
|
161
|
+
payload = self.valid_token(token)
|
|
153
162
|
if payload:
|
|
154
163
|
return payload
|
|
155
164
|
if self.validate_api_key(token):
|
|
@@ -7,6 +7,7 @@ CLI commands for development workflow.
|
|
|
7
7
|
tina4python migrate # Run pending migrations
|
|
8
8
|
tina4python migrate:create # Create a migration file
|
|
9
9
|
tina4python migrate:rollback # Rollback last batch
|
|
10
|
+
tina4python migrate:status # Show completed and pending migrations
|
|
10
11
|
tina4python seed # Run seeders
|
|
11
12
|
tina4python routes # List registered routes
|
|
12
13
|
tina4python test # Run tests
|
|
@@ -34,6 +35,7 @@ def main():
|
|
|
34
35
|
"migrate": _migrate,
|
|
35
36
|
"migrate:create": _migrate_create,
|
|
36
37
|
"migrate:rollback": _migrate_rollback,
|
|
38
|
+
"migrate:status": _migrate_status,
|
|
37
39
|
"seed": _seed,
|
|
38
40
|
"routes": _routes,
|
|
39
41
|
"test": _test,
|
|
@@ -63,6 +65,7 @@ Commands:
|
|
|
63
65
|
migrate Run pending database migrations
|
|
64
66
|
migrate:create <desc> Create a new migration file
|
|
65
67
|
migrate:rollback Rollback last migration batch
|
|
68
|
+
migrate:status Show completed and pending migrations
|
|
66
69
|
seed Run database seeders
|
|
67
70
|
routes List all registered routes
|
|
68
71
|
test Run test suite
|
|
@@ -233,7 +236,8 @@ def _migrate_rollback(args):
|
|
|
233
236
|
|
|
234
237
|
db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
|
|
235
238
|
db = Database(db_url)
|
|
236
|
-
|
|
239
|
+
mig_dir = args[0] if args else "migrations"
|
|
240
|
+
rolled = rollback(db, mig_dir)
|
|
237
241
|
if rolled:
|
|
238
242
|
for f in rolled:
|
|
239
243
|
print(f" Rolled back: {f}")
|
|
@@ -243,6 +247,38 @@ def _migrate_rollback(args):
|
|
|
243
247
|
db.close()
|
|
244
248
|
|
|
245
249
|
|
|
250
|
+
def _migrate_status(args):
|
|
251
|
+
"""Show completed and pending migrations."""
|
|
252
|
+
_load_env()
|
|
253
|
+
from tina4_python.database import Database
|
|
254
|
+
from tina4_python.migration import status
|
|
255
|
+
|
|
256
|
+
db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
|
|
257
|
+
db = Database(db_url)
|
|
258
|
+
mig_dir = args[0] if args else "migrations"
|
|
259
|
+
result = status(db, mig_dir)
|
|
260
|
+
|
|
261
|
+
completed = result["completed"]
|
|
262
|
+
pending = result["pending"]
|
|
263
|
+
|
|
264
|
+
if completed:
|
|
265
|
+
print("\nCompleted migrations:")
|
|
266
|
+
for m in completed:
|
|
267
|
+
print(f" [batch {m['batch']}] {m['migration_id']} ({m['executed_at']})")
|
|
268
|
+
else:
|
|
269
|
+
print("\nNo completed migrations.")
|
|
270
|
+
|
|
271
|
+
if pending:
|
|
272
|
+
print("\nPending migrations:")
|
|
273
|
+
for m in pending:
|
|
274
|
+
print(f" {m['migration_id']} ({m['description']})")
|
|
275
|
+
else:
|
|
276
|
+
print("\nNo pending migrations.")
|
|
277
|
+
|
|
278
|
+
print(f"\nTotal: {len(completed)} completed, {len(pending)} pending.")
|
|
279
|
+
db.close()
|
|
280
|
+
|
|
281
|
+
|
|
246
282
|
def _seed(args):
|
|
247
283
|
"""Run seeders from src/seeds/."""
|
|
248
284
|
_load_env()
|
|
@@ -14,7 +14,7 @@ from tina4_python.core.request import Request
|
|
|
14
14
|
from tina4_python.core.response import Response
|
|
15
15
|
from tina4_python.core.router import (
|
|
16
16
|
Router, get, post, put, patch, delete, any_method,
|
|
17
|
-
noauth, secured, middleware, cached,
|
|
17
|
+
noauth, secured, middleware, cached, websocket,
|
|
18
18
|
)
|
|
19
19
|
from tina4_python.core.middleware import CorsMiddleware, RateLimiter
|
|
20
20
|
from tina4_python.core.cache import Cache
|
|
@@ -23,7 +23,7 @@ from tina4_python.core.server import run, resolve_config
|
|
|
23
23
|
|
|
24
24
|
__all__ = [
|
|
25
25
|
"Request", "Response", "Router",
|
|
26
|
-
"get", "post", "put", "patch", "delete", "any_method",
|
|
26
|
+
"get", "post", "put", "patch", "delete", "any_method", "websocket",
|
|
27
27
|
"noauth", "secured", "middleware", "cached",
|
|
28
28
|
"CorsMiddleware", "RateLimiter",
|
|
29
29
|
"Cache",
|
|
@@ -65,9 +65,14 @@ class Request:
|
|
|
65
65
|
|
|
66
66
|
return req
|
|
67
67
|
|
|
68
|
+
def merge_route_params(self):
|
|
69
|
+
"""Merge route params into params dict (route params take priority)."""
|
|
70
|
+
if self._route_params:
|
|
71
|
+
self.params.update(self._route_params)
|
|
72
|
+
|
|
68
73
|
def param(self, key: str, default=None):
|
|
69
|
-
"""Get a route parameter (from URL path)."""
|
|
70
|
-
return self._route_params.get(key, default)
|
|
74
|
+
"""Get a route parameter (from URL path). Alias for params[key]."""
|
|
75
|
+
return self.params.get(key, self._route_params.get(key, default))
|
|
71
76
|
|
|
72
77
|
|
|
73
78
|
def _extract_ip(scope: dict, headers: dict) -> str:
|
|
@@ -134,9 +139,10 @@ def _parse_multipart(body: bytes, content_type: str) -> dict:
|
|
|
134
139
|
header_section = part[:header_end].decode(errors="replace")
|
|
135
140
|
content = part[header_end + 4:].rstrip(b"\r\n")
|
|
136
141
|
|
|
137
|
-
# Parse Content-Disposition
|
|
142
|
+
# Parse Content-Disposition and Content-Type
|
|
138
143
|
name = None
|
|
139
144
|
filename = None
|
|
145
|
+
file_type = "application/octet-stream"
|
|
140
146
|
for line in header_section.split("\r\n"):
|
|
141
147
|
if "Content-Disposition" in line:
|
|
142
148
|
for token in line.split(";"):
|
|
@@ -145,15 +151,17 @@ def _parse_multipart(body: bytes, content_type: str) -> dict:
|
|
|
145
151
|
name = token[5:].strip('"')
|
|
146
152
|
elif token.startswith("filename="):
|
|
147
153
|
filename = token[9:].strip('"')
|
|
154
|
+
elif "Content-Type" in line:
|
|
155
|
+
file_type = line.split(":", 1)[1].strip()
|
|
148
156
|
|
|
149
157
|
if not name:
|
|
150
158
|
continue
|
|
151
159
|
|
|
152
160
|
if filename:
|
|
153
|
-
import base64
|
|
154
161
|
result[name] = {
|
|
155
|
-
"
|
|
156
|
-
"
|
|
162
|
+
"filename": filename,
|
|
163
|
+
"type": file_type,
|
|
164
|
+
"content": bytes(content),
|
|
157
165
|
"size": len(content),
|
|
158
166
|
}
|
|
159
167
|
else:
|
|
@@ -23,12 +23,146 @@ from tina4_python.debug import Log
|
|
|
23
23
|
# Global route registry
|
|
24
24
|
_routes: list[dict] = []
|
|
25
25
|
|
|
26
|
+
# Global WebSocket route registry
|
|
27
|
+
_ws_routes: list[dict] = []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RouteRef:
|
|
31
|
+
"""Thin wrapper around a registered route dict, enabling chained modifiers.
|
|
32
|
+
|
|
33
|
+
Usage::
|
|
34
|
+
|
|
35
|
+
Router.get("/api/data", handler).secure().cache()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("_route",)
|
|
39
|
+
|
|
40
|
+
def __init__(self, route: dict):
|
|
41
|
+
self._route = route
|
|
42
|
+
|
|
43
|
+
def secure(self):
|
|
44
|
+
"""Mark this route as requiring bearer-token authentication."""
|
|
45
|
+
self._route["auth_required"] = True
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def cache(self, max_age: int | None = None):
|
|
49
|
+
"""Mark this route as cacheable.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
max_age: Optional TTL override in seconds.
|
|
53
|
+
"""
|
|
54
|
+
self._route["cached"] = True
|
|
55
|
+
if max_age is not None:
|
|
56
|
+
self._route["cache_max_age"] = max_age
|
|
57
|
+
return self
|
|
58
|
+
|
|
26
59
|
|
|
27
60
|
class Router:
|
|
28
61
|
"""Route registry and matcher."""
|
|
29
62
|
|
|
63
|
+
# ── Group state (used by Router.group) ────────────────────────
|
|
64
|
+
_group_prefix: str = ""
|
|
65
|
+
_group_middleware: list = []
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def group(cls, prefix: str, callback, middleware=None):
|
|
69
|
+
"""Register routes with a shared prefix and optional middleware.
|
|
70
|
+
|
|
71
|
+
Saves/restores static prefix and middleware state around the
|
|
72
|
+
callback so that nested groups concatenate correctly.
|
|
73
|
+
|
|
74
|
+
Usage::
|
|
75
|
+
|
|
76
|
+
Router.group("/api", lambda: [
|
|
77
|
+
Router.get("/users", handler),
|
|
78
|
+
Router.post("/users", handler),
|
|
79
|
+
], middleware=[auth_check])
|
|
80
|
+
"""
|
|
81
|
+
prev_prefix = cls._group_prefix
|
|
82
|
+
prev_middleware = list(cls._group_middleware)
|
|
83
|
+
|
|
84
|
+
cls._group_prefix = prev_prefix + prefix.rstrip("/")
|
|
85
|
+
cls._group_middleware = prev_middleware + (middleware or [])
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
callback()
|
|
89
|
+
finally:
|
|
90
|
+
cls._group_prefix = prev_prefix
|
|
91
|
+
cls._group_middleware = prev_middleware
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def websocket(cls, path: str, handler) -> None:
|
|
95
|
+
"""Register a WebSocket route (imperative, non-decorator style).
|
|
96
|
+
|
|
97
|
+
The handler signature is::
|
|
98
|
+
|
|
99
|
+
async def handler(connection, event, data):
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
Where:
|
|
103
|
+
- ``connection`` is a :class:`WebSocketConnection`
|
|
104
|
+
- ``event`` is ``"open"``, ``"message"``, or ``"close"``
|
|
105
|
+
- ``data`` is the message payload (str for message, None for open/close)
|
|
106
|
+
"""
|
|
107
|
+
pattern, param_names = _compile_pattern(path)
|
|
108
|
+
route = {
|
|
109
|
+
"path": path,
|
|
110
|
+
"pattern": pattern,
|
|
111
|
+
"param_names": param_names,
|
|
112
|
+
"handler": handler,
|
|
113
|
+
}
|
|
114
|
+
_ws_routes.append(route)
|
|
115
|
+
Log.debug(f"WebSocket route registered: {path}")
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def match_ws(path: str) -> tuple[dict | None, dict]:
|
|
119
|
+
"""Find a WebSocket route matching the given path. Returns (route, params)."""
|
|
120
|
+
for route in _ws_routes:
|
|
121
|
+
m = route["pattern"].match(path)
|
|
122
|
+
if m:
|
|
123
|
+
params = {}
|
|
124
|
+
for i, name in enumerate(route["param_names"]):
|
|
125
|
+
params[name] = m.group(i + 1)
|
|
126
|
+
return route, params
|
|
127
|
+
return None, {}
|
|
128
|
+
|
|
30
129
|
@staticmethod
|
|
31
|
-
def
|
|
130
|
+
def all_ws() -> list[dict]:
|
|
131
|
+
"""Return all registered WebSocket routes."""
|
|
132
|
+
return _ws_routes
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def get(cls, path: str, handler, **options) -> "RouteRef":
|
|
136
|
+
"""Register a GET route (imperative, non-decorator style)."""
|
|
137
|
+
return cls.add("GET", path, handler, **options)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def post(cls, path: str, handler, **options) -> "RouteRef":
|
|
141
|
+
"""Register a POST route (imperative, non-decorator style)."""
|
|
142
|
+
return cls.add("POST", path, handler, **options)
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def put(cls, path: str, handler, **options) -> "RouteRef":
|
|
146
|
+
"""Register a PUT route (imperative, non-decorator style)."""
|
|
147
|
+
return cls.add("PUT", path, handler, **options)
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def patch(cls, path: str, handler, **options) -> "RouteRef":
|
|
151
|
+
"""Register a PATCH route (imperative, non-decorator style)."""
|
|
152
|
+
return cls.add("PATCH", path, handler, **options)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def delete(cls, path: str, handler, **options) -> "RouteRef":
|
|
156
|
+
"""Register a DELETE route (imperative, non-decorator style)."""
|
|
157
|
+
return cls.add("DELETE", path, handler, **options)
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def any(cls, path: str, handler, **options) -> "RouteRef":
|
|
161
|
+
"""Register a route for any HTTP method (imperative, non-decorator style)."""
|
|
162
|
+
return cls.add("ANY", path, handler, **options)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def add(cls, method: str, path: str, handler, **options) -> "RouteRef":
|
|
32
166
|
"""Register a route handler.
|
|
33
167
|
|
|
34
168
|
Auth defaults:
|
|
@@ -36,7 +170,21 @@ class Router:
|
|
|
36
170
|
- POST/PUT/PATCH/DELETE require auth by default
|
|
37
171
|
- Use @noauth() to make a write route public
|
|
38
172
|
- Use @secured() to protect a GET route
|
|
173
|
+
|
|
174
|
+
Returns a :class:`RouteRef` so callers can chain ``.secure()`` /
|
|
175
|
+
``.cache()``::
|
|
176
|
+
|
|
177
|
+
Router.get("/api/data", handler).secure().cache()
|
|
39
178
|
"""
|
|
179
|
+
# Apply group prefix
|
|
180
|
+
if cls._group_prefix:
|
|
181
|
+
path = cls._group_prefix + path
|
|
182
|
+
|
|
183
|
+
# Merge group middleware with route-level middleware
|
|
184
|
+
if cls._group_middleware:
|
|
185
|
+
route_mw = options.get("middleware", [])
|
|
186
|
+
options["middleware"] = list(cls._group_middleware) + list(route_mw)
|
|
187
|
+
|
|
40
188
|
pattern, param_names = _compile_pattern(path)
|
|
41
189
|
|
|
42
190
|
# Auth default: GET=public, writes=secured
|
|
@@ -50,7 +198,7 @@ class Router:
|
|
|
50
198
|
else:
|
|
51
199
|
auth_required = m not in ("GET", "ANY")
|
|
52
200
|
|
|
53
|
-
|
|
201
|
+
route = {
|
|
54
202
|
"method": m,
|
|
55
203
|
"path": path,
|
|
56
204
|
"pattern": pattern,
|
|
@@ -60,8 +208,10 @@ class Router:
|
|
|
60
208
|
"auth_required": auth_required,
|
|
61
209
|
"cached": options.get("cached", False),
|
|
62
210
|
"cache_max_age": options.get("cache_max_age", 60),
|
|
63
|
-
}
|
|
211
|
+
}
|
|
212
|
+
_routes.append(route)
|
|
64
213
|
Log.debug(f"Route registered: {m} {path} (auth={'required' if auth_required else 'public'})")
|
|
214
|
+
return RouteRef(route)
|
|
65
215
|
|
|
66
216
|
@staticmethod
|
|
67
217
|
def match(method: str, path: str) -> tuple[dict | None, dict]:
|
|
@@ -87,6 +237,7 @@ class Router:
|
|
|
87
237
|
def clear():
|
|
88
238
|
"""Clear all routes (for testing)."""
|
|
89
239
|
_routes.clear()
|
|
240
|
+
_ws_routes.clear()
|
|
90
241
|
|
|
91
242
|
|
|
92
243
|
def _compile_pattern(path: str) -> tuple[re.Pattern, list[str]]:
|
|
@@ -173,6 +324,27 @@ def any_method(path: str, **options):
|
|
|
173
324
|
return fn
|
|
174
325
|
return decorator
|
|
175
326
|
|
|
327
|
+
# Alias — @any() is the standard name across all Tina4 frameworks
|
|
328
|
+
any = any_method
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def websocket(path: str):
|
|
332
|
+
"""Register a WebSocket route.
|
|
333
|
+
|
|
334
|
+
Usage::
|
|
335
|
+
|
|
336
|
+
@websocket("/ws/chat/{room}")
|
|
337
|
+
async def chat(connection, event, data):
|
|
338
|
+
if event == "message":
|
|
339
|
+
await connection.broadcast(data)
|
|
340
|
+
elif event == "open":
|
|
341
|
+
await connection.send(f"Welcome to {connection.params['room']}")
|
|
342
|
+
"""
|
|
343
|
+
def decorator(fn):
|
|
344
|
+
Router.websocket(path, fn)
|
|
345
|
+
return fn
|
|
346
|
+
return decorator
|
|
347
|
+
|
|
176
348
|
|
|
177
349
|
# ── Auth Decorators ────────────────────────────────────────────
|
|
178
350
|
|