tina4-python 3.11.35__tar.gz → 3.12.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 (154) hide show
  1. {tina4_python-3.11.35 → tina4_python-3.12.0}/.gitignore +4 -0
  2. {tina4_python-3.11.35 → tina4_python-3.12.0}/PKG-INFO +2 -2
  3. {tina4_python-3.11.35 → tina4_python-3.12.0}/pyproject.toml +5 -2
  4. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/auth/__init__.py +7 -7
  6. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/cli/__init__.py +53 -5
  7. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/middleware.py +2 -2
  8. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/server.py +240 -21
  9. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/connection.py +38 -7
  10. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/firebird.py +16 -90
  11. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/postgres.py +16 -2
  12. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/debug/__init__.py +6 -3
  13. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/dev_admin/__init__.py +675 -109
  14. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/dev_admin/plan.py +108 -0
  15. tina4_python-3.12.0/tina4_python/docs.py +821 -0
  16. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/dotenv/__init__.py +1 -1
  17. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/frond/engine.py +77 -1
  18. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/mcp/__init__.py +1 -1
  19. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/mcp/tools.py +39 -0
  20. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/messenger/__init__.py +9 -9
  21. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/orm/model.py +1011 -1017
  22. tina4_python-3.12.0/tina4_python/public/js/frond.js +600 -0
  23. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/js/frond.min.js +1 -1
  24. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/js/tina4-dev-admin.js +274 -140
  25. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/js/tina4-dev-admin.min.js +274 -140
  26. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/swagger/__init__.py +3 -3
  27. {tina4_python-3.11.35 → tina4_python-3.12.0}/README.md +0 -0
  28. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/CLAUDE.md +0 -0
  29. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/HtmlElement.py +0 -0
  30. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/Testing.py +0 -0
  31. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/ai/__init__.py +0 -0
  32. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/api/__init__.py +0 -0
  33. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/cache/__init__.py +0 -0
  34. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/container/__init__.py +0 -0
  35. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/__init__.py +0 -0
  36. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/cache.py +0 -0
  37. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/constants.py +0 -0
  38. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/events.py +0 -0
  39. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/rate_limiter.py +0 -0
  40. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/request.py +0 -0
  41. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/response.py +0 -0
  42. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/core/router.py +0 -0
  43. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/crud/__init__.py +0 -0
  44. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/__init__.py +0 -0
  45. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/adapter.py +0 -0
  46. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/mongodb.py +0 -0
  47. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/mssql.py +0 -0
  48. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/mysql.py +0 -0
  49. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/odbc.py +0 -0
  50. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/database/sqlite.py +0 -0
  51. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/debug/error_overlay.py +0 -0
  52. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/dev_admin/metrics.py +0 -0
  53. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/dev_admin/project_index.py +0 -0
  54. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/frond/FROND.md +0 -0
  55. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/frond/__init__.py +0 -0
  56. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/auth/meta.json +0 -0
  57. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  58. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/database/meta.json +0 -0
  59. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  60. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/error-overlay/meta.json +0 -0
  61. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  62. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/orm/meta.json +0 -0
  63. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  64. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  65. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/queue/meta.json +0 -0
  66. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  67. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/rest-api/meta.json +0 -0
  68. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  69. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/templates/meta.json +0 -0
  70. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  71. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  72. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/graphql/__init__.py +0 -0
  73. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/i18n/__init__.py +0 -0
  74. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/mcp/protocol.py +0 -0
  75. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/migration/__init__.py +0 -0
  76. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/migration/runner.py +0 -0
  77. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/orm/__init__.py +0 -0
  78. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/orm/fields.py +0 -0
  79. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/css/tina4.css +0 -0
  80. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/css/tina4.min.css +0 -0
  81. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/favicon.ico +0 -0
  82. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/images/logo.svg +0 -0
  83. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  84. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/js/tina4.min.js +0 -0
  85. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/js/tina4js.min.js +0 -0
  86. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/swagger/index.html +0 -0
  87. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  88. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/query_builder/__init__.py +0 -0
  89. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/__init__.py +0 -0
  90. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/job.py +0 -0
  91. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/kafka_backend.py +0 -0
  92. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/lite_backend.py +0 -0
  93. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/mongo_backend.py +0 -0
  94. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue/rabbitmq_backend.py +0 -0
  95. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue_backends/__init__.py +0 -0
  96. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue_backends/kafka_backend.py +0 -0
  97. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue_backends/mongo_backend.py +0 -0
  98. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  99. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/__init__.py +0 -0
  100. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  101. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_badges.scss +0 -0
  102. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  103. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_cards.scss +0 -0
  104. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_forms.scss +0 -0
  105. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_grid.scss +0 -0
  106. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_modals.scss +0 -0
  107. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_nav.scss +0 -0
  108. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_reset.scss +0 -0
  109. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_tables.scss +0 -0
  110. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_typography.scss +0 -0
  111. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  112. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_variables.scss +0 -0
  113. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/base.scss +0 -0
  114. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/colors.scss +0 -0
  115. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/scss/tina4css/tina4.scss +0 -0
  116. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/seeder/__init__.py +0 -0
  117. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/service/__init__.py +0 -0
  118. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/session/__init__.py +0 -0
  119. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/session_handlers/__init__.py +0 -0
  120. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  121. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/session_handlers/redis_handler.py +0 -0
  122. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/session_handlers/valkey_handler.py +0 -0
  123. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/components/crud.twig +0 -0
  124. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  125. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  126. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/docker/python/Dockerfile +0 -0
  127. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  128. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/302.twig +0 -0
  129. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/401.twig +0 -0
  130. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/403.twig +0 -0
  131. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/404.twig +0 -0
  132. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/500.twig +0 -0
  133. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/502.twig +0 -0
  134. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/503.twig +0 -0
  135. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/errors/base.twig +0 -0
  136. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/frontend/README.md +0 -0
  137. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/templates/readme.md +0 -0
  138. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/test_client/__init__.py +0 -0
  139. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  140. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  141. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  142. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  143. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/validator/__init__.py +0 -0
  152. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/websocket/__init__.py +0 -0
  153. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/websocket/backplane.py +0 -0
  154. {tina4_python-3.11.35 → tina4_python-3.12.0}/tina4_python/wsdl/__init__.py +0 -0
@@ -74,3 +74,7 @@ example/store/__pycache__/
74
74
  example/store/src/**/__pycache__/
75
75
  .claude/settings.local.json
76
76
  .claude/worktrees/
77
+ /nonexistent_path/
78
+ /data/store.db
79
+ /example/store/
80
+ /example/uv.lock
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.11.35
4
- Summary: Tina4 Python v3 Zero-dependency, lightweight web framework
3
+ Version: 3.12.0
4
+ Summary: Tina4 for Python — 54 built-in features, zero dependencies
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
7
7
  Requires-Python: >=3.12
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.11.35"
4
- description = "Tina4 Python v3 Zero-dependency, lightweight web framework"
3
+ dynamic = ["version"]
4
+ description = "Tina4 for Python — 54 built-in features, zero dependencies"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
7
7
  ]
@@ -55,6 +55,9 @@ dev = [
55
55
  requires = ["hatchling"]
56
56
  build-backend = "hatchling.build"
57
57
 
58
+ [tool.hatch.version]
59
+ path = "tina4_python/__init__.py"
60
+
58
61
  [tool.hatch.build]
59
62
  include = ["tina4_python/**/*"]
60
63
 
@@ -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.11.35"
11
+ __version__ = "3.12.0"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -60,7 +60,7 @@ class Auth:
60
60
  algorithm: JWT algorithm (default HS256).
61
61
  expires_in: Token lifetime in seconds (default 3600).
62
62
  """
63
- self.secret = secret or os.environ.get("SECRET", "tina4-default-secret")
63
+ self.secret = secret or os.environ.get("TINA4_SECRET", "tina4-default-secret")
64
64
  self.algorithm = algorithm
65
65
  self.expires_in = expires_in or int(
66
66
  os.environ.get("TINA4_TOKEN_LIMIT", "60")
@@ -161,28 +161,28 @@ class Auth:
161
161
  @classmethod
162
162
  def get_token_static(cls, payload: dict, expires_in: int = 60) -> str:
163
163
  """Create a JWT without instantiating Auth — reads SECRET from env."""
164
- secret = os.environ.get("SECRET", "tina4-default-secret")
164
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
165
165
  auth = cls(secret=secret, expires_in=expires_in)
166
166
  return auth.get_token(payload)
167
167
 
168
168
  @classmethod
169
169
  def valid_token_static(cls, token: str) -> bool:
170
170
  """Validate a JWT without instantiating Auth — reads SECRET from env."""
171
- secret = os.environ.get("SECRET", "tina4-default-secret")
171
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
172
172
  auth = cls(secret=secret)
173
173
  return auth.valid_token(token)
174
174
 
175
175
  @classmethod
176
176
  def get_payload_static(cls, token: str) -> dict | None:
177
177
  """Decode payload (no validation) without instantiating Auth."""
178
- secret = os.environ.get("SECRET", "tina4-default-secret")
178
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
179
179
  auth = cls(secret=secret)
180
180
  return auth.get_payload(token)
181
181
 
182
182
  @classmethod
183
183
  def refresh_token_static(cls, token: str, expires_in: int = 60) -> str | None:
184
184
  """Refresh a JWT without instantiating Auth — reads SECRET from env."""
185
- secret = os.environ.get("SECRET", "tina4-default-secret")
185
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
186
186
  auth = cls(secret=secret, expires_in=expires_in)
187
187
  return auth.refresh_token(token)
188
188
 
@@ -193,7 +193,7 @@ class Auth:
193
193
  Reads SECRET from env. Checks: Bearer JWT, Bearer API key, Basic auth.
194
194
  Returns payload dict on success, None on failure.
195
195
  """
196
- secret = os.environ.get("SECRET", "tina4-default-secret")
196
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
197
197
  auth = cls(secret=secret)
198
198
  return auth.authenticate_request(headers)
199
199
 
@@ -246,7 +246,7 @@ class Auth:
246
246
  Returns: True if the provided key matches.
247
247
  """
248
248
  if expected is None:
249
- expected = os.environ.get("TINA4_API_KEY", os.environ.get("API_KEY", ""))
249
+ expected = os.environ.get("TINA4_API_KEY", "")
250
250
  if not expected:
251
251
  return False
252
252
  return hmac.compare_digest(provided, expected)
@@ -137,6 +137,7 @@ def main():
137
137
  "migrate:create": _migrate_create,
138
138
  "migrate:rollback": _migrate_rollback,
139
139
  "migrate:status": _migrate_status,
140
+ "env-migrate": _env_migrate,
140
141
  "seed": _seed,
141
142
  "routes": _routes,
142
143
  "test": _test,
@@ -155,6 +156,52 @@ def main():
155
156
  _help([])
156
157
 
157
158
 
159
+ def _env_migrate(args):
160
+ """Rewrite a .env file in place, renaming pre-3.12 names to TINA4_ form.
161
+
162
+ Usage: tina4python env-migrate [path] (default path: .env)
163
+
164
+ Backs the original up to <path>.bak before rewriting. Prints a diff of
165
+ each rename. Idempotent — running twice is a no-op on the second run.
166
+ """
167
+ from tina4_python.core.server import _LEGACY_ENV_VARS
168
+ target = Path(args[0]) if args else Path(".env")
169
+ if not target.is_file():
170
+ print(f" no .env at {target}")
171
+ return
172
+ text = target.read_text(encoding="utf-8")
173
+ backup = target.with_suffix(target.suffix + ".bak")
174
+ backup.write_text(text, encoding="utf-8")
175
+ print(f" backup written: {backup}")
176
+
177
+ renamed = 0
178
+ new_lines = []
179
+ for line in text.splitlines(keepends=True):
180
+ stripped = line.lstrip()
181
+ if not stripped or stripped.startswith("#"):
182
+ new_lines.append(line); continue
183
+ if "=" not in stripped:
184
+ new_lines.append(line); continue
185
+ key = stripped.split("=", 1)[0].strip()
186
+ if key in _LEGACY_ENV_VARS:
187
+ new_key = _LEGACY_ENV_VARS[key]
188
+ new_line = line.replace(key, new_key, 1)
189
+ print(f" {key:<28} → {new_key}")
190
+ new_lines.append(new_line)
191
+ renamed += 1
192
+ else:
193
+ new_lines.append(line)
194
+
195
+ if renamed == 0:
196
+ print(" nothing to rename — your .env is already on the new convention")
197
+ backup.unlink() # don't leave a noise backup
198
+ return
199
+
200
+ target.write_text("".join(new_lines), encoding="utf-8")
201
+ print(f"\n done: {renamed} rename(s) applied to {target}")
202
+ print(f" original kept at {backup} (delete once you've verified)")
203
+
204
+
158
205
  def _help(args=None):
159
206
  print("""
160
207
  Tina4 Python — CLI
@@ -168,6 +215,7 @@ Commands:
168
215
  migrate:create <desc> Create a new migration file
169
216
  migrate:rollback Rollback last migration batch
170
217
  migrate:status Show migration status
218
+ env-migrate [path] Rewrite .env to TINA4_-prefixed names (v3.12 migration)
171
219
  seed Run database seeders
172
220
  routes List all registered routes
173
221
  test Run test suite
@@ -212,7 +260,7 @@ def _console(args=None):
212
260
 
213
261
  # Try to connect database from DATABASE_URL
214
262
  db = None
215
- db_url = os.environ.get("DATABASE_URL")
263
+ db_url = os.environ.get("TINA4_DATABASE_URL")
216
264
  if db_url:
217
265
  try:
218
266
  db = Database(db_url)
@@ -404,7 +452,7 @@ def _migrate(args):
404
452
  from tina4_python.database import Database
405
453
  from tina4_python.migration import Migration
406
454
 
407
- db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
455
+ db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
408
456
  db = Database(db_url)
409
457
  mig_dir = args[0] if args else "migrations"
410
458
  ran = Migration(db, mig_dir).migrate()
@@ -434,7 +482,7 @@ def _migrate_rollback(args):
434
482
  from tina4_python.database import Database
435
483
  from tina4_python.migration import Migration
436
484
 
437
- db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
485
+ db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
438
486
  db = Database(db_url)
439
487
  mig_dir = args[0] if args else "migrations"
440
488
  rolled = Migration(db, mig_dir).rollback()
@@ -453,7 +501,7 @@ def _migrate_status(args):
453
501
  from tina4_python.database import Database
454
502
  from tina4_python.migration import Migration
455
503
 
456
- db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
504
+ db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
457
505
  db = Database(db_url)
458
506
  result = Migration(db, args[0] if args else "migrations").status()
459
507
  completed, pending = result["completed"], result["pending"]
@@ -489,7 +537,7 @@ def _seed(args):
489
537
  import importlib.util
490
538
  from tina4_python.database import Database
491
539
 
492
- db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
540
+ db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
493
541
  db = Database(db_url)
494
542
  sys.path.insert(0, str(Path.cwd()))
495
543
 
@@ -287,7 +287,7 @@ class CsrfMiddleware:
287
287
  bearer_token = auth_header[7:].strip()
288
288
  if bearer_token:
289
289
  from tina4_python.auth import Auth as _CsrfAuth
290
- secret = os.environ.get("SECRET", "tina4-default-secret")
290
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
291
291
  auth = _CsrfAuth(secret=secret)
292
292
  if auth.valid_token(bearer_token):
293
293
  return request, response
@@ -326,7 +326,7 @@ class CsrfMiddleware:
326
326
 
327
327
  # Validate the token
328
328
  from tina4_python.auth import Auth as _CsrfAuth
329
- secret = os.environ.get("SECRET", "tina4-default-secret")
329
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
330
330
  auth = _CsrfAuth(secret=secret)
331
331
  if not auth.valid_token(token):
332
332
  return request, response.error(
@@ -193,20 +193,93 @@ def _render_error_page(status_code: int, path: str, request_id: str, error_messa
193
193
  _template_cache: dict[str, str] | None = None
194
194
 
195
195
 
196
+ # Auto-routing scans this single subdirectory of src/templates/. Only files
197
+ # in src/templates/pages/ become URLs — everything else (partials, layouts,
198
+ # base.twig, errors, components, macros) is never URL-exposed and remains
199
+ # renderable only via {% include %} / {% extends %} / response.render().
200
+ #
201
+ # Convention adapted from Next.js' pages/ directory and Nuxt's pages/ folder.
202
+ # Explicit, secure by default, no skip lists to maintain.
203
+ _TEMPLATE_PAGES_DIR = "pages"
204
+
205
+
206
+ def _is_dev_mode() -> bool:
207
+ """True when ``TINA4_DEBUG`` is one of the truthy values (true|1|yes).
208
+
209
+ Centralised so every dev-mode gate (landing page, dev toolbar, error
210
+ overlay, dev admin) reads the same flag the same way.
211
+ """
212
+ return os.environ.get("TINA4_DEBUG", "false").strip().lower() in ("true", "1", "yes")
213
+
214
+
215
+ # RFC 7231 / RFC 9110 status reason phrases. We use this to write a correct
216
+ # HTTP status line in the dev server's HTTP/1.1 → ASGI bridge — previously
217
+ # the bridge wrote "HTTP/1.1 404 OK" regardless of code, which is malformed.
218
+ _HTTP_REASON_PHRASES: dict[int, str] = {
219
+ 100: "Continue", 101: "Switching Protocols",
220
+ 200: "OK", 201: "Created", 202: "Accepted", 204: "No Content",
221
+ 206: "Partial Content",
222
+ 301: "Moved Permanently", 302: "Found", 303: "See Other",
223
+ 304: "Not Modified", 307: "Temporary Redirect", 308: "Permanent Redirect",
224
+ 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
225
+ 404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable",
226
+ 409: "Conflict", 410: "Gone", 413: "Content Too Large",
227
+ 415: "Unsupported Media Type", 422: "Unprocessable Content",
228
+ 429: "Too Many Requests",
229
+ 500: "Internal Server Error", 501: "Not Implemented",
230
+ 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
231
+ }
232
+
233
+
234
+ def _http_reason(status: int) -> str:
235
+ """Return the canonical HTTP reason phrase for ``status``.
236
+
237
+ Falls back to a sensible label when an exotic status is used. Never
238
+ returns an empty string — the HTTP/1.1 status line requires a phrase.
239
+ """
240
+ return _HTTP_REASON_PHRASES.get(int(status), "OK" if 200 <= status < 300 else "Error")
241
+
242
+
243
+ def _template_auto_routing_enabled() -> bool:
244
+ """Honour TINA4_TEMPLATE_ROUTING=off|false|0|no as an explicit kill switch.
245
+
246
+ Default: enabled. Drop a file in src/templates/pages/ and it serves at
247
+ the matching URL — the zero-config Tina4 convention. Operators who want
248
+ explicit-only routing can set TINA4_TEMPLATE_ROUTING=off and every URL
249
+ must be registered via @get / @post (or be a static file).
250
+ """
251
+ val = os.environ.get("TINA4_TEMPLATE_ROUTING", "on").strip().lower()
252
+ return val not in ("off", "false", "0", "no", "disabled")
253
+
254
+
196
255
  def _resolve_template(path: str) -> str | None:
197
- """Resolve a URL path to a template file in src/templates/.
256
+ """Resolve a URL path to a template file in src/templates/pages/.
257
+
258
+ Only files inside ``src/templates/pages/`` auto-route from a URL.
259
+ Anything in ``src/templates/`` outside ``pages/`` (partials, layouts,
260
+ base.twig, errors, components) is never served standalone.
261
+
198
262
  Dev mode: checks filesystem every time for live changes.
199
263
  Production: uses a cached lookup built once at startup.
264
+
265
+ The whole feature can be turned off with ``TINA4_TEMPLATE_ROUTING=off``.
200
266
  """
267
+ if not _template_auto_routing_enabled():
268
+ return None
269
+
201
270
  clean_path = path.strip("/") or "index"
202
271
  is_dev = os.environ.get("TINA4_DEBUG", "false").lower() in ("true", "1", "yes")
203
272
 
204
273
  if is_dev:
205
- template_dir = Path("src/templates")
274
+ # Skip underscore-prefixed files even within pages/ — they're private
275
+ # by Hugo/Jekyll convention (helpers, fragments) and shouldn't auto-serve.
276
+ if any(seg.startswith("_") for seg in clean_path.split("/")):
277
+ return None
278
+ pages_dir = Path("src/templates") / _TEMPLATE_PAGES_DIR
206
279
  for ext in (".twig", ".html"):
207
- candidate = clean_path + ext
208
- if (template_dir / candidate).is_file():
209
- return candidate
280
+ candidate_rel = f"{_TEMPLATE_PAGES_DIR}/{clean_path}{ext}"
281
+ if (pages_dir / (clean_path + ext)).is_file():
282
+ return candidate_rel
210
283
  return None
211
284
 
212
285
  global _template_cache
@@ -216,17 +289,25 @@ def _resolve_template(path: str) -> str | None:
216
289
 
217
290
 
218
291
  def _build_template_cache() -> None:
219
- """Scan src/templates/ once and build url_path -> template_file lookup."""
292
+ """Scan src/templates/pages/ once and build url_path -> template_file lookup.
293
+ Only files under ``pages/`` are eligible — partials, layouts, base.twig,
294
+ errors etc remain renderable via explicit response.render() but never
295
+ auto-serve from a URL.
296
+ """
220
297
  global _template_cache
221
298
  _template_cache = {}
222
- template_dir = Path("src/templates")
223
- if not template_dir.is_dir():
299
+ pages_dir = Path("src/templates") / _TEMPLATE_PAGES_DIR
300
+ if not pages_dir.is_dir():
224
301
  return
225
- for f in template_dir.rglob("*"):
302
+ for f in pages_dir.rglob("*"):
226
303
  if not f.is_file() or f.suffix not in (".twig", ".html"):
227
304
  continue
228
- rel = str(f.relative_to(template_dir)).replace("\\", "/")
229
- url_path = rel.rsplit(".", 1)[0]
305
+ # Skip private files even within pages/ (e.g. pages/_helper.twig)
306
+ rel_inside_pages = f.relative_to(pages_dir)
307
+ if any(p.startswith("_") for p in rel_inside_pages.parts):
308
+ continue
309
+ rel = str(f.relative_to(Path("src/templates"))).replace("\\", "/")
310
+ url_path = str(rel_inside_pages).replace("\\", "/").rsplit(".", 1)[0]
230
311
  if url_path not in _template_cache:
231
312
  _template_cache[url_path] = rel
232
313
 
@@ -837,7 +918,10 @@ async def _handle_dev_admin(request: Request, response: Response) -> Response:
837
918
  """Serve the /__dev dashboard and API routes."""
838
919
  from tina4_python.dev_admin import get_api_handlers
839
920
  if request.path in ("/__dev/", "/__dev", "/__dev/v2", "/__dev/v2/"):
840
- # Unified SPA dev admin
921
+ # Unified SPA dev admin. The bundle derives its WS URL from
922
+ # `location.host` directly, so no environment shim is needed —
923
+ # the framework serves /__dev_reload on its own port and the
924
+ # SPA reaches it as `ws://<page-host>/__dev_reload`.
841
925
  response.html("""<!DOCTYPE html>
842
926
  <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev Admin</title></head>
843
927
  <body><div id="app" data-framework="python" data-color="#3b82f6"></div>
@@ -847,8 +931,19 @@ async def _handle_dev_admin(request: Request, response: Response) -> Response:
847
931
  handler_info = handlers.get(request.path)
848
932
  if handler_info and request.method == handler_info[0]:
849
933
  try:
850
- def _resp(data, code=200):
851
- if isinstance(data, str):
934
+ def _resp(data, code=200, content_type=None):
935
+ # content_type overrides the auto-detected MIME —
936
+ # lets handlers stream binary with an explicit
937
+ # Content-Type (e.g. /__dev/api/file/raw).
938
+ if content_type is not None:
939
+ response.status(code)
940
+ response.content_type = content_type
941
+ response.content = data if isinstance(data, (bytes, bytearray)) else str(data).encode("utf-8")
942
+ elif isinstance(data, (bytes, bytearray)):
943
+ response.status(code)
944
+ response.content_type = "application/octet-stream"
945
+ response.content = data
946
+ elif isinstance(data, str):
852
947
  response.status(code).html(data)
853
948
  else:
854
949
  response.status(code).json(data)
@@ -906,7 +1001,7 @@ def _check_auth(request: Request, response: Response, route: dict) -> bool:
906
1001
  if not route.get("auth_required"):
907
1002
  return False
908
1003
  _auth_header = request.headers.get("authorization", "")
909
- _api_key = os.environ.get("TINA4_API_KEY", os.environ.get("API_KEY", ""))
1004
+ _api_key = os.environ.get("TINA4_API_KEY", "")
910
1005
  _auth_ok = False
911
1006
  if _auth_header and _auth_header.startswith("Bearer "):
912
1007
  _token = _auth_header[7:]
@@ -1062,7 +1157,19 @@ def _handle_route_error(
1062
1157
 
1063
1158
 
1064
1159
  def _handle_no_route(request: Request, response: Response, request_id: str) -> Response:
1065
- """Serve static files, templates, landing page, or 404."""
1160
+ """Serve static files, templates, landing page, or 404.
1161
+
1162
+ Lookup order at any URL with no registered route:
1163
+ 1. Static file (public/, src/public/, framework public/, with /
1164
+ resolving to index.html so SPAs Just Work)
1165
+ 2. Auto-routed template from src/templates/pages/ (gated by
1166
+ TINA4_TEMPLATE_ROUTING)
1167
+ 3. Framework landing page — only at "/", and only in dev
1168
+ (``TINA4_DEBUG=true``). Production never shows it, so the
1169
+ framework version, dev-admin link, and gallery never leak
1170
+ to real users.
1171
+ 4. 404
1172
+ """
1066
1173
  static = _try_static(request.path)
1067
1174
  if static:
1068
1175
  return static
@@ -1071,7 +1178,7 @@ def _handle_no_route(request: Request, response: Response, request_id: str) -> R
1071
1178
  from tina4_python.core.response import get_frond
1072
1179
  html = get_frond().render(tpl_file, {})
1073
1180
  response.html(html)
1074
- elif request.path == "/":
1181
+ elif request.path == "/" and _is_dev_mode():
1075
1182
  response.html(_render_landing_page())
1076
1183
  else:
1077
1184
  html = _render_error_page(404, request.path, request_id)
@@ -1182,8 +1289,14 @@ async def handle(request: Request) -> Response:
1182
1289
  from tina4_python.dotenv import is_truthy
1183
1290
  _is_dev = is_truthy(os.environ.get("TINA4_DEBUG", ""))
1184
1291
 
1185
- # Dev admin
1186
- if _is_dev and request.path.startswith("/__dev"):
1292
+ # Dev admin — also catches /ai/api/chat (SPA's ollama proxy) and the
1293
+ # bare /ai /vision /embed /image /rag service-health probes that
1294
+ # drive the "SERVICES ●●●●●" dots in the dev-admin UI.
1295
+ _dev_extra_paths = {"/ai/api/chat", "/ai", "/vision", "/embed", "/image", "/rag"}
1296
+ if _is_dev and (
1297
+ request.path.startswith("/__dev")
1298
+ or request.path in _dev_extra_paths
1299
+ ):
1187
1300
  return await _handle_dev_admin(request, response)
1188
1301
 
1189
1302
  # Swagger
@@ -1304,8 +1417,16 @@ def _try_static(path: str) -> Response | None:
1304
1417
  2. public/ (simple, IDE-friendly)
1305
1418
  3. src/public/ (nested convention)
1306
1419
  4. tina4_python/public/ (framework built-in assets)
1420
+
1421
+ Index resolution: when ``path`` is ``/`` or ends with ``/``, the lookup
1422
+ appends ``index.html`` so a Vite/SPA build with ``src/public/index.html``
1423
+ serves at the matching URL — no custom ``@get("/")`` route needed.
1307
1424
  """
1308
1425
  clean = path.lstrip("/")
1426
+ # Index resolution: '/' or '/foo/' -> append 'index.html' so SPA builds
1427
+ # in src/public/ Just Work without a custom root route.
1428
+ if clean == "" or clean.endswith("/"):
1429
+ clean = clean + "index.html"
1309
1430
  custom = os.environ.get("TINA4_PUBLIC_DIR")
1310
1431
  candidates = []
1311
1432
  if custom:
@@ -1555,6 +1676,69 @@ def _print_banner(host: str, port: int, server_name: str = "asyncio", ai_port: i
1555
1676
  print(banner)
1556
1677
 
1557
1678
 
1679
+ # Legacy env var names that v3.12 has retired. If any of these are set in
1680
+ # the environment we refuse to boot — silently ignoring them would cause
1681
+ # auth/db/mail to fall back to defaults with no warning. Each maps to its
1682
+ # new TINA4_-prefixed canonical name (or DROPPED for deleted features).
1683
+ _LEGACY_ENV_VARS: dict[str, str] = {
1684
+ "DATABASE_URL": "TINA4_DATABASE_URL",
1685
+ "DATABASE_USERNAME": "TINA4_DATABASE_USERNAME",
1686
+ "DATABASE_PASSWORD": "TINA4_DATABASE_PASSWORD",
1687
+ "DB_URL": "TINA4_DATABASE_URL",
1688
+ "SECRET": "TINA4_SECRET",
1689
+ "API_KEY": "TINA4_API_KEY",
1690
+ "JWT_ALGORITHM": "TINA4_JWT_ALGORITHM",
1691
+ "SMTP_HOST": "TINA4_MAIL_HOST",
1692
+ "SMTP_PORT": "TINA4_MAIL_PORT",
1693
+ "SMTP_USERNAME": "TINA4_MAIL_USERNAME",
1694
+ "SMTP_PASSWORD": "TINA4_MAIL_PASSWORD",
1695
+ "SMTP_FROM": "TINA4_MAIL_FROM",
1696
+ "SMTP_FROM_NAME": "TINA4_MAIL_FROM_NAME",
1697
+ "IMAP_HOST": "TINA4_MAIL_IMAP_HOST",
1698
+ "IMAP_PORT": "TINA4_MAIL_IMAP_PORT",
1699
+ "IMAP_USER": "TINA4_MAIL_IMAP_USERNAME",
1700
+ "IMAP_PASS": "TINA4_MAIL_IMAP_PASSWORD",
1701
+ "HOST_NAME": "TINA4_HOST_NAME",
1702
+ "SWAGGER_TITLE": "TINA4_SWAGGER_TITLE",
1703
+ "SWAGGER_DESCRIPTION": "TINA4_SWAGGER_DESCRIPTION",
1704
+ "SWAGGER_VERSION": "TINA4_SWAGGER_VERSION",
1705
+ "ORM_PLURAL_TABLE_NAMES": "TINA4_ORM_PLURAL_TABLE_NAMES",
1706
+ }
1707
+
1708
+
1709
+ def _check_legacy_env_vars() -> None:
1710
+ """Refuse to boot if pre-3.12 un-prefixed env vars are still set.
1711
+
1712
+ Tina4 v3.12 hard-renamed every framework-specific env var to use the
1713
+ ``TINA4_`` prefix. Booting silently with a legacy ``DATABASE_URL`` or
1714
+ ``SECRET`` would let auth, DB, or mail fall back to insecure defaults
1715
+ while the user thought their config was being read. Better to die
1716
+ loudly with a list of names to fix.
1717
+
1718
+ Bypass with ``TINA4_ALLOW_LEGACY_ENV=true`` in CI / migration scripts
1719
+ that genuinely need both names set during a transition window.
1720
+ """
1721
+ if os.environ.get("TINA4_ALLOW_LEGACY_ENV", "").lower() in ("true", "1", "yes"):
1722
+ return
1723
+ found = sorted(name for name in _LEGACY_ENV_VARS if name in os.environ)
1724
+ if not found:
1725
+ return
1726
+ msg = ["", "─" * 72,
1727
+ "Tina4 v3.12 requires TINA4_ prefix on all framework env vars.",
1728
+ "Your environment still has these legacy names:",
1729
+ ""]
1730
+ for old in found:
1731
+ new = _LEGACY_ENV_VARS[old]
1732
+ msg.append(f" {old:<28} → {new}")
1733
+ msg.extend(["",
1734
+ "Run `tina4 env-migrate` to rewrite your .env automatically,",
1735
+ "or rename manually. See https://tina4.com/release/3.12.0",
1736
+ "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
1737
+ "─" * 72, ""])
1738
+ print("\n".join(msg), file=sys.stderr)
1739
+ sys.exit(2)
1740
+
1741
+
1558
1742
  def run(host: str | None = None, port: int | None = None, no_browser: bool = False, no_reload: bool = False):
1559
1743
  """Start the Tina4 dev server.
1560
1744
 
@@ -1570,6 +1754,9 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1570
1754
  global _start_time
1571
1755
  _start_time = time.time()
1572
1756
 
1757
+ # Refuse to boot with v3.11 / v2 era un-prefixed env vars set.
1758
+ _check_legacy_env_vars()
1759
+
1573
1760
  # ── Require tina4 CLI ─────────────────────────────────────────
1574
1761
  # The framework must be launched via `tina4 serve`, not `python app.py`.
1575
1762
  # The tina4 CLI passes --managed when spawning the server process.
@@ -1615,6 +1802,38 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1615
1802
  log_level = os.environ.get("TINA4_LOG_LEVEL", "error" if not is_production else "error")
1616
1803
  Log.configure(level=log_level, production=is_production)
1617
1804
 
1805
+ # Install a top-level exception hook so uncaught exceptions bubbling
1806
+ # out of anything (a route handler, a background task, the event
1807
+ # loop itself on startup) land in logs/error.log. Without this,
1808
+ # an uncaught exception surfaces only via Python's default stderr
1809
+ # writer and never touches Log — the same gap PHP had before its
1810
+ # set_exception_handler fix. Chains to the previous hook so any
1811
+ # debugger / IDE hook already in place still fires.
1812
+ import sys as _sys
1813
+ import traceback as _traceback
1814
+ _prior_excepthook = _sys.excepthook
1815
+
1816
+ def _tina4_excepthook(exc_type, exc_value, exc_tb):
1817
+ # KeyboardInterrupt is a user-initiated Ctrl+C, not an error —
1818
+ # defer to the prior hook (which prints a clean traceback).
1819
+ if issubclass(exc_type, KeyboardInterrupt):
1820
+ _prior_excepthook(exc_type, exc_value, exc_tb)
1821
+ return
1822
+ try:
1823
+ trace_text = "".join(_traceback.format_exception(exc_type, exc_value, exc_tb))
1824
+ Log.error(
1825
+ f"Uncaught {exc_type.__name__}: {exc_value}",
1826
+ trace=trace_text,
1827
+ )
1828
+ except Exception:
1829
+ # If logging itself fails (disk full, permissions, logger
1830
+ # not initialised yet), fall through to the prior hook so
1831
+ # the user still sees something in stderr.
1832
+ pass
1833
+ _prior_excepthook(exc_type, exc_value, exc_tb)
1834
+
1835
+ _sys.excepthook = _tina4_excepthook
1836
+
1618
1837
  # Ensure folders
1619
1838
  _ensure_folders()
1620
1839
 
@@ -1777,7 +1996,7 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1777
1996
  # Streaming mode — flush headers on first chunk, then write each chunk immediately
1778
1997
  if not _headers_sent:
1779
1998
  _headers_sent = True
1780
- writer.write(f"HTTP/1.1 {resp_status} OK\r\n".encode())
1999
+ writer.write(f"HTTP/1.1 {resp_status} {_http_reason(resp_status)}\r\n".encode())
1781
2000
  for name, value in resp_headers:
1782
2001
  writer.write(name + b": " + value + b"\r\n")
1783
2002
  writer.write(b"\r\n")
@@ -1797,7 +2016,7 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1797
2016
 
1798
2017
  # Write HTTP/1.1 response (only if headers weren't already sent by streaming)
1799
2018
  if not _headers_sent:
1800
- status_line = f"HTTP/1.1 {resp_status} OK\r\n"
2019
+ status_line = f"HTTP/1.1 {resp_status} {_http_reason(resp_status)}\r\n"
1801
2020
  writer.write(status_line.encode())
1802
2021
  for name, value in resp_headers:
1803
2022
  writer.write(name + b": " + value + b"\r\n")