tina4-python 3.9.1__tar.gz → 3.10.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.
Files changed (139) hide show
  1. {tina4_python-3.9.1 → tina4_python-3.10.0}/PKG-INFO +1 -1
  2. {tina4_python-3.9.1 → tina4_python-3.10.0}/pyproject.toml +1 -1
  3. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/CLAUDE.md +17 -8
  4. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/cli/__init__.py +31 -0
  6. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/core/response.py +75 -21
  7. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/core/server.py +14 -18
  8. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/messenger/__init__.py +2 -3
  9. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/query_builder/__init__.py +146 -0
  10. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/session/__init__.py +5 -0
  11. tina4_python-3.10.0/tina4_python/websocket/backplane.py +121 -0
  12. {tina4_python-3.9.1 → tina4_python-3.10.0}/.gitignore +0 -0
  13. {tina4_python-3.9.1 → tina4_python-3.10.0}/README.md +0 -0
  14. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/HtmlElement.py +0 -0
  15. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/Testing.py +0 -0
  16. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/ai/__init__.py +0 -0
  17. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/api/__init__.py +0 -0
  18. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/auth/__init__.py +0 -0
  19. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/cache/__init__.py +0 -0
  20. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/container/__init__.py +0 -0
  21. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/core/__init__.py +0 -0
  22. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/core/cache.py +0 -0
  23. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/core/constants.py +0 -0
  24. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/core/events.py +0 -0
  25. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/core/middleware.py +0 -0
  26. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/core/request.py +0 -0
  27. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/core/router.py +0 -0
  28. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/crud/__init__.py +0 -0
  29. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/database/__init__.py +0 -0
  30. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/database/adapter.py +0 -0
  31. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/database/connection.py +0 -0
  32. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/database/firebird.py +0 -0
  33. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/database/mssql.py +0 -0
  34. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/database/mysql.py +0 -0
  35. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/database/odbc.py +0 -0
  36. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/database/postgres.py +0 -0
  37. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/database/sqlite.py +0 -0
  38. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/debug/__init__.py +0 -0
  39. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/debug/error_overlay.py +0 -0
  40. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/dev_admin/__init__.py +0 -0
  41. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/dev_reload.py +0 -0
  42. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/dotenv/__init__.py +0 -0
  43. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/frond/FROND.md +0 -0
  44. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/frond/__init__.py +0 -0
  45. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/frond/engine.py +0 -0
  46. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/auth/meta.json +0 -0
  47. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  48. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/database/meta.json +0 -0
  49. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  50. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/error-overlay/meta.json +0 -0
  51. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  52. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/orm/meta.json +0 -0
  53. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  54. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  55. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/queue/meta.json +0 -0
  56. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  57. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/rest-api/meta.json +0 -0
  58. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  59. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/templates/meta.json +0 -0
  60. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  61. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  62. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/graphql/__init__.py +0 -0
  63. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/i18n/__init__.py +0 -0
  64. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/migration/__init__.py +0 -0
  65. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/migration/runner.py +0 -0
  66. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/orm/__init__.py +0 -0
  67. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/orm/fields.py +0 -0
  68. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/orm/model.py +0 -0
  69. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/public/css/tina4.css +0 -0
  70. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/public/css/tina4.min.css +0 -0
  71. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/public/favicon.ico +0 -0
  72. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/public/images/logo.svg +0 -0
  73. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  74. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/public/js/frond.min.js +0 -0
  75. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  76. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/public/js/tina4.min.js +0 -0
  77. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/public/js/tina4js.min.js +0 -0
  78. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/public/swagger/index.html +0 -0
  79. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  80. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/queue/__init__.py +0 -0
  81. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/queue_backends/__init__.py +0 -0
  82. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/queue_backends/kafka_backend.py +0 -0
  83. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/queue_backends/mongo_backend.py +0 -0
  84. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  85. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/__init__.py +0 -0
  86. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  87. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_badges.scss +0 -0
  88. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  89. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_cards.scss +0 -0
  90. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_forms.scss +0 -0
  91. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_grid.scss +0 -0
  92. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_modals.scss +0 -0
  93. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_nav.scss +0 -0
  94. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_reset.scss +0 -0
  95. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_tables.scss +0 -0
  96. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_typography.scss +0 -0
  97. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  98. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/_variables.scss +0 -0
  99. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/base.scss +0 -0
  100. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/colors.scss +0 -0
  101. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/scss/tina4css/tina4.scss +0 -0
  102. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/seeder/__init__.py +0 -0
  103. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/service/__init__.py +0 -0
  104. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/session_handlers/__init__.py +0 -0
  105. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  106. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/session_handlers/redis_handler.py +0 -0
  107. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/session_handlers/valkey_handler.py +0 -0
  108. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/swagger/__init__.py +0 -0
  109. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/components/crud.twig +0 -0
  110. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  111. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  112. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/docker/python/Dockerfile +0 -0
  113. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  114. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/errors/302.twig +0 -0
  115. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/errors/401.twig +0 -0
  116. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/errors/403.twig +0 -0
  117. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/errors/404.twig +0 -0
  118. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/errors/500.twig +0 -0
  119. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/errors/502.twig +0 -0
  120. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/errors/503.twig +0 -0
  121. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/errors/base.twig +0 -0
  122. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/frontend/README.md +0 -0
  123. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/templates/readme.md +0 -0
  124. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/test_client/__init__.py +0 -0
  125. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  126. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  127. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  128. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  129. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  130. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  131. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  132. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  133. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  134. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  135. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  136. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  137. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/validator/__init__.py +0 -0
  138. {tina4_python-3.9.1 → tina4_python-3.10.0}/tina4_python/websocket/__init__.py +0 -0
  139. {tina4_python-3.9.1 → tina4_python-3.10.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.9.1
3
+ Version: 3.10.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.9.1"
3
+ version = "3.10.0"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -233,7 +233,7 @@ Tina4 provides a full toolkit. Before writing custom code, check if the framewor
233
233
  | GraphQL API | `GraphQL` from `tina4_python.graphql` |
234
234
  | SOAP/WSDL services | `WSDL` from `tina4_python.wsdl` |
235
235
  | Database migrations | `migrate`, `create_migration` from `tina4_python.migration` |
236
- | WebSockets | `WebSocketServer` from `tina4_python.websocket` |
236
+ | WebSockets | `WebSocketServer` from `tina4_python.websocket`. Backplane via Redis pub/sub (`TINA4_WS_BACKPLANE`, `TINA4_WS_BACKPLANE_URL`) |
237
237
  | SCSS compilation | Drop `.scss` in `src/scss/` — auto-compiled |
238
238
  | Static file serving | Put files in `src/public/` — auto-served |
239
239
  | Translations / i18n | `I18n` from `tina4_python.i18n` |
@@ -336,7 +336,7 @@ tina4_python/ # Core framework package (v3.0.0)
336
336
  ├── migration/ # SQL-file migrations (migrate, create_migration, rollback)
337
337
  │ └── runner.py # Migration runner
338
338
  ├── session/ # Pluggable sessions (Session, FileSessionHandler, DatabaseSessionHandler)
339
- ├── websocket/ # RFC 6455 WebSocket server (WebSocketServer, WebSocketConnection)
339
+ ├── websocket/ # RFC 6455 WebSocket server (WebSocketServer, WebSocketConnection, backplane)
340
340
  ├── graphql/ # Zero-dep GraphQL engine (GraphQL, Schema)
341
341
  ├── wsdl/ # SOAP 1.1 / WSDL server (WSDL, wsdl_operation)
342
342
  ├── crud/ # Auto-CRUD REST endpoint generator (AutoCrud)
@@ -480,7 +480,7 @@ Response.add_header("X-Custom", "value")
480
480
 
481
481
  ## Sessions
482
482
 
483
- TINA4_TOKEN_EXPIRES_IN is used to set the session time, recommend 15-60 minutes
483
+ TINA4_TOKEN_LIMIT is used to set the session time, default 60 minutes
484
484
 
485
485
  ### Session Backends
486
486
 
@@ -504,6 +504,7 @@ TINA4_SESSION_MONGO_USERNAME= # optional
504
504
  TINA4_SESSION_MONGO_PASSWORD= # optional
505
505
  TINA4_SESSION_MONGO_DB=tina4_sessions # default database
506
506
  TINA4_SESSION_MONGO_COLLECTION=sessions # default collection
507
+ TINA4_SESSION_SAMESITE=Lax # SameSite attribute for session cookies (default: Lax)
507
508
  ```
508
509
 
509
510
  ### Authentication & Security
@@ -836,6 +837,9 @@ result = User().select(filter="name = ?", params=["Alice"], limit=10)
836
837
 
837
838
  # Convert to dict
838
839
  user.to_dict()
840
+
841
+ # NoSQL support: to_mongo() generates MongoDB query documents from the same fluent API
842
+ result.to_mongo()
839
843
  ```
840
844
 
841
845
  ### Available field types
@@ -1515,8 +1519,8 @@ Key `.env` settings:
1515
1519
  ```bash
1516
1520
  # Authentication
1517
1521
  SECRET=your-jwt-secret # JWT signing (default uses insecure placeholder)
1518
- API_KEY=your-api-key # Static bearer token for API auth
1519
- TINA4_TOKEN_EXPIRES_IN=60 # Token lifetime in minutes (default: 60)
1522
+ TINA4_API_KEY=your-api-key # Static bearer token for API auth (API_KEY fallback supported)
1523
+ TINA4_TOKEN_LIMIT=60 # Token lifetime in minutes (default: 60)
1520
1524
 
1521
1525
  # Database
1522
1526
  DATABASE_URL=sqlite:///app.db # Connection URL (driver://host:port/database)
@@ -1525,16 +1529,17 @@ DATABASE_PASSWORD= # DB password
1525
1529
 
1526
1530
  # Framework
1527
1531
  TINA4_DEBUG=true # Enable dev mode (toolbar, live reload, error overlay)
1528
- TINA4_LOG_LEVEL=ALL # Log verbosity: ALL, DEBUG, INFO, WARNING, ERROR
1532
+ TINA4_LOG_LEVEL=ERROR # Log verbosity: ALL, DEBUG, INFO, WARNING, ERROR (default: ERROR)
1529
1533
  TINA4_LOCALE=en # Language for framework messages (en, fr, af, zh, ja, es)
1530
1534
  TINA4_DEFAULT_WEBSERVER=FALSE # Set to TRUE to use Tina4's built-in webserver instead of ASGI
1531
1535
  HOST_NAME=localhost:7145
1532
1536
 
1533
1537
  # Sessions
1534
1538
  TINA4_SESSION_HANDLER=SessionFileHandler # SessionFileHandler, SessionRedisHandler, SessionValkeyHandler, SessionMongoHandler
1539
+ TINA4_SESSION_SAMESITE=Lax # SameSite attribute for session cookies (default: Lax)
1535
1540
 
1536
1541
  # Swagger/OpenAPI
1537
- SWAGGER_TITLE=My API # API title (default: "Tina4 Python API")
1542
+ SWAGGER_TITLE=Tina4 API # API title (default: "Tina4 API")
1538
1543
  SWAGGER_VERSION=1.0.0 # API version
1539
1544
  SWAGGER_DESCRIPTION= # API description
1540
1545
  SWAGGER_CONTACT_TEAM= # Contact name
@@ -1763,11 +1768,15 @@ async def dashboard(request, response):
1763
1768
  - **`tina4python generate`**: model, route, migration, middleware scaffolding
1764
1769
  - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), query caching (`TINA4_DB_CACHE=true`, `cache_stats()`, `cache_clear()`)
1765
1770
  - **Sessions**: 4 backends (file, Redis/Valkey, MongoDB, database)
1766
- - **Queue**: SQLite/RabbitMQ/Kafka/MongoDB backends, configured via env vars
1771
+ - **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
1767
1772
  - **Cache**: memory/Redis/file backends
1768
1773
  - **Messenger**: .env driven SMTP/IMAP
1769
1774
  - **ORM relationships**: `has_many`, `has_one`, `belongs_to` with eager loading (`include=`)
1770
1775
  - **Frond pre-compilation**: 2.8x template render improvement, `Frond.clear_cache()`
1776
+ - **QueryBuilder** with NoSQL/MongoDB support (`to_mongo()`)
1777
+ - **WebSocket backplane** (Redis pub/sub) for horizontal scaling
1778
+ - **SameSite=Lax** default on session cookies (`TINA4_SESSION_SAMESITE`)
1779
+ - **`tina4 init`** generates Dockerfile and .dockerignore
1771
1780
  - **Gallery**: 7 interactive examples with Try It deploy at `/__dev/`
1772
1781
 
1773
1782
 
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.9.1"
11
+ __version__ = "3.9.2"
12
12
 
13
13
  # ── HTTP Constants ──
14
14
  from tina4_python.core.constants import ( # noqa: E402, F401
@@ -141,6 +141,37 @@ def _init(args):
141
141
  if not dst_file.exists() and src_file.exists():
142
142
  dst_file.write_text(src_file.read_text(encoding="utf-8"), encoding="utf-8")
143
143
 
144
+ # Create root Dockerfile (uv variant) if it doesn't exist
145
+ root_dockerfile = target / "Dockerfile"
146
+ if not root_dockerfile.exists():
147
+ root_dockerfile.write_text(
148
+ 'FROM python:3.13-slim AS build\n'
149
+ 'WORKDIR /app\n'
150
+ 'COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv\n'
151
+ 'COPY pyproject.toml uv.lock* ./\n'
152
+ 'RUN uv sync --frozen --no-dev\n'
153
+ 'COPY . .\n'
154
+ '\n'
155
+ 'FROM python:3.13-slim\n'
156
+ 'WORKDIR /app\n'
157
+ 'COPY --from=build /app .\n'
158
+ 'COPY --from=build /usr/local/bin/uv /usr/local/bin/uv\n'
159
+ 'ENV PATH="/app/.venv/bin:$PATH"\n'
160
+ 'ENV HOST=0.0.0.0\n'
161
+ 'ENV PORT=7145\n'
162
+ 'EXPOSE 7145\n'
163
+ 'CMD ["python", "app.py"]\n',
164
+ encoding="utf-8",
165
+ )
166
+
167
+ # Create root .dockerignore if it doesn't exist
168
+ root_dockerignore = target / ".dockerignore"
169
+ if not root_dockerignore.exists():
170
+ root_dockerignore.write_text(
171
+ ".venv\n__pycache__\n.git\n.claude\n.env\n*.log\ntests\ntmp\n",
172
+ encoding="utf-8",
173
+ )
174
+
144
175
  # Auto-detect AI tools and install context
145
176
  from tina4_python.ai import detect_ai_names, install_context, install_all
146
177
  detected = detect_ai_names(str(target))
@@ -22,6 +22,54 @@ import mimetypes
22
22
  from pathlib import Path
23
23
 
24
24
 
25
+ # ---------------------------------------------------------------------------
26
+ # Global Frond template engine registry
27
+ # ---------------------------------------------------------------------------
28
+ _global_frond = None
29
+ _framework_frond = None
30
+
31
+
32
+ def get_frond():
33
+ """Return the global Frond engine, creating a default if needed."""
34
+ global _global_frond
35
+ if _global_frond is None:
36
+ from tina4_python.frond.engine import Frond
37
+ _global_frond = Frond("src/templates")
38
+ return _global_frond
39
+
40
+
41
+ def get_framework_frond():
42
+ """Return the singleton Frond engine for built-in framework templates."""
43
+ global _framework_frond
44
+ framework_dir = Path(__file__).resolve().parent.parent / "templates"
45
+ if _framework_frond is None and framework_dir.is_dir():
46
+ from tina4_python.frond.engine import Frond
47
+ _framework_frond = Frond(str(framework_dir))
48
+ # Sync custom filters/globals from the user engine
49
+ if _framework_frond is not None:
50
+ user_engine = get_frond()
51
+ _framework_frond._filters.update(user_engine._filters)
52
+ _framework_frond._globals.update(user_engine._globals)
53
+ return _framework_frond
54
+
55
+
56
+ def set_frond(engine):
57
+ """Register a pre-configured Frond engine for response.render().
58
+
59
+ Call this at startup after registering custom filters and globals:
60
+
61
+ from tina4_python.frond import Frond
62
+ from tina4_python.core.response import set_frond
63
+
64
+ engine = Frond("src/templates")
65
+ engine.add_filter("money", my_money_filter)
66
+ engine.add_global("APP_VERSION", "1.0")
67
+ set_frond(engine)
68
+ """
69
+ global _global_frond
70
+ _global_frond = engine
71
+
72
+
25
73
  class Response:
26
74
  """HTTP response builder with compression and ETag support."""
27
75
 
@@ -175,27 +223,33 @@ class Response:
175
223
  return self
176
224
 
177
225
  def render(self, template: str, data: dict = None) -> "Response":
178
- """Render a Frond/Twig template with data."""
179
- from tina4_python.frond.engine import Frond
180
- from pathlib import Path
181
-
182
- # Search for templates in user dir first, then framework dir
183
- template_dirs = []
184
- user_dir = Path("src/templates")
185
- if user_dir.is_dir():
186
- template_dirs.append(str(user_dir))
187
- framework_dir = Path(__file__).resolve().parent.parent / "templates"
188
- if framework_dir.is_dir():
189
- template_dirs.append(str(framework_dir))
190
-
191
- for tdir in template_dirs:
192
- if (Path(tdir) / template).exists():
193
- try:
194
- frond = Frond(tdir)
195
- html = frond.render(template, data or {})
196
- return self.html(html)
197
- except Exception as e:
198
- return self.html(f"<pre>Template error: {e}</pre>", 500)
226
+ """Render a Frond/Twig template with data.
227
+
228
+ Uses the global Frond engine (registered via set_frond()) so that
229
+ custom filters and globals are available in all templates.
230
+ Falls back to framework templates if not found in user dir.
231
+ """
232
+ engine = get_frond()
233
+
234
+ # Try user templates first (the global engine's directory)
235
+ try:
236
+ html = engine.render(template, data or {})
237
+ return self.html(html)
238
+ except FileNotFoundError:
239
+ pass
240
+ except Exception as e:
241
+ return self.html(f"<pre>Template error: {e}</pre>", 500)
242
+
243
+ # Fallback: framework templates (singleton, filters/globals synced)
244
+ fw_engine = get_framework_frond()
245
+ if fw_engine is not None:
246
+ try:
247
+ html = fw_engine.render(template, data or {})
248
+ return self.html(html)
249
+ except FileNotFoundError:
250
+ pass
251
+ except Exception as e:
252
+ return self.html(f"<pre>Template error: {e}</pre>", 500)
199
253
 
200
254
  return self.html(f"<pre>Template not found: {template}</pre>", 404)
201
255
 
@@ -105,7 +105,7 @@ def _render_error_page(status_code: int, path: str, request_id: str, error_messa
105
105
 
106
106
  Returns rendered HTML string, or None if no template found.
107
107
  """
108
- from tina4_python.frond.engine import Frond
108
+ from tina4_python.core.response import get_frond, get_framework_frond
109
109
 
110
110
  template_name = f"errors/{status_code}.twig"
111
111
  data = {
@@ -115,21 +115,17 @@ def _render_error_page(status_code: int, path: str, request_id: str, error_messa
115
115
  "status_code": status_code,
116
116
  }
117
117
 
118
- # 1. Try user override
119
- user_dir = Path("src/templates")
120
- if (user_dir / template_name).exists():
121
- try:
122
- engine = Frond(str(user_dir))
123
- return engine.render(template_name, data)
124
- except Exception:
125
- pass
118
+ # 1. Try user override (singleton engine with custom filters/globals)
119
+ try:
120
+ return get_frond().render(template_name, data)
121
+ except (FileNotFoundError, Exception):
122
+ pass
126
123
 
127
- # 2. Try framework default
128
- framework_dir = Path(__file__).resolve().parent.parent / "templates"
129
- if (framework_dir / template_name).exists():
124
+ # 2. Try framework default (singleton, filters/globals synced)
125
+ fw_engine = get_framework_frond()
126
+ if fw_engine is not None:
130
127
  try:
131
- engine = Frond(str(framework_dir))
132
- return engine.render(template_name, data)
128
+ return fw_engine.render(template_name, data)
133
129
  except Exception:
134
130
  pass
135
131
 
@@ -848,9 +844,8 @@ async def app(scope: dict, receive, send):
848
844
  # Try serving a template file (e.g. /hello -> src/templates/hello.twig or hello.html)
849
845
  tpl_file = _resolve_template(request.path)
850
846
  if tpl_file:
851
- from tina4_python.frond import Frond
852
- frond = Frond()
853
- html = frond.render(tpl_file, {})
847
+ from tina4_python.core.response import get_frond
848
+ html = get_frond().render(tpl_file, {})
854
849
  response.html(html)
855
850
  elif request.path == "/":
856
851
  response.html(_render_landing_page())
@@ -911,7 +906,8 @@ async def app(scope: dict, receive, send):
911
906
  sid = request.session.session_id if hasattr(request.session, 'session_id') else getattr(request.session, 'id', None)
912
907
  if sid:
913
908
  ttl = int(os.environ.get("TINA4_SESSION_TTL", "3600"))
914
- response.header("set-cookie", f"tina4_session={sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age={ttl}")
909
+ samesite = os.environ.get("TINA4_SESSION_SAMESITE", "Lax")
910
+ response.header("set-cookie", f"tina4_session={sid}; Path=/; HttpOnly; SameSite={samesite}; Max-Age={ttl}")
915
911
  except Exception:
916
912
  pass
917
913
 
@@ -203,9 +203,8 @@ class Messenger:
203
203
  **kwargs: Passed to send() (cc, bcc, attachments, etc.)
204
204
  """
205
205
  try:
206
- from tina4_python.frond import Frond
207
- engine = Frond()
208
- body = engine.render_string(template, data or {})
206
+ from tina4_python.core.response import get_frond
207
+ body = get_frond().render_string(template, data or {})
209
208
  except ImportError:
210
209
  body = template
211
210
 
@@ -257,6 +257,152 @@ class QueryBuilder:
257
257
  """
258
258
  return self.count() > 0
259
259
 
260
+ def to_mongo(self) -> dict:
261
+ """Convert the fluent builder state into a MongoDB-compatible query.
262
+
263
+ Returns:
264
+ A dict with keys: filter, projection, sort, limit, skip.
265
+ Only non-empty keys are included.
266
+ """
267
+ result = {}
268
+
269
+ # -- projection --
270
+ if self._columns != ["*"]:
271
+ result["projection"] = {col.strip(): 1 for col in self._columns}
272
+
273
+ # -- filter --
274
+ if self._wheres:
275
+ param_index = 0
276
+ and_conditions = []
277
+ or_conditions = []
278
+
279
+ for i, (connector, condition) in enumerate(self._wheres):
280
+ mongo_cond, param_index = self._parse_condition_to_mongo(
281
+ condition, param_index
282
+ )
283
+ if i == 0 or connector == "AND":
284
+ and_conditions.append(mongo_cond)
285
+ else:
286
+ or_conditions.append(mongo_cond)
287
+
288
+ if or_conditions:
289
+ # Merge AND conditions into a single dict, then $or with OR ones
290
+ and_merged = self._merge_mongo_conditions(and_conditions)
291
+ all_branches = [and_merged] + or_conditions
292
+ result["filter"] = {"$or": all_branches}
293
+ else:
294
+ result["filter"] = self._merge_mongo_conditions(and_conditions)
295
+
296
+ # -- sort --
297
+ if self._order_by_cols:
298
+ sort_list = []
299
+ for expr in self._order_by_cols:
300
+ parts = expr.strip().split()
301
+ field = parts[0]
302
+ direction = -1 if len(parts) > 1 and parts[1].upper() == "DESC" else 1
303
+ sort_list.append((field, direction))
304
+ result["sort"] = sort_list
305
+
306
+ # -- limit / skip --
307
+ if self._limit_val is not None:
308
+ result["limit"] = self._limit_val
309
+ if self._offset_val is not None:
310
+ result["skip"] = self._offset_val
311
+
312
+ return result
313
+
314
+ def _parse_condition_to_mongo(self, condition: str, param_index: int) -> tuple[dict, int]:
315
+ """Parse a single SQL condition string into a MongoDB filter dict.
316
+
317
+ Returns:
318
+ (mongo_filter_dict, updated_param_index)
319
+ """
320
+ import re
321
+
322
+ cond = condition.strip()
323
+
324
+ # IS NOT NULL
325
+ match = re.match(r"^(\w+)\s+IS\s+NOT\s+NULL$", cond, re.IGNORECASE)
326
+ if match:
327
+ return {match.group(1): {"$exists": True, "$ne": None}}, param_index
328
+
329
+ # IS NULL
330
+ match = re.match(r"^(\w+)\s+IS\s+NULL$", cond, re.IGNORECASE)
331
+ if match:
332
+ return {match.group(1): {"$exists": False}}, param_index
333
+
334
+ # NOT IN
335
+ match = re.match(r"^(\w+)\s+NOT\s+IN\s*\(\s*\?\s*\)$", cond, re.IGNORECASE)
336
+ if match:
337
+ val = self._params[param_index] if param_index < len(self._params) else []
338
+ values = val if isinstance(val, list) else [val]
339
+ return {match.group(1): {"$nin": values}}, param_index + 1
340
+
341
+ # IN
342
+ match = re.match(r"^(\w+)\s+IN\s*\(\s*\?\s*\)$", cond, re.IGNORECASE)
343
+ if match:
344
+ val = self._params[param_index] if param_index < len(self._params) else []
345
+ values = val if isinstance(val, list) else [val]
346
+ return {match.group(1): {"$in": values}}, param_index + 1
347
+
348
+ # LIKE
349
+ match = re.match(r"^(\w+)\s+LIKE\s+\?$", cond, re.IGNORECASE)
350
+ if match:
351
+ val = self._params[param_index] if param_index < len(self._params) else ""
352
+ # Convert SQL LIKE pattern to regex: % -> .*, _ -> .
353
+ pattern = str(val).replace("%", ".*").replace("_", ".")
354
+ return {match.group(1): {"$regex": pattern, "$options": "i"}}, param_index + 1
355
+
356
+ # Comparison operators: >=, <=, <>, !=, >, <, =
357
+ match = re.match(r"^(\w+)\s*(>=|<=|<>|!=|>|<|=)\s*\?$", cond)
358
+ if match:
359
+ field = match.group(1)
360
+ op = match.group(2)
361
+ val = self._params[param_index] if param_index < len(self._params) else None
362
+ op_map = {
363
+ "=": None,
364
+ "!=": "$ne",
365
+ "<>": "$ne",
366
+ ">": "$gt",
367
+ ">=": "$gte",
368
+ "<": "$lt",
369
+ "<=": "$lte",
370
+ }
371
+ mongo_op = op_map.get(op)
372
+ if mongo_op is None:
373
+ return {field: val}, param_index + 1
374
+ return {field: {mongo_op: val}}, param_index + 1
375
+
376
+ # Fallback: return condition as-is in $where (raw JS expression)
377
+ return {"$where": cond}, param_index
378
+
379
+ @staticmethod
380
+ def _merge_mongo_conditions(conditions: list[dict]) -> dict:
381
+ """Merge a list of single-field mongo condition dicts into one dict.
382
+
383
+ If the same field appears in multiple conditions, wraps them in $and.
384
+ """
385
+ if len(conditions) == 1:
386
+ return conditions[0]
387
+
388
+ merged = {}
389
+ conflicts = []
390
+ for cond in conditions:
391
+ for key, val in cond.items():
392
+ if key in merged:
393
+ conflicts.append(cond)
394
+ break
395
+ else:
396
+ merged[key] = val
397
+ else:
398
+ continue
399
+ # If we broke out due to conflict, don't add to merged
400
+ pass
401
+
402
+ if conflicts:
403
+ return {"$and": conditions}
404
+ return merged
405
+
260
406
  # -- Private helpers --
261
407
 
262
408
  def _build_where(self) -> str:
@@ -265,6 +265,11 @@ class Session:
265
265
  self.unset(flash_key)
266
266
  return val
267
267
 
268
+ def cookie_header(self, cookie_name: str = "tina4_session") -> str:
269
+ """Return a Set-Cookie header value for this session."""
270
+ samesite = os.environ.get("TINA4_SESSION_SAMESITE", "Lax")
271
+ return f"{cookie_name}={self._session_id}; Path=/; HttpOnly; SameSite={samesite}; Max-Age={self._ttl}"
272
+
268
273
  def gc(self):
269
274
  """Run garbage collection on the backend."""
270
275
  self._handler.gc(self._ttl)
@@ -0,0 +1,121 @@
1
+ """
2
+ WebSocket Backplane Abstraction for Tina4 Python.
3
+
4
+ Enables broadcasting WebSocket messages across multiple server instances
5
+ using a shared pub/sub channel (e.g. Redis). Without a backplane configured,
6
+ broadcast() only reaches connections on the local process.
7
+
8
+ Configuration via environment variables:
9
+ TINA4_WS_BACKPLANE — Backend type: "redis", "nats", or "" (default: none)
10
+ TINA4_WS_BACKPLANE_URL — Connection string (default: redis://localhost:6379)
11
+
12
+ Usage:
13
+ backplane = create_backplane()
14
+ if backplane:
15
+ backplane.subscribe("chat", lambda msg: relay_to_local(msg))
16
+ backplane.publish("chat", '{"user": "A", "text": "hello"}')
17
+ """
18
+
19
+ import os
20
+ import threading
21
+ import logging
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class WebSocketBackplane:
27
+ """Base backplane interface for scaling WebSocket broadcast across instances.
28
+
29
+ Subclasses implement publish/subscribe over a shared message bus so that
30
+ every server instance receives every broadcast, not just the originator.
31
+ """
32
+
33
+ def publish(self, channel: str, message: str) -> None:
34
+ """Publish a message to all instances listening on *channel*."""
35
+ raise NotImplementedError
36
+
37
+ def subscribe(self, channel: str, callback) -> None:
38
+ """Subscribe to *channel*. *callback(message: str)* is invoked for
39
+ each incoming message. Runs in a background thread."""
40
+ raise NotImplementedError
41
+
42
+ def unsubscribe(self, channel: str) -> None:
43
+ """Stop listening on *channel*."""
44
+ raise NotImplementedError
45
+
46
+ def close(self) -> None:
47
+ """Tear down connections and background threads."""
48
+ raise NotImplementedError
49
+
50
+
51
+ class RedisBackplane(WebSocketBackplane):
52
+ """Redis pub/sub backplane.
53
+
54
+ Requires the ``redis`` package (``pip install redis``). The import is
55
+ deferred so the rest of Tina4 works fine without it installed — an error
56
+ is raised only when this class is actually instantiated.
57
+ """
58
+
59
+ def __init__(self, url: str | None = None):
60
+ try:
61
+ import redis
62
+ except ImportError:
63
+ raise ImportError(
64
+ "The 'redis' package is required for RedisBackplane. "
65
+ "Install it with: pip install redis"
66
+ )
67
+
68
+ self._url = url or os.environ.get(
69
+ "TINA4_WS_BACKPLANE_URL", "redis://localhost:6379"
70
+ )
71
+ self._redis = redis.Redis.from_url(self._url)
72
+ self._pubsub = self._redis.pubsub()
73
+ self._threads: dict[str, threading.Thread] = {}
74
+ self._running = True
75
+ logger.info("RedisBackplane connected to %s", self._url)
76
+
77
+ def publish(self, channel: str, message: str) -> None:
78
+ self._redis.publish(channel, message)
79
+
80
+ def subscribe(self, channel: str, callback) -> None:
81
+ self._pubsub.subscribe(**{channel: lambda raw: callback(raw["data"].decode() if isinstance(raw["data"], bytes) else raw["data"])})
82
+ thread = self._pubsub.run_in_thread(sleep_time=0.01, daemon=True)
83
+ self._threads[channel] = thread
84
+ logger.info("RedisBackplane subscribed to channel '%s'", channel)
85
+
86
+ def unsubscribe(self, channel: str) -> None:
87
+ self._pubsub.unsubscribe(channel)
88
+ thread = self._threads.pop(channel, None)
89
+ if thread:
90
+ thread.stop()
91
+ logger.info("RedisBackplane unsubscribed from channel '%s'", channel)
92
+
93
+ def close(self) -> None:
94
+ self._running = False
95
+ for thread in self._threads.values():
96
+ thread.stop()
97
+ self._threads.clear()
98
+ self._pubsub.close()
99
+ self._redis.close()
100
+ logger.info("RedisBackplane closed")
101
+
102
+
103
+ def create_backplane(url: str | None = None) -> WebSocketBackplane | None:
104
+ """Factory that reads TINA4_WS_BACKPLANE and returns the appropriate
105
+ backplane instance, or *None* if no backplane is configured.
106
+
107
+ This keeps backplane usage entirely optional — callers simply check
108
+ ``if backplane:`` before publishing.
109
+ """
110
+ backend = os.environ.get("TINA4_WS_BACKPLANE", "").strip().lower()
111
+
112
+ if backend == "redis":
113
+ return RedisBackplane(url=url)
114
+ elif backend == "nats":
115
+ raise NotImplementedError(
116
+ "NATS backplane is on the roadmap but not yet implemented."
117
+ )
118
+ elif backend == "":
119
+ return None
120
+ else:
121
+ raise ValueError(f"Unknown TINA4_WS_BACKPLANE value: '{backend}'")
File without changes
File without changes