tina4-python 3.13.12__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.12 → tina4_python-3.13.14}/PKG-INFO +1 -1
  2. {tina4_python-3.13.12 → tina4_python-3.13.14}/pyproject.toml +1 -1
  3. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/core/middleware.py +9 -4
  5. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/core/server.py +37 -1
  6. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/database/adapter.py +21 -0
  7. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/database/mssql.py +16 -5
  8. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/database/mysql.py +8 -2
  9. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/database/postgres.py +30 -10
  10. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/database/sqlite.py +16 -3
  11. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/debug/__init__.py +12 -4
  12. {tina4_python-3.13.12 → tina4_python-3.13.14}/.gitignore +0 -0
  13. {tina4_python-3.13.12 → tina4_python-3.13.14}/README.md +0 -0
  14. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/CLAUDE.md +0 -0
  15. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/HtmlElement.py +0 -0
  16. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/Testing.py +0 -0
  17. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/ai/__init__.py +0 -0
  18. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/api/__init__.py +0 -0
  19. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/auth/__init__.py +0 -0
  20. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/cache/__init__.py +0 -0
  21. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/cli/__init__.py +0 -0
  22. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/container/__init__.py +0 -0
  23. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/core/__init__.py +0 -0
  24. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/core/cache.py +0 -0
  25. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/core/constants.py +0 -0
  26. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/core/events.py +0 -0
  27. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/core/rate_limiter.py +0 -0
  28. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/core/request.py +0 -0
  29. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/core/response.py +0 -0
  30. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/core/router.py +0 -0
  31. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/crud/__init__.py +0 -0
  32. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/database/__init__.py +0 -0
  33. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/database/connection.py +0 -0
  34. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/database/firebird.py +0 -0
  35. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/database/mongodb.py +0 -0
  36. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/database/odbc.py +0 -0
  37. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/debug/error_overlay.py +0 -0
  38. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/dev_admin/__init__.py +0 -0
  39. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/dev_admin/metrics.py +0 -0
  40. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/dev_admin/plan.py +0 -0
  41. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/dev_admin/project_index.py +0 -0
  42. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/docs.py +0 -0
  43. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/dotenv/__init__.py +0 -0
  44. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/env.py +0 -0
  45. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/frond/FROND.md +0 -0
  46. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/frond/__init__.py +0 -0
  47. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/frond/engine.py +0 -0
  48. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/auth/meta.json +0 -0
  49. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  50. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/database/meta.json +0 -0
  51. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  52. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/error-overlay/meta.json +0 -0
  53. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  54. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/orm/meta.json +0 -0
  55. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  56. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  57. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/queue/meta.json +0 -0
  58. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  59. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/rest-api/meta.json +0 -0
  60. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  61. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/templates/meta.json +0 -0
  62. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  63. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  64. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/graphql/__init__.py +0 -0
  65. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/i18n/__init__.py +0 -0
  66. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/mcp/__init__.py +0 -0
  67. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/mcp/protocol.py +0 -0
  68. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/mcp/tools.py +0 -0
  69. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/messenger/__init__.py +0 -0
  70. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/migration/__init__.py +0 -0
  71. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/migration/runner.py +0 -0
  72. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/orm/__init__.py +0 -0
  73. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/orm/fields.py +0 -0
  74. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/orm/model.py +0 -0
  75. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/__feedback/widget.js +0 -0
  76. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/css/tina4.css +0 -0
  77. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/css/tina4.min.css +0 -0
  78. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/favicon.ico +0 -0
  79. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/images/logo.svg +0 -0
  80. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  81. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/js/frond.js +0 -0
  82. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/js/frond.min.js +0 -0
  83. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  84. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  85. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/js/tina4.min.js +0 -0
  86. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/js/tina4js.min.js +0 -0
  87. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/swagger/index.html +0 -0
  88. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  89. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/query_builder/__init__.py +0 -0
  90. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/queue/__init__.py +0 -0
  91. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/queue/job.py +0 -0
  92. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/queue/kafka_backend.py +0 -0
  93. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/queue/lite_backend.py +0 -0
  94. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.12 → tina4_python-3.13.14}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.12 → 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.12
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.12"
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.12"
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
@@ -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
 
@@ -423,20 +423,40 @@ class PostgreSQLAdapter(DatabaseAdapter):
423
423
  self._in_transaction = False
424
424
 
425
425
  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
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)
431
436
 
432
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().
433
442
  result = self.fetch(
434
- "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
+ """,
435
448
  limit=10000,
436
449
  )
437
- 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
+ ]
438
455
 
439
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"
440
460
  sql = """
441
461
  SELECT c.column_name, c.data_type, c.is_nullable, c.column_default,
442
462
  CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_primary
@@ -446,12 +466,12 @@ class PostgreSQLAdapter(DatabaseAdapter):
446
466
  FROM information_schema.table_constraints tc
447
467
  JOIN information_schema.key_column_usage ku
448
468
  ON tc.constraint_name = ku.constraint_name
449
- 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'
450
470
  ) pk ON c.column_name = pk.column_name
451
- WHERE c.table_name = %s AND c.table_schema = 'public'
471
+ WHERE c.table_name = %s AND c.table_schema = %s
452
472
  ORDER BY c.ordinal_position
453
473
  """
454
- result = self.fetch(sql, [table, table], limit=10000)
474
+ result = self.fetch(sql, [tbl, schema, tbl, schema], limit=10000)
455
475
  return [
456
476
  {
457
477
  "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