tina4-python 3.13.36__tar.gz → 3.13.38__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 (159) hide show
  1. {tina4_python-3.13.36 → tina4_python-3.13.38}/.gitignore +2 -0
  2. {tina4_python-3.13.36 → tina4_python-3.13.38}/PKG-INFO +1 -1
  3. {tina4_python-3.13.36 → tina4_python-3.13.38}/pyproject.toml +1 -1
  4. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/CLAUDE.md +5 -3
  5. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/HtmlElement.py +24 -0
  6. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/__init__.py +1 -1
  7. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/auth/__init__.py +131 -9
  8. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/cli/__init__.py +91 -2
  9. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/core/events.py +51 -9
  10. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/core/middleware.py +33 -9
  11. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/core/server.py +143 -35
  12. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/database/connection.py +315 -52
  13. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/database/firebird.py +6 -2
  14. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/database/mssql.py +14 -3
  15. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/database/mysql.py +15 -4
  16. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/database/sqlite.py +7 -2
  17. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/dev_admin/__init__.py +80 -62
  18. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/dev_admin/metrics.py +172 -48
  19. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/graphql/__init__.py +32 -9
  20. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/mcp/tools.py +6 -2
  21. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/messenger/__init__.py +117 -30
  22. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/orm/model.py +10 -8
  23. tina4_python-3.13.38/tina4_python/public/js/tina4-dev-admin.js +1091 -0
  24. tina4_python-3.13.38/tina4_python/public/js/tina4-dev-admin.min.js +1091 -0
  25. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/queue_backends/kafka_backend.py +35 -1
  26. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/seeder/__init__.py +354 -44
  27. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/session/__init__.py +84 -9
  28. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/websocket/__init__.py +284 -20
  29. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/wsdl/__init__.py +15 -1
  30. tina4_python-3.13.36/tina4_python/public/js/tina4-dev-admin.js +0 -1091
  31. tina4_python-3.13.36/tina4_python/public/js/tina4-dev-admin.min.js +0 -1091
  32. {tina4_python-3.13.36 → tina4_python-3.13.38}/README.md +0 -0
  33. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/Testing.py +0 -0
  34. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/ai/__init__.py +0 -0
  35. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/api/__init__.py +0 -0
  36. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/cache/__init__.py +0 -0
  37. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/container/__init__.py +0 -0
  38. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/core/__init__.py +0 -0
  39. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/core/cache.py +0 -0
  40. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/core/constants.py +0 -0
  41. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/core/rate_limiter.py +0 -0
  42. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/core/request.py +0 -0
  43. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/core/response.py +0 -0
  44. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/core/router.py +0 -0
  45. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/crud/__init__.py +0 -0
  46. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/database/__init__.py +0 -0
  47. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/database/adapter.py +0 -0
  48. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/database/mongodb.py +0 -0
  49. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/database/odbc.py +0 -0
  50. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/database/postgres.py +0 -0
  51. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/debug/__init__.py +0 -0
  52. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/debug/error_overlay.py +0 -0
  53. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/dev_admin/plan.py +0 -0
  54. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/dev_admin/project_index.py +0 -0
  55. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/docs.py +0 -0
  56. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/dotenv/__init__.py +0 -0
  57. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/env.py +0 -0
  58. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/frond/FROND.md +0 -0
  59. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/frond/__init__.py +0 -0
  60. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/frond/engine.py +0 -0
  61. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/auth/meta.json +0 -0
  62. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  63. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/database/meta.json +0 -0
  64. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  65. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/error-overlay/meta.json +0 -0
  66. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  67. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/orm/meta.json +0 -0
  68. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  69. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  70. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/queue/meta.json +0 -0
  71. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  72. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/rest-api/meta.json +0 -0
  73. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  74. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/templates/meta.json +0 -0
  75. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  76. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  77. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/i18n/__init__.py +0 -0
  78. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/mcp/__init__.py +0 -0
  79. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/mcp/protocol.py +0 -0
  80. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/migration/__init__.py +0 -0
  81. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/migration/runner.py +0 -0
  82. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/orm/__init__.py +0 -0
  83. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/orm/fields.py +0 -0
  84. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/__feedback/widget.js +0 -0
  85. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/css/tina4.css +0 -0
  86. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/css/tina4.min.css +0 -0
  87. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/favicon.ico +0 -0
  88. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/images/logo.svg +0 -0
  89. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  90. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/js/frond.js +0 -0
  91. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/js/frond.min.js +0 -0
  92. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/js/tina4.min.js +0 -0
  93. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/js/tina4js.min.js +0 -0
  94. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/swagger/index.html +0 -0
  95. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  96. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/query_builder/__init__.py +0 -0
  97. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/queue/__init__.py +0 -0
  98. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/queue/job.py +0 -0
  99. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/queue/kafka_backend.py +0 -0
  100. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/queue/lite_backend.py +0 -0
  101. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/queue/mongo_backend.py +0 -0
  102. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/queue/rabbitmq_backend.py +0 -0
  103. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/queue_backends/__init__.py +0 -0
  104. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/queue_backends/mongo_backend.py +0 -0
  105. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  106. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/__init__.py +0 -0
  107. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  108. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_badges.scss +0 -0
  109. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  110. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_cards.scss +0 -0
  111. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_forms.scss +0 -0
  112. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_grid.scss +0 -0
  113. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_modals.scss +0 -0
  114. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_nav.scss +0 -0
  115. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_reset.scss +0 -0
  116. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_tables.scss +0 -0
  117. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_typography.scss +0 -0
  118. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  119. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/_variables.scss +0 -0
  120. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/base.scss +0 -0
  121. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/colors.scss +0 -0
  122. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/scss/tina4css/tina4.scss +0 -0
  123. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/service/__init__.py +0 -0
  124. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/session_handlers/__init__.py +0 -0
  125. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  126. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/session_handlers/redis_handler.py +0 -0
  127. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/session_handlers/valkey_handler.py +0 -0
  128. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/swagger/__init__.py +0 -0
  129. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/components/crud.twig +0 -0
  130. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  131. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  132. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/docker/python/Dockerfile +0 -0
  133. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  134. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/errors/302.twig +0 -0
  135. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/errors/401.twig +0 -0
  136. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/errors/403.twig +0 -0
  137. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/errors/404.twig +0 -0
  138. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/errors/500.twig +0 -0
  139. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/errors/502.twig +0 -0
  140. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/errors/503.twig +0 -0
  141. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/errors/base.twig +0 -0
  142. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/frontend/README.md +0 -0
  143. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/templates/readme.md +0 -0
  144. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/test/__init__.py +0 -0
  145. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/test_client/__init__.py +0 -0
  146. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  157. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  158. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/validator/__init__.py +0 -0
  159. {tina4_python-3.13.36 → tina4_python-3.13.38}/tina4_python/websocket/backplane.py +0 -0
@@ -6,6 +6,8 @@
6
6
  /src/templates/index.twig
7
7
  /secrets/
8
8
  /.env
9
+ /.env.local
10
+ .env.local
9
11
  /public/css/test.css
10
12
  /sessions/
11
13
  /tests/secrets/domain.cert
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.36
3
+ Version: 3.13.38
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.13.36"
3
+ version = "3.13.38"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -1165,6 +1165,8 @@ queue.purge("completed")
1165
1165
 
1166
1166
  Tina4 includes zero-config SOAP 1.1 support with automatic WSDL generation.
1167
1167
 
1168
+ **Security:** SOAP requests containing a `<!DOCTYPE>` (DTD) are rejected with a `Client` fault before parsing — SOAP 1.1 forbids DTDs, and this closes the XML entity-expansion (billion-laughs) and external-entity (XXE) attack surface. An operation that raises returns a `Server` fault whose `<faultstring>` is the real cause **only** in debug mode (`TINA4_DEBUG`); in production it is a generic "Internal server error" and the real cause is written to the log — so a resolver exception never leaks internal state to a SOAP client.
1169
+
1168
1170
  ```python
1169
1171
  from typing import List, Optional
1170
1172
  from tina4_python.wsdl import WSDL, wsdl_operation
@@ -1249,7 +1251,7 @@ result = gql.execute('{ users(limit: 3) { id name } }', variables={}, context={}
1249
1251
  # {"data": {"users": [...]}}
1250
1252
  ```
1251
1253
 
1252
- Supports: queries, mutations, variables, fragments, aliases, `@skip`/`@include` directives, nested selections, list types, inline fragments. Resolver exceptions are captured as GraphQL errors.
1254
+ Supports: queries, mutations, variables, fragments, aliases, `@skip`/`@include` directives, nested selections, list types, inline fragments. Resolver exceptions are captured as GraphQL errors — the message is the real cause only in debug mode (`TINA4_DEBUG`); in production it is a generic "Internal server error" (the real cause is logged) so a resolver exception never leaks internal state. **Depth guard:** selection-set nesting is bounded by `TINA4_GRAPHQL_MAX_DEPTH` (default `50`; set `<= 0` to disable). An over-deep query or a circular fragment fails with a `"Query exceeds maximum depth of N"` error instead of overflowing the stack.
1253
1255
 
1254
1256
  | ORM Field | GraphQL Type |
1255
1257
  |-----------|-------------|
@@ -1604,7 +1606,7 @@ Key `.env` settings:
1604
1606
 
1605
1607
  ```bash
1606
1608
  # Authentication
1607
- TINA4_SECRET=your-jwt-secret # JWT signing (warns + uses a blank, insecure secret if unset)
1609
+ TINA4_SECRET=your-jwt-secret # JWT signing. In DEV (TINA4_DEBUG truthy, not CI/prod) a blank value auto-generates a per-machine secret saved to gitignored .env.local; in CI/prod a blank value warns actionably (set it with `openssl rand -hex 32`)
1608
1610
  TINA4_API_KEY=your-api-key # Static bearer token for API auth (API_KEY fallback supported)
1609
1611
  TINA4_TOKEN_LIMIT=60 # Token lifetime in minutes (default: 60)
1610
1612
 
@@ -1853,7 +1855,7 @@ async def dashboard(request, response):
1853
1855
  - **2,899 tests** passing across all modules
1854
1856
  - **Production server auto-detect**: `tina4python serve --production` auto-installs uvicorn
1855
1857
  - **`tina4python generate`**: model, route, migration, middleware scaffolding
1856
- - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **on by default** (`TINA4_AUTO_CACHING=true`, TTL `TINA4_AUTO_CACHING_TTL=5`s) dedupes identical reads within a request and flushes on writes; persistent cross-request cache opt-in via `TINA4_DB_CACHE=true` (TTL `TINA4_DB_CACHE_TTL=30`s) routed through the unified backend set via `TINA4_DB_CACHE_BACKEND` (memory/file/redis/valkey/memcached/mongodb/database) + `TINA4_DB_CACHE_URL` so instances share one cache with global write-invalidation; `cache_stats()` reports `mode` (request/persistent/off) and `backend`, `cache_clear()`
1858
+ - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **off by default — opt-in via `TINA4_AUTO_CACHING=true`** (TTL `TINA4_AUTO_CACHING_TTL=5`s) dedupes identical reads within a request and flushes on writes; it ships OFF because a request-scoped cache can return pre-write state in a read-after-write (`SELECT MAX(id)` before an `INSERT` → duplicate keys), so opt in per read-heavy endpoint. Persistent cross-request cache also opt-in via `TINA4_DB_CACHE=true` (TTL `TINA4_DB_CACHE_TTL=30`s) routed through the unified backend set via `TINA4_DB_CACHE_BACKEND` (memory/file/redis/valkey/memcached/mongodb/database) + `TINA4_DB_CACHE_URL` so instances share one cache with global write-invalidation; `cache_stats()` reports `mode` (request/persistent/off) and `backend`, `cache_clear()`
1857
1859
  - **Sessions**: 4 backends (file, Redis/Valkey, MongoDB, database)
1858
1860
  - **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
1859
1861
  - **Cache**: unified backend set — memory (default), file, redis, valkey, memcached, mongodb, database — via `TINA4_CACHE_BACKEND` (+ `TINA4_CACHE_URL`/credentials); file-backend fallback if a backend is unreachable
@@ -17,6 +17,25 @@ Usage:
17
17
  import html as _html
18
18
 
19
19
 
20
+ class Raw(str):
21
+ """Marker for trusted, pre-sanitised HTML that must render UNESCAPED.
22
+
23
+ String/scalar children of an HTMLElement are HTML-escaped by default to
24
+ prevent stored/reflected XSS. Wrap a value in Raw() to opt out of escaping
25
+ when (and only when) you have already sanitised it yourself.
26
+
27
+ HTMLElement("div")("<b>x</b>") # &lt;b&gt;x&lt;/b&gt; (escaped)
28
+ HTMLElement("div")(Raw("<b>x</b>")) # <b>x</b> (raw)
29
+
30
+ Alias: SafeString.
31
+ """
32
+ pass
33
+
34
+
35
+ # Alias — some callers/frameworks prefer the SafeString name (matches Frond).
36
+ SafeString = Raw
37
+
38
+
20
39
  class HTMLElement:
21
40
  """A single HTML element that renders itself and its children to a string."""
22
41
 
@@ -65,8 +84,13 @@ class HTMLElement:
65
84
 
66
85
  for child in self.children:
67
86
  if isinstance(child, HTMLElement):
87
+ # Nested elements render themselves (already escape their own children)
88
+ parts.append(str(child))
89
+ elif isinstance(child, Raw):
90
+ # Explicitly trusted markup — emit unescaped
68
91
  parts.append(str(child))
69
92
  else:
93
+ # Plain string/scalar child — escape to defeat XSS
70
94
  parts.append(_html.escape(str(child), quote=True))
71
95
 
72
96
  parts.append(f"</{self.tag}>")
@@ -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.36"
11
+ __version__ = "3.13.38"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -49,24 +49,145 @@ class _DualMethod:
49
49
  return functools.partial(self._func, obj)
50
50
 
51
51
 
52
+ def _is_ci() -> bool:
53
+ """True when running under a CI system.
54
+
55
+ Honours the de-facto ``CI`` env var that GitHub Actions, GitLab CI,
56
+ CircleCI, Travis, etc. all set to a truthy value. We never generate or
57
+ persist a dev secret in CI — a CI run with a blank secret must surface
58
+ the actionable warning, not silently mint one.
59
+ """
60
+ from tina4_python.dotenv import is_truthy
61
+ return is_truthy(os.environ.get("CI"))
62
+
63
+
64
+ def _is_dev() -> bool:
65
+ """True when the framework is in development mode (TINA4_DEBUG truthy)."""
66
+ from tina4_python.dotenv import is_truthy
67
+ return is_truthy(os.environ.get("TINA4_DEBUG"))
68
+
69
+
70
+ def _is_production() -> bool:
71
+ """True when running in production (TINA4_ENV=production)."""
72
+ return os.environ.get("TINA4_ENV", "development") == "production"
73
+
74
+
75
+ # Actionable message shown when TINA4_SECRET is blank in CI/prod — tells the
76
+ # operator exactly what to set and how. Kept as a constant so the bootstrap
77
+ # and the lazy resolver emit the identical guidance.
78
+ _BLANK_SECRET_WARNING = (
79
+ "Auth: TINA4_SECRET is not set — JWT signing is insecure. "
80
+ "Set TINA4_SECRET to a random value (e.g. `openssl rand -hex 32`) "
81
+ "in your environment or .env before serving traffic."
82
+ )
83
+
84
+
85
+ def ensure_dev_secret(cwd: str = None) -> str | None:
86
+ """Generate a per-machine development JWT secret once, at server boot.
87
+
88
+ Fail-safe default for local dev: a blank ``TINA4_SECRET`` used to log a
89
+ loud warning on every boot even though the developer was never told what
90
+ to set. Instead, in DEV (and only in dev), we mint a cryptographically
91
+ random secret, persist it to a gitignored ``.env.local`` so it survives
92
+ restarts, and set it in the process env for this run.
93
+
94
+ Generation happens ONLY when ALL of these hold:
95
+ * ``TINA4_SECRET`` is currently blank, AND
96
+ * we are in DEV (``TINA4_DEBUG`` truthy), AND
97
+ * we are NOT in CI (``CI`` env var not truthy), AND
98
+ * we are NOT in production (``TINA4_ENV`` != "production").
99
+
100
+ SECURITY: never generate or persist a secret in CI or production, and
101
+ only ever write to ``.env.local`` (gitignored) — never ``.env``. The
102
+ signing secret must never become a guessable built-in default.
103
+
104
+ In CI/prod with a blank secret, this emits the actionable warning instead
105
+ of generating anything.
106
+
107
+ Args:
108
+ cwd: Directory in which to create/append ``.env.local`` (defaults to
109
+ the current working directory). Used by tests to point at a temp
110
+ dir without chdir'ing the whole process.
111
+
112
+ Returns:
113
+ The generated secret string when one was minted, else ``None``.
114
+ """
115
+ if os.environ.get("TINA4_SECRET"):
116
+ return None # already configured — nothing to do
117
+
118
+ if not _is_dev() or _is_ci() or _is_production():
119
+ # CI / prod / non-dev with a blank secret: warn actionably, never mint.
120
+ _warn_blank_secret()
121
+ return None
122
+
123
+ # Dev, not CI, not prod, blank secret → mint a per-machine dev secret.
124
+ new_secret = secrets.token_hex(32)
125
+ os.environ["TINA4_SECRET"] = new_secret # available for THIS run immediately
126
+
127
+ from pathlib import Path
128
+ base = Path(cwd) if cwd else Path.cwd()
129
+ env_local = base / ".env.local"
130
+ try:
131
+ # Append (create if missing). A trailing newline keeps the file
132
+ # parseable if it already held entries without a final newline.
133
+ prefix = ""
134
+ if env_local.exists():
135
+ existing = env_local.read_text(encoding="utf-8")
136
+ if existing and not existing.endswith("\n"):
137
+ prefix = "\n"
138
+ with env_local.open("a", encoding="utf-8") as fh:
139
+ fh.write(f"{prefix}TINA4_SECRET={new_secret}\n")
140
+ _log_info(
141
+ "Auth: generated a development secret, saved to .env.local (gitignored)"
142
+ )
143
+ except Exception as exc:
144
+ # Never crash boot over a file write — keep the in-memory secret for
145
+ # this run and warn that it won't persist.
146
+ _log_warning(
147
+ "Auth: generated a development secret but could not write "
148
+ f".env.local ({exc}); using it for this run only"
149
+ )
150
+ return new_secret
151
+
152
+
153
+ def _log_info(message: str) -> None:
154
+ try:
155
+ from tina4_python.debug import Log
156
+ Log.info(message)
157
+ except Exception:
158
+ import sys
159
+ print(message, file=sys.stderr)
160
+
161
+
162
+ def _log_warning(message: str) -> None:
163
+ try:
164
+ from tina4_python.debug import Log
165
+ Log.warning(message)
166
+ except Exception:
167
+ import sys
168
+ print(message, file=sys.stderr)
169
+
170
+
171
+ def _warn_blank_secret() -> None:
172
+ _log_warning(_BLANK_SECRET_WARNING)
173
+
174
+
52
175
  def _resolve_secret(secret: str = None) -> str:
53
176
  """Resolve the JWT signing secret.
54
177
 
55
178
  Reads ``TINA4_SECRET`` from the environment. When neither an explicit
56
- secret nor ``TINA4_SECRET`` is set, warns loudly and returns a blank
57
- secret — parity with the PHP/Node frameworks. Tina4 never silently signs
58
- with a guessable built-in default, which would make tokens forgeable.
179
+ secret nor ``TINA4_SECRET`` is set, warns loudly with an actionable
180
+ message and returns a blank secret — parity with the PHP/Node frameworks.
181
+ Tina4 never silently signs with a guessable built-in default, which would
182
+ make tokens forgeable. (In dev, ``ensure_dev_secret()`` runs at boot and
183
+ mints + persists a secret so this blank path is hit only in CI/prod or
184
+ when auth is used before boot.)
59
185
  """
60
186
  if secret:
61
187
  return secret
62
188
  env_secret = os.environ.get("TINA4_SECRET", "")
63
189
  if not env_secret:
64
- try:
65
- from tina4_python.debug import Log
66
- Log.warning("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)")
67
- except Exception:
68
- import sys
69
- print("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)", file=sys.stderr)
190
+ _warn_blank_secret()
70
191
  return ""
71
192
  return env_secret
72
193
 
@@ -403,4 +524,5 @@ class AuthMiddleware:
403
524
  __all__ = [
404
525
  "Auth", "AuthMiddleware", "get_token", "valid_token", "get_payload",
405
526
  "refresh_token", "authenticate_request", "validate_api_key",
527
+ "ensure_dev_secret",
406
528
  ]
@@ -71,7 +71,7 @@ def _parse_fields(fields_str: str) -> list[tuple[str, str]]:
71
71
  def _parse_flags(args: list[str]) -> tuple[dict, list[str]]:
72
72
  """Parse --key value and --flag from args. Returns (flags, positional)."""
73
73
  # Boolean-only flags that never take a value argument
74
- boolean_flags = {"no-browser", "no-reload", "production", "managed", "all", "clear"}
74
+ boolean_flags = {"no-browser", "no-reload", "production", "managed", "all", "clear", "json"}
75
75
 
76
76
  flags = {}
77
77
  positional = []
@@ -145,6 +145,7 @@ def main():
145
145
  "ai": _ai,
146
146
  "generate": _generate,
147
147
  "console": _console,
148
+ "metrics": _metrics,
148
149
  "help": _help,
149
150
  }
150
151
 
@@ -222,6 +223,7 @@ Commands:
222
223
  build Build distributable package
223
224
  ai [--all] Install AI coding assistant context
224
225
  console Start interactive REPL with framework loaded
226
+ metrics [--top N] [--json] [--fail-on warn|error] [--path DIR] Rank top code-quality offenders
225
227
 
226
228
  Generators:
227
229
  generate model <Name> [--fields "name:string,price:float"]
@@ -291,6 +293,93 @@ def _console(args=None):
291
293
  code.interact(banner=banner, local=local_vars)
292
294
 
293
295
 
296
+ # ── Metrics ───────────────────────────────────────────────────────────
297
+
298
+ def _metrics(args):
299
+ """Report top code-quality offenders (complexity, size, maintainability, tests).
300
+
301
+ tina4python metrics # human report, scans src/ (or framework)
302
+ tina4python metrics --top 10 # only the worst 10
303
+ tina4python metrics --path tina4_python # scan a specific directory
304
+ tina4python metrics --json # machine-readable for CI
305
+ tina4python metrics --fail-on warn # exit 1 if any warn/error offender
306
+ tina4python metrics --fail-on error # exit 1 only on error-severity
307
+ """
308
+ import json
309
+ from tina4_python.dev_admin import metrics as _m
310
+
311
+ flags, _ = _parse_flags(args)
312
+
313
+ top = int(flags["top"]) if "top" in flags and str(flags["top"]).isdigit() else 20
314
+ as_json = "json" in flags
315
+ path = flags.get("path", "src")
316
+ fail_on = flags.get("fail-on")
317
+ if fail_on not in (None, "warn", "error"):
318
+ print(f" invalid --fail-on '{fail_on}' (use warn or error)")
319
+ sys.exit(2)
320
+
321
+ result = _m.offenders(path, top=top)
322
+ summary = result["summary"]
323
+ found = result["offenders"]
324
+
325
+ if "error" in summary:
326
+ print(f" metrics error: {summary['error']}")
327
+ sys.exit(2)
328
+
329
+ # Decide exit code from the FULL offender set, not just the printed top-N.
330
+ # full_analysis is cached, so this reuses the same analysis.
331
+ all_offenders = _m.offenders(path, top=summary["total_offenders"] or 1)["offenders"]
332
+ severities = {o["severity"] for o in all_offenders}
333
+ exit_code = 0
334
+ if fail_on == "warn" and ({"warn", "error"} & severities):
335
+ exit_code = 1
336
+ elif fail_on == "error" and ("error" in severities):
337
+ exit_code = 1
338
+
339
+ if as_json:
340
+ print(json.dumps({"summary": summary, "offenders": found}, indent=2))
341
+ sys.exit(exit_code)
342
+
343
+ # ── Human report ──────────────────────────────────────────────────
344
+ use_color = sys.stdout.isatty()
345
+
346
+ def _c(text, code):
347
+ return f"\033[{code}m{text}\033[0m" if use_color else text
348
+
349
+ sev_color = {"error": "31", "warn": "33", "info": "2"} # red / yellow / dim
350
+
351
+ print()
352
+ print(f" Tina4 Metrics — {summary['scan_mode']} scan ({summary['scan_root']})")
353
+ print(f" files: {summary['files_analyzed']} "
354
+ f"functions: {summary['total_functions']} "
355
+ f"avg complexity: {summary['avg_complexity']} "
356
+ f"avg maintainability: {summary['avg_maintainability']}")
357
+ print(f" offenders: {summary['total_offenders']} total"
358
+ + (f" (showing top {len(found)})" if found else ""))
359
+ print()
360
+
361
+ if not found:
362
+ print(" " + _c("✓ no offenders — clean", "32"))
363
+ print()
364
+ sys.exit(exit_code)
365
+
366
+ # Compute a column width for the file:line cell so the table lines up.
367
+ locs = [f"{o['file']}:{o['line']}" for o in found]
368
+ loc_w = max(len("FILE:LINE"), max(len(s) for s in locs))
369
+ kind_w = max(len("KIND"), max(len(o["kind"]) for o in found))
370
+
371
+ header = f" {'#':>3} {'SEVERITY':<8} {'KIND':<{kind_w}} {'FILE:LINE':<{loc_w}} DETAIL"
372
+ print(_c(header, "1"))
373
+ print(" " + "-" * (len(header) - 2))
374
+ for i, o in enumerate(found, 1):
375
+ sev = o["severity"]
376
+ sev_cell = _c(f"{sev:<8}", sev_color[sev])
377
+ print(f" {i:>3} {sev_cell} {o['kind']:<{kind_w}} "
378
+ f"{locs[i - 1]:<{loc_w}} {o['detail']}")
379
+ print()
380
+ sys.exit(exit_code)
381
+
382
+
294
383
  # ── Init ──────────────────────────────────────────────────────────────
295
384
 
296
385
  def _init(args):
@@ -363,7 +452,7 @@ def _init(args):
363
452
  gitignore = target / ".gitignore"
364
453
  if not gitignore.exists():
365
454
  gitignore.write_text(
366
- ".env\n__pycache__/\n*.pyc\n.venv/\ndata/\nlogs/\n"
455
+ ".env\n.env.local\n__pycache__/\n*.pyc\n.venv/\ndata/\nlogs/\n"
367
456
  "sessions/\nsecrets/\n*.db\n",
368
457
  encoding="utf-8",
369
458
  )
@@ -68,30 +68,72 @@ def off(event: str, listener: callable = None):
68
68
  ]
69
69
 
70
70
 
71
- def emit(event: str, *args, **kwargs) -> list:
71
+ def _log_listener_error(event: str, error: Exception) -> None:
72
+ """Log a listener failure without ever raising. Visible, never silent."""
73
+ try:
74
+ from tina4_python.debug import Log
75
+ Log.warning(
76
+ f"Event listener for '{event}' raised "
77
+ f"{type(error).__name__}: {error}"
78
+ )
79
+ except Exception:
80
+ # The logger itself must never break the event bus. Fall back to
81
+ # stderr so the failure is still surfaced, never swallowed.
82
+ import sys
83
+ print(
84
+ f"[tina4.events] listener for '{event}' raised "
85
+ f"{type(error).__name__}: {error}",
86
+ file=sys.stderr,
87
+ flush=True,
88
+ )
89
+
90
+
91
+ def emit(event: str, *args, strict: bool = False, **kwargs) -> list:
72
92
  """Fire an event synchronously. Returns list of listener results.
73
93
 
74
94
  results = emit("user.created", user_data)
95
+
96
+ Each listener is isolated: if one raises, the error is LOGGED
97
+ (``Log.warning`` with the event name + error) and the remaining
98
+ listeners still run. A failed listener contributes a ``None`` slot to
99
+ the results list, so N listeners always yield N results in priority
100
+ order. Pass ``strict=True`` to RE-RAISE on the first listener error
101
+ instead of isolating it. Errors are never silently swallowed.
75
102
  """
76
103
  results = []
77
104
  for _, listener in _listeners.get(event, []):
78
- result = listener(*args, **kwargs)
79
- results.append(result)
105
+ try:
106
+ results.append(listener(*args, **kwargs))
107
+ except Exception as error:
108
+ if strict:
109
+ raise
110
+ _log_listener_error(event, error)
111
+ results.append(None)
80
112
  return results
81
113
 
82
114
 
83
- async def emit_async(event: str, *args, **kwargs) -> list:
115
+ async def emit_async(event: str, *args, strict: bool = False, **kwargs) -> list:
84
116
  """Fire an event, awaiting async listeners. Returns list of results.
85
117
 
86
118
  results = await emit_async("order.placed", order)
119
+
120
+ Mirrors :func:`emit` — each awaited listener is isolated: one
121
+ rejection does not abort the others. On a listener error the failure
122
+ is LOGGED and a ``None`` slot is appended; ``strict=True`` re-raises
123
+ on the first error.
87
124
  """
88
125
  results = []
89
126
  for _, listener in _listeners.get(event, []):
90
- if asyncio.iscoroutinefunction(listener):
91
- result = await listener(*args, **kwargs)
92
- else:
93
- result = listener(*args, **kwargs)
94
- results.append(result)
127
+ try:
128
+ if asyncio.iscoroutinefunction(listener):
129
+ results.append(await listener(*args, **kwargs))
130
+ else:
131
+ results.append(listener(*args, **kwargs))
132
+ except Exception as error:
133
+ if strict:
134
+ raise
135
+ _log_listener_error(event, error)
136
+ results.append(None)
95
137
  return results
96
138
 
97
139
 
@@ -30,8 +30,18 @@ class Middleware:
30
30
  """Standardized middleware orchestrator.
31
31
 
32
32
  Registers middleware classes globally and runs their ``before_*`` /
33
- ``after_*`` static methods in alphabetical order. Mirrors the PHP,
34
- Ruby and Node.js orchestrators.
33
+ ``after_*`` static methods. Mirrors the PHP, Ruby and Node.js
34
+ orchestrators.
35
+
36
+ Ordering rule (v3.13.38) — deterministic, never alphabetical:
37
+
38
+ * Across middleware classes: REGISTRATION order — the order they were
39
+ attached via ``Middleware.use`` / ``@middleware`` / ``Router.group``.
40
+ * Within a single class: DEFINITION order — ``before_*`` / ``after_*``
41
+ methods run in the order they appear in the class body (Python
42
+ preserves this in ``__dict__``), NOT ``dir()`` alphabetical order.
43
+
44
+ ``before_*`` always runs before the route handler, ``after_*`` after.
35
45
  """
36
46
 
37
47
  _global_middleware: list = []
@@ -81,13 +91,27 @@ class Middleware:
81
91
 
82
92
  @staticmethod
83
93
  def _discover_methods(mw_class, prefix: str) -> list:
84
- """Return sorted list of public static method names with ``prefix``."""
85
- names = [
86
- name
87
- for name in dir(mw_class)
88
- if name.startswith(prefix) and callable(getattr(mw_class, name, None))
89
- ]
90
- return sorted(names)
94
+ """Return prefixed method names in DEFINITION order (not alphabetical).
95
+
96
+ Walks the MRO from the base classes up to the most-derived so a
97
+ subclass's own methods run after the methods it inherits, and uses
98
+ each class's ``__dict__`` (insertion-ordered in Python 3.7+) to
99
+ preserve the order methods were written in the source. A subclass
100
+ override keeps the position of its first definition.
101
+ """
102
+ seen = set()
103
+ names = []
104
+ klass = mw_class if isinstance(mw_class, type) else type(mw_class)
105
+ for base in reversed(klass.__mro__):
106
+ for name in base.__dict__:
107
+ if (
108
+ name.startswith(prefix)
109
+ and name not in seen
110
+ and callable(getattr(mw_class, name, None))
111
+ ):
112
+ seen.add(name)
113
+ names.append(name)
114
+ return names
91
115
 
92
116
 
93
117
  class CorsMiddleware: