tina4-python 3.12.5__tar.gz → 3.12.8__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.12.5 → tina4_python-3.12.8}/PKG-INFO +1 -1
  2. {tina4_python-3.12.5 → tina4_python-3.12.8}/pyproject.toml +1 -1
  3. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/core/router.py +93 -3
  5. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/core/server.py +37 -1
  6. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/database/postgres.py +26 -4
  7. {tina4_python-3.12.5 → tina4_python-3.12.8}/.gitignore +0 -0
  8. {tina4_python-3.12.5 → tina4_python-3.12.8}/README.md +0 -0
  9. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/CLAUDE.md +0 -0
  10. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/HtmlElement.py +0 -0
  11. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/Testing.py +0 -0
  12. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/ai/__init__.py +0 -0
  13. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/api/__init__.py +0 -0
  14. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/auth/__init__.py +0 -0
  15. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/cache/__init__.py +0 -0
  16. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/cli/__init__.py +0 -0
  17. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/container/__init__.py +0 -0
  18. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/core/__init__.py +0 -0
  19. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/core/cache.py +0 -0
  20. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/core/constants.py +0 -0
  21. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/core/events.py +0 -0
  22. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/core/middleware.py +0 -0
  23. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/core/rate_limiter.py +0 -0
  24. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/core/request.py +0 -0
  25. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/core/response.py +0 -0
  26. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/crud/__init__.py +0 -0
  27. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/database/__init__.py +0 -0
  28. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/database/adapter.py +0 -0
  29. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/database/connection.py +0 -0
  30. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/database/firebird.py +0 -0
  31. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/database/mongodb.py +0 -0
  32. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/database/mssql.py +0 -0
  33. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/database/mysql.py +0 -0
  34. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/database/odbc.py +0 -0
  35. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/database/sqlite.py +0 -0
  36. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/debug/__init__.py +0 -0
  37. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/debug/error_overlay.py +0 -0
  38. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/dev_admin/__init__.py +0 -0
  39. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/dev_admin/metrics.py +0 -0
  40. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/dev_admin/plan.py +0 -0
  41. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/dev_admin/project_index.py +0 -0
  42. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/docs.py +0 -0
  43. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/dotenv/__init__.py +0 -0
  44. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/frond/FROND.md +0 -0
  45. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/frond/__init__.py +0 -0
  46. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/frond/engine.py +0 -0
  47. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/auth/meta.json +0 -0
  48. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  49. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/database/meta.json +0 -0
  50. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  51. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/error-overlay/meta.json +0 -0
  52. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  53. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/orm/meta.json +0 -0
  54. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  55. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  56. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/queue/meta.json +0 -0
  57. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  58. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/rest-api/meta.json +0 -0
  59. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  60. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/templates/meta.json +0 -0
  61. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  62. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  63. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/graphql/__init__.py +0 -0
  64. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/i18n/__init__.py +0 -0
  65. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/mcp/__init__.py +0 -0
  66. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/mcp/protocol.py +0 -0
  67. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/mcp/tools.py +0 -0
  68. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/messenger/__init__.py +0 -0
  69. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/migration/__init__.py +0 -0
  70. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/migration/runner.py +0 -0
  71. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/orm/__init__.py +0 -0
  72. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/orm/fields.py +0 -0
  73. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/orm/model.py +0 -0
  74. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/css/tina4.css +0 -0
  75. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/css/tina4.min.css +0 -0
  76. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/favicon.ico +0 -0
  77. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/images/logo.svg +0 -0
  78. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  79. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/js/frond.js +0 -0
  80. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/js/frond.min.js +0 -0
  81. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  82. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  83. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/js/tina4.min.js +0 -0
  84. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/js/tina4js.min.js +0 -0
  85. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/swagger/index.html +0 -0
  86. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  87. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/query_builder/__init__.py +0 -0
  88. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/queue/__init__.py +0 -0
  89. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/queue/job.py +0 -0
  90. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/queue/kafka_backend.py +0 -0
  91. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/queue/lite_backend.py +0 -0
  92. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/queue/mongo_backend.py +0 -0
  93. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/queue/rabbitmq_backend.py +0 -0
  94. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/queue_backends/__init__.py +0 -0
  95. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/queue_backends/kafka_backend.py +0 -0
  96. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/queue_backends/mongo_backend.py +0 -0
  97. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  98. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/__init__.py +0 -0
  99. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  100. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_badges.scss +0 -0
  101. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  102. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_cards.scss +0 -0
  103. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_forms.scss +0 -0
  104. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_grid.scss +0 -0
  105. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_modals.scss +0 -0
  106. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_nav.scss +0 -0
  107. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_reset.scss +0 -0
  108. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_tables.scss +0 -0
  109. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_typography.scss +0 -0
  110. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  111. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/_variables.scss +0 -0
  112. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/base.scss +0 -0
  113. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/colors.scss +0 -0
  114. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/scss/tina4css/tina4.scss +0 -0
  115. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/seeder/__init__.py +0 -0
  116. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/service/__init__.py +0 -0
  117. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/session/__init__.py +0 -0
  118. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/session_handlers/__init__.py +0 -0
  119. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  120. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/session_handlers/redis_handler.py +0 -0
  121. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/session_handlers/valkey_handler.py +0 -0
  122. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/swagger/__init__.py +0 -0
  123. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/components/crud.twig +0 -0
  124. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  125. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  126. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/docker/python/Dockerfile +0 -0
  127. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  128. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/errors/302.twig +0 -0
  129. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/errors/401.twig +0 -0
  130. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/errors/403.twig +0 -0
  131. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/errors/404.twig +0 -0
  132. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/errors/500.twig +0 -0
  133. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/errors/502.twig +0 -0
  134. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/errors/503.twig +0 -0
  135. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/errors/base.twig +0 -0
  136. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/frontend/README.md +0 -0
  137. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/templates/readme.md +0 -0
  138. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/test_client/__init__.py +0 -0
  139. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  140. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  141. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  142. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  143. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/validator/__init__.py +0 -0
  152. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/websocket/__init__.py +0 -0
  153. {tina4_python-3.12.5 → tina4_python-3.12.8}/tina4_python/websocket/backplane.py +0 -0
  154. {tina4_python-3.12.5 → tina4_python-3.12.8}/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.5
3
+ Version: 3.12.8
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.5"
3
+ version = "3.12.8"
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.5"
11
+ __version__ = "3.12.8"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -246,6 +246,33 @@ class Router:
246
246
  """Register a DELETE route (imperative, non-decorator style)."""
247
247
  return cls.add("DELETE", path, handler, middleware=middleware, swagger_meta=swagger_meta, template=template, **options)
248
248
 
249
+ @classmethod
250
+ def head(cls, path: str, handler, middleware: list = None, swagger_meta: dict = None, template: str = None, **options) -> "RouteRef":
251
+ """Register an explicit HEAD route.
252
+
253
+ By default the framework auto-handles HEAD by falling back to the GET
254
+ route and stripping the body (RFC 9110 §9.3.2). Use this method only
255
+ when you need a HEAD handler that does something different from GET —
256
+ e.g. cheaper existence-check logic, custom validator headers without
257
+ the cost of building the body.
258
+
259
+ The framework still strips the response body for you on the way out —
260
+ HEAD MUST NOT return content, even if your handler does, so we
261
+ enforce that unconditionally rather than relying on developer care.
262
+ """
263
+ return cls.add("HEAD", path, handler, middleware=middleware, swagger_meta=swagger_meta, template=template, **options)
264
+
265
+ @classmethod
266
+ def options(cls, path: str, handler, middleware: list = None, swagger_meta: dict = None, template: str = None, **options) -> "RouteRef":
267
+ """Register an explicit OPTIONS route.
268
+
269
+ By default the framework auto-handles OPTIONS by building an Allow
270
+ header from every method registered for the path and returning 204
271
+ (RFC 9110 §9.3.7). Use this method to take over that behaviour —
272
+ e.g. to return a richer OPTIONS payload describing the resource.
273
+ """
274
+ return cls.add("OPTIONS", path, handler, middleware=middleware, swagger_meta=swagger_meta, template=template, **options)
275
+
249
276
  @classmethod
250
277
  def any(cls, path: str, handler, middleware: list = None, swagger_meta: dict = None, template: str = None, **options) -> "RouteRef":
251
278
  """Register a route for any HTTP method (imperative, non-decorator style)."""
@@ -291,7 +318,11 @@ class Router:
291
318
  # Route has custom middleware — developer handles auth themselves
292
319
  auth_required = False
293
320
  else:
294
- auth_required = m not in ("GET", "ANY")
321
+ # GET, HEAD, OPTIONS, and ANY are public by default. HEAD and
322
+ # OPTIONS are safe/idempotent introspection methods (RFC 9110
323
+ # §9.2.1) — requiring auth on them breaks cache validators
324
+ # and CORS preflight probes.
325
+ auth_required = m not in ("GET", "HEAD", "OPTIONS", "ANY")
295
326
 
296
327
  route = {
297
328
  "method": m,
@@ -312,9 +343,18 @@ class Router:
312
343
 
313
344
  @staticmethod
314
345
  def match(method: str, path: str) -> tuple[dict | None, dict]:
315
- """Find a route matching method + path. Returns (route, params)."""
346
+ """Find a route matching method + path. Returns (route, params).
347
+
348
+ RFC 9110 §9.3.2: HEAD is identical to GET except the response carries
349
+ no body. If the app didn't register a dedicated HEAD route, we
350
+ transparently match the GET route; the dispatcher strips the body on
351
+ the way out, so the handler doesn't need to know HEAD even happened.
352
+ """
353
+ method_upper = method.upper()
354
+
355
+ # First pass: exact method match (covers HEAD → explicit HEAD route too)
316
356
  for route in _routes:
317
- if route["method"] not in (method.upper(), "ANY"):
357
+ if route["method"] not in (method_upper, "ANY"):
318
358
  continue
319
359
  m = route["pattern"].match(path)
320
360
  if m:
@@ -323,8 +363,58 @@ class Router:
323
363
  params[name] = m.group(i + 1)
324
364
  return route, params
325
365
 
366
+ # Second pass: HEAD auto-fallback to GET when no HEAD route registered
367
+ if method_upper == "HEAD":
368
+ for route in _routes:
369
+ if route["method"] not in ("GET", "ANY"):
370
+ continue
371
+ m = route["pattern"].match(path)
372
+ if m:
373
+ params = {}
374
+ for i, name in enumerate(route["param_names"]):
375
+ params[name] = m.group(i + 1)
376
+ return route, params
377
+
326
378
  return None, {}
327
379
 
380
+ @staticmethod
381
+ def methods_allowed_for_path(path: str) -> list[str]:
382
+ """Return the list of HTTP methods registered for ``path``, in the
383
+ order GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS. Used by
384
+ the dispatcher to build the ``Allow:`` header on 405 / OPTIONS
385
+ responses (RFC 9110 §10.2.1, §9.3.7).
386
+
387
+ If GET is registered, HEAD is appended implicitly (the framework
388
+ auto-falls-back HEAD to GET). OPTIONS is appended whenever the
389
+ path has any registered method (the framework auto-handles OPTIONS).
390
+ """
391
+ # ANY routes count for every method but we don't enumerate them
392
+ # individually — flag whether ANY matched and union it with the
393
+ # concrete-method matches.
394
+ method_order = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
395
+ seen: set[str] = set()
396
+ any_matched = False
397
+
398
+ for route in _routes:
399
+ if not route["pattern"].match(path):
400
+ continue
401
+ m = route["method"]
402
+ if m == "ANY":
403
+ any_matched = True
404
+ elif m in method_order:
405
+ seen.add(m)
406
+
407
+ if any_matched:
408
+ seen.update(method_order)
409
+
410
+ # GET implies HEAD; any registered method implies OPTIONS.
411
+ if seen:
412
+ if "GET" in seen:
413
+ seen.add("HEAD")
414
+ seen.add("OPTIONS")
415
+
416
+ return [m for m in method_order if m in seen]
417
+
328
418
  @staticmethod
329
419
  def get_routes() -> list[dict]:
330
420
  """Return all registered routes."""
@@ -1338,7 +1338,43 @@ async def handle(request: Request) -> Response:
1338
1338
  except Exception as e:
1339
1339
  response = _handle_route_error(e, request, response, request_id, _is_dev)
1340
1340
  else:
1341
- response = _handle_no_route(request, response, request_id)
1341
+ # RFC 9110 conformance — before falling through to 404 / static / template,
1342
+ # check whether the PATH is known to the router under any OTHER method.
1343
+ # If yes:
1344
+ # - OPTIONS request → 204 No Content with Allow listing the methods
1345
+ # (RFC 9110 §9.3.7). Generic OPTIONS handler.
1346
+ # - Any other method (PUT on GET-only route, TRACE, CONNECT, etc.)
1347
+ # → 405 Method Not Allowed with Allow header (§15.5.6 + §10.2.1).
1348
+ allowed = Router.methods_allowed_for_path(request.path)
1349
+ if allowed:
1350
+ allow_header = ", ".join(allowed)
1351
+ if request.method.upper() == "OPTIONS":
1352
+ response.header("Allow", allow_header)
1353
+ response.status(204)
1354
+ else:
1355
+ response.header("Allow", allow_header)
1356
+ response.status(405).json({
1357
+ "error": "Method Not Allowed",
1358
+ "path": request.path,
1359
+ "method": request.method,
1360
+ "allow": allowed,
1361
+ "status": 405,
1362
+ })
1363
+ else:
1364
+ response = _handle_no_route(request, response, request_id)
1365
+
1366
+ # RFC 9110 §9.3.2: a HEAD response MUST NOT include content. Strip the
1367
+ # body unconditionally (even for explicit Router.head() handlers that
1368
+ # accidentally returned one) and record what Content-Length the GET
1369
+ # would have sent — cache validators / link checkers / monitoring
1370
+ # probes rely on that header to size estimates.
1371
+ if request.method.upper() == "HEAD":
1372
+ body = response.content if response.content is not None else b""
1373
+ if isinstance(body, str):
1374
+ body = body.encode("utf-8")
1375
+ if body:
1376
+ response.header("Content-Length", str(len(body)))
1377
+ response.content = b""
1342
1378
 
1343
1379
  return _finalize_response(request, response, route, request_id, _is_dev, _req_start)
1344
1380
 
@@ -20,6 +20,28 @@ class PostgreSQLAdapter(DatabaseAdapter):
20
20
  self._conn = None
21
21
  self._in_transaction: bool = False
22
22
 
23
+ @staticmethod
24
+ def _safe_execute(cursor, sql: str, params=None):
25
+ """Wrapper for ``cursor.execute`` that side-steps psycopg2's
26
+ always-on ``%`` substitution when no params are needed.
27
+
28
+ Issue #40: psycopg2 interprets ``%`` characters in the SQL
29
+ as parameter placeholders WHENEVER the ``params`` argument is
30
+ supplied — even an empty list ``[]``. So a migration body
31
+ containing ``RAISE EXCEPTION 'thing % conflicts with %', a, b``
32
+ (perfectly valid PL/pgSQL) blows up with the misleading
33
+ ``list index out of range`` because psycopg2 thinks ``%`` is a
34
+ placeholder and there are no values to substitute.
35
+
36
+ Pass ``None`` (or omit the second arg) and psycopg2 skips the
37
+ substitution pass entirely — literal ``%`` flows through
38
+ untouched. So we route empty/None params through that path.
39
+ """
40
+ if params:
41
+ cursor.execute(sql, params)
42
+ else:
43
+ cursor.execute(sql)
44
+
23
45
  def connect(self, connection_string: str, username: str = "", password: str = "", **kwargs):
24
46
  """Connect to PostgreSQL.
25
47
 
@@ -63,7 +85,7 @@ class PostgreSQLAdapter(DatabaseAdapter):
63
85
  )
64
86
 
65
87
  cursor = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
66
- cursor.execute(sql, params or [])
88
+ self._safe_execute(cursor, sql, params)
67
89
 
68
90
  records = []
69
91
  last_id = None
@@ -124,7 +146,7 @@ class PostgreSQLAdapter(DatabaseAdapter):
124
146
  # Count total rows
125
147
  count_sql = f"SELECT COUNT(*) AS cnt FROM ({sql}) AS _count_subquery"
126
148
  try:
127
- cursor.execute(count_sql, params or [])
149
+ self._safe_execute(cursor, count_sql, params)
128
150
  total = cursor.fetchone()["cnt"]
129
151
  except Exception:
130
152
  total = 0
@@ -132,7 +154,7 @@ class PostgreSQLAdapter(DatabaseAdapter):
132
154
  # Apply pagination
133
155
  paginated_sql = f"{sql} LIMIT %s OFFSET %s"
134
156
  paginated_params = (params or []) + [limit, offset]
135
- cursor.execute(paginated_sql, paginated_params)
157
+ self._safe_execute(cursor, paginated_sql, paginated_params)
136
158
  rows = [self._decode_blobs(dict(row)) for row in cursor.fetchall()]
137
159
 
138
160
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
@@ -142,7 +164,7 @@ class PostgreSQLAdapter(DatabaseAdapter):
142
164
 
143
165
  sql = self._translate_sql(sql)
144
166
  cursor = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
145
- cursor.execute(sql, params or [])
167
+ self._safe_execute(cursor, sql, params)
146
168
  row = cursor.fetchone()
147
169
  return self._decode_blobs(dict(row)) if row else None
148
170
 
File without changes
File without changes