tina4-python 3.11.35__tar.gz → 3.11.36__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 (153) hide show
  1. {tina4_python-3.11.35 → tina4_python-3.11.36}/.gitignore +4 -0
  2. {tina4_python-3.11.35 → tina4_python-3.11.36}/PKG-INFO +1 -1
  3. {tina4_python-3.11.35 → tina4_python-3.11.36}/pyproject.toml +1 -1
  4. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/core/server.py +57 -5
  6. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/database/connection.py +32 -1
  7. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/debug/__init__.py +6 -3
  8. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/dev_admin/__init__.py +656 -90
  9. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/dev_admin/plan.py +108 -0
  10. tina4_python-3.11.36/tina4_python/docs.py +821 -0
  11. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/frond/engine.py +76 -0
  12. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/mcp/tools.py +39 -0
  13. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/orm/model.py +0 -6
  14. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/js/tina4-dev-admin.js +274 -140
  15. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/js/tina4-dev-admin.min.js +274 -140
  16. {tina4_python-3.11.35 → tina4_python-3.11.36}/README.md +0 -0
  17. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/CLAUDE.md +0 -0
  18. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/HtmlElement.py +0 -0
  19. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/Testing.py +0 -0
  20. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/ai/__init__.py +0 -0
  21. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/api/__init__.py +0 -0
  22. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/auth/__init__.py +0 -0
  23. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/cache/__init__.py +0 -0
  24. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/cli/__init__.py +0 -0
  25. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/container/__init__.py +0 -0
  26. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/core/__init__.py +0 -0
  27. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/core/cache.py +0 -0
  28. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/core/constants.py +0 -0
  29. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/core/events.py +0 -0
  30. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/core/middleware.py +0 -0
  31. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/core/rate_limiter.py +0 -0
  32. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/core/request.py +0 -0
  33. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/core/response.py +0 -0
  34. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/core/router.py +0 -0
  35. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/crud/__init__.py +0 -0
  36. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/database/__init__.py +0 -0
  37. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/database/adapter.py +0 -0
  38. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/database/firebird.py +0 -0
  39. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/database/mongodb.py +0 -0
  40. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/database/mssql.py +0 -0
  41. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/database/mysql.py +0 -0
  42. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/database/odbc.py +0 -0
  43. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/database/postgres.py +0 -0
  44. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/database/sqlite.py +0 -0
  45. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/debug/error_overlay.py +0 -0
  46. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/dev_admin/metrics.py +0 -0
  47. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/dev_admin/project_index.py +0 -0
  48. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/dotenv/__init__.py +0 -0
  49. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/frond/FROND.md +0 -0
  50. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/frond/__init__.py +0 -0
  51. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/auth/meta.json +0 -0
  52. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  53. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/database/meta.json +0 -0
  54. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  55. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/error-overlay/meta.json +0 -0
  56. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  57. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/orm/meta.json +0 -0
  58. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  59. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  60. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/queue/meta.json +0 -0
  61. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  62. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/rest-api/meta.json +0 -0
  63. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  64. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/templates/meta.json +0 -0
  65. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  66. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  67. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/graphql/__init__.py +0 -0
  68. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/i18n/__init__.py +0 -0
  69. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/mcp/__init__.py +0 -0
  70. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/mcp/protocol.py +0 -0
  71. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/messenger/__init__.py +0 -0
  72. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/migration/__init__.py +0 -0
  73. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/migration/runner.py +0 -0
  74. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/orm/__init__.py +0 -0
  75. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/orm/fields.py +0 -0
  76. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/css/tina4.css +0 -0
  77. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/css/tina4.min.css +0 -0
  78. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/favicon.ico +0 -0
  79. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/images/logo.svg +0 -0
  80. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  81. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/js/frond.min.js +0 -0
  82. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/js/tina4.min.js +0 -0
  83. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/js/tina4js.min.js +0 -0
  84. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/swagger/index.html +0 -0
  85. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  86. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/query_builder/__init__.py +0 -0
  87. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/queue/__init__.py +0 -0
  88. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/queue/job.py +0 -0
  89. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/queue/kafka_backend.py +0 -0
  90. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/queue/lite_backend.py +0 -0
  91. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/queue/mongo_backend.py +0 -0
  92. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/queue/rabbitmq_backend.py +0 -0
  93. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/queue_backends/__init__.py +0 -0
  94. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/queue_backends/kafka_backend.py +0 -0
  95. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/queue_backends/mongo_backend.py +0 -0
  96. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  97. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/__init__.py +0 -0
  98. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  99. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_badges.scss +0 -0
  100. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  101. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_cards.scss +0 -0
  102. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_forms.scss +0 -0
  103. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_grid.scss +0 -0
  104. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_modals.scss +0 -0
  105. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_nav.scss +0 -0
  106. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_reset.scss +0 -0
  107. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_tables.scss +0 -0
  108. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_typography.scss +0 -0
  109. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  110. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/_variables.scss +0 -0
  111. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/base.scss +0 -0
  112. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/colors.scss +0 -0
  113. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/scss/tina4css/tina4.scss +0 -0
  114. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/seeder/__init__.py +0 -0
  115. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/service/__init__.py +0 -0
  116. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/session/__init__.py +0 -0
  117. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/session_handlers/__init__.py +0 -0
  118. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  119. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/session_handlers/redis_handler.py +0 -0
  120. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/session_handlers/valkey_handler.py +0 -0
  121. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/swagger/__init__.py +0 -0
  122. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/components/crud.twig +0 -0
  123. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  124. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  125. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/docker/python/Dockerfile +0 -0
  126. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  127. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/errors/302.twig +0 -0
  128. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/errors/401.twig +0 -0
  129. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/errors/403.twig +0 -0
  130. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/errors/404.twig +0 -0
  131. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/errors/500.twig +0 -0
  132. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/errors/502.twig +0 -0
  133. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/errors/503.twig +0 -0
  134. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/errors/base.twig +0 -0
  135. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/frontend/README.md +0 -0
  136. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/templates/readme.md +0 -0
  137. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/test_client/__init__.py +0 -0
  138. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  139. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  140. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  141. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  142. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/validator/__init__.py +0 -0
  151. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/websocket/__init__.py +0 -0
  152. {tina4_python-3.11.35 → tina4_python-3.11.36}/tina4_python/websocket/backplane.py +0 -0
  153. {tina4_python-3.11.35 → tina4_python-3.11.36}/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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.11.35
3
+ Version: 3.11.36
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.11.35"
3
+ version = "3.11.36"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -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.11.36"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -837,7 +837,10 @@ async def _handle_dev_admin(request: Request, response: Response) -> Response:
837
837
  """Serve the /__dev dashboard and API routes."""
838
838
  from tina4_python.dev_admin import get_api_handlers
839
839
  if request.path in ("/__dev/", "/__dev", "/__dev/v2", "/__dev/v2/"):
840
- # Unified SPA dev admin
840
+ # Unified SPA dev admin. The bundle derives its WS URL from
841
+ # `location.host` directly, so no environment shim is needed —
842
+ # the framework serves /__dev_reload on its own port and the
843
+ # SPA reaches it as `ws://<page-host>/__dev_reload`.
841
844
  response.html("""<!DOCTYPE html>
842
845
  <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
846
  <body><div id="app" data-framework="python" data-color="#3b82f6"></div>
@@ -847,8 +850,19 @@ async def _handle_dev_admin(request: Request, response: Response) -> Response:
847
850
  handler_info = handlers.get(request.path)
848
851
  if handler_info and request.method == handler_info[0]:
849
852
  try:
850
- def _resp(data, code=200):
851
- if isinstance(data, str):
853
+ def _resp(data, code=200, content_type=None):
854
+ # content_type overrides the auto-detected MIME —
855
+ # lets handlers stream binary with an explicit
856
+ # Content-Type (e.g. /__dev/api/file/raw).
857
+ if content_type is not None:
858
+ response.status(code)
859
+ response.content_type = content_type
860
+ response.content = data if isinstance(data, (bytes, bytearray)) else str(data).encode("utf-8")
861
+ elif isinstance(data, (bytes, bytearray)):
862
+ response.status(code)
863
+ response.content_type = "application/octet-stream"
864
+ response.content = data
865
+ elif isinstance(data, str):
852
866
  response.status(code).html(data)
853
867
  else:
854
868
  response.status(code).json(data)
@@ -1182,8 +1196,14 @@ async def handle(request: Request) -> Response:
1182
1196
  from tina4_python.dotenv import is_truthy
1183
1197
  _is_dev = is_truthy(os.environ.get("TINA4_DEBUG", ""))
1184
1198
 
1185
- # Dev admin
1186
- if _is_dev and request.path.startswith("/__dev"):
1199
+ # Dev admin — also catches /ai/api/chat (SPA's ollama proxy) and the
1200
+ # bare /ai /vision /embed /image /rag service-health probes that
1201
+ # drive the "SERVICES ●●●●●" dots in the dev-admin UI.
1202
+ _dev_extra_paths = {"/ai/api/chat", "/ai", "/vision", "/embed", "/image", "/rag"}
1203
+ if _is_dev and (
1204
+ request.path.startswith("/__dev")
1205
+ or request.path in _dev_extra_paths
1206
+ ):
1187
1207
  return await _handle_dev_admin(request, response)
1188
1208
 
1189
1209
  # Swagger
@@ -1615,6 +1635,38 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1615
1635
  log_level = os.environ.get("TINA4_LOG_LEVEL", "error" if not is_production else "error")
1616
1636
  Log.configure(level=log_level, production=is_production)
1617
1637
 
1638
+ # Install a top-level exception hook so uncaught exceptions bubbling
1639
+ # out of anything (a route handler, a background task, the event
1640
+ # loop itself on startup) land in logs/error.log. Without this,
1641
+ # an uncaught exception surfaces only via Python's default stderr
1642
+ # writer and never touches Log — the same gap PHP had before its
1643
+ # set_exception_handler fix. Chains to the previous hook so any
1644
+ # debugger / IDE hook already in place still fires.
1645
+ import sys as _sys
1646
+ import traceback as _traceback
1647
+ _prior_excepthook = _sys.excepthook
1648
+
1649
+ def _tina4_excepthook(exc_type, exc_value, exc_tb):
1650
+ # KeyboardInterrupt is a user-initiated Ctrl+C, not an error —
1651
+ # defer to the prior hook (which prints a clean traceback).
1652
+ if issubclass(exc_type, KeyboardInterrupt):
1653
+ _prior_excepthook(exc_type, exc_value, exc_tb)
1654
+ return
1655
+ try:
1656
+ trace_text = "".join(_traceback.format_exception(exc_type, exc_value, exc_tb))
1657
+ Log.error(
1658
+ f"Uncaught {exc_type.__name__}: {exc_value}",
1659
+ trace=trace_text,
1660
+ )
1661
+ except Exception:
1662
+ # If logging itself fails (disk full, permissions, logger
1663
+ # not initialised yet), fall through to the prior hook so
1664
+ # the user still sees something in stderr.
1665
+ pass
1666
+ _prior_excepthook(exc_type, exc_value, exc_tb)
1667
+
1668
+ _sys.excepthook = _tina4_excepthook
1669
+
1618
1670
  # Ensure folders
1619
1671
  _ensure_folders()
1620
1672
 
@@ -166,6 +166,11 @@ class Database:
166
166
  self._adapter: DatabaseAdapter = self._create_adapter()
167
167
  self._adapter.connect(self._connection_path(), username=self.username, password=self.password, **kwargs)
168
168
 
169
+ # Per-thread transaction adapter pin. While set, every operation
170
+ # on this thread routes to the same adapter — so the round-robin
171
+ # pool can't rotate mid-transaction and silently break atomicity.
172
+ self._tx_local = threading.local()
173
+
169
174
  # Query cache — off by default, opt-in via TINA4_DB_CACHE=true
170
175
  from tina4_python.dotenv import is_truthy
171
176
  self._cache_enabled: bool = is_truthy(os.environ.get("TINA4_DB_CACHE", "false"))
@@ -308,7 +313,25 @@ class Database:
308
313
  # ── Pool-aware adapter access ─────────────────────────────
309
314
 
310
315
  def _get_adapter(self) -> DatabaseAdapter:
311
- """Get an adapter from pool (round-robin) or single connection."""
316
+ """Get an adapter for the next operation.
317
+
318
+ With pooling enabled, ordinary calls round-robin through the pool.
319
+ Inside a transaction, however, all calls must land on the SAME
320
+ adapter — otherwise start_transaction(), execute() and commit()
321
+ each rotate to a different connection and the transaction is
322
+ meaningless (executes autocommit on whatever adapter they hit;
323
+ the final commit lands on yet another adapter that has nothing
324
+ to commit; rollback() is silently no-op'd).
325
+
326
+ We pin the adapter to the calling thread for the duration of the
327
+ transaction. start_transaction() sets the pin, commit()/rollback()
328
+ clear it. While pinned, _get_adapter() returns that same adapter
329
+ for every call so the whole transaction is atomic on one
330
+ connection.
331
+ """
332
+ pinned = getattr(self._tx_local, "adapter", None)
333
+ if pinned is not None:
334
+ return pinned
312
335
  if self._pool is not None:
313
336
  return self._pool.checkout()
314
337
  return self._adapter
@@ -422,16 +445,24 @@ class Database:
422
445
  return adapter.delete(table, filter_sql, params)
423
446
 
424
447
  def start_transaction(self):
448
+ """Begin a transaction. Pins the adapter to this thread for the
449
+ whole transaction so executes and the final commit/rollback all
450
+ run on the same connection."""
425
451
  adapter = self._get_adapter()
452
+ self._tx_local.adapter = adapter
426
453
  adapter.start_transaction()
427
454
 
428
455
  def commit(self):
456
+ """Commit the current transaction and release the adapter pin."""
429
457
  adapter = self._get_adapter()
430
458
  adapter.commit()
459
+ self._tx_local.adapter = None
431
460
 
432
461
  def rollback(self):
462
+ """Roll back the current transaction and release the adapter pin."""
433
463
  adapter = self._get_adapter()
434
464
  adapter.rollback()
465
+ self._tx_local.adapter = None
435
466
 
436
467
  def table_exists(self, name: str) -> bool:
437
468
  adapter = self._get_adapter()
@@ -200,12 +200,15 @@ class Log:
200
200
  color = cls.COLORS.get(level, "")
201
201
  print(f"{color}{line}{cls.RESET}")
202
202
 
203
- # Always write ALL levels to file (raw log, no filtering)
203
+ # Always write ALL levels to the main file (raw log, no filtering)
204
204
  if cls._writer:
205
205
  cls._writer.write(line)
206
206
 
207
- # Write errors to separate file
208
- if level == "error" and cls._error_writer:
207
+ # Mirror WARNING and ERROR into the dedicated error log so
208
+ # `tail -f logs/error.log` gives just the stuff worth looking
209
+ # at, without wading through DEBUG / INFO noise. Parity with
210
+ # tina4-php's Log class.
211
+ if cls._error_writer and cls.LEVELS.get(level, 0) >= cls.LEVELS["warning"]:
209
212
  cls._error_writer.write(line)
210
213
 
211
214
  @classmethod