tina4-python 3.12.0__tar.gz → 3.12.2__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.12.0 → tina4_python-3.12.2}/PKG-INFO +2 -2
  2. {tina4_python-3.12.0 → tina4_python-3.12.2}/pyproject.toml +2 -5
  3. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/database/firebird.py +163 -18
  5. {tina4_python-3.12.0 → tina4_python-3.12.2}/.gitignore +0 -0
  6. {tina4_python-3.12.0 → tina4_python-3.12.2}/README.md +0 -0
  7. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/CLAUDE.md +0 -0
  8. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/HtmlElement.py +0 -0
  9. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/Testing.py +0 -0
  10. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/ai/__init__.py +0 -0
  11. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/api/__init__.py +0 -0
  12. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/auth/__init__.py +0 -0
  13. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/cache/__init__.py +0 -0
  14. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/cli/__init__.py +0 -0
  15. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/container/__init__.py +0 -0
  16. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/core/__init__.py +0 -0
  17. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/core/cache.py +0 -0
  18. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/core/constants.py +0 -0
  19. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/core/events.py +0 -0
  20. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/core/middleware.py +0 -0
  21. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/core/rate_limiter.py +0 -0
  22. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/core/request.py +0 -0
  23. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/core/response.py +0 -0
  24. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/core/router.py +0 -0
  25. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/core/server.py +0 -0
  26. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/crud/__init__.py +0 -0
  27. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/database/__init__.py +0 -0
  28. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/database/adapter.py +0 -0
  29. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/database/connection.py +0 -0
  30. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/database/mongodb.py +0 -0
  31. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/database/mssql.py +0 -0
  32. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/database/mysql.py +0 -0
  33. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/database/odbc.py +0 -0
  34. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/database/postgres.py +0 -0
  35. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/database/sqlite.py +0 -0
  36. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/debug/__init__.py +0 -0
  37. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/debug/error_overlay.py +0 -0
  38. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/dev_admin/__init__.py +0 -0
  39. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/dev_admin/metrics.py +0 -0
  40. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/dev_admin/plan.py +0 -0
  41. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/dev_admin/project_index.py +0 -0
  42. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/docs.py +0 -0
  43. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/dotenv/__init__.py +0 -0
  44. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/frond/FROND.md +0 -0
  45. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/frond/__init__.py +0 -0
  46. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/frond/engine.py +0 -0
  47. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/auth/meta.json +0 -0
  48. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  49. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/database/meta.json +0 -0
  50. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  51. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/error-overlay/meta.json +0 -0
  52. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  53. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/orm/meta.json +0 -0
  54. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  55. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  56. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/queue/meta.json +0 -0
  57. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  58. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/rest-api/meta.json +0 -0
  59. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  60. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/templates/meta.json +0 -0
  61. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  62. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  63. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/graphql/__init__.py +0 -0
  64. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/i18n/__init__.py +0 -0
  65. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/mcp/__init__.py +0 -0
  66. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/mcp/protocol.py +0 -0
  67. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/mcp/tools.py +0 -0
  68. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/messenger/__init__.py +0 -0
  69. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/migration/__init__.py +0 -0
  70. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/migration/runner.py +0 -0
  71. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/orm/__init__.py +0 -0
  72. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/orm/fields.py +0 -0
  73. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/orm/model.py +0 -0
  74. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/css/tina4.css +0 -0
  75. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/css/tina4.min.css +0 -0
  76. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/favicon.ico +0 -0
  77. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/images/logo.svg +0 -0
  78. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  79. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/js/frond.js +0 -0
  80. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/js/frond.min.js +0 -0
  81. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  82. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  83. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/js/tina4.min.js +0 -0
  84. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/js/tina4js.min.js +0 -0
  85. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/swagger/index.html +0 -0
  86. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  87. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/query_builder/__init__.py +0 -0
  88. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/queue/__init__.py +0 -0
  89. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/queue/job.py +0 -0
  90. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/queue/kafka_backend.py +0 -0
  91. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/queue/lite_backend.py +0 -0
  92. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/queue/mongo_backend.py +0 -0
  93. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/queue/rabbitmq_backend.py +0 -0
  94. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/queue_backends/__init__.py +0 -0
  95. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/queue_backends/kafka_backend.py +0 -0
  96. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/queue_backends/mongo_backend.py +0 -0
  97. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  98. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/__init__.py +0 -0
  99. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  100. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_badges.scss +0 -0
  101. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  102. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_cards.scss +0 -0
  103. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_forms.scss +0 -0
  104. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_grid.scss +0 -0
  105. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_modals.scss +0 -0
  106. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_nav.scss +0 -0
  107. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_reset.scss +0 -0
  108. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_tables.scss +0 -0
  109. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_typography.scss +0 -0
  110. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  111. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/_variables.scss +0 -0
  112. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/base.scss +0 -0
  113. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/colors.scss +0 -0
  114. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/scss/tina4css/tina4.scss +0 -0
  115. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/seeder/__init__.py +0 -0
  116. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/service/__init__.py +0 -0
  117. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/session/__init__.py +0 -0
  118. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/session_handlers/__init__.py +0 -0
  119. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  120. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/session_handlers/redis_handler.py +0 -0
  121. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/session_handlers/valkey_handler.py +0 -0
  122. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/swagger/__init__.py +0 -0
  123. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/components/crud.twig +0 -0
  124. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  125. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  126. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/docker/python/Dockerfile +0 -0
  127. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  128. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/errors/302.twig +0 -0
  129. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/errors/401.twig +0 -0
  130. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/errors/403.twig +0 -0
  131. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/errors/404.twig +0 -0
  132. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/errors/500.twig +0 -0
  133. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/errors/502.twig +0 -0
  134. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/errors/503.twig +0 -0
  135. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/errors/base.twig +0 -0
  136. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/frontend/README.md +0 -0
  137. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/templates/readme.md +0 -0
  138. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/test_client/__init__.py +0 -0
  139. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  140. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  141. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  142. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  143. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/validator/__init__.py +0 -0
  152. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/websocket/__init__.py +0 -0
  153. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/websocket/backplane.py +0 -0
  154. {tina4_python-3.12.0 → tina4_python-3.12.2}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.12.0
4
- Summary: Tina4 for Python — 54 built-in features, zero dependencies
3
+ Version: 3.12.2
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.12.2"
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.12.0"
11
+ __version__ = "3.12.2"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -6,10 +6,68 @@ Firebird adapter using firebird-driver (preferred) or fdb (fallback).
6
6
 
7
7
  Requires: pip install firebird-driver (or pip install fdb for legacy)
8
8
  """
9
+ import os
9
10
  import re
10
11
  from urllib.parse import urlparse, unquote
11
12
  from tina4_python.database.adapter import DatabaseAdapter, DatabaseResult, SQLTranslator
12
13
 
14
+
15
+ # Detects a Windows drive-letter prefix like "C:/" or "C:\". The leading-slash
16
+ # variant ("/C:/...") shows up after a URL parse strips one slash off
17
+ # "firebird://host:port/C:/...".
18
+ _WIN_DRIVE_RE = re.compile(r"^/?[A-Za-z]:[/\\]")
19
+
20
+
21
+ def _normalize_firebird_db_identifier(raw_path: str) -> str:
22
+ """Turn the URL path component into a Firebird database identifier.
23
+
24
+ Firebird is the awkward one — it needs either an absolute file path
25
+ on the server, a Windows drive-letter path, or an alias name. The
26
+ classic URI form uses a double-slash to keep the leading "/" of an
27
+ absolute path through ``urlparse``::
28
+
29
+ firebird://host:port//firebird/data/app.fdb → /firebird/data/app.fdb
30
+
31
+ But that double slash is unintuitive to anyone used to the way
32
+ postgres / mysql / mssql encode the database name. We accept five
33
+ equivalent forms and normalise all of them:
34
+
35
+ * ``//abs/path/db.fdb`` → ``/abs/path/db.fdb`` (classic double-slash)
36
+ * ``/abs/path/db.fdb`` → ``/abs/path/db.fdb`` (single-slash, what most people type)
37
+ * ``/C:/Data/db.fdb`` → ``C:/Data/db.fdb`` (Windows, leading URL slash dropped)
38
+ * ``/C%3A/Data/db.fdb`` → ``C:/Data/db.fdb`` (Windows with URL-encoded colon)
39
+ * ``/employee`` → ``employee`` (alias — single token)
40
+
41
+ Aliases are detected as the leftover case: a single token with no
42
+ slashes. Anything path-like is kept as a path.
43
+ """
44
+ decoded = unquote(raw_path)
45
+
46
+ # Classic double-slash form: //abs/path → /abs/path
47
+ if decoded.startswith("//"):
48
+ decoded = decoded[1:]
49
+
50
+ # Windows drive-letter — drop the URL-introduced leading slash.
51
+ # /C:/Data/db.fdb → C:/Data/db.fdb
52
+ if _WIN_DRIVE_RE.match(decoded):
53
+ if decoded.startswith("/"):
54
+ decoded = decoded[1:]
55
+ return decoded
56
+
57
+ # Look at the content after stripping the leading slash. If it's a
58
+ # single token with no separators, it's a Firebird alias — return
59
+ # WITHOUT the leading slash (the alias name itself is the identifier).
60
+ body = decoded[1:] if decoded.startswith("/") else decoded
61
+ if body and "/" not in body and "\\" not in body:
62
+ return body
63
+
64
+ # Otherwise it's a file path. If it already has a leading slash,
65
+ # keep it. If it's a relative-looking path (slash-separated but no
66
+ # leading "/") promote it to absolute — Firebird needs absolute paths
67
+ # and we don't know the server's CWD anyway.
68
+ return decoded if decoded.startswith("/") else "/" + decoded
69
+
70
+
13
71
  # Try modern firebird-driver first, fall back to legacy fdb
14
72
  _driver = None
15
73
  _driver_name = None
@@ -28,10 +86,27 @@ except ImportError:
28
86
  class FirebirdAdapter(DatabaseAdapter):
29
87
  """Firebird database driver using firebird-driver or fdb."""
30
88
 
89
+ # Substring markers (lowercased) that identify a dead-socket Firebird
90
+ # error worth reconnecting for. Idle Firebird connections die silently
91
+ # behind NAT timeouts, server-side ConnectionIdleTimeout, or Docker
92
+ # network rotation; without this the next prepare() crashes the request.
93
+ _DEAD_CONN_MARKERS = (
94
+ "error writing data to the connection",
95
+ "error reading data from the connection",
96
+ "connection shutdown",
97
+ "connection lost",
98
+ "network error",
99
+ "connection is not active",
100
+ "broken pipe",
101
+ )
102
+
31
103
  def __init__(self):
32
104
  super().__init__()
33
105
  self._conn = None
34
106
  self._in_transaction: bool = False
107
+ # Remembered connection params — populated by connect(), used by
108
+ # _reconnect() when a dead socket is detected mid-request.
109
+ self._connect_params: dict | None = None
35
110
 
36
111
  def connect(self, connection_string: str, username: str = "", password: str = "", **kwargs):
37
112
  """Connect to Firebird.
@@ -48,34 +123,101 @@ class FirebirdAdapter(DatabaseAdapter):
48
123
  parsed = urlparse(connection_string)
49
124
  host = parsed.hostname or "localhost"
50
125
  port = parsed.port or 3050
51
- # Firebird database path — decode URL-encoded characters
52
- db_path = unquote(parsed.path[1:]) if parsed.path.startswith("/") else unquote(parsed.path)
126
+
127
+ # Firebird database identifier resolution two layers:
128
+ #
129
+ # 1. ``TINA4_DATABASE_FIREBIRD_PATH`` env override wins if set.
130
+ # Useful for Windows users with raw backslash paths (no URL
131
+ # encoding required) and for ops setups that keep server URL
132
+ # and DB location in separate config layers.
133
+ # 2. Otherwise normalise the URL path component — accepts every
134
+ # sensible variant (single/double slash, drive letter, alias).
135
+ env_override = os.environ.get("TINA4_DATABASE_FIREBIRD_PATH", "")
136
+ if env_override:
137
+ db_path = env_override
138
+ else:
139
+ db_path = _normalize_firebird_db_identifier(parsed.path)
140
+
53
141
  user = parsed.username or username or "SYSDBA"
54
142
  password = parsed.password or password or "masterkey"
55
143
  charset = kwargs.pop("charset", "UTF8")
56
144
 
145
+ # Cache for transparent reconnect — never logged, lives only in
146
+ # adapter memory alongside the connection it owns.
147
+ self._connect_params = {
148
+ "host": host, "port": port, "db_path": db_path,
149
+ "user": user, "password": password, "charset": charset,
150
+ "extra": dict(kwargs),
151
+ }
152
+ self._open()
153
+
154
+ def _open(self) -> None:
155
+ """Open the underlying Firebird connection from cached params."""
156
+ p = self._connect_params
157
+ if p is None:
158
+ raise RuntimeError("FirebirdAdapter._open called before connect()")
159
+
57
160
  if _driver_name == "firebird-driver":
58
161
  # Modern firebird-driver uses dsn format: host/port:path
59
- dsn = f"{host}/{port}:{db_path}" if port != 3050 else f"{host}:{db_path}"
162
+ dsn = f"{p['host']}/{p['port']}:{p['db_path']}" if p['port'] != 3050 else f"{p['host']}:{p['db_path']}"
60
163
  self._conn = _driver.connect(
61
164
  dsn,
62
- user=user,
63
- password=password,
64
- charset=charset,
65
- **kwargs,
165
+ user=p["user"],
166
+ password=p["password"],
167
+ charset=p["charset"],
168
+ **p["extra"],
66
169
  )
67
170
  else:
68
171
  # Legacy fdb
69
172
  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,
173
+ host=p["host"],
174
+ port=p["port"],
175
+ database=p["db_path"],
176
+ user=p["user"],
177
+ password=p["password"],
178
+ charset=p["charset"],
179
+ **p["extra"],
77
180
  )
78
181
 
182
+ @classmethod
183
+ def _is_dead_connection(cls, exc: BaseException) -> bool:
184
+ """Match dead-socket error messages from firebird-driver / fdb.
185
+ Substring + case-insensitive so we catch both driver wording variants.
186
+ """
187
+ msg = str(exc).lower()
188
+ return any(m in msg for m in cls._DEAD_CONN_MARKERS)
189
+
190
+ def _reconnect(self) -> None:
191
+ """Force-close any stale handle and reopen. Safe to call repeatedly;
192
+ idempotent on a dead connection."""
193
+ try:
194
+ if self._conn is not None:
195
+ self._conn.close()
196
+ except Exception:
197
+ pass # connection already gone — nothing to clean up
198
+ self._conn = None
199
+ self._in_transaction = False
200
+ self._open()
201
+
202
+ def _safe_cursor_execute(self, cursor, sql: str, params: list | None):
203
+ """Execute on a cursor with one transparent reconnect+retry on
204
+ dead-connection errors. Skipped inside an explicit transaction —
205
+ atomicity beats resilience there; the caller handles rollback.
206
+
207
+ Returns the cursor (possibly a fresh one after reconnect) so the
208
+ caller can fetch results from it.
209
+ """
210
+ try:
211
+ cursor.execute(sql, params or [])
212
+ return cursor
213
+ except Exception as e:
214
+ if not self._is_dead_connection(e) or self._in_transaction:
215
+ raise
216
+ self._reconnect()
217
+ cursor = self._conn.cursor()
218
+ cursor.execute(sql, params or [])
219
+ return cursor
220
+
79
221
  def close(self):
80
222
  if self._conn:
81
223
  self._conn.close()
@@ -92,7 +234,7 @@ class FirebirdAdapter(DatabaseAdapter):
92
234
  sql = sql[:returning_match.start()]
93
235
 
94
236
  cursor = self._conn.cursor()
95
- cursor.execute(sql, params or [])
237
+ cursor = self._safe_cursor_execute(cursor, sql, params)
96
238
 
97
239
  records = []
98
240
  last_id = None
@@ -145,16 +287,19 @@ class FirebirdAdapter(DatabaseAdapter):
145
287
  # Count total rows
146
288
  count_sql = f"SELECT COUNT(*) FROM ({sql})"
147
289
  try:
148
- cursor.execute(count_sql, params or [])
290
+ cursor = self._safe_cursor_execute(cursor, count_sql, params)
149
291
  total = cursor.fetchone()[0]
150
292
  except Exception:
151
293
  total = 0
294
+ # Reconnect may have just happened — get a fresh cursor for the
295
+ # paginated query below regardless of whether count succeeded.
296
+ cursor = self._conn.cursor()
152
297
 
153
298
  # Apply Firebird pagination — ROWS start TO end
154
299
  start = offset + 1
155
300
  end = offset + limit
156
301
  paginated_sql = f"{sql} ROWS {start} TO {end}"
157
- cursor.execute(paginated_sql, params or [])
302
+ cursor = self._safe_cursor_execute(cursor, paginated_sql, params)
158
303
 
159
304
  desc = cursor.description
160
305
  col_names = [d[0].strip().lower() for d in desc] if desc else []
@@ -165,7 +310,7 @@ class FirebirdAdapter(DatabaseAdapter):
165
310
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
166
311
  sql = self._translate_sql(sql)
167
312
  cursor = self._conn.cursor()
168
- cursor.execute(sql, params or [])
313
+ cursor = self._safe_cursor_execute(cursor, sql, params)
169
314
  desc = cursor.description
170
315
  row = cursor.fetchone()
171
316
  if row is None:
File without changes
File without changes