tina4-python 3.13.11__tar.gz → 3.13.14__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.11 → tina4_python-3.13.14}/PKG-INFO +1 -1
  2. {tina4_python-3.13.11 → tina4_python-3.13.14}/pyproject.toml +1 -1
  3. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/middleware.py +9 -4
  5. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/server.py +37 -1
  6. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/adapter.py +47 -0
  7. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/connection.py +6 -2
  8. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/firebird.py +13 -4
  9. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/mssql.py +31 -12
  10. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/mysql.py +20 -5
  11. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/postgres.py +44 -12
  12. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/sqlite.py +25 -5
  13. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/debug/__init__.py +12 -4
  14. {tina4_python-3.13.11 → tina4_python-3.13.14}/.gitignore +0 -0
  15. {tina4_python-3.13.11 → tina4_python-3.13.14}/README.md +0 -0
  16. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/CLAUDE.md +0 -0
  17. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/HtmlElement.py +0 -0
  18. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/Testing.py +0 -0
  19. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/ai/__init__.py +0 -0
  20. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/api/__init__.py +0 -0
  21. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/auth/__init__.py +0 -0
  22. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/cache/__init__.py +0 -0
  23. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/cli/__init__.py +0 -0
  24. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/container/__init__.py +0 -0
  25. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/__init__.py +0 -0
  26. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/cache.py +0 -0
  27. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/constants.py +0 -0
  28. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/events.py +0 -0
  29. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/rate_limiter.py +0 -0
  30. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/request.py +0 -0
  31. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/response.py +0 -0
  32. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/router.py +0 -0
  33. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/crud/__init__.py +0 -0
  34. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/__init__.py +0 -0
  35. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/mongodb.py +0 -0
  36. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/odbc.py +0 -0
  37. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/debug/error_overlay.py +0 -0
  38. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/dev_admin/__init__.py +0 -0
  39. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/dev_admin/metrics.py +0 -0
  40. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/dev_admin/plan.py +0 -0
  41. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/dev_admin/project_index.py +0 -0
  42. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/docs.py +0 -0
  43. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/dotenv/__init__.py +0 -0
  44. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/env.py +0 -0
  45. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/frond/FROND.md +0 -0
  46. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/frond/__init__.py +0 -0
  47. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/frond/engine.py +0 -0
  48. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/auth/meta.json +0 -0
  49. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  50. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/database/meta.json +0 -0
  51. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  52. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/error-overlay/meta.json +0 -0
  53. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  54. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/orm/meta.json +0 -0
  55. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  56. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  57. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/queue/meta.json +0 -0
  58. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  59. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/rest-api/meta.json +0 -0
  60. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  61. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/templates/meta.json +0 -0
  62. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  63. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  64. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/graphql/__init__.py +0 -0
  65. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/i18n/__init__.py +0 -0
  66. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/mcp/__init__.py +0 -0
  67. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/mcp/protocol.py +0 -0
  68. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/mcp/tools.py +0 -0
  69. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/messenger/__init__.py +0 -0
  70. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/migration/__init__.py +0 -0
  71. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/migration/runner.py +0 -0
  72. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/orm/__init__.py +0 -0
  73. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/orm/fields.py +0 -0
  74. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/orm/model.py +0 -0
  75. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/__feedback/widget.js +0 -0
  76. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/css/tina4.css +0 -0
  77. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/css/tina4.min.css +0 -0
  78. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/favicon.ico +0 -0
  79. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/images/logo.svg +0 -0
  80. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  81. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/frond.js +0 -0
  82. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/frond.min.js +0 -0
  83. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  84. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  85. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/tina4.min.js +0 -0
  86. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/tina4js.min.js +0 -0
  87. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/swagger/index.html +0 -0
  88. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  89. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/query_builder/__init__.py +0 -0
  90. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/__init__.py +0 -0
  91. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/job.py +0 -0
  92. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/kafka_backend.py +0 -0
  93. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/lite_backend.py +0 -0
  94. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.11 → tina4_python-3.13.14}/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.11
3
+ Version: 3.13.14
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.11"
3
+ version = "3.13.14"
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.11"
11
+ __version__ = "3.13.14"
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
@@ -349,6 +349,53 @@ class DatabaseAdapter:
349
349
  last_id=last_id,
350
350
  )
351
351
 
352
+ @staticmethod
353
+ def _strip_trailing_semicolons(sql: str) -> str:
354
+ """v3.13.12: normalize user-supplied SQL by stripping trailing
355
+ semicolons and whitespace.
356
+
357
+ ``fetch()`` and ``fetch_one()`` wrap the user's SQL — fetch()
358
+ builds a ``SELECT COUNT(*) FROM ({sql}) AS _count_subquery``
359
+ for the pagination probe and appends ``LIMIT/OFFSET`` to the
360
+ real query. A trailing ``;`` in the user's SQL breaks both:
361
+
362
+ user input: "SELECT * FROM users;"
363
+ wrapped: "SELECT COUNT(*) FROM (SELECT * FROM users;) ..." ← syntax error
364
+ paginated: "SELECT * FROM users; LIMIT 100 OFFSET 0" ← invalid
365
+
366
+ Stripping trailing semicolons before any wrapping is the
367
+ single-line defence. Internal semicolons (between meaningful
368
+ SQL statements) are left alone — the driver will reject those
369
+ if multi-statement isn't supported on the engine.
370
+ """
371
+ if not sql:
372
+ return sql
373
+ stripped = sql.rstrip()
374
+ while stripped.endswith(";"):
375
+ stripped = stripped[:-1].rstrip()
376
+ return stripped
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
+
352
399
  def fetch(self, sql: str, params: list = None,
353
400
  limit: int = 100, offset: int = 0) -> DatabaseResult:
354
401
  """Execute a read query and return multiple rows."""
@@ -457,8 +457,8 @@ class Database:
457
457
  raise
458
458
 
459
459
  def fetch_all(self, sql: str, params: list = None,
460
- limit: int = 100, offset: int = 0) -> list[dict]:
461
- """Fetch rows and return the records list directly.
460
+ limit: int = 0, offset: int = 0) -> list[dict]:
461
+ """Fetch ALL rows and return the records list directly.
462
462
 
463
463
  Symmetric with ``fetch_one``. For the common case where you just
464
464
  want the rows and don't need the ``DatabaseResult`` metadata
@@ -469,6 +469,10 @@ class Database:
469
469
  for row in rows:
470
470
  print(row["name"])
471
471
 
472
+ v3.13.12: default ``limit`` is **0** (no truncation) — the method
473
+ name says ``fetch_all``, so it returns all matching rows. Pre-v3.13.12
474
+ silently truncated to 100. Pass an explicit ``limit=N`` to cap.
475
+
472
476
  Returns ``[]`` (not ``None``) when no rows match.
473
477
  """
474
478
  return self.fetch(sql, params, limit, offset).records
@@ -283,6 +283,9 @@ class FirebirdAdapter(DatabaseAdapter):
283
283
 
284
284
  def fetch(self, sql: str, params: list = None,
285
285
  limit: int = 100, offset: int = 0) -> DatabaseResult:
286
+ # v3.13.12: strip trailing `;` — Firebird wraps with COUNT(*)
287
+ # and appends ROWS pagination; a trailing semicolon breaks both.
288
+ sql = self._strip_trailing_semicolons(sql)
286
289
  sql = self._translate_sql(sql)
287
290
  cursor = self._conn.cursor()
288
291
 
@@ -297,10 +300,15 @@ class FirebirdAdapter(DatabaseAdapter):
297
300
  # paginated query below regardless of whether count succeeded.
298
301
  cursor = self._conn.cursor()
299
302
 
300
- # Apply Firebird pagination — ROWS start TO end
301
- start = offset + 1
302
- end = offset + limit
303
- paginated_sql = f"{sql} ROWS {start} TO {end}"
303
+ # Apply Firebird pagination — ROWS start TO end.
304
+ # v3.13.12: limit <= 0 means "no pagination" (fetch_all's
305
+ # default give me ALL rows).
306
+ if limit is None or limit <= 0:
307
+ paginated_sql = sql
308
+ else:
309
+ start = offset + 1
310
+ end = offset + limit
311
+ paginated_sql = f"{sql} ROWS {start} TO {end}"
304
312
  cursor = self._safe_cursor_execute(cursor, paginated_sql, params)
305
313
 
306
314
  desc = cursor.description
@@ -310,6 +318,7 @@ class FirebirdAdapter(DatabaseAdapter):
310
318
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
311
319
 
312
320
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
321
+ sql = self._strip_trailing_semicolons(sql)
313
322
  sql = self._translate_sql(sql)
314
323
  cursor = self._conn.cursor()
315
324
  cursor = self._safe_cursor_execute(cursor, sql, params)
@@ -105,6 +105,8 @@ class MSSQLAdapter(DatabaseAdapter):
105
105
 
106
106
  def fetch(self, sql: str, params: list = None,
107
107
  limit: int = 100, offset: int = 0) -> DatabaseResult:
108
+ # v3.13.12: strip trailing `;` — see DatabaseAdapter helper.
109
+ sql = self._strip_trailing_semicolons(sql)
108
110
  sql = self._translate_sql(sql)
109
111
  cursor = self._conn.cursor(as_dict=True)
110
112
 
@@ -116,20 +118,26 @@ class MSSQLAdapter(DatabaseAdapter):
116
118
  except Exception:
117
119
  total = 0
118
120
 
119
- # Apply pagination — MSSQL uses OFFSET/FETCH
120
- # This requires an ORDER BY; if none exists, add a default
121
- if not re.search(r"\bORDER\s+BY\b", sql, re.IGNORECASE):
122
- paginated_sql = f"{sql} ORDER BY (SELECT NULL) OFFSET %s ROWS FETCH NEXT %s ROWS ONLY"
121
+ # Apply pagination — MSSQL uses OFFSET/FETCH.
122
+ # v3.13.12: limit <= 0 means "no pagination" (fetch_all's
123
+ # default give me ALL rows).
124
+ if limit is None or limit <= 0:
125
+ paginated_sql = sql
126
+ paginated_params = tuple(params or [])
123
127
  else:
124
- paginated_sql = f"{sql} OFFSET %s ROWS FETCH NEXT %s ROWS ONLY"
125
-
126
- paginated_params = tuple(params or []) + (offset, limit)
128
+ # OFFSET/FETCH requires an ORDER BY; if none exists, add a default
129
+ if not re.search(r"\bORDER\s+BY\b", sql, re.IGNORECASE):
130
+ paginated_sql = f"{sql} ORDER BY (SELECT NULL) OFFSET %s ROWS FETCH NEXT %s ROWS ONLY"
131
+ else:
132
+ paginated_sql = f"{sql} OFFSET %s ROWS FETCH NEXT %s ROWS ONLY"
133
+ paginated_params = tuple(params or []) + (offset, limit)
127
134
  cursor.execute(paginated_sql, paginated_params)
128
135
  rows = [dict(row) for row in cursor.fetchall()]
129
136
 
130
137
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
131
138
 
132
139
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
140
+ sql = self._strip_trailing_semicolons(sql)
133
141
  sql = self._translate_sql(sql)
134
142
  cursor = self._conn.cursor(as_dict=True)
135
143
  cursor.execute(sql, tuple(params) if params else ())
@@ -182,10 +190,17 @@ class MSSQLAdapter(DatabaseAdapter):
182
190
  self._in_transaction = False
183
191
 
184
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)
185
199
  row = self.fetch_one(
186
200
  "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES "
187
- "WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME = %s",
188
- [name],
201
+ "WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME = %s "
202
+ "AND (%s IS NULL OR TABLE_SCHEMA = %s)",
203
+ [tbl, schema, schema],
189
204
  )
190
205
  return row is not None
191
206
 
@@ -198,6 +213,9 @@ class MSSQLAdapter(DatabaseAdapter):
198
213
  return [r["TABLE_NAME"] for r in result.records]
199
214
 
200
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)
201
219
  sql = """
202
220
  SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT,
203
221
  CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS is_primary
@@ -207,12 +225,13 @@ class MSSQLAdapter(DatabaseAdapter):
207
225
  FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
208
226
  JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku
209
227
  ON tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME
210
- 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'
211
230
  ) pk ON c.COLUMN_NAME = pk.COLUMN_NAME
212
- WHERE c.TABLE_NAME = %s
231
+ WHERE c.TABLE_NAME = %s AND (%s IS NULL OR c.TABLE_SCHEMA = %s)
213
232
  ORDER BY c.ORDINAL_POSITION
214
233
  """
215
- result = self.fetch(sql, [table, table], limit=10000)
234
+ result = self.fetch(sql, [tbl, schema, schema, tbl, schema, schema], limit=10000)
216
235
  return [
217
236
  {
218
237
  "name": r["COLUMN_NAME"],
@@ -94,6 +94,9 @@ class MySQLAdapter(DatabaseAdapter):
94
94
 
95
95
  def fetch(self, sql: str, params: list = None,
96
96
  limit: int = 100, offset: int = 0) -> DatabaseResult:
97
+ # v3.13.12: strip trailing `;` before wrapping with COUNT(*)
98
+ # and appending LIMIT/OFFSET — see DatabaseAdapter helper.
99
+ sql = self._strip_trailing_semicolons(sql)
97
100
  sql = self._translate_sql(sql)
98
101
  cursor = self._conn.cursor(dictionary=True)
99
102
 
@@ -105,15 +108,21 @@ class MySQLAdapter(DatabaseAdapter):
105
108
  except Exception:
106
109
  total = 0
107
110
 
108
- # Apply pagination
109
- paginated_sql = f"{sql} LIMIT %s OFFSET %s"
110
- paginated_params = (params or []) + [limit, offset]
111
+ # Apply pagination — v3.13.12: limit <= 0 means "no pagination"
112
+ # (fetch_all's default give me ALL rows).
113
+ if limit is None or limit <= 0:
114
+ paginated_sql = sql
115
+ paginated_params = params or []
116
+ else:
117
+ paginated_sql = f"{sql} LIMIT %s OFFSET %s"
118
+ paginated_params = (params or []) + [limit, offset]
111
119
  cursor.execute(paginated_sql, paginated_params)
112
120
  rows = [dict(row) for row in cursor.fetchall()]
113
121
 
114
122
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
115
123
 
116
124
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
125
+ sql = self._strip_trailing_semicolons(sql)
117
126
  sql = self._translate_sql(sql)
118
127
  cursor = self._conn.cursor(dictionary=True)
119
128
  cursor.execute(sql, params or [])
@@ -165,10 +174,16 @@ class MySQLAdapter(DatabaseAdapter):
165
174
  self._in_transaction = False
166
175
 
167
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)
168
183
  row = self.fetch_one(
169
184
  "SELECT TABLE_NAME FROM information_schema.TABLES "
170
- "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
171
- [name],
185
+ "WHERE TABLE_SCHEMA = COALESCE(%s, DATABASE()) AND TABLE_NAME = %s",
186
+ [schema, tbl],
172
187
  )
173
188
  return row is not None
174
189
 
@@ -306,6 +306,10 @@ class PostgreSQLAdapter(DatabaseAdapter):
306
306
  limit: int = 100, offset: int = 0) -> DatabaseResult:
307
307
  import psycopg2.extras
308
308
 
309
+ # v3.13.12: strip trailing `;` before the framework wraps with
310
+ # COUNT(*) and appends LIMIT/OFFSET — otherwise a user-supplied
311
+ # `"SELECT * FROM users;"` produces invalid wrapped SQL.
312
+ sql = self._strip_trailing_semicolons(sql)
309
313
  sql = self._translate_sql(sql)
310
314
 
311
315
  # v3.13.8: heal first so the COUNT probe below doesn't open on a
@@ -342,8 +346,14 @@ class PostgreSQLAdapter(DatabaseAdapter):
342
346
  # Apply pagination — the real query, so use the error-handling
343
347
  # wrapper. The count probe above is best-effort and stays on
344
348
  # _safe_execute (a failed probe shouldn't taint last_error).
345
- paginated_sql = f"{sql} LIMIT %s OFFSET %s"
346
- paginated_params = (params or []) + [limit, offset]
349
+ # v3.13.12: limit <= 0 means "no pagination" (fetch_all's
350
+ # default give me ALL rows, not a silent first-100 slice).
351
+ if limit is None or limit <= 0:
352
+ paginated_sql = sql
353
+ paginated_params = params or []
354
+ else:
355
+ paginated_sql = f"{sql} LIMIT %s OFFSET %s"
356
+ paginated_params = (params or []) + [limit, offset]
347
357
  self._exec_with_handling(cursor, paginated_sql, paginated_params)
348
358
  rows = [self._decode_blobs(dict(row)) for row in cursor.fetchall()]
349
359
 
@@ -352,6 +362,8 @@ class PostgreSQLAdapter(DatabaseAdapter):
352
362
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
353
363
  import psycopg2.extras
354
364
 
365
+ # v3.13.12: see fetch() — trailing semicolons break the wrappers.
366
+ sql = self._strip_trailing_semicolons(sql)
355
367
  sql = self._translate_sql(sql)
356
368
  cursor = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
357
369
  self._exec_with_handling(cursor, sql, params)
@@ -411,20 +423,40 @@ class PostgreSQLAdapter(DatabaseAdapter):
411
423
  self._in_transaction = False
412
424
 
413
425
  def table_exists(self, name: str) -> bool:
414
- row = self.fetch_one(
415
- "SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = %s",
416
- [name],
417
- )
418
- return row is not None
426
+ # v3.13.x (#48): support schema-qualified names ("schema.table") and the
427
+ # connection search_path. to_regclass() resolves a (possibly qualified)
428
+ # relation name to its OID using the SAME rules Postgres applies to a
429
+ # FROM clause, returning NULL when absent. Pre-fix this hardcoded
430
+ # schemaname='public' AND matched the whole dotted string as a flat
431
+ # tablename, so a table in a non-public schema was invisible —
432
+ # table_exists() always returned False, so create_table()/migrations
433
+ # misfired and ORM introspection saw nothing.
434
+ row = self.fetch_one("SELECT to_regclass(%s) AS oid", [name])
435
+ return bool(row and row.get("oid") is not None)
419
436
 
420
437
  def get_tables(self) -> list[str]:
438
+ # v3.13.x (#48): list tables from every user schema, not just public.
439
+ # public tables stay bare ("users"); tables in other schemas are
440
+ # returned schema-qualified ("gift_cards.gift_card") so the names round
441
+ # -trip back through table_exists()/get_columns().
421
442
  result = self.fetch(
422
- "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
443
+ """
444
+ SELECT schemaname, tablename FROM pg_tables
445
+ WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
446
+ ORDER BY schemaname, tablename
447
+ """,
423
448
  limit=10000,
424
449
  )
425
- return [r["tablename"] for r in result.records]
450
+ return [
451
+ r["tablename"] if r["schemaname"] == "public"
452
+ else f"{r['schemaname']}.{r['tablename']}"
453
+ for r in result.records
454
+ ]
426
455
 
427
456
  def get_columns(self, table: str) -> list[dict]:
457
+ # v3.13.x (#48): honour a schema-qualified name; default to public.
458
+ schema, tbl = self._split_schema(table)
459
+ schema = schema or "public"
428
460
  sql = """
429
461
  SELECT c.column_name, c.data_type, c.is_nullable, c.column_default,
430
462
  CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_primary
@@ -434,12 +466,12 @@ class PostgreSQLAdapter(DatabaseAdapter):
434
466
  FROM information_schema.table_constraints tc
435
467
  JOIN information_schema.key_column_usage ku
436
468
  ON tc.constraint_name = ku.constraint_name
437
- WHERE tc.table_name = %s AND tc.constraint_type = 'PRIMARY KEY'
469
+ WHERE tc.table_name = %s AND tc.table_schema = %s AND tc.constraint_type = 'PRIMARY KEY'
438
470
  ) pk ON c.column_name = pk.column_name
439
- WHERE c.table_name = %s AND c.table_schema = 'public'
471
+ WHERE c.table_name = %s AND c.table_schema = %s
440
472
  ORDER BY c.ordinal_position
441
473
  """
442
- result = self.fetch(sql, [table, table], limit=10000)
474
+ result = self.fetch(sql, [tbl, schema, tbl, schema], limit=10000)
443
475
  return [
444
476
  {
445
477
  "name": r["column_name"],
@@ -108,6 +108,8 @@ class SQLiteAdapter(DatabaseAdapter):
108
108
 
109
109
  def fetch(self, sql: str, params: list = None,
110
110
  limit: int = 100, offset: int = 0) -> DatabaseResult:
111
+ # v3.13.12: strip trailing `;` before wrapping — see DatabaseAdapter.
112
+ sql = self._strip_trailing_semicolons(sql)
111
113
  # Count total rows (without LIMIT/OFFSET)
112
114
  count_sql = f"SELECT COUNT(*) as cnt FROM ({sql})"
113
115
  try:
@@ -115,8 +117,12 @@ class SQLiteAdapter(DatabaseAdapter):
115
117
  except Exception:
116
118
  total = 0
117
119
 
118
- # Apply pagination — skip if SQL already has LIMIT
119
- if "LIMIT" in sql.upper().split("--")[0]:
120
+ # Apply pagination — skip if SQL already has LIMIT, or if
121
+ # limit <= 0 (v3.13.12: fetch_all's "give me all rows" path).
122
+ if limit is None or limit <= 0:
123
+ paginated_sql = sql
124
+ paginated_params = params or []
125
+ elif "LIMIT" in sql.upper().split("--")[0]:
120
126
  paginated_sql = sql
121
127
  paginated_params = params or []
122
128
  else:
@@ -128,6 +134,7 @@ class SQLiteAdapter(DatabaseAdapter):
128
134
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
129
135
 
130
136
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
137
+ sql = self._strip_trailing_semicolons(sql)
131
138
  cursor = self._conn.execute(sql, params or [])
132
139
  row = cursor.fetchone()
133
140
  return dict(row) if row else None
@@ -172,9 +179,16 @@ class SQLiteAdapter(DatabaseAdapter):
172
179
  self._in_transaction = False
173
180
 
174
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"
175
189
  row = self.fetch_one(
176
- "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
177
- [name]
190
+ f"SELECT name FROM {master} WHERE type='table' AND name=?",
191
+ [tbl if (schema and schema.isidentifier()) else name]
178
192
  )
179
193
  return row is not None
180
194
 
@@ -186,7 +200,13 @@ class SQLiteAdapter(DatabaseAdapter):
186
200
  return [r["name"] for r in result.records]
187
201
 
188
202
  def get_columns(self, table: str) -> list[dict]:
189
- 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})")
190
210
  return [
191
211
  {
192
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