tina4-python 3.12.3__tar.gz → 3.12.4__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 (155) hide show
  1. {tina4_python-3.12.3 → tina4_python-3.12.4}/PKG-INFO +1 -1
  2. {tina4_python-3.12.3 → tina4_python-3.12.4}/pyproject.toml +1 -1
  3. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/core/server.py +31 -7
  5. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/database/connection.py +9 -1
  6. tina4_python-3.12.4/tina4_python/debug/__init__.py +406 -0
  7. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/dotenv/__init__.py +6 -2
  8. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/frond/engine.py +20 -5
  9. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/graphql/__init__.py +37 -1
  10. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/mcp/__init__.py +39 -1
  11. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/messenger/__init__.py +33 -5
  12. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/session/__init__.py +27 -3
  13. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/swagger/__init__.py +47 -7
  14. tina4_python-3.12.3/tina4_python/debug/__init__.py +0 -228
  15. {tina4_python-3.12.3 → tina4_python-3.12.4}/.gitignore +0 -0
  16. {tina4_python-3.12.3 → tina4_python-3.12.4}/README.md +0 -0
  17. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/CLAUDE.md +0 -0
  18. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/HtmlElement.py +0 -0
  19. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/Testing.py +0 -0
  20. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/ai/__init__.py +0 -0
  21. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/api/__init__.py +0 -0
  22. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/auth/__init__.py +0 -0
  23. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/cache/__init__.py +0 -0
  24. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/cli/__init__.py +0 -0
  25. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/container/__init__.py +0 -0
  26. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/core/__init__.py +0 -0
  27. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/core/cache.py +0 -0
  28. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/core/constants.py +0 -0
  29. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/core/events.py +0 -0
  30. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/core/middleware.py +0 -0
  31. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/core/rate_limiter.py +0 -0
  32. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/core/request.py +0 -0
  33. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/core/response.py +0 -0
  34. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/core/router.py +0 -0
  35. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/crud/__init__.py +0 -0
  36. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/database/__init__.py +0 -0
  37. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/database/adapter.py +0 -0
  38. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/database/firebird.py +0 -0
  39. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/database/mongodb.py +0 -0
  40. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/database/mssql.py +0 -0
  41. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/database/mysql.py +0 -0
  42. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/database/odbc.py +0 -0
  43. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/database/postgres.py +0 -0
  44. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/database/sqlite.py +0 -0
  45. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/debug/error_overlay.py +0 -0
  46. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/dev_admin/__init__.py +0 -0
  47. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/dev_admin/metrics.py +0 -0
  48. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/dev_admin/plan.py +0 -0
  49. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/dev_admin/project_index.py +0 -0
  50. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/docs.py +0 -0
  51. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/frond/FROND.md +0 -0
  52. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/frond/__init__.py +0 -0
  53. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/auth/meta.json +0 -0
  54. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  55. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/database/meta.json +0 -0
  56. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  57. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/error-overlay/meta.json +0 -0
  58. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  59. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/orm/meta.json +0 -0
  60. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  61. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  62. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/queue/meta.json +0 -0
  63. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  64. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/rest-api/meta.json +0 -0
  65. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  66. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/templates/meta.json +0 -0
  67. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  68. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  69. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/i18n/__init__.py +0 -0
  70. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/mcp/protocol.py +0 -0
  71. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/mcp/tools.py +0 -0
  72. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/migration/__init__.py +0 -0
  73. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/migration/runner.py +0 -0
  74. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/orm/__init__.py +0 -0
  75. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/orm/fields.py +0 -0
  76. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/orm/model.py +0 -0
  77. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/css/tina4.css +0 -0
  78. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/css/tina4.min.css +0 -0
  79. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/favicon.ico +0 -0
  80. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/images/logo.svg +0 -0
  81. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  82. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/js/frond.js +0 -0
  83. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/js/frond.min.js +0 -0
  84. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  85. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  86. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/js/tina4.min.js +0 -0
  87. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/js/tina4js.min.js +0 -0
  88. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/swagger/index.html +0 -0
  89. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  90. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/query_builder/__init__.py +0 -0
  91. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/queue/__init__.py +0 -0
  92. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/queue/job.py +0 -0
  93. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/queue/kafka_backend.py +0 -0
  94. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/queue/lite_backend.py +0 -0
  95. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/queue/mongo_backend.py +0 -0
  96. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/queue/rabbitmq_backend.py +0 -0
  97. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/queue_backends/__init__.py +0 -0
  98. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/queue_backends/kafka_backend.py +0 -0
  99. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/queue_backends/mongo_backend.py +0 -0
  100. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  101. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/__init__.py +0 -0
  102. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  103. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_badges.scss +0 -0
  104. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  105. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_cards.scss +0 -0
  106. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_forms.scss +0 -0
  107. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_grid.scss +0 -0
  108. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_modals.scss +0 -0
  109. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_nav.scss +0 -0
  110. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_reset.scss +0 -0
  111. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_tables.scss +0 -0
  112. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_typography.scss +0 -0
  113. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  114. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/_variables.scss +0 -0
  115. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/base.scss +0 -0
  116. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/colors.scss +0 -0
  117. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/scss/tina4css/tina4.scss +0 -0
  118. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/seeder/__init__.py +0 -0
  119. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/service/__init__.py +0 -0
  120. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/components/crud.twig +0 -0
  125. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  126. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  127. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/docker/python/Dockerfile +0 -0
  128. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  129. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/errors/302.twig +0 -0
  130. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/errors/401.twig +0 -0
  131. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/errors/403.twig +0 -0
  132. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/errors/404.twig +0 -0
  133. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/errors/500.twig +0 -0
  134. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/errors/502.twig +0 -0
  135. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/errors/503.twig +0 -0
  136. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/errors/base.twig +0 -0
  137. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/frontend/README.md +0 -0
  138. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/templates/readme.md +0 -0
  139. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/test_client/__init__.py +0 -0
  140. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  141. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  142. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/validator/__init__.py +0 -0
  153. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/websocket/__init__.py +0 -0
  154. {tina4_python-3.12.3 → tina4_python-3.12.4}/tina4_python/websocket/backplane.py +0 -0
  155. {tina4_python-3.12.3 → tina4_python-3.12.4}/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.12.3
3
+ Version: 3.12.4
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.12.3"
3
+ version = "3.12.4"
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.12.3"
11
+ __version__ = "3.12.4"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -150,8 +150,14 @@ async def _health_handler(request: Request, response: Response) -> Response:
150
150
  return response.status(code).json(health)
151
151
 
152
152
 
153
- # Register health check
154
- Router.add("GET", "/health", _health_handler)
153
+ # Register health check.
154
+ # TINA4_HEALTH_PATH overrides the URL path. We also keep /health registered
155
+ # under the env path; if the env path differs we register both so existing
156
+ # probes don't break. Default "/__health" matches PHP/Ruby/Node parity.
157
+ _HEALTH_PATH = os.environ.get("TINA4_HEALTH_PATH", "/__health")
158
+ Router.add("GET", _HEALTH_PATH, _health_handler)
159
+ if _HEALTH_PATH != "/health":
160
+ Router.add("GET", "/health", _health_handler)
155
161
 
156
162
 
157
163
  def _render_error_page(status_code: int, path: str, request_id: str, error_message: str = "") -> str | None:
@@ -1289,6 +1295,17 @@ async def handle(request: Request) -> Response:
1289
1295
  from tina4_python.dotenv import is_truthy
1290
1296
  _is_dev = is_truthy(os.environ.get("TINA4_DEBUG", ""))
1291
1297
 
1298
+ # Trailing-slash redirect — when TINA4_TRAILING_SLASH_REDIRECT=true and a
1299
+ # request arrives at `/foo/`, return 301 to `/foo`. Skip the root `/` so
1300
+ # the homepage still works. Cross-framework parity v3.12.4.
1301
+ if (
1302
+ is_truthy(os.environ.get("TINA4_TRAILING_SLASH_REDIRECT", ""))
1303
+ and len(request.path) > 1
1304
+ and request.path.endswith("/")
1305
+ ):
1306
+ canonical = request.path.rstrip("/") or "/"
1307
+ return response.status(301).header("location", canonical)
1308
+
1292
1309
  # Dev admin — also catches /ai/api/chat (SPA's ollama proxy) and the
1293
1310
  # bare /ai /vision /embed /image /rag service-health probes that
1294
1311
  # drive the "SERVICES ●●●●●" dots in the dev-admin UI.
@@ -1629,11 +1646,14 @@ def resolve_config(cli_host: str | None = None, cli_port: int | None = None) ->
1629
1646
  default_host = "0.0.0.0"
1630
1647
  default_port = 7146
1631
1648
 
1632
- # Host: CLI flag > HOST env > default
1649
+ # Host: CLI flag > TINA4_HOST env > HOST env > default.
1650
+ # TINA4_HOST takes precedence over the legacy plain HOST so a stray
1651
+ # OS-level HOST (common on shared CI runners) can't silently override
1652
+ # the framework's bind address. See cross-framework v3.12.4 plan.
1633
1653
  if cli_host is not None:
1634
1654
  host = cli_host
1635
1655
  else:
1636
- host = os.environ.get("HOST", default_host)
1656
+ host = os.environ.get("TINA4_HOST") or os.environ.get("HOST", default_host)
1637
1657
 
1638
1658
  # Port: CLI flag > PORT env > default
1639
1659
  if cli_port is not None:
@@ -1731,7 +1751,7 @@ def _check_legacy_env_vars() -> None:
1731
1751
  new = _LEGACY_ENV_VARS[old]
1732
1752
  msg.append(f" {old:<28} → {new}")
1733
1753
  msg.extend(["",
1734
- "Run `tina4 env-migrate` to rewrite your .env automatically,",
1754
+ "Run `tina4 env --migrate` to rewrite your .env automatically,",
1735
1755
  "or rename manually. See https://tina4.com/release/3.12.0",
1736
1756
  "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
1737
1757
  "─" * 72, ""])
@@ -1869,8 +1889,12 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1869
1889
  _no_ai_port = os.environ.get("TINA4_NO_AI_PORT", "").lower() in ("true", "1", "yes")
1870
1890
  _ai_port = (port + 1000) if (is_debug and not _no_ai_port) else None
1871
1891
 
1872
- # Banner — printed directly to stdout, not through the logger
1873
- _print_banner(host, port, server_name, ai_port=_ai_port)
1892
+ # Banner — printed directly to stdout, not through the logger.
1893
+ # TINA4_SUPPRESS=true silences the startup banner (useful in CI / Docker
1894
+ # logs where the ASCII art is just noise). Cross-framework parity v3.12.4.
1895
+ from tina4_python.dotenv import is_truthy as _is_truthy
1896
+ if not _is_truthy(os.environ.get("TINA4_SUPPRESS", "")):
1897
+ _print_banner(host, port, server_name, ai_port=_ai_port)
1874
1898
 
1875
1899
  display = "localhost" if host in ("0.0.0.0", "::") else host
1876
1900
  Log.info(f"Server started http://{display}:{port} ({server_name})")
@@ -144,7 +144,15 @@ class Database:
144
144
  # Priority: constructor params > env vars > empty
145
145
  self.username = username or os.environ.get("TINA4_DATABASE_USERNAME", "")
146
146
  self.password = password or os.environ.get("TINA4_DATABASE_PASSWORD", "")
147
- self.pool_size = pool # 0 = single connection, N>0 = N pooled connections
147
+ # Pool size caller's explicit value wins; otherwise honour
148
+ # TINA4_DB_POOL so deployments can flip pooling on without code
149
+ # changes. 0 = single connection, N>0 = N pooled connections.
150
+ if pool == 0:
151
+ try:
152
+ pool = int(os.environ.get("TINA4_DB_POOL", "0"))
153
+ except (TypeError, ValueError):
154
+ pool = 0
155
+ self.pool_size = pool
148
156
  self._connect_kwargs = kwargs # Extra kwargs passed through to adapter.connect()
149
157
  self.last_error = None # Last execute() error message
150
158
  self._last_id = None # Last insert ID from execute/insert
@@ -0,0 +1,406 @@
1
+ # Tina4 Debug — Structured logging with rotation.
2
+ """
3
+ Zero-dependency structured logger.
4
+
5
+ from tina4_python.debug import Log
6
+
7
+ Log.info("Request completed", method="GET", path="/api/users", duration_ms=45)
8
+ Log.error("Database failed", error="connection refused")
9
+
10
+ Production: JSON lines → logs/tina4.log (with rotation)
11
+ Development: Human-readable → stdout + logs/tina4.log
12
+
13
+ Environment variables (all optional — defaults match v2 behaviour):
14
+ TINA4_LOG_FILE Log filename. Empty string = stdout only.
15
+ TINA4_LOG_DIR Directory for log files (default: "logs"). Joined
16
+ with TINA4_LOG_FILE unless that is absolute.
17
+ TINA4_LOG_FORMAT "text" (default) or "json".
18
+ TINA4_LOG_OUTPUT "stdout" (default), "file", or "both".
19
+ TINA4_LOG_ROTATE_SIZE Bytes per file before rotation. Default 10 MB.
20
+ Set to 0 to disable rotation.
21
+ TINA4_LOG_ROTATE_KEEP Number of rotated files to keep (default: 5).
22
+ TINA4_LOG_CRITICAL When truthy, Log.critical(...) is accepted and
23
+ mapped to error level. Default: false.
24
+ TINA4_LOG_MAX_SIZE [legacy] Megabytes per file. Used only when
25
+ TINA4_LOG_ROTATE_SIZE is unset (back-compat).
26
+ TINA4_LOG_KEEP [legacy] Alias for TINA4_LOG_ROTATE_KEEP.
27
+ """
28
+ import os
29
+ import re
30
+ import json
31
+ import logging
32
+ import threading
33
+ from datetime import datetime, timezone
34
+ from logging.handlers import RotatingFileHandler
35
+ from pathlib import Path
36
+
37
+
38
+ # Request ID context (set per-request by middleware)
39
+ _request_id_var = threading.local()
40
+
41
+ # Regex to strip ANSI escape codes
42
+ _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
43
+
44
+
45
+ def set_request_id(request_id: str):
46
+ """Set the current request ID (called by middleware)."""
47
+ _request_id_var.id = request_id
48
+
49
+
50
+ def get_request_id() -> str | None:
51
+ """Get the current request ID."""
52
+ return getattr(_request_id_var, "id", None)
53
+
54
+
55
+ def _strip_ansi(text: str) -> str:
56
+ """Remove ANSI escape codes from text."""
57
+ return _ANSI_RE.sub("", text)
58
+
59
+
60
+ def _is_truthy(val) -> bool:
61
+ return str(val or "").strip().lower() in ("true", "1", "yes", "on")
62
+
63
+
64
+ class _LogWriter:
65
+ """File writer with numbered rotation support — used as the default
66
+ fallback when TINA4_LOG_FILE is unset (legacy "logs/tina4.log" path).
67
+
68
+ Rotation scheme:
69
+ tina4.log → tina4.log.1 → tina4.log.2 → ... → tina4.log.{keep}
70
+ """
71
+
72
+ def __init__(self, log_dir: str = "logs", filename: str = "tina4.log",
73
+ max_size_mb: int = 10, keep: int = 5):
74
+ self.log_dir = Path(log_dir)
75
+ self.filename = filename
76
+ self.max_size = max_size_mb * 1024 * 1024
77
+ self.keep = keep
78
+ self._lock = threading.Lock()
79
+ self._ensure_dir()
80
+
81
+ def _ensure_dir(self):
82
+ self.log_dir.mkdir(parents=True, exist_ok=True)
83
+
84
+ def _log_path(self) -> Path:
85
+ return self.log_dir / self.filename
86
+
87
+ def _rotate_if_needed(self):
88
+ log_path = self._log_path()
89
+
90
+ if not log_path.exists():
91
+ return
92
+
93
+ try:
94
+ if log_path.stat().st_size < self.max_size:
95
+ return
96
+ except OSError:
97
+ return
98
+
99
+ # Delete the oldest rotated file if it exists
100
+ oldest = self.log_dir / f"{self.filename}.{self.keep}"
101
+ if oldest.exists():
102
+ try:
103
+ oldest.unlink()
104
+ except OSError:
105
+ pass
106
+
107
+ # Shift existing rotated files: .{n} → .{n+1}
108
+ for n in range(self.keep - 1, 0, -1):
109
+ src = self.log_dir / f"{self.filename}.{n}"
110
+ dst = self.log_dir / f"{self.filename}.{n + 1}"
111
+ if src.exists():
112
+ try:
113
+ src.rename(dst)
114
+ except OSError:
115
+ pass
116
+
117
+ # Rename current log to .1
118
+ try:
119
+ log_path.rename(self.log_dir / f"{self.filename}.1")
120
+ except OSError:
121
+ pass
122
+
123
+ def write(self, line: str):
124
+ """Write a line to the log file, stripping ANSI codes. Rotates if needed."""
125
+ clean_line = _strip_ansi(line)
126
+ with self._lock:
127
+ self._rotate_if_needed()
128
+ log_path = self._log_path()
129
+ try:
130
+ with open(log_path, "a", encoding="utf-8") as f:
131
+ f.write(clean_line + "\n")
132
+ except OSError:
133
+ pass # Can't write logs — don't crash the app
134
+
135
+
136
+ class _StdlibFileWriter:
137
+ """Adapter around stdlib logging.handlers.RotatingFileHandler / FileHandler.
138
+
139
+ Used when TINA4_LOG_FILE is set explicitly. Keeps the same .write(line)
140
+ interface as _LogWriter so the Log class doesn't care which backend
141
+ produced the file output.
142
+ """
143
+
144
+ def __init__(self, path: Path, max_bytes: int, backup_count: int):
145
+ # Resolve dir up-front so callers can fail fast on a bad path.
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+ self.path = path
148
+ if max_bytes > 0:
149
+ self._handler = RotatingFileHandler(
150
+ str(path),
151
+ maxBytes=max_bytes,
152
+ backupCount=backup_count,
153
+ encoding="utf-8",
154
+ )
155
+ else:
156
+ # 0 disables rotation — use a plain FileHandler so the file just
157
+ # grows. Matches the documented contract for TINA4_LOG_ROTATE_SIZE=0.
158
+ self._handler = logging.FileHandler(str(path), encoding="utf-8")
159
+ # Bare formatter — Log already builds the full line itself.
160
+ self._handler.setFormatter(logging.Formatter("%(message)s"))
161
+ self._lock = threading.Lock()
162
+
163
+ def write(self, line: str):
164
+ clean = _strip_ansi(line)
165
+ record = logging.LogRecord(
166
+ name="tina4",
167
+ level=logging.INFO,
168
+ pathname="",
169
+ lineno=0,
170
+ msg=clean,
171
+ args=(),
172
+ exc_info=None,
173
+ )
174
+ with self._lock:
175
+ try:
176
+ self._handler.emit(record)
177
+ self._handler.flush()
178
+ except OSError:
179
+ pass
180
+
181
+ def close(self):
182
+ try:
183
+ self._handler.close()
184
+ except Exception:
185
+ pass
186
+
187
+
188
+ class Log:
189
+ """Structured logger with request ID tracking and log rotation."""
190
+
191
+ _writer: _LogWriter | _StdlibFileWriter | None = None
192
+ _error_writer: _LogWriter | None = None
193
+ _level: str = "info"
194
+ _is_production: bool = False
195
+ _initialized: bool = False
196
+ # Output toggles — driven by TINA4_LOG_OUTPUT.
197
+ _stdout_enabled: bool = True
198
+ _file_enabled: bool = True
199
+ # Format — "text" or "json". Independent of _is_production so users
200
+ # can opt into JSON in dev too (TINA4_LOG_FORMAT=json). Stored under
201
+ # _format_mode so it doesn't clash with the legacy _format() method
202
+ # name kept below for backward compatibility.
203
+ _format_mode: str = "text"
204
+ # Whether Log.critical() is accepted (TINA4_LOG_CRITICAL).
205
+ _critical_enabled: bool = False
206
+
207
+ LEVELS = {"debug": 0, "info": 1, "warning": 2, "error": 3}
208
+
209
+ @classmethod
210
+ def configure(cls, log_dir: str = "logs", level: str = "info",
211
+ production: bool = False):
212
+ """Configure the logger. Called once at startup.
213
+
214
+ Reads the new TINA4_LOG_* env vars (rotation size/keep, format,
215
+ output, file, dir, critical) so individual deployments can tune
216
+ the logger without code changes. Defaults preserve existing
217
+ behaviour: file output to logs/tina4.log + stdout, text format,
218
+ 10 MB rotation, keep 5.
219
+ """
220
+ cls._level = level.lower()
221
+ cls._is_production = production
222
+
223
+ # ── Output channels ──────────────────────────────────────
224
+ output = os.environ.get("TINA4_LOG_OUTPUT", "stdout").lower().strip()
225
+ if output == "file":
226
+ cls._stdout_enabled = False
227
+ cls._file_enabled = True
228
+ elif output == "both":
229
+ cls._stdout_enabled = True
230
+ cls._file_enabled = True
231
+ else:
232
+ # "stdout" (default) — but we still keep file output on for
233
+ # parity with v2 behaviour where logs/tina4.log is always
234
+ # written. Operators who want stdout-only can flip the new
235
+ # explicit "stdout-only" by setting TINA4_LOG_FILE="" AND
236
+ # TINA4_LOG_OUTPUT=stdout — handled below where the file
237
+ # path resolves to empty.
238
+ cls._stdout_enabled = True
239
+ cls._file_enabled = True
240
+
241
+ # ── Format ───────────────────────────────────────────────
242
+ fmt = os.environ.get("TINA4_LOG_FORMAT", "text").lower().strip()
243
+ cls._format_mode = "json" if fmt == "json" else "text"
244
+
245
+ # ── Critical level toggle ────────────────────────────────
246
+ cls._critical_enabled = _is_truthy(os.environ.get("TINA4_LOG_CRITICAL"))
247
+
248
+ # ── Rotation config ──────────────────────────────────────
249
+ # New-style: TINA4_LOG_ROTATE_SIZE in BYTES (0 = disabled).
250
+ # Legacy: TINA4_LOG_MAX_SIZE in MEGABYTES.
251
+ rotate_size_env = os.environ.get("TINA4_LOG_ROTATE_SIZE")
252
+ if rotate_size_env is not None:
253
+ try:
254
+ rotate_bytes = int(rotate_size_env)
255
+ except ValueError:
256
+ rotate_bytes = 10 * 1024 * 1024
257
+ else:
258
+ try:
259
+ rotate_bytes = int(os.environ.get("TINA4_LOG_MAX_SIZE", "10")) * 1024 * 1024
260
+ except ValueError:
261
+ rotate_bytes = 10 * 1024 * 1024
262
+
263
+ keep_env = os.environ.get("TINA4_LOG_ROTATE_KEEP", os.environ.get("TINA4_LOG_KEEP", "5"))
264
+ try:
265
+ keep = int(keep_env)
266
+ except ValueError:
267
+ keep = 5
268
+
269
+ # ── File path resolution ─────────────────────────────────
270
+ log_file = os.environ.get("TINA4_LOG_FILE", "")
271
+ log_dir_env = os.environ.get("TINA4_LOG_DIR", log_dir)
272
+
273
+ # Close any previous writer so reconfigure during tests doesn't
274
+ # leak file handles.
275
+ if isinstance(cls._writer, _StdlibFileWriter):
276
+ cls._writer.close()
277
+ cls._writer = None
278
+
279
+ if log_file:
280
+ # Explicit log file path. Honour absolute paths verbatim;
281
+ # otherwise join with TINA4_LOG_DIR per spec.
282
+ if os.path.isabs(log_file):
283
+ resolved = Path(log_file)
284
+ else:
285
+ resolved = Path(log_dir_env) / log_file
286
+ cls._writer = _StdlibFileWriter(resolved, rotate_bytes, keep)
287
+ elif cls._file_enabled:
288
+ # Default behaviour: keep the existing logs/tina4.log writer
289
+ # so v2 deployments don't need to change a thing.
290
+ mb = max(1, rotate_bytes // (1024 * 1024)) if rotate_bytes > 0 else 10
291
+ cls._writer = _LogWriter(log_dir_env, "tina4.log", mb, keep)
292
+
293
+ # Error mirror — only when no explicit TINA4_LOG_FILE is set.
294
+ # When the operator points at a custom file they almost certainly
295
+ # don't want a second sibling error.log appearing alongside it.
296
+ if not log_file and cls._file_enabled:
297
+ mb = max(1, rotate_bytes // (1024 * 1024)) if rotate_bytes > 0 else 10
298
+ cls._error_writer = _LogWriter(log_dir_env, "error.log", mb, keep)
299
+ else:
300
+ cls._error_writer = None
301
+
302
+ cls._initialized = True
303
+
304
+ @classmethod
305
+ def _should_log(cls, level: str) -> bool:
306
+ return cls.LEVELS.get(level, 0) >= cls.LEVELS.get(cls._level, 0)
307
+
308
+ # ANSI color codes for dev mode (matching PHP reference)
309
+ COLORS = {
310
+ "debug": "\033[36m", # Cyan
311
+ "info": "\033[32m", # Green
312
+ "warning": "\033[33m", # Yellow
313
+ "error": "\033[31m", # Red
314
+ }
315
+ RESET = "\033[0m"
316
+
317
+ @classmethod
318
+ def _timestamp(cls) -> str:
319
+ """ISO 8601 UTC timestamp with milliseconds: YYYY-MM-DDTHH:MM:SS.mmmZ"""
320
+ now = datetime.now(timezone.utc)
321
+ return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
322
+
323
+ @classmethod
324
+ def _format_line(cls, level: str, message: str, **kwargs) -> str:
325
+ timestamp = cls._timestamp()
326
+ request_id = get_request_id()
327
+
328
+ # JSON format wins whenever the explicit env opt-in is set,
329
+ # regardless of production flag (so dev devs can ship JSON to
330
+ # `jq` if they prefer).
331
+ if cls._format_mode == "json" or cls._is_production:
332
+ entry = {
333
+ "timestamp": timestamp,
334
+ "level": level.upper(),
335
+ "message": message,
336
+ }
337
+ if request_id:
338
+ entry["request_id"] = request_id
339
+ if kwargs:
340
+ entry["context"] = {k: v for k, v in kwargs.items()}
341
+ return json.dumps(entry, default=str)
342
+
343
+ # Human-readable for development
344
+ level_str = level.upper().ljust(7)
345
+ parts = [timestamp, f"[{level_str}]"]
346
+ if request_id:
347
+ parts.append(f"[{request_id}]")
348
+ parts.append(message)
349
+ if kwargs:
350
+ parts.append(json.dumps(kwargs, default=str))
351
+ return " ".join(parts)
352
+
353
+ # Kept under the old name so any external code calling Log._format
354
+ # (e.g. tests) still works.
355
+ @classmethod
356
+ def _format(cls, level: str, message: str, **kwargs) -> str:
357
+ return cls._format_line(level, message, **kwargs)
358
+
359
+ @classmethod
360
+ def _log(cls, level: str, message: str, **kwargs):
361
+ # File always gets ALL levels (no filtering for file output)
362
+ line = cls._format_line(level, message, **kwargs)
363
+
364
+ # Console output respects TINA4_LOG_LEVEL and the stdout toggle
365
+ if cls._stdout_enabled and not cls._is_production and cls._should_log(level):
366
+ color = cls.COLORS.get(level, "")
367
+ print(f"{color}{line}{cls.RESET}")
368
+
369
+ # Always write ALL levels to the main file (raw log, no filtering)
370
+ if cls._writer:
371
+ cls._writer.write(line)
372
+
373
+ # Mirror WARNING and ERROR into the dedicated error log so
374
+ # `tail -f logs/error.log` gives just the stuff worth looking
375
+ # at, without wading through DEBUG / INFO noise. Parity with
376
+ # tina4-php's Log class.
377
+ if cls._error_writer and cls.LEVELS.get(level, 0) >= cls.LEVELS["warning"]:
378
+ cls._error_writer.write(line)
379
+
380
+ @classmethod
381
+ def debug(cls, message: str, **kwargs):
382
+ cls._log("debug", message, **kwargs)
383
+
384
+ @classmethod
385
+ def info(cls, message: str, **kwargs):
386
+ cls._log("info", message, **kwargs)
387
+
388
+ @classmethod
389
+ def warning(cls, message: str, **kwargs):
390
+ cls._log("warning", message, **kwargs)
391
+
392
+ @classmethod
393
+ def error(cls, message: str, **kwargs):
394
+ cls._log("error", message, **kwargs)
395
+
396
+ @classmethod
397
+ def critical(cls, message: str, **kwargs):
398
+ """Critical-level log — accepted only when TINA4_LOG_CRITICAL=true.
399
+
400
+ Maps to error so existing log consumers (alerting, error.log)
401
+ keep working. When the toggle is off the call is a no-op so
402
+ deployments that have standardised on debug/info/warning/error
403
+ don't get surprise log lines.
404
+ """
405
+ if cls._critical_enabled:
406
+ cls._log("error", message, **kwargs)
@@ -17,7 +17,7 @@ import os
17
17
  from pathlib import Path
18
18
 
19
19
 
20
- def load_env(file_path: str = ".env", override: bool = False) -> dict:
20
+ def load_env(file_path: str = None, override: bool = False) -> dict:
21
21
  """Load environment variables from a .env file.
22
22
 
23
23
  Supports:
@@ -31,12 +31,16 @@ def load_env(file_path: str = ".env", override: bool = False) -> dict:
31
31
  multi-word unquoted values
32
32
 
33
33
  Args:
34
- file_path: Path to .env file (default: ".env")
34
+ file_path: Path to .env file. When None, falls back to the
35
+ TINA4_ENV_FILE env var, then ".env". This lets ops point at
36
+ an alternate file (e.g. ".env.staging") without code changes.
35
37
  override: If True, overwrite existing env vars (default: False)
36
38
 
37
39
  Returns:
38
40
  Dict of loaded key-value pairs
39
41
  """
42
+ if file_path is None:
43
+ file_path = os.environ.get("TINA4_ENV_FILE", ".env")
40
44
  env_file = Path(file_path)
41
45
  loaded = {}
42
46
 
@@ -10,6 +10,7 @@ import html
10
10
  import hashlib
11
11
  import json
12
12
  import secrets
13
+ import time
13
14
  from functools import lru_cache
14
15
  from pathlib import Path
15
16
  from datetime import datetime
@@ -1270,10 +1271,20 @@ class Frond:
1270
1271
  # Fragment cache (key → (html, expires_at))
1271
1272
  self._fragment_cache: dict[str, tuple[str, float]] = {}
1272
1273
  # Token pre-compilation cache
1273
- self._compiled: dict[str, tuple[list, float]] = {} # {template_name: (tokens, mtime)}
1274
+ self._compiled: dict[str, tuple[list, float]] = {} # {template_name: (tokens, expires_at)}
1274
1275
  self._compiled_strings: dict[str, list] = {} # {md5_hash: tokens}
1275
1276
  # Filter chain cache: expr → (var_name, [(filter_name, [args])])
1276
1277
  self._filter_chain_cache: dict[str, tuple[str, list]] = {}
1278
+ # Compile-cache TTL — TINA4_TEMPLATE_CACHE_TTL in seconds.
1279
+ # 0 (default) means "no expiry" so the existing permanent-cache
1280
+ # behaviour is preserved. Anything >0 forces re-tokenisation
1281
+ # after that many seconds, which is handy for long-running
1282
+ # workers that want to pick up template edits without a full
1283
+ # restart but without paying the dev-mode "always re-read" tax.
1284
+ try:
1285
+ self._cache_ttl: int = int(os.environ.get("TINA4_TEMPLATE_CACHE_TTL", "0"))
1286
+ except (TypeError, ValueError):
1287
+ self._cache_ttl = 0
1277
1288
 
1278
1289
  # Built-in global functions
1279
1290
  self._globals["form_token"] = _form_token
@@ -1363,18 +1374,22 @@ class Frond:
1363
1374
  debug_mode = os.environ.get("TINA4_DEBUG", "").lower() == "true"
1364
1375
 
1365
1376
  if not debug_mode:
1366
- # Production: permanent cache, no filesystem checks
1377
+ # Production: cached. If TINA4_TEMPLATE_CACHE_TTL > 0 we honour
1378
+ # the expiry; otherwise the cache is permanent (legacy behaviour).
1367
1379
  cached = self._compiled.get(template)
1368
1380
  if cached is not None:
1369
- return self._execute_cached(cached[0], context, template)
1381
+ tokens_cached, expires_at = cached
1382
+ if self._cache_ttl <= 0 or time.time() < expires_at:
1383
+ return self._execute_cached(tokens_cached, context, template)
1370
1384
 
1371
- # Dev mode: skip cache entirely — always re-read and re-tokenize.
1385
+ # Dev mode (or expired entry): re-read and re-tokenize.
1372
1386
  # mtime-based invalidation doesn't catch changes to included/extended
1373
1387
  # templates (parent or partial changes don't update the caller's mtime).
1374
1388
  source = path.read_text(encoding="utf-8")
1375
1389
  tokens = _tokenize(source)
1376
1390
  if not debug_mode:
1377
- self._compiled[template] = (tokens, 0)
1391
+ expires_at = (time.time() + self._cache_ttl) if self._cache_ttl > 0 else 0
1392
+ self._compiled[template] = (tokens, expires_at)
1378
1393
  return self._execute_with_source(source, tokens, context, template)
1379
1394
 
1380
1395
  def render_string(self, source: str, data: dict = None) -> str:
@@ -23,6 +23,7 @@ Supported:
23
23
  - Error capture (resolver exceptions become GraphQL errors)
24
24
  """
25
25
  import json
26
+ import os
26
27
  import re
27
28
  from typing import Any
28
29
 
@@ -464,10 +465,45 @@ def _make_orm_delete_resolver(orm_class, pk_field):
464
465
  # ── Executor ──────────────────────────────────────────────────
465
466
 
466
467
  class GraphQL:
467
- """GraphQL engine — parse, validate, execute."""
468
+ """GraphQL engine — parse, validate, execute.
469
+
470
+ Env vars (cross-framework parity v3.12.4):
471
+ TINA4_GRAPHQL_AUTO_SCHEMA When truthy (default), discovered ORM
472
+ subclasses passed via auto_register()
473
+ are wired into the schema. Set to false
474
+ to opt out and build the schema manually.
475
+ TINA4_GRAPHQL_ENDPOINT Default URL path for the HTTP endpoint
476
+ (default: "/graphql"). Read by .endpoint.
477
+ """
468
478
 
469
479
  def __init__(self):
470
480
  self.schema = Schema()
481
+ # Default endpoint URL — env-overridable so deployments can mount
482
+ # GraphQL at e.g. /api/graphql without changing app code.
483
+ self.endpoint: str = os.environ.get("TINA4_GRAPHQL_ENDPOINT", "/graphql")
484
+ # Auto-schema toggle. The flag is stored on the instance so the
485
+ # outer app can introspect it (e.g. dev_admin shows "auto-schema:
486
+ # off" in the GraphQL panel) and so test fixtures can flip it
487
+ # without monkey-patching env.
488
+ self.auto_schema: bool = str(
489
+ os.environ.get("TINA4_GRAPHQL_AUTO_SCHEMA", "true")
490
+ ).strip().lower() in ("true", "1", "yes", "on")
491
+
492
+ def auto_register(self, *orm_classes) -> int:
493
+ """Wire each ORM class into the schema via from_orm().
494
+
495
+ No-op when TINA4_GRAPHQL_AUTO_SCHEMA is falsy. Returns the number
496
+ of classes actually registered. Callers (dev_admin, app bootstrap)
497
+ use this instead of looping themselves so the env-var gate is
498
+ honoured in one place.
499
+ """
500
+ if not self.auto_schema:
501
+ return 0
502
+ count = 0
503
+ for cls in orm_classes:
504
+ self.schema.from_orm(cls)
505
+ count += 1
506
+ return count
471
507
 
472
508
  def execute(self, query: str, variables: dict = None, context: dict = None) -> dict:
473
509
  """Execute a GraphQL query string. Returns {"data": ..., "errors": [...]}."""