tina4-python 3.13.38__tar.gz → 3.13.39__tar.gz

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