tina4-python 3.11.32__tar.gz → 3.11.35__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.32 → tina4_python-3.11.35}/.gitignore +0 -4
  2. {tina4_python-3.11.32 → tina4_python-3.11.35}/PKG-INFO +2 -2
  3. {tina4_python-3.11.32 → tina4_python-3.11.35}/pyproject.toml +2 -5
  4. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/core/server.py +5 -57
  6. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/database/connection.py +1 -32
  7. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/database/firebird.py +90 -16
  8. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/debug/__init__.py +3 -6
  9. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/dev_admin/__init__.py +90 -656
  10. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/dev_admin/plan.py +0 -108
  11. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/frond/engine.py +0 -76
  12. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/mcp/tools.py +0 -39
  13. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/orm/model.py +6 -0
  14. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/js/tina4-dev-admin.js +140 -274
  15. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/js/tina4-dev-admin.min.js +140 -274
  16. tina4_python-3.11.32/tina4_python/docs.py +0 -821
  17. {tina4_python-3.11.32 → tina4_python-3.11.35}/README.md +0 -0
  18. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/CLAUDE.md +0 -0
  19. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/HtmlElement.py +0 -0
  20. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/Testing.py +0 -0
  21. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/ai/__init__.py +0 -0
  22. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/api/__init__.py +0 -0
  23. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/auth/__init__.py +0 -0
  24. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/cache/__init__.py +0 -0
  25. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/cli/__init__.py +0 -0
  26. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/container/__init__.py +0 -0
  27. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/core/__init__.py +0 -0
  28. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/core/cache.py +0 -0
  29. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/core/constants.py +0 -0
  30. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/core/events.py +0 -0
  31. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/core/middleware.py +0 -0
  32. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/core/rate_limiter.py +0 -0
  33. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/core/request.py +0 -0
  34. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/core/response.py +0 -0
  35. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/core/router.py +0 -0
  36. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/crud/__init__.py +0 -0
  37. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/database/__init__.py +0 -0
  38. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/database/adapter.py +0 -0
  39. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/database/mongodb.py +0 -0
  40. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/database/mssql.py +0 -0
  41. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/database/mysql.py +0 -0
  42. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/database/odbc.py +0 -0
  43. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/database/postgres.py +0 -0
  44. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/database/sqlite.py +0 -0
  45. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/debug/error_overlay.py +0 -0
  46. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/dev_admin/metrics.py +0 -0
  47. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/dev_admin/project_index.py +0 -0
  48. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/dotenv/__init__.py +0 -0
  49. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/frond/FROND.md +0 -0
  50. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/frond/__init__.py +0 -0
  51. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/auth/meta.json +0 -0
  52. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  53. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/database/meta.json +0 -0
  54. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  55. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/error-overlay/meta.json +0 -0
  56. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  57. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/orm/meta.json +0 -0
  58. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  59. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  60. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/queue/meta.json +0 -0
  61. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  62. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/rest-api/meta.json +0 -0
  63. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  64. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/templates/meta.json +0 -0
  65. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  66. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  67. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/graphql/__init__.py +0 -0
  68. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/i18n/__init__.py +0 -0
  69. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/mcp/__init__.py +0 -0
  70. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/mcp/protocol.py +0 -0
  71. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/messenger/__init__.py +0 -0
  72. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/migration/__init__.py +0 -0
  73. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/migration/runner.py +0 -0
  74. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/orm/__init__.py +0 -0
  75. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/orm/fields.py +0 -0
  76. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/css/tina4.css +0 -0
  77. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/css/tina4.min.css +0 -0
  78. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/favicon.ico +0 -0
  79. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/images/logo.svg +0 -0
  80. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  81. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/js/frond.min.js +0 -0
  82. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/js/tina4.min.js +0 -0
  83. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/js/tina4js.min.js +0 -0
  84. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/swagger/index.html +0 -0
  85. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  86. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/query_builder/__init__.py +0 -0
  87. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/queue/__init__.py +0 -0
  88. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/queue/job.py +0 -0
  89. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/queue/kafka_backend.py +0 -0
  90. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/queue/lite_backend.py +0 -0
  91. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/queue/mongo_backend.py +0 -0
  92. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/queue/rabbitmq_backend.py +0 -0
  93. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/queue_backends/__init__.py +0 -0
  94. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/queue_backends/kafka_backend.py +0 -0
  95. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/queue_backends/mongo_backend.py +0 -0
  96. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  97. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/__init__.py +0 -0
  98. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  99. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_badges.scss +0 -0
  100. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  101. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_cards.scss +0 -0
  102. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_forms.scss +0 -0
  103. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_grid.scss +0 -0
  104. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_modals.scss +0 -0
  105. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_nav.scss +0 -0
  106. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_reset.scss +0 -0
  107. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_tables.scss +0 -0
  108. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_typography.scss +0 -0
  109. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  110. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/_variables.scss +0 -0
  111. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/base.scss +0 -0
  112. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/colors.scss +0 -0
  113. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/scss/tina4css/tina4.scss +0 -0
  114. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/seeder/__init__.py +0 -0
  115. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/service/__init__.py +0 -0
  116. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/session/__init__.py +0 -0
  117. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/session_handlers/__init__.py +0 -0
  118. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  119. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/session_handlers/redis_handler.py +0 -0
  120. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/session_handlers/valkey_handler.py +0 -0
  121. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/swagger/__init__.py +0 -0
  122. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/components/crud.twig +0 -0
  123. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  124. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  125. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/docker/python/Dockerfile +0 -0
  126. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  127. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/errors/302.twig +0 -0
  128. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/errors/401.twig +0 -0
  129. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/errors/403.twig +0 -0
  130. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/errors/404.twig +0 -0
  131. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/errors/500.twig +0 -0
  132. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/errors/502.twig +0 -0
  133. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/errors/503.twig +0 -0
  134. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/errors/base.twig +0 -0
  135. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/frontend/README.md +0 -0
  136. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/templates/readme.md +0 -0
  137. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/test_client/__init__.py +0 -0
  138. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  139. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  140. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  141. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  142. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/validator/__init__.py +0 -0
  151. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/websocket/__init__.py +0 -0
  152. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/websocket/backplane.py +0 -0
  153. {tina4_python-3.11.32 → tina4_python-3.11.35}/tina4_python/wsdl/__init__.py +0 -0
@@ -74,7 +74,3 @@ 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.32
4
- Summary: Tina4 for Python — 54 built-in features, zero dependencies
3
+ Version: 3.11.35
4
+ Summary: Tina4 Python v3 Zero-dependency, lightweight web framework
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
- dynamic = ["version"]
4
- description = "Tina4 for Python — 54 built-in features, zero dependencies"
3
+ version = "3.11.35"
4
+ description = "Tina4 Python v3 Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
7
7
  ]
@@ -55,9 +55,6 @@ dev = [
55
55
  requires = ["hatchling"]
56
56
  build-backend = "hatchling.build"
57
57
 
58
- [tool.hatch.version]
59
- path = "tina4_python/__init__.py"
60
-
61
58
  [tool.hatch.build]
62
59
  include = ["tina4_python/**/*"]
63
60
 
@@ -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.32"
11
+ __version__ = "3.11.35"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -837,10 +837,7 @@ 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. 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`.
840
+ # Unified SPA dev admin
844
841
  response.html("""<!DOCTYPE html>
845
842
  <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>
846
843
  <body><div id="app" data-framework="python" data-color="#3b82f6"></div>
@@ -850,19 +847,8 @@ async def _handle_dev_admin(request: Request, response: Response) -> Response:
850
847
  handler_info = handlers.get(request.path)
851
848
  if handler_info and request.method == handler_info[0]:
852
849
  try:
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):
850
+ def _resp(data, code=200):
851
+ if isinstance(data, str):
866
852
  response.status(code).html(data)
867
853
  else:
868
854
  response.status(code).json(data)
@@ -1196,14 +1182,8 @@ async def handle(request: Request) -> Response:
1196
1182
  from tina4_python.dotenv import is_truthy
1197
1183
  _is_dev = is_truthy(os.environ.get("TINA4_DEBUG", ""))
1198
1184
 
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
- ):
1185
+ # Dev admin
1186
+ if _is_dev and request.path.startswith("/__dev"):
1207
1187
  return await _handle_dev_admin(request, response)
1208
1188
 
1209
1189
  # Swagger
@@ -1635,38 +1615,6 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1635
1615
  log_level = os.environ.get("TINA4_LOG_LEVEL", "error" if not is_production else "error")
1636
1616
  Log.configure(level=log_level, production=is_production)
1637
1617
 
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
-
1670
1618
  # Ensure folders
1671
1619
  _ensure_folders()
1672
1620
 
@@ -166,11 +166,6 @@ 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
-
174
169
  # Query cache — off by default, opt-in via TINA4_DB_CACHE=true
175
170
  from tina4_python.dotenv import is_truthy
176
171
  self._cache_enabled: bool = is_truthy(os.environ.get("TINA4_DB_CACHE", "false"))
@@ -313,25 +308,7 @@ class Database:
313
308
  # ── Pool-aware adapter access ─────────────────────────────
314
309
 
315
310
  def _get_adapter(self) -> DatabaseAdapter:
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
311
+ """Get an adapter from pool (round-robin) or single connection."""
335
312
  if self._pool is not None:
336
313
  return self._pool.checkout()
337
314
  return self._adapter
@@ -445,24 +422,16 @@ class Database:
445
422
  return adapter.delete(table, filter_sql, params)
446
423
 
447
424
  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."""
451
425
  adapter = self._get_adapter()
452
- self._tx_local.adapter = adapter
453
426
  adapter.start_transaction()
454
427
 
455
428
  def commit(self):
456
- """Commit the current transaction and release the adapter pin."""
457
429
  adapter = self._get_adapter()
458
430
  adapter.commit()
459
- self._tx_local.adapter = None
460
431
 
461
432
  def rollback(self):
462
- """Roll back the current transaction and release the adapter pin."""
463
433
  adapter = self._get_adapter()
464
434
  adapter.rollback()
465
- self._tx_local.adapter = None
466
435
 
467
436
  def table_exists(self, name: str) -> bool:
468
437
  adapter = self._get_adapter()
@@ -28,10 +28,27 @@ except ImportError:
28
28
  class FirebirdAdapter(DatabaseAdapter):
29
29
  """Firebird database driver using firebird-driver or fdb."""
30
30
 
31
+ # Substring markers (lowercased) that identify a dead-socket Firebird
32
+ # error worth reconnecting for. Idle Firebird connections die silently
33
+ # behind NAT timeouts, server-side ConnectionIdleTimeout, or Docker
34
+ # network rotation; without this the next prepare() crashes the request.
35
+ _DEAD_CONN_MARKERS = (
36
+ "error writing data to the connection",
37
+ "error reading data from the connection",
38
+ "connection shutdown",
39
+ "connection lost",
40
+ "network error",
41
+ "connection is not active",
42
+ "broken pipe",
43
+ )
44
+
31
45
  def __init__(self):
32
46
  super().__init__()
33
47
  self._conn = None
34
48
  self._in_transaction: bool = False
49
+ # Remembered connection params — populated by connect(), used by
50
+ # _reconnect() when a dead socket is detected mid-request.
51
+ self._connect_params: dict | None = None
35
52
 
36
53
  def connect(self, connection_string: str, username: str = "", password: str = "", **kwargs):
37
54
  """Connect to Firebird.
@@ -54,28 +71,82 @@ class FirebirdAdapter(DatabaseAdapter):
54
71
  password = parsed.password or password or "masterkey"
55
72
  charset = kwargs.pop("charset", "UTF8")
56
73
 
74
+ # Cache for transparent reconnect — never logged, lives only in
75
+ # adapter memory alongside the connection it owns.
76
+ self._connect_params = {
77
+ "host": host, "port": port, "db_path": db_path,
78
+ "user": user, "password": password, "charset": charset,
79
+ "extra": dict(kwargs),
80
+ }
81
+ self._open()
82
+
83
+ def _open(self) -> None:
84
+ """Open the underlying Firebird connection from cached params."""
85
+ p = self._connect_params
86
+ if p is None:
87
+ raise RuntimeError("FirebirdAdapter._open called before connect()")
88
+
57
89
  if _driver_name == "firebird-driver":
58
90
  # Modern firebird-driver uses dsn format: host/port:path
59
- dsn = f"{host}/{port}:{db_path}" if port != 3050 else f"{host}:{db_path}"
91
+ dsn = f"{p['host']}/{p['port']}:{p['db_path']}" if p['port'] != 3050 else f"{p['host']}:{p['db_path']}"
60
92
  self._conn = _driver.connect(
61
93
  dsn,
62
- user=user,
63
- password=password,
64
- charset=charset,
65
- **kwargs,
94
+ user=p["user"],
95
+ password=p["password"],
96
+ charset=p["charset"],
97
+ **p["extra"],
66
98
  )
67
99
  else:
68
100
  # Legacy fdb
69
101
  self._conn = _driver.connect(
70
- host=host,
71
- port=port,
72
- database=db_path,
73
- user=user,
74
- password=password,
75
- charset=charset,
76
- **kwargs,
102
+ host=p["host"],
103
+ port=p["port"],
104
+ database=p["db_path"],
105
+ user=p["user"],
106
+ password=p["password"],
107
+ charset=p["charset"],
108
+ **p["extra"],
77
109
  )
78
110
 
111
+ @classmethod
112
+ def _is_dead_connection(cls, exc: BaseException) -> bool:
113
+ """Match dead-socket error messages from firebird-driver / fdb.
114
+ Substring + case-insensitive so we catch both driver wording variants.
115
+ """
116
+ msg = str(exc).lower()
117
+ return any(m in msg for m in cls._DEAD_CONN_MARKERS)
118
+
119
+ def _reconnect(self) -> None:
120
+ """Force-close any stale handle and reopen. Safe to call repeatedly;
121
+ idempotent on a dead connection."""
122
+ try:
123
+ if self._conn is not None:
124
+ self._conn.close()
125
+ except Exception:
126
+ pass # connection already gone — nothing to clean up
127
+ self._conn = None
128
+ self._in_transaction = False
129
+ self._open()
130
+
131
+ def _safe_cursor_execute(self, cursor, sql: str, params: list | None):
132
+ """Execute on a cursor with one transparent reconnect+retry on
133
+ dead-connection errors. Skipped inside an explicit transaction —
134
+ atomicity beats resilience there; the caller handles rollback.
135
+
136
+ Returns the cursor (possibly a fresh one after reconnect) so the
137
+ caller can fetch results from it.
138
+ """
139
+ try:
140
+ cursor.execute(sql, params or [])
141
+ return cursor
142
+ except Exception as e:
143
+ if not self._is_dead_connection(e) or self._in_transaction:
144
+ raise
145
+ self._reconnect()
146
+ cursor = self._conn.cursor()
147
+ cursor.execute(sql, params or [])
148
+ return cursor
149
+
79
150
  def close(self):
80
151
  if self._conn:
81
152
  self._conn.close()
@@ -92,7 +163,7 @@ class FirebirdAdapter(DatabaseAdapter):
92
163
  sql = sql[:returning_match.start()]
93
164
 
94
165
  cursor = self._conn.cursor()
95
- cursor.execute(sql, params or [])
166
+ cursor = self._safe_cursor_execute(cursor, sql, params)
96
167
 
97
168
  records = []
98
169
  last_id = None
@@ -145,16 +216,19 @@ class FirebirdAdapter(DatabaseAdapter):
145
216
  # Count total rows
146
217
  count_sql = f"SELECT COUNT(*) FROM ({sql})"
147
218
  try:
148
- cursor.execute(count_sql, params or [])
219
+ cursor = self._safe_cursor_execute(cursor, count_sql, params)
149
220
  total = cursor.fetchone()[0]
150
221
  except Exception:
151
222
  total = 0
223
+ # Reconnect may have just happened — get a fresh cursor for the
224
+ # paginated query below regardless of whether count succeeded.
225
+ cursor = self._conn.cursor()
152
226
 
153
227
  # Apply Firebird pagination — ROWS start TO end
154
228
  start = offset + 1
155
229
  end = offset + limit
156
230
  paginated_sql = f"{sql} ROWS {start} TO {end}"
157
- cursor.execute(paginated_sql, params or [])
231
+ cursor = self._safe_cursor_execute(cursor, paginated_sql, params)
158
232
 
159
233
  desc = cursor.description
160
234
  col_names = [d[0].strip().lower() for d in desc] if desc else []
@@ -165,7 +239,7 @@ class FirebirdAdapter(DatabaseAdapter):
165
239
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
166
240
  sql = self._translate_sql(sql)
167
241
  cursor = self._conn.cursor()
168
- cursor.execute(sql, params or [])
242
+ cursor = self._safe_cursor_execute(cursor, sql, params)
169
243
  desc = cursor.description
170
244
  row = cursor.fetchone()
171
245
  if row is None:
@@ -200,15 +200,12 @@ 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 the main file (raw log, no filtering)
203
+ # Always write ALL levels to file (raw log, no filtering)
204
204
  if cls._writer:
205
205
  cls._writer.write(line)
206
206
 
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"]:
207
+ # Write errors to separate file
208
+ if level == "error" and cls._error_writer:
212
209
  cls._error_writer.write(line)
213
210
 
214
211
  @classmethod