tina4-python 3.13.12__tar.gz → 3.13.15__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 (157) hide show
  1. {tina4_python-3.13.12 → tina4_python-3.13.15}/PKG-INFO +1 -1
  2. {tina4_python-3.13.12 → tina4_python-3.13.15}/pyproject.toml +1 -1
  3. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/core/middleware.py +9 -4
  5. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/core/server.py +37 -1
  6. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/database/adapter.py +21 -0
  7. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/database/mssql.py +16 -5
  8. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/database/mysql.py +8 -2
  9. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/database/postgres.py +70 -11
  10. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/database/sqlite.py +16 -3
  11. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/debug/__init__.py +12 -4
  12. {tina4_python-3.13.12 → tina4_python-3.13.15}/.gitignore +0 -0
  13. {tina4_python-3.13.12 → tina4_python-3.13.15}/README.md +0 -0
  14. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/CLAUDE.md +0 -0
  15. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/HtmlElement.py +0 -0
  16. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/Testing.py +0 -0
  17. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/ai/__init__.py +0 -0
  18. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/api/__init__.py +0 -0
  19. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/auth/__init__.py +0 -0
  20. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/cache/__init__.py +0 -0
  21. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/cli/__init__.py +0 -0
  22. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/container/__init__.py +0 -0
  23. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/core/__init__.py +0 -0
  24. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/core/cache.py +0 -0
  25. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/core/constants.py +0 -0
  26. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/core/events.py +0 -0
  27. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/core/rate_limiter.py +0 -0
  28. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/core/request.py +0 -0
  29. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/core/response.py +0 -0
  30. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/core/router.py +0 -0
  31. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/crud/__init__.py +0 -0
  32. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/database/__init__.py +0 -0
  33. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/database/connection.py +0 -0
  34. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/database/firebird.py +0 -0
  35. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/database/mongodb.py +0 -0
  36. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/database/odbc.py +0 -0
  37. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/debug/error_overlay.py +0 -0
  38. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/dev_admin/__init__.py +0 -0
  39. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/dev_admin/metrics.py +0 -0
  40. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/dev_admin/plan.py +0 -0
  41. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/dev_admin/project_index.py +0 -0
  42. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/docs.py +0 -0
  43. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/dotenv/__init__.py +0 -0
  44. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/env.py +0 -0
  45. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/frond/FROND.md +0 -0
  46. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/frond/__init__.py +0 -0
  47. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/frond/engine.py +0 -0
  48. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/auth/meta.json +0 -0
  49. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  50. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/database/meta.json +0 -0
  51. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  52. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/error-overlay/meta.json +0 -0
  53. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  54. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/orm/meta.json +0 -0
  55. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  56. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  57. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/queue/meta.json +0 -0
  58. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  59. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/rest-api/meta.json +0 -0
  60. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  61. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/templates/meta.json +0 -0
  62. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  63. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  64. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/graphql/__init__.py +0 -0
  65. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/i18n/__init__.py +0 -0
  66. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/mcp/__init__.py +0 -0
  67. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/mcp/protocol.py +0 -0
  68. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/mcp/tools.py +0 -0
  69. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/messenger/__init__.py +0 -0
  70. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/migration/__init__.py +0 -0
  71. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/migration/runner.py +0 -0
  72. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/orm/__init__.py +0 -0
  73. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/orm/fields.py +0 -0
  74. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/orm/model.py +0 -0
  75. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/__feedback/widget.js +0 -0
  76. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/css/tina4.css +0 -0
  77. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/css/tina4.min.css +0 -0
  78. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/favicon.ico +0 -0
  79. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/images/logo.svg +0 -0
  80. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  81. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/js/frond.js +0 -0
  82. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/js/frond.min.js +0 -0
  83. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  84. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  85. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/js/tina4.min.js +0 -0
  86. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/js/tina4js.min.js +0 -0
  87. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/swagger/index.html +0 -0
  88. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  89. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/query_builder/__init__.py +0 -0
  90. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/queue/__init__.py +0 -0
  91. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/queue/job.py +0 -0
  92. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/queue/kafka_backend.py +0 -0
  93. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/queue/lite_backend.py +0 -0
  94. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.12 → tina4_python-3.13.15}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.12
3
+ Version: 3.13.15
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.12"
3
+ version = "3.13.15"
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.13.12"
11
+ __version__ = "3.13.15"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -361,9 +361,15 @@ class RequestLoggerMiddleware:
361
361
  """Request logger — stamps start time before the handler and logs elapsed time after.
362
362
 
363
363
  Mirrors the PHP, Ruby and Node.js RequestLogger classes.
364
+
365
+ v3.13.14: routes through the Tina4 ``Log`` class (was stdlib
366
+ ``logging.getLogger``, whose ``info()`` is silently dropped by an
367
+ unconfigured root logger — so these lines never reached stdout).
368
+ The dev server also logs every request globally (see
369
+ ``server._finalize_response``); this middleware remains for callers
370
+ that want per-route request logging.
364
371
  """
365
372
 
366
- _logger = logging.getLogger("tina4.request")
367
373
  _start_times: dict = {}
368
374
  _lock = threading.Lock()
369
375
 
@@ -385,7 +391,6 @@ class RequestLoggerMiddleware:
385
391
  method = getattr(request, "method", "?")
386
392
  path = getattr(request, "url", None) or getattr(request, "path", "/")
387
393
  status = getattr(response, "status_code", None) or getattr(response, "status", 0)
388
- RequestLoggerMiddleware._logger.info(
389
- "[RequestLogger] %s %s -> %s (%sms)", method, path, status, elapsed_ms
390
- )
394
+ from tina4_python.debug import Log
395
+ Log.info(f"{method} {path} -> {status} ({elapsed_ms}ms)")
391
396
  return request, response
@@ -1404,6 +1404,21 @@ def _handle_no_route(request: Request, response: Response, request_id: str) -> R
1404
1404
  return response
1405
1405
 
1406
1406
 
1407
+ def _request_logging_enabled(is_dev: bool) -> bool:
1408
+ """Whether to emit a per-request log line (v3.13.14).
1409
+
1410
+ ``TINA4_LOG_REQUESTS`` is the explicit control (true/false). When unset,
1411
+ request logging follows dev mode: on under ``TINA4_DEBUG``, off in
1412
+ production (so prod doesn't pay the per-request logging cost unless the
1413
+ operator opts in). Same contract across all four frameworks.
1414
+ """
1415
+ from tina4_python.dotenv import is_truthy
1416
+ val = os.environ.get("TINA4_LOG_REQUESTS")
1417
+ if val is not None and val != "":
1418
+ return is_truthy(val)
1419
+ return is_dev
1420
+
1421
+
1407
1422
  def _finalize_response(
1408
1423
  request: Request, response: Response, route: dict | None,
1409
1424
  request_id: str, is_dev: bool, req_start: float,
@@ -1444,6 +1459,22 @@ def _finalize_response(
1444
1459
  except Exception:
1445
1460
  pass
1446
1461
 
1462
+ # Request log line (v3.13.14). The dev dashboard's RequestInspector above
1463
+ # only feeds the /__dev UI — it never reached stdout, so `tina4 serve`
1464
+ # printed the startup banner then went silent even as requests flowed.
1465
+ # Emit a per-request line through the Tina4 Log so it lands on stdout
1466
+ # (docker logs / k8s) like every other log. On by default in dev; opt-in
1467
+ # in production via TINA4_LOG_REQUESTS to avoid per-request overhead.
1468
+ if _request_logging_enabled(is_dev):
1469
+ try:
1470
+ import time as _time
1471
+ elapsed_ms = round((_time.perf_counter() - req_start) * 1000, 3)
1472
+ Log.info(
1473
+ f"{request.method} {request.path} -> {response.status_code} ({elapsed_ms}ms)"
1474
+ )
1475
+ except Exception:
1476
+ pass
1477
+
1447
1478
  # Session save + cookie.
1448
1479
  # Skip saving if this was a brand-new session that the route never wrote to —
1449
1480
  # that prevents empty orphaned session files accumulating on disk.
@@ -2081,7 +2112,12 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
2081
2112
 
2082
2113
  # Init logger
2083
2114
  is_production = os.environ.get("TINA4_ENV", "development") == "production"
2084
- log_level = os.environ.get("TINA4_LOG_LEVEL", "error" if not is_production else "error")
2115
+ # v3.13.14: default level is INFO (was ERROR). ERROR-by-default meant a
2116
+ # deployed app that logged at info/debug appeared silent — operators
2117
+ # "weren't getting logs". INFO shows request/startup/warn/error without
2118
+ # debug noise, and matches PHP/Ruby/Node defaults. Override per-deploy
2119
+ # with TINA4_LOG_LEVEL.
2120
+ log_level = os.environ.get("TINA4_LOG_LEVEL", "info")
2085
2121
  Log.configure(level=log_level, production=is_production)
2086
2122
 
2087
2123
  # Install a top-level exception hook so uncaught exceptions bubbling
@@ -375,6 +375,27 @@ class DatabaseAdapter:
375
375
  stripped = stripped[:-1].rstrip()
376
376
  return stripped
377
377
 
378
+ @staticmethod
379
+ def _split_schema(name: str) -> tuple[str | None, str]:
380
+ """Split a possibly-qualified table name into (schema, table).
381
+
382
+ v3.13.14 (#48): a model whose ``table_name`` is qualified —
383
+ PostgreSQL ``gift_cards.gift_card``, MSSQL ``dbo.widget``,
384
+ MySQL ``otherdb.table``, SQLite ``attached.table`` — lives in
385
+ that schema/catalog, not the default. Each adapter's
386
+ ``table_exists`` / ``get_columns`` use this to query the right
387
+ namespace instead of matching the whole dotted string as one
388
+ flat table name. Returns ``(None, name)`` for a bare name so
389
+ callers default to the engine's current namespace. Splits on the
390
+ first dot; quoted identifiers with embedded dots aren't supported
391
+ (they weren't before either). Firebird has no schemas, so its
392
+ adapter ignores this.
393
+ """
394
+ if "." in name:
395
+ schema, _, table = name.partition(".")
396
+ return schema, table
397
+ return None, name
398
+
378
399
  def fetch(self, sql: str, params: list = None,
379
400
  limit: int = 100, offset: int = 0) -> DatabaseResult:
380
401
  """Execute a read query and return multiple rows."""
@@ -190,10 +190,17 @@ class MSSQLAdapter(DatabaseAdapter):
190
190
  self._in_transaction = False
191
191
 
192
192
  def table_exists(self, name: str) -> bool:
193
+ # v3.13.14 (#48): honour a schema-qualified name ("dbo.widget",
194
+ # "sales.orders"). Schemas are everyday in SQL Server; pre-fix this
195
+ # had no schema filter and matched the whole dotted string as a flat
196
+ # TABLE_NAME, so any qualified table_name was invisible. A bare name
197
+ # still matches in any schema (unchanged behaviour).
198
+ schema, tbl = self._split_schema(name)
193
199
  row = self.fetch_one(
194
200
  "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES "
195
- "WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME = %s",
196
- [name],
201
+ "WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME = %s "
202
+ "AND (%s IS NULL OR TABLE_SCHEMA = %s)",
203
+ [tbl, schema, schema],
197
204
  )
198
205
  return row is not None
199
206
 
@@ -206,6 +213,9 @@ class MSSQLAdapter(DatabaseAdapter):
206
213
  return [r["TABLE_NAME"] for r in result.records]
207
214
 
208
215
  def get_columns(self, table: str) -> list[dict]:
216
+ # v3.13.14 (#48): honour a schema-qualified name; a bare name matches
217
+ # in any schema (unchanged).
218
+ schema, tbl = self._split_schema(table)
209
219
  sql = """
210
220
  SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT,
211
221
  CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS is_primary
@@ -215,12 +225,13 @@ class MSSQLAdapter(DatabaseAdapter):
215
225
  FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
216
226
  JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku
217
227
  ON tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME
218
- WHERE tc.TABLE_NAME = %s AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
228
+ WHERE tc.TABLE_NAME = %s AND (%s IS NULL OR tc.TABLE_SCHEMA = %s)
229
+ AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
219
230
  ) pk ON c.COLUMN_NAME = pk.COLUMN_NAME
220
- WHERE c.TABLE_NAME = %s
231
+ WHERE c.TABLE_NAME = %s AND (%s IS NULL OR c.TABLE_SCHEMA = %s)
221
232
  ORDER BY c.ORDINAL_POSITION
222
233
  """
223
- result = self.fetch(sql, [table, table], limit=10000)
234
+ result = self.fetch(sql, [tbl, schema, schema, tbl, schema, schema], limit=10000)
224
235
  return [
225
236
  {
226
237
  "name": r["COLUMN_NAME"],
@@ -174,10 +174,16 @@ class MySQLAdapter(DatabaseAdapter):
174
174
  self._in_transaction = False
175
175
 
176
176
  def table_exists(self, name: str) -> bool:
177
+ # v3.13.14 (#48): honour a database-qualified name ("otherdb.table").
178
+ # In MySQL "schema" == database; default to the connected database.
179
+ # Pre-fix this matched the whole dotted string as a flat TABLE_NAME
180
+ # under DATABASE() only, so a cross-database qualified name was never
181
+ # found and create_table()/migrations misfired.
182
+ schema, tbl = self._split_schema(name)
177
183
  row = self.fetch_one(
178
184
  "SELECT TABLE_NAME FROM information_schema.TABLES "
179
- "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
180
- [name],
185
+ "WHERE TABLE_SCHEMA = COALESCE(%s, DATABASE()) AND TABLE_NAME = %s",
186
+ [schema, tbl],
181
187
  )
182
188
  return row is not None
183
189
 
@@ -207,6 +207,35 @@ class PostgreSQLAdapter(DatabaseAdapter):
207
207
  except Exception:
208
208
  pass
209
209
 
210
+ def _end_read_txn(self):
211
+ """v3.13.15 (issue #51): close the implicit transaction a read
212
+ opens, so the connection doesn't leak as ``idle in transaction``.
213
+
214
+ psycopg2 runs with ``autocommit = False`` (the framework's safe
215
+ default — writes must be committed explicitly). A bare ``SELECT``
216
+ therefore opens a transaction too, and with nothing to commit it
217
+ sits ``idle in transaction`` for the life of the connection,
218
+ holding a pool slot and any locks it touched. Short-lived boot
219
+ connections (the migration runner's ``MAX(batch)`` lookup) leak
220
+ one each, eventually exhausting ``max_connections`` — at which
221
+ point module autodiscovery fails mid-boot and every route 404s
222
+ while ``/health-check`` still passes ("ready but broken").
223
+
224
+ A read has nothing to persist, so a ROLLBACK is the safe close.
225
+ Inside an explicit ``start_transaction()`` the caller owns the
226
+ transaction, so we defer (mirrors the heal / error-path guards).
227
+ Writes go through :meth:`execute`, which is deliberately left
228
+ untouched — with autocommit off they must be committed explicitly,
229
+ so auto-closing them here would silently drop data.
230
+ """
231
+ if self._in_transaction or self._conn is None:
232
+ return
233
+ try:
234
+ self._conn.rollback()
235
+ except Exception:
236
+ # Never let bookkeeping mask the rows we already fetched.
237
+ pass
238
+
210
239
  def connect(self, connection_string: str, username: str = "", password: str = "", **kwargs):
211
240
  """Connect to PostgreSQL.
212
241
 
@@ -357,6 +386,10 @@ class PostgreSQLAdapter(DatabaseAdapter):
357
386
  self._exec_with_handling(cursor, paginated_sql, paginated_params)
358
387
  rows = [self._decode_blobs(dict(row)) for row in cursor.fetchall()]
359
388
 
389
+ # v3.13.15 (#51): rows are materialised above — close the implicit
390
+ # read transaction so the connection doesn't sit idle-in-transaction.
391
+ self._end_read_txn()
392
+
360
393
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
361
394
 
362
395
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
@@ -368,7 +401,13 @@ class PostgreSQLAdapter(DatabaseAdapter):
368
401
  cursor = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
369
402
  self._exec_with_handling(cursor, sql, params)
370
403
  row = cursor.fetchone()
371
- return self._decode_blobs(dict(row)) if row else None
404
+ result = self._decode_blobs(dict(row)) if row else None
405
+ # v3.13.15 (#51): close the implicit read transaction so the
406
+ # connection doesn't sit 'idle in transaction' (psycopg2 autocommit
407
+ # is off). This is the path the migration runner's MAX(batch)
408
+ # lookup leaked through.
409
+ self._end_read_txn()
410
+ return result
372
411
 
373
412
  @staticmethod
374
413
  def _decode_blobs(row: dict) -> dict:
@@ -423,20 +462,40 @@ class PostgreSQLAdapter(DatabaseAdapter):
423
462
  self._in_transaction = False
424
463
 
425
464
  def table_exists(self, name: str) -> bool:
426
- row = self.fetch_one(
427
- "SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = %s",
428
- [name],
429
- )
430
- return row is not None
465
+ # v3.13.x (#48): support schema-qualified names ("schema.table") and the
466
+ # connection search_path. to_regclass() resolves a (possibly qualified)
467
+ # relation name to its OID using the SAME rules Postgres applies to a
468
+ # FROM clause, returning NULL when absent. Pre-fix this hardcoded
469
+ # schemaname='public' AND matched the whole dotted string as a flat
470
+ # tablename, so a table in a non-public schema was invisible —
471
+ # table_exists() always returned False, so create_table()/migrations
472
+ # misfired and ORM introspection saw nothing.
473
+ row = self.fetch_one("SELECT to_regclass(%s) AS oid", [name])
474
+ return bool(row and row.get("oid") is not None)
431
475
 
432
476
  def get_tables(self) -> list[str]:
477
+ # v3.13.x (#48): list tables from every user schema, not just public.
478
+ # public tables stay bare ("users"); tables in other schemas are
479
+ # returned schema-qualified ("gift_cards.gift_card") so the names round
480
+ # -trip back through table_exists()/get_columns().
433
481
  result = self.fetch(
434
- "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
482
+ """
483
+ SELECT schemaname, tablename FROM pg_tables
484
+ WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
485
+ ORDER BY schemaname, tablename
486
+ """,
435
487
  limit=10000,
436
488
  )
437
- return [r["tablename"] for r in result.records]
489
+ return [
490
+ r["tablename"] if r["schemaname"] == "public"
491
+ else f"{r['schemaname']}.{r['tablename']}"
492
+ for r in result.records
493
+ ]
438
494
 
439
495
  def get_columns(self, table: str) -> list[dict]:
496
+ # v3.13.x (#48): honour a schema-qualified name; default to public.
497
+ schema, tbl = self._split_schema(table)
498
+ schema = schema or "public"
440
499
  sql = """
441
500
  SELECT c.column_name, c.data_type, c.is_nullable, c.column_default,
442
501
  CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_primary
@@ -446,12 +505,12 @@ class PostgreSQLAdapter(DatabaseAdapter):
446
505
  FROM information_schema.table_constraints tc
447
506
  JOIN information_schema.key_column_usage ku
448
507
  ON tc.constraint_name = ku.constraint_name
449
- WHERE tc.table_name = %s AND tc.constraint_type = 'PRIMARY KEY'
508
+ WHERE tc.table_name = %s AND tc.table_schema = %s AND tc.constraint_type = 'PRIMARY KEY'
450
509
  ) pk ON c.column_name = pk.column_name
451
- WHERE c.table_name = %s AND c.table_schema = 'public'
510
+ WHERE c.table_name = %s AND c.table_schema = %s
452
511
  ORDER BY c.ordinal_position
453
512
  """
454
- result = self.fetch(sql, [table, table], limit=10000)
513
+ result = self.fetch(sql, [tbl, schema, tbl, schema], limit=10000)
455
514
  return [
456
515
  {
457
516
  "name": r["column_name"],
@@ -179,9 +179,16 @@ class SQLiteAdapter(DatabaseAdapter):
179
179
  self._in_transaction = False
180
180
 
181
181
  def table_exists(self, name: str) -> bool:
182
+ # v3.13.14 (#48): honour an attached-database prefix ("attached.table").
183
+ # SQLite's "schema" is an ATTACH alias; each has its own sqlite_master.
184
+ # Bare names use the main database (unchanged). The alias must be a
185
+ # plain identifier (it's developer-supplied, but guard against the dot
186
+ # being part of an odd name).
187
+ schema, tbl = self._split_schema(name)
188
+ master = f"{schema}.sqlite_master" if schema and schema.isidentifier() else "sqlite_master"
182
189
  row = self.fetch_one(
183
- "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
184
- [name]
190
+ f"SELECT name FROM {master} WHERE type='table' AND name=?",
191
+ [tbl if (schema and schema.isidentifier()) else name]
185
192
  )
186
193
  return row is not None
187
194
 
@@ -193,7 +200,13 @@ class SQLiteAdapter(DatabaseAdapter):
193
200
  return [r["name"] for r in result.records]
194
201
 
195
202
  def get_columns(self, table: str) -> list[dict]:
196
- cursor = self._conn.execute(f"PRAGMA table_info({table})")
203
+ # v3.13.14 (#48): honour an attached-database prefix via
204
+ # `PRAGMA <schema>.table_info(<table>)`.
205
+ schema, tbl = self._split_schema(table)
206
+ if schema and schema.isidentifier() and tbl.isidentifier():
207
+ cursor = self._conn.execute(f"PRAGMA {schema}.table_info({tbl})")
208
+ else:
209
+ cursor = self._conn.execute(f"PRAGMA table_info({table})")
197
210
  return [
198
211
  {
199
212
  "name": row["name"],
@@ -412,10 +412,18 @@ class Log:
412
412
  # File always gets ALL levels (no filtering for file output)
413
413
  line = cls._format_line(level, message, **kwargs)
414
414
 
415
- # Console output respects TINA4_LOG_LEVEL and the stdout toggle
416
- if cls._stdout_enabled and not cls._is_production and cls._should_log(level):
417
- color = cls.COLORS.get(level, "")
418
- print(f"{color}{line}{cls.RESET}")
415
+ # Console output respects TINA4_LOG_LEVEL and the stdout toggle.
416
+ # v3.13.14: stdout is NOT suppressed in production — containers
417
+ # treat stdout as the canonical log sink (docker logs / k8s read
418
+ # PID 1 stdout), and the pre-v3.13.14 `not _is_production` gate
419
+ # meant deployed containers got nothing. Production still emits
420
+ # JSON (see _format_line / _is_production), just on stdout now.
421
+ # flush=True so logs appear immediately on a non-TTY pipe rather
422
+ # than sitting in Python's block buffer until the process exits.
423
+ if cls._stdout_enabled and cls._should_log(level):
424
+ color = "" if cls._is_production else cls.COLORS.get(level, "")
425
+ reset = "" if cls._is_production else cls.RESET
426
+ print(f"{color}{line}{reset}", flush=True)
419
427
 
420
428
  # Always write ALL levels to the main file (raw log, no filtering)
421
429
  if cls._writer:
File without changes