tina4-python 3.11.36__tar.gz → 3.12.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. {tina4_python-3.11.36 → tina4_python-3.12.0}/PKG-INFO +2 -2
  2. {tina4_python-3.11.36 → tina4_python-3.12.0}/pyproject.toml +5 -2
  3. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/auth/__init__.py +7 -7
  5. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/cli/__init__.py +53 -5
  6. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/core/middleware.py +2 -2
  7. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/core/server.py +183 -16
  8. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/database/connection.py +6 -6
  9. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/database/firebird.py +16 -90
  10. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/database/postgres.py +16 -2
  11. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/dev_admin/__init__.py +19 -19
  12. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/dotenv/__init__.py +1 -1
  13. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/frond/engine.py +1 -1
  14. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/mcp/__init__.py +1 -1
  15. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/messenger/__init__.py +9 -9
  16. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/orm/model.py +1011 -1011
  17. tina4_python-3.12.0/tina4_python/public/js/frond.js +600 -0
  18. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/js/frond.min.js +1 -1
  19. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/swagger/__init__.py +3 -3
  20. {tina4_python-3.11.36 → tina4_python-3.12.0}/.gitignore +0 -0
  21. {tina4_python-3.11.36 → tina4_python-3.12.0}/README.md +0 -0
  22. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/CLAUDE.md +0 -0
  23. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/HtmlElement.py +0 -0
  24. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/Testing.py +0 -0
  25. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/ai/__init__.py +0 -0
  26. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/api/__init__.py +0 -0
  27. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/cache/__init__.py +0 -0
  28. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/container/__init__.py +0 -0
  29. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/core/__init__.py +0 -0
  30. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/core/cache.py +0 -0
  31. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/core/constants.py +0 -0
  32. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/core/events.py +0 -0
  33. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/core/rate_limiter.py +0 -0
  34. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/core/request.py +0 -0
  35. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/core/response.py +0 -0
  36. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/core/router.py +0 -0
  37. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/crud/__init__.py +0 -0
  38. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/database/__init__.py +0 -0
  39. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/database/adapter.py +0 -0
  40. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/database/mongodb.py +0 -0
  41. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/database/mssql.py +0 -0
  42. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/database/mysql.py +0 -0
  43. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/database/odbc.py +0 -0
  44. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/database/sqlite.py +0 -0
  45. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/debug/__init__.py +0 -0
  46. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/debug/error_overlay.py +0 -0
  47. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/dev_admin/metrics.py +0 -0
  48. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/dev_admin/plan.py +0 -0
  49. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/dev_admin/project_index.py +0 -0
  50. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/docs.py +0 -0
  51. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/frond/FROND.md +0 -0
  52. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/frond/__init__.py +0 -0
  53. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/auth/meta.json +0 -0
  54. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  55. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/database/meta.json +0 -0
  56. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  57. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/error-overlay/meta.json +0 -0
  58. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  59. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/orm/meta.json +0 -0
  60. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  61. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  62. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/queue/meta.json +0 -0
  63. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  64. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/rest-api/meta.json +0 -0
  65. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  66. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/templates/meta.json +0 -0
  67. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  68. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  69. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/graphql/__init__.py +0 -0
  70. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/i18n/__init__.py +0 -0
  71. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/mcp/protocol.py +0 -0
  72. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/mcp/tools.py +0 -0
  73. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/migration/__init__.py +0 -0
  74. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/migration/runner.py +0 -0
  75. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/orm/__init__.py +0 -0
  76. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/orm/fields.py +0 -0
  77. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/css/tina4.css +0 -0
  78. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/css/tina4.min.css +0 -0
  79. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/favicon.ico +0 -0
  80. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/images/logo.svg +0 -0
  81. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  82. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  83. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  84. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/js/tina4.min.js +0 -0
  85. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/js/tina4js.min.js +0 -0
  86. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/swagger/index.html +0 -0
  87. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  88. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/query_builder/__init__.py +0 -0
  89. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/queue/__init__.py +0 -0
  90. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/queue/job.py +0 -0
  91. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/queue/kafka_backend.py +0 -0
  92. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/queue/lite_backend.py +0 -0
  93. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/queue/mongo_backend.py +0 -0
  94. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/queue/rabbitmq_backend.py +0 -0
  95. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/queue_backends/__init__.py +0 -0
  96. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/queue_backends/kafka_backend.py +0 -0
  97. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/queue_backends/mongo_backend.py +0 -0
  98. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  99. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/__init__.py +0 -0
  100. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  101. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_badges.scss +0 -0
  102. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  103. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_cards.scss +0 -0
  104. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_forms.scss +0 -0
  105. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_grid.scss +0 -0
  106. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_modals.scss +0 -0
  107. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_nav.scss +0 -0
  108. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_reset.scss +0 -0
  109. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_tables.scss +0 -0
  110. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_typography.scss +0 -0
  111. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  112. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/_variables.scss +0 -0
  113. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/base.scss +0 -0
  114. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/colors.scss +0 -0
  115. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/scss/tina4css/tina4.scss +0 -0
  116. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/seeder/__init__.py +0 -0
  117. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/service/__init__.py +0 -0
  118. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/session/__init__.py +0 -0
  119. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/session_handlers/__init__.py +0 -0
  120. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  121. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/session_handlers/redis_handler.py +0 -0
  122. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/session_handlers/valkey_handler.py +0 -0
  123. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/components/crud.twig +0 -0
  124. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  125. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  126. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/docker/python/Dockerfile +0 -0
  127. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  128. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/errors/302.twig +0 -0
  129. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/errors/401.twig +0 -0
  130. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/errors/403.twig +0 -0
  131. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/errors/404.twig +0 -0
  132. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/errors/500.twig +0 -0
  133. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/errors/502.twig +0 -0
  134. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/errors/503.twig +0 -0
  135. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/errors/base.twig +0 -0
  136. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/frontend/README.md +0 -0
  137. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/templates/readme.md +0 -0
  138. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/test_client/__init__.py +0 -0
  139. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  140. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  141. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  142. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  143. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/validator/__init__.py +0 -0
  152. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/websocket/__init__.py +0 -0
  153. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/websocket/backplane.py +0 -0
  154. {tina4_python-3.11.36 → tina4_python-3.12.0}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.11.36
4
- Summary: Tina4 Python v3 Zero-dependency, lightweight web framework
3
+ Version: 3.12.0
4
+ Summary: Tina4 for Python — 54 built-in features, zero dependencies
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
7
7
  Requires-Python: >=3.12
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.11.36"
4
- description = "Tina4 Python v3 Zero-dependency, lightweight web framework"
3
+ dynamic = ["version"]
4
+ description = "Tina4 for Python — 54 built-in features, zero dependencies"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
7
7
  ]
@@ -55,6 +55,9 @@ dev = [
55
55
  requires = ["hatchling"]
56
56
  build-backend = "hatchling.build"
57
57
 
58
+ [tool.hatch.version]
59
+ path = "tina4_python/__init__.py"
60
+
58
61
  [tool.hatch.build]
59
62
  include = ["tina4_python/**/*"]
60
63
 
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.11.36"
11
+ __version__ = "3.12.0"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -60,7 +60,7 @@ class Auth:
60
60
  algorithm: JWT algorithm (default HS256).
61
61
  expires_in: Token lifetime in seconds (default 3600).
62
62
  """
63
- self.secret = secret or os.environ.get("SECRET", "tina4-default-secret")
63
+ self.secret = secret or os.environ.get("TINA4_SECRET", "tina4-default-secret")
64
64
  self.algorithm = algorithm
65
65
  self.expires_in = expires_in or int(
66
66
  os.environ.get("TINA4_TOKEN_LIMIT", "60")
@@ -161,28 +161,28 @@ class Auth:
161
161
  @classmethod
162
162
  def get_token_static(cls, payload: dict, expires_in: int = 60) -> str:
163
163
  """Create a JWT without instantiating Auth — reads SECRET from env."""
164
- secret = os.environ.get("SECRET", "tina4-default-secret")
164
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
165
165
  auth = cls(secret=secret, expires_in=expires_in)
166
166
  return auth.get_token(payload)
167
167
 
168
168
  @classmethod
169
169
  def valid_token_static(cls, token: str) -> bool:
170
170
  """Validate a JWT without instantiating Auth — reads SECRET from env."""
171
- secret = os.environ.get("SECRET", "tina4-default-secret")
171
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
172
172
  auth = cls(secret=secret)
173
173
  return auth.valid_token(token)
174
174
 
175
175
  @classmethod
176
176
  def get_payload_static(cls, token: str) -> dict | None:
177
177
  """Decode payload (no validation) without instantiating Auth."""
178
- secret = os.environ.get("SECRET", "tina4-default-secret")
178
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
179
179
  auth = cls(secret=secret)
180
180
  return auth.get_payload(token)
181
181
 
182
182
  @classmethod
183
183
  def refresh_token_static(cls, token: str, expires_in: int = 60) -> str | None:
184
184
  """Refresh a JWT without instantiating Auth — reads SECRET from env."""
185
- secret = os.environ.get("SECRET", "tina4-default-secret")
185
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
186
186
  auth = cls(secret=secret, expires_in=expires_in)
187
187
  return auth.refresh_token(token)
188
188
 
@@ -193,7 +193,7 @@ class Auth:
193
193
  Reads SECRET from env. Checks: Bearer JWT, Bearer API key, Basic auth.
194
194
  Returns payload dict on success, None on failure.
195
195
  """
196
- secret = os.environ.get("SECRET", "tina4-default-secret")
196
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
197
197
  auth = cls(secret=secret)
198
198
  return auth.authenticate_request(headers)
199
199
 
@@ -246,7 +246,7 @@ class Auth:
246
246
  Returns: True if the provided key matches.
247
247
  """
248
248
  if expected is None:
249
- expected = os.environ.get("TINA4_API_KEY", os.environ.get("API_KEY", ""))
249
+ expected = os.environ.get("TINA4_API_KEY", "")
250
250
  if not expected:
251
251
  return False
252
252
  return hmac.compare_digest(provided, expected)
@@ -137,6 +137,7 @@ def main():
137
137
  "migrate:create": _migrate_create,
138
138
  "migrate:rollback": _migrate_rollback,
139
139
  "migrate:status": _migrate_status,
140
+ "env-migrate": _env_migrate,
140
141
  "seed": _seed,
141
142
  "routes": _routes,
142
143
  "test": _test,
@@ -155,6 +156,52 @@ def main():
155
156
  _help([])
156
157
 
157
158
 
159
+ def _env_migrate(args):
160
+ """Rewrite a .env file in place, renaming pre-3.12 names to TINA4_ form.
161
+
162
+ Usage: tina4python env-migrate [path] (default path: .env)
163
+
164
+ Backs the original up to <path>.bak before rewriting. Prints a diff of
165
+ each rename. Idempotent — running twice is a no-op on the second run.
166
+ """
167
+ from tina4_python.core.server import _LEGACY_ENV_VARS
168
+ target = Path(args[0]) if args else Path(".env")
169
+ if not target.is_file():
170
+ print(f" no .env at {target}")
171
+ return
172
+ text = target.read_text(encoding="utf-8")
173
+ backup = target.with_suffix(target.suffix + ".bak")
174
+ backup.write_text(text, encoding="utf-8")
175
+ print(f" backup written: {backup}")
176
+
177
+ renamed = 0
178
+ new_lines = []
179
+ for line in text.splitlines(keepends=True):
180
+ stripped = line.lstrip()
181
+ if not stripped or stripped.startswith("#"):
182
+ new_lines.append(line); continue
183
+ if "=" not in stripped:
184
+ new_lines.append(line); continue
185
+ key = stripped.split("=", 1)[0].strip()
186
+ if key in _LEGACY_ENV_VARS:
187
+ new_key = _LEGACY_ENV_VARS[key]
188
+ new_line = line.replace(key, new_key, 1)
189
+ print(f" {key:<28} → {new_key}")
190
+ new_lines.append(new_line)
191
+ renamed += 1
192
+ else:
193
+ new_lines.append(line)
194
+
195
+ if renamed == 0:
196
+ print(" nothing to rename — your .env is already on the new convention")
197
+ backup.unlink() # don't leave a noise backup
198
+ return
199
+
200
+ target.write_text("".join(new_lines), encoding="utf-8")
201
+ print(f"\n done: {renamed} rename(s) applied to {target}")
202
+ print(f" original kept at {backup} (delete once you've verified)")
203
+
204
+
158
205
  def _help(args=None):
159
206
  print("""
160
207
  Tina4 Python — CLI
@@ -168,6 +215,7 @@ Commands:
168
215
  migrate:create <desc> Create a new migration file
169
216
  migrate:rollback Rollback last migration batch
170
217
  migrate:status Show migration status
218
+ env-migrate [path] Rewrite .env to TINA4_-prefixed names (v3.12 migration)
171
219
  seed Run database seeders
172
220
  routes List all registered routes
173
221
  test Run test suite
@@ -212,7 +260,7 @@ def _console(args=None):
212
260
 
213
261
  # Try to connect database from DATABASE_URL
214
262
  db = None
215
- db_url = os.environ.get("DATABASE_URL")
263
+ db_url = os.environ.get("TINA4_DATABASE_URL")
216
264
  if db_url:
217
265
  try:
218
266
  db = Database(db_url)
@@ -404,7 +452,7 @@ def _migrate(args):
404
452
  from tina4_python.database import Database
405
453
  from tina4_python.migration import Migration
406
454
 
407
- db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
455
+ db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
408
456
  db = Database(db_url)
409
457
  mig_dir = args[0] if args else "migrations"
410
458
  ran = Migration(db, mig_dir).migrate()
@@ -434,7 +482,7 @@ def _migrate_rollback(args):
434
482
  from tina4_python.database import Database
435
483
  from tina4_python.migration import Migration
436
484
 
437
- db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
485
+ db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
438
486
  db = Database(db_url)
439
487
  mig_dir = args[0] if args else "migrations"
440
488
  rolled = Migration(db, mig_dir).rollback()
@@ -453,7 +501,7 @@ def _migrate_status(args):
453
501
  from tina4_python.database import Database
454
502
  from tina4_python.migration import Migration
455
503
 
456
- db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
504
+ db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
457
505
  db = Database(db_url)
458
506
  result = Migration(db, args[0] if args else "migrations").status()
459
507
  completed, pending = result["completed"], result["pending"]
@@ -489,7 +537,7 @@ def _seed(args):
489
537
  import importlib.util
490
538
  from tina4_python.database import Database
491
539
 
492
- db_url = os.environ.get("DATABASE_URL", "sqlite:///data/app.db")
540
+ db_url = os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/app.db")
493
541
  db = Database(db_url)
494
542
  sys.path.insert(0, str(Path.cwd()))
495
543
 
@@ -287,7 +287,7 @@ class CsrfMiddleware:
287
287
  bearer_token = auth_header[7:].strip()
288
288
  if bearer_token:
289
289
  from tina4_python.auth import Auth as _CsrfAuth
290
- secret = os.environ.get("SECRET", "tina4-default-secret")
290
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
291
291
  auth = _CsrfAuth(secret=secret)
292
292
  if auth.valid_token(bearer_token):
293
293
  return request, response
@@ -326,7 +326,7 @@ class CsrfMiddleware:
326
326
 
327
327
  # Validate the token
328
328
  from tina4_python.auth import Auth as _CsrfAuth
329
- secret = os.environ.get("SECRET", "tina4-default-secret")
329
+ secret = os.environ.get("TINA4_SECRET", "tina4-default-secret")
330
330
  auth = _CsrfAuth(secret=secret)
331
331
  if not auth.valid_token(token):
332
332
  return request, response.error(
@@ -193,20 +193,93 @@ def _render_error_page(status_code: int, path: str, request_id: str, error_messa
193
193
  _template_cache: dict[str, str] | None = None
194
194
 
195
195
 
196
+ # Auto-routing scans this single subdirectory of src/templates/. Only files
197
+ # in src/templates/pages/ become URLs — everything else (partials, layouts,
198
+ # base.twig, errors, components, macros) is never URL-exposed and remains
199
+ # renderable only via {% include %} / {% extends %} / response.render().
200
+ #
201
+ # Convention adapted from Next.js' pages/ directory and Nuxt's pages/ folder.
202
+ # Explicit, secure by default, no skip lists to maintain.
203
+ _TEMPLATE_PAGES_DIR = "pages"
204
+
205
+
206
+ def _is_dev_mode() -> bool:
207
+ """True when ``TINA4_DEBUG`` is one of the truthy values (true|1|yes).
208
+
209
+ Centralised so every dev-mode gate (landing page, dev toolbar, error
210
+ overlay, dev admin) reads the same flag the same way.
211
+ """
212
+ return os.environ.get("TINA4_DEBUG", "false").strip().lower() in ("true", "1", "yes")
213
+
214
+
215
+ # RFC 7231 / RFC 9110 status reason phrases. We use this to write a correct
216
+ # HTTP status line in the dev server's HTTP/1.1 → ASGI bridge — previously
217
+ # the bridge wrote "HTTP/1.1 404 OK" regardless of code, which is malformed.
218
+ _HTTP_REASON_PHRASES: dict[int, str] = {
219
+ 100: "Continue", 101: "Switching Protocols",
220
+ 200: "OK", 201: "Created", 202: "Accepted", 204: "No Content",
221
+ 206: "Partial Content",
222
+ 301: "Moved Permanently", 302: "Found", 303: "See Other",
223
+ 304: "Not Modified", 307: "Temporary Redirect", 308: "Permanent Redirect",
224
+ 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
225
+ 404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable",
226
+ 409: "Conflict", 410: "Gone", 413: "Content Too Large",
227
+ 415: "Unsupported Media Type", 422: "Unprocessable Content",
228
+ 429: "Too Many Requests",
229
+ 500: "Internal Server Error", 501: "Not Implemented",
230
+ 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
231
+ }
232
+
233
+
234
+ def _http_reason(status: int) -> str:
235
+ """Return the canonical HTTP reason phrase for ``status``.
236
+
237
+ Falls back to a sensible label when an exotic status is used. Never
238
+ returns an empty string — the HTTP/1.1 status line requires a phrase.
239
+ """
240
+ return _HTTP_REASON_PHRASES.get(int(status), "OK" if 200 <= status < 300 else "Error")
241
+
242
+
243
+ def _template_auto_routing_enabled() -> bool:
244
+ """Honour TINA4_TEMPLATE_ROUTING=off|false|0|no as an explicit kill switch.
245
+
246
+ Default: enabled. Drop a file in src/templates/pages/ and it serves at
247
+ the matching URL — the zero-config Tina4 convention. Operators who want
248
+ explicit-only routing can set TINA4_TEMPLATE_ROUTING=off and every URL
249
+ must be registered via @get / @post (or be a static file).
250
+ """
251
+ val = os.environ.get("TINA4_TEMPLATE_ROUTING", "on").strip().lower()
252
+ return val not in ("off", "false", "0", "no", "disabled")
253
+
254
+
196
255
  def _resolve_template(path: str) -> str | None:
197
- """Resolve a URL path to a template file in src/templates/.
256
+ """Resolve a URL path to a template file in src/templates/pages/.
257
+
258
+ Only files inside ``src/templates/pages/`` auto-route from a URL.
259
+ Anything in ``src/templates/`` outside ``pages/`` (partials, layouts,
260
+ base.twig, errors, components) is never served standalone.
261
+
198
262
  Dev mode: checks filesystem every time for live changes.
199
263
  Production: uses a cached lookup built once at startup.
264
+
265
+ The whole feature can be turned off with ``TINA4_TEMPLATE_ROUTING=off``.
200
266
  """
267
+ if not _template_auto_routing_enabled():
268
+ return None
269
+
201
270
  clean_path = path.strip("/") or "index"
202
271
  is_dev = os.environ.get("TINA4_DEBUG", "false").lower() in ("true", "1", "yes")
203
272
 
204
273
  if is_dev:
205
- template_dir = Path("src/templates")
274
+ # Skip underscore-prefixed files even within pages/ — they're private
275
+ # by Hugo/Jekyll convention (helpers, fragments) and shouldn't auto-serve.
276
+ if any(seg.startswith("_") for seg in clean_path.split("/")):
277
+ return None
278
+ pages_dir = Path("src/templates") / _TEMPLATE_PAGES_DIR
206
279
  for ext in (".twig", ".html"):
207
- candidate = clean_path + ext
208
- if (template_dir / candidate).is_file():
209
- return candidate
280
+ candidate_rel = f"{_TEMPLATE_PAGES_DIR}/{clean_path}{ext}"
281
+ if (pages_dir / (clean_path + ext)).is_file():
282
+ return candidate_rel
210
283
  return None
211
284
 
212
285
  global _template_cache
@@ -216,17 +289,25 @@ def _resolve_template(path: str) -> str | None:
216
289
 
217
290
 
218
291
  def _build_template_cache() -> None:
219
- """Scan src/templates/ once and build url_path -> template_file lookup."""
292
+ """Scan src/templates/pages/ once and build url_path -> template_file lookup.
293
+ Only files under ``pages/`` are eligible — partials, layouts, base.twig,
294
+ errors etc remain renderable via explicit response.render() but never
295
+ auto-serve from a URL.
296
+ """
220
297
  global _template_cache
221
298
  _template_cache = {}
222
- template_dir = Path("src/templates")
223
- if not template_dir.is_dir():
299
+ pages_dir = Path("src/templates") / _TEMPLATE_PAGES_DIR
300
+ if not pages_dir.is_dir():
224
301
  return
225
- for f in template_dir.rglob("*"):
302
+ for f in pages_dir.rglob("*"):
226
303
  if not f.is_file() or f.suffix not in (".twig", ".html"):
227
304
  continue
228
- rel = str(f.relative_to(template_dir)).replace("\\", "/")
229
- url_path = rel.rsplit(".", 1)[0]
305
+ # Skip private files even within pages/ (e.g. pages/_helper.twig)
306
+ rel_inside_pages = f.relative_to(pages_dir)
307
+ if any(p.startswith("_") for p in rel_inside_pages.parts):
308
+ continue
309
+ rel = str(f.relative_to(Path("src/templates"))).replace("\\", "/")
310
+ url_path = str(rel_inside_pages).replace("\\", "/").rsplit(".", 1)[0]
230
311
  if url_path not in _template_cache:
231
312
  _template_cache[url_path] = rel
232
313
 
@@ -920,7 +1001,7 @@ def _check_auth(request: Request, response: Response, route: dict) -> bool:
920
1001
  if not route.get("auth_required"):
921
1002
  return False
922
1003
  _auth_header = request.headers.get("authorization", "")
923
- _api_key = os.environ.get("TINA4_API_KEY", os.environ.get("API_KEY", ""))
1004
+ _api_key = os.environ.get("TINA4_API_KEY", "")
924
1005
  _auth_ok = False
925
1006
  if _auth_header and _auth_header.startswith("Bearer "):
926
1007
  _token = _auth_header[7:]
@@ -1076,7 +1157,19 @@ def _handle_route_error(
1076
1157
 
1077
1158
 
1078
1159
  def _handle_no_route(request: Request, response: Response, request_id: str) -> Response:
1079
- """Serve static files, templates, landing page, or 404."""
1160
+ """Serve static files, templates, landing page, or 404.
1161
+
1162
+ Lookup order at any URL with no registered route:
1163
+ 1. Static file (public/, src/public/, framework public/, with /
1164
+ resolving to index.html so SPAs Just Work)
1165
+ 2. Auto-routed template from src/templates/pages/ (gated by
1166
+ TINA4_TEMPLATE_ROUTING)
1167
+ 3. Framework landing page — only at "/", and only in dev
1168
+ (``TINA4_DEBUG=true``). Production never shows it, so the
1169
+ framework version, dev-admin link, and gallery never leak
1170
+ to real users.
1171
+ 4. 404
1172
+ """
1080
1173
  static = _try_static(request.path)
1081
1174
  if static:
1082
1175
  return static
@@ -1085,7 +1178,7 @@ def _handle_no_route(request: Request, response: Response, request_id: str) -> R
1085
1178
  from tina4_python.core.response import get_frond
1086
1179
  html = get_frond().render(tpl_file, {})
1087
1180
  response.html(html)
1088
- elif request.path == "/":
1181
+ elif request.path == "/" and _is_dev_mode():
1089
1182
  response.html(_render_landing_page())
1090
1183
  else:
1091
1184
  html = _render_error_page(404, request.path, request_id)
@@ -1324,8 +1417,16 @@ def _try_static(path: str) -> Response | None:
1324
1417
  2. public/ (simple, IDE-friendly)
1325
1418
  3. src/public/ (nested convention)
1326
1419
  4. tina4_python/public/ (framework built-in assets)
1420
+
1421
+ Index resolution: when ``path`` is ``/`` or ends with ``/``, the lookup
1422
+ appends ``index.html`` so a Vite/SPA build with ``src/public/index.html``
1423
+ serves at the matching URL — no custom ``@get("/")`` route needed.
1327
1424
  """
1328
1425
  clean = path.lstrip("/")
1426
+ # Index resolution: '/' or '/foo/' -> append 'index.html' so SPA builds
1427
+ # in src/public/ Just Work without a custom root route.
1428
+ if clean == "" or clean.endswith("/"):
1429
+ clean = clean + "index.html"
1329
1430
  custom = os.environ.get("TINA4_PUBLIC_DIR")
1330
1431
  candidates = []
1331
1432
  if custom:
@@ -1575,6 +1676,69 @@ def _print_banner(host: str, port: int, server_name: str = "asyncio", ai_port: i
1575
1676
  print(banner)
1576
1677
 
1577
1678
 
1679
+ # Legacy env var names that v3.12 has retired. If any of these are set in
1680
+ # the environment we refuse to boot — silently ignoring them would cause
1681
+ # auth/db/mail to fall back to defaults with no warning. Each maps to its
1682
+ # new TINA4_-prefixed canonical name (or DROPPED for deleted features).
1683
+ _LEGACY_ENV_VARS: dict[str, str] = {
1684
+ "DATABASE_URL": "TINA4_DATABASE_URL",
1685
+ "DATABASE_USERNAME": "TINA4_DATABASE_USERNAME",
1686
+ "DATABASE_PASSWORD": "TINA4_DATABASE_PASSWORD",
1687
+ "DB_URL": "TINA4_DATABASE_URL",
1688
+ "SECRET": "TINA4_SECRET",
1689
+ "API_KEY": "TINA4_API_KEY",
1690
+ "JWT_ALGORITHM": "TINA4_JWT_ALGORITHM",
1691
+ "SMTP_HOST": "TINA4_MAIL_HOST",
1692
+ "SMTP_PORT": "TINA4_MAIL_PORT",
1693
+ "SMTP_USERNAME": "TINA4_MAIL_USERNAME",
1694
+ "SMTP_PASSWORD": "TINA4_MAIL_PASSWORD",
1695
+ "SMTP_FROM": "TINA4_MAIL_FROM",
1696
+ "SMTP_FROM_NAME": "TINA4_MAIL_FROM_NAME",
1697
+ "IMAP_HOST": "TINA4_MAIL_IMAP_HOST",
1698
+ "IMAP_PORT": "TINA4_MAIL_IMAP_PORT",
1699
+ "IMAP_USER": "TINA4_MAIL_IMAP_USERNAME",
1700
+ "IMAP_PASS": "TINA4_MAIL_IMAP_PASSWORD",
1701
+ "HOST_NAME": "TINA4_HOST_NAME",
1702
+ "SWAGGER_TITLE": "TINA4_SWAGGER_TITLE",
1703
+ "SWAGGER_DESCRIPTION": "TINA4_SWAGGER_DESCRIPTION",
1704
+ "SWAGGER_VERSION": "TINA4_SWAGGER_VERSION",
1705
+ "ORM_PLURAL_TABLE_NAMES": "TINA4_ORM_PLURAL_TABLE_NAMES",
1706
+ }
1707
+
1708
+
1709
+ def _check_legacy_env_vars() -> None:
1710
+ """Refuse to boot if pre-3.12 un-prefixed env vars are still set.
1711
+
1712
+ Tina4 v3.12 hard-renamed every framework-specific env var to use the
1713
+ ``TINA4_`` prefix. Booting silently with a legacy ``DATABASE_URL`` or
1714
+ ``SECRET`` would let auth, DB, or mail fall back to insecure defaults
1715
+ while the user thought their config was being read. Better to die
1716
+ loudly with a list of names to fix.
1717
+
1718
+ Bypass with ``TINA4_ALLOW_LEGACY_ENV=true`` in CI / migration scripts
1719
+ that genuinely need both names set during a transition window.
1720
+ """
1721
+ if os.environ.get("TINA4_ALLOW_LEGACY_ENV", "").lower() in ("true", "1", "yes"):
1722
+ return
1723
+ found = sorted(name for name in _LEGACY_ENV_VARS if name in os.environ)
1724
+ if not found:
1725
+ return
1726
+ msg = ["", "─" * 72,
1727
+ "Tina4 v3.12 requires TINA4_ prefix on all framework env vars.",
1728
+ "Your environment still has these legacy names:",
1729
+ ""]
1730
+ for old in found:
1731
+ new = _LEGACY_ENV_VARS[old]
1732
+ msg.append(f" {old:<28} → {new}")
1733
+ msg.extend(["",
1734
+ "Run `tina4 env-migrate` to rewrite your .env automatically,",
1735
+ "or rename manually. See https://tina4.com/release/3.12.0",
1736
+ "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
1737
+ "─" * 72, ""])
1738
+ print("\n".join(msg), file=sys.stderr)
1739
+ sys.exit(2)
1740
+
1741
+
1578
1742
  def run(host: str | None = None, port: int | None = None, no_browser: bool = False, no_reload: bool = False):
1579
1743
  """Start the Tina4 dev server.
1580
1744
 
@@ -1590,6 +1754,9 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1590
1754
  global _start_time
1591
1755
  _start_time = time.time()
1592
1756
 
1757
+ # Refuse to boot with v3.11 / v2 era un-prefixed env vars set.
1758
+ _check_legacy_env_vars()
1759
+
1593
1760
  # ── Require tina4 CLI ─────────────────────────────────────────
1594
1761
  # The framework must be launched via `tina4 serve`, not `python app.py`.
1595
1762
  # The tina4 CLI passes --managed when spawning the server process.
@@ -1829,7 +1996,7 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1829
1996
  # Streaming mode — flush headers on first chunk, then write each chunk immediately
1830
1997
  if not _headers_sent:
1831
1998
  _headers_sent = True
1832
- writer.write(f"HTTP/1.1 {resp_status} OK\r\n".encode())
1999
+ writer.write(f"HTTP/1.1 {resp_status} {_http_reason(resp_status)}\r\n".encode())
1833
2000
  for name, value in resp_headers:
1834
2001
  writer.write(name + b": " + value + b"\r\n")
1835
2002
  writer.write(b"\r\n")
@@ -1849,7 +2016,7 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1849
2016
 
1850
2017
  # Write HTTP/1.1 response (only if headers weren't already sent by streaming)
1851
2018
  if not _headers_sent:
1852
- status_line = f"HTTP/1.1 {resp_status} OK\r\n"
2019
+ status_line = f"HTTP/1.1 {resp_status} {_http_reason(resp_status)}\r\n"
1853
2020
  writer.write(status_line.encode())
1854
2021
  for name, value in resp_headers:
1855
2022
  writer.write(name + b": " + value + b"\r\n")
@@ -140,10 +140,10 @@ class Database:
140
140
  """
141
141
 
142
142
  def __init__(self, url: str = None, username: str = "", password: str = "", pool: int = 0, **kwargs):
143
- self.url = url or os.environ.get("DATABASE_URL", "sqlite:///data/tina4.db")
143
+ self.url = url or os.environ.get("TINA4_DATABASE_URL", "sqlite:///data/tina4.db")
144
144
  # Priority: constructor params > env vars > empty
145
- self.username = username or os.environ.get("DATABASE_USERNAME", "")
146
- self.password = password or os.environ.get("DATABASE_PASSWORD", "")
145
+ self.username = username or os.environ.get("TINA4_DATABASE_USERNAME", "")
146
+ self.password = password or os.environ.get("TINA4_DATABASE_PASSWORD", "")
147
147
  self.pool_size = pool # 0 = single connection, N>0 = N pooled connections
148
148
  self._connect_kwargs = kwargs # Extra kwargs passed through to adapter.connect()
149
149
  self.last_error = None # Last execute() error message
@@ -671,7 +671,7 @@ class Database:
671
671
  return Database(url, username=username, password=password, pool=pool)
672
672
 
673
673
  @staticmethod
674
- def from_env(env_key: str = "DATABASE_URL", pool: int = 0) -> "Database | None":
674
+ def from_env(env_key: str = "TINA4_DATABASE_URL", pool: int = 0) -> "Database | None":
675
675
  """Construct a Database instance from environment variables.
676
676
 
677
677
  Reads the connection URL from the named env var (default DATABASE_URL),
@@ -687,8 +687,8 @@ class Database:
687
687
  url = os.environ.get(env_key)
688
688
  if not url:
689
689
  return None
690
- username = os.environ.get("DATABASE_USERNAME", "")
691
- password = os.environ.get("DATABASE_PASSWORD", "")
690
+ username = os.environ.get("TINA4_DATABASE_USERNAME", "")
691
+ password = os.environ.get("TINA4_DATABASE_PASSWORD", "")
692
692
  return Database(url, username=username, password=password, pool=pool)
693
693
 
694
694
  # ── Adapter / pool inspection ─────────────────────────────────