tina4-python 3.13.41__tar.gz → 3.13.42__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 (158) hide show
  1. {tina4_python-3.13.41 → tina4_python-3.13.42}/PKG-INFO +1 -1
  2. {tina4_python-3.13.41 → tina4_python-3.13.42}/pyproject.toml +1 -1
  3. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/CLAUDE.md +31 -0
  4. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/swagger/__init__.py +260 -12
  6. {tina4_python-3.13.41 → tina4_python-3.13.42}/.gitignore +0 -0
  7. {tina4_python-3.13.41 → tina4_python-3.13.42}/README.md +0 -0
  8. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/HtmlElement.py +0 -0
  9. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/Testing.py +0 -0
  10. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/ai/__init__.py +0 -0
  11. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/api/__init__.py +0 -0
  12. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/auth/__init__.py +0 -0
  13. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/cache/__init__.py +0 -0
  14. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/cli/__init__.py +0 -0
  15. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/container/__init__.py +0 -0
  16. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/core/__init__.py +0 -0
  17. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/core/cache.py +0 -0
  18. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/core/constants.py +0 -0
  19. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/core/events.py +0 -0
  20. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/core/middleware.py +0 -0
  21. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/core/rate_limiter.py +0 -0
  22. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/core/request.py +0 -0
  23. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/core/response.py +0 -0
  24. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/core/router.py +0 -0
  25. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/core/server.py +0 -0
  26. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/crud/__init__.py +0 -0
  27. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/database/__init__.py +0 -0
  28. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/database/adapter.py +0 -0
  29. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/database/connection.py +0 -0
  30. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/database/firebird.py +0 -0
  31. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/database/mongodb.py +0 -0
  32. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/database/mssql.py +0 -0
  33. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/database/mysql.py +0 -0
  34. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/database/odbc.py +0 -0
  35. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/database/postgres.py +0 -0
  36. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/database/sqlite.py +0 -0
  37. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/debug/__init__.py +0 -0
  38. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/debug/error_overlay.py +0 -0
  39. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/dev_admin/__init__.py +0 -0
  40. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/dev_admin/metrics.py +0 -0
  41. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/dev_admin/plan.py +0 -0
  42. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/dev_admin/project_index.py +0 -0
  43. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/docs.py +0 -0
  44. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/docstore/__init__.py +0 -0
  45. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/dotenv/__init__.py +0 -0
  46. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/env.py +0 -0
  47. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/frond/FROND.md +0 -0
  48. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/frond/__init__.py +0 -0
  49. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/frond/engine.py +0 -0
  50. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/auth/meta.json +0 -0
  51. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  52. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/database/meta.json +0 -0
  53. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  54. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/error-overlay/meta.json +0 -0
  55. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  56. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/orm/meta.json +0 -0
  57. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  58. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  59. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/queue/meta.json +0 -0
  60. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  61. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/rest-api/meta.json +0 -0
  62. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  63. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/templates/meta.json +0 -0
  64. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  65. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  66. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/graphql/__init__.py +0 -0
  67. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/i18n/__init__.py +0 -0
  68. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/mcp/__init__.py +0 -0
  69. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/mcp/protocol.py +0 -0
  70. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/mcp/tools.py +0 -0
  71. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/messenger/__init__.py +0 -0
  72. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/migration/__init__.py +0 -0
  73. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/migration/runner.py +0 -0
  74. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/orm/__init__.py +0 -0
  75. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/orm/fields.py +0 -0
  76. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/orm/model.py +0 -0
  77. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/__feedback/widget.js +0 -0
  78. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/css/tina4.css +0 -0
  79. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/css/tina4.min.css +0 -0
  80. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/favicon.ico +0 -0
  81. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/images/logo.svg +0 -0
  82. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  83. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/js/frond.js +0 -0
  84. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/js/frond.min.js +0 -0
  85. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  86. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  87. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/js/tina4.min.js +0 -0
  88. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/js/tina4js.min.js +0 -0
  89. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/swagger/index.html +0 -0
  90. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  91. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/query_builder/__init__.py +0 -0
  92. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/queue/__init__.py +0 -0
  93. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/queue/job.py +0 -0
  94. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/queue/kafka_backend.py +0 -0
  95. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/queue/lite_backend.py +0 -0
  96. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/queue/mongo_backend.py +0 -0
  97. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/queue/rabbitmq_backend.py +0 -0
  98. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/queue_backends/__init__.py +0 -0
  99. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/queue_backends/kafka_backend.py +0 -0
  100. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/queue_backends/mongo_backend.py +0 -0
  101. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  102. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/__init__.py +0 -0
  103. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  104. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_badges.scss +0 -0
  105. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  106. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_cards.scss +0 -0
  107. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_forms.scss +0 -0
  108. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_grid.scss +0 -0
  109. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_modals.scss +0 -0
  110. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_nav.scss +0 -0
  111. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_reset.scss +0 -0
  112. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_tables.scss +0 -0
  113. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_typography.scss +0 -0
  114. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  115. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/_variables.scss +0 -0
  116. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/base.scss +0 -0
  117. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/colors.scss +0 -0
  118. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/scss/tina4css/tina4.scss +0 -0
  119. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/seeder/__init__.py +0 -0
  120. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/service/__init__.py +0 -0
  121. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/session/__init__.py +0 -0
  122. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/session_handlers/__init__.py +0 -0
  123. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  124. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/session_handlers/redis_handler.py +0 -0
  125. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/session_handlers/valkey_handler.py +0 -0
  126. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/components/crud.twig +0 -0
  127. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  128. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  129. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/docker/python/Dockerfile +0 -0
  130. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  131. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/errors/302.twig +0 -0
  132. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/errors/401.twig +0 -0
  133. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/errors/403.twig +0 -0
  134. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/errors/404.twig +0 -0
  135. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/errors/500.twig +0 -0
  136. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/errors/502.twig +0 -0
  137. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/errors/503.twig +0 -0
  138. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/errors/base.twig +0 -0
  139. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/frontend/README.md +0 -0
  140. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/templates/readme.md +0 -0
  141. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/test/__init__.py +0 -0
  142. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/test_client/__init__.py +0 -0
  143. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  152. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  153. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  154. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  155. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/validator/__init__.py +0 -0
  156. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/websocket/__init__.py +0 -0
  157. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/websocket/backplane.py +0 -0
  158. {tina4_python-3.13.41 → tina4_python-3.13.42}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.41
3
+ Version: 3.13.42
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.41"
3
+ version = "3.13.42"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -1657,6 +1657,37 @@ TINA4_SWAGGER_LICENSE= # SPDX license name (e.g. MIT) for info.licens
1657
1657
  TINA4_SWAGGER_SERVERS= # comma-separated server URLs for the OpenAPI servers[] block; falls back to SWAGGER_DEV_URL
1658
1658
  TINA4_SWAGGER_UI_CDN= # base URL for the Swagger UI assets (default jsdelivr); point at a self-hosted mirror for air-gapped use
1659
1659
  SWAGGER_DEV_URL=http://localhost:7145 # single dev-server URL (used when TINA4_SWAGGER_SERVERS is unset)
1660
+ TINA4_SWAGGER_OPENAPI=3.0.3 # OpenAPI version: 3.0.3 (default) or 3.1 (-> emits 3.1.0)
1661
+ TINA4_SWAGGER_BEARER_FORMAT=JWT # bearerFormat on the built-in bearerAuth scheme (e.g. opaque for sk_live_ keys)
1662
+ TINA4_SWAGGER_API_KEY_NAME= # if set, emit an apiKeyAuth scheme with this header/query name (e.g. X-Api-Key)
1663
+ TINA4_SWAGGER_API_KEY_IN=header # where the apiKey lives: header (default) | query | cookie
1664
+ TINA4_SWAGGER_DEFAULT_SCHEME=bearerAuth # scheme secured routes use when no @security is set
1665
+ TINA4_SWAGGER_INCLUDE= # comma-separated path prefixes to include (allow-list; only these documented)
1666
+ TINA4_SWAGGER_EXCLUDE= # comma-separated path prefixes to drop (/swagger + /__dev are always excluded)
1667
+ ```
1668
+
1669
+ **Per-route security + reusable schemas (v3.13.42).** Configure named security
1670
+ schemes (configurable `bearerFormat`, an optional `apiKey` scheme, or register
1671
+ arbitrary schemes incl. `oauth2` with scopes via `Swagger.add_security_scheme(name, def)`),
1672
+ then declare them per route with `@security`:
1673
+
1674
+ ```python
1675
+ from tina4_python.swagger import security, request_schema, response_schema, Swagger
1676
+
1677
+ @security("oauth2", scopes=["read:users"]) # scopes kept only for oauth2/openIdConnect
1678
+ @get("/api/v1/users")
1679
+ async def list_users(request, response): ...
1680
+
1681
+ @security("public") # explicitly public (overrides write-secure-by-default)
1682
+ @post("/api/v1/webhook")
1683
+ async def webhook(request, response): ...
1684
+
1685
+ # Reusable component schemas referenced by $ref (beyond ORM-model auto-schemas):
1686
+ Swagger.add_schema("CreateUser", {"type": "object", "properties": {"email": {"type": "string"}}})
1687
+ @request_schema("CreateUser")
1688
+ @response_schema("User", status=200)
1689
+ @post("/api/v1/users")
1690
+ async def create_user(request, response): ...
1660
1691
  ```
1661
1692
 
1662
1693
  The spec is OpenAPI 3.0.3. ORM models registered via AutoCrud become reusable
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.13.41"
11
+ __version__ = "3.13.42"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -23,6 +23,18 @@ v3.13.40 — the generator now:
23
23
  request bodies (@example(..., content_type="multipart/form-data")),
24
24
  - converts wildcard/splat routes to spec-valid {wildcard} templating,
25
25
  - de-duplicates operationIds.
26
+
27
+ v3.13.42 — configurability for external/public APIs:
28
+ - per-route security + scopes via @security("oauth2", scopes=[...]) (overrides
29
+ the default scheme; scopes kept valid — only oauth2/openIdConnect carry them),
30
+ - configurable security schemes: TINA4_SWAGGER_BEARER_FORMAT (non-JWT bearers),
31
+ an apiKey scheme via TINA4_SWAGGER_API_KEY_NAME/_IN, TINA4_SWAGGER_DEFAULT_SCHEME,
32
+ and Swagger.add_security_scheme(name, def) for arbitrary schemes (oauth2 + scopes),
33
+ - path filtering: TINA4_SWAGGER_INCLUDE / _EXCLUDE prefixes (framework
34
+ internals /swagger + /__dev always excluded),
35
+ - OpenAPI 3.1 opt-in: TINA4_SWAGGER_OPENAPI=3.1 (default 3.0.3),
36
+ - reusable custom schemas: Swagger.add_schema(name, schema) + @request_schema /
37
+ @response_schema referencing them by $ref.
26
38
  """
27
39
  import json
28
40
  import os
@@ -40,9 +52,19 @@ _SWAGGER_ATTRS = (
40
52
  "_swagger_summary", "_swagger_tags", "_swagger_example", "_swagger_example_content_type",
41
53
  "_swagger_example_response", "_swagger_example_responses", "_swagger_deprecated",
42
54
  "_swagger_model", "_swagger_model_list",
55
+ "_swagger_security", "_swagger_request_schema", "_swagger_response_schemas",
43
56
  )
44
57
 
45
58
 
59
+ # ── Configuration registry ─────────────────────────────────────
60
+ # Process-wide registries for security schemes and reusable component schemas
61
+ # declared programmatically (Swagger.add_security_scheme / Swagger.add_schema).
62
+ # Kept module-level so app bootstrap can register before any generate() call;
63
+ # reset_registry() clears them (tests).
64
+ _REGISTERED_SCHEMES: dict[str, dict] = {}
65
+ _REGISTERED_SCHEMAS: dict[str, dict] = {}
66
+
67
+
46
68
  def _carry(fn, wrapper):
47
69
  """Copy any already-set swagger attrs from fn onto wrapper (idempotent)."""
48
70
  for attr in _SWAGGER_ATTRS:
@@ -199,12 +221,114 @@ def deprecated():
199
221
  return decorator
200
222
 
201
223
 
224
+ def _normalize_security(scheme_or_reqs, scopes):
225
+ """Normalize @security args to an OpenAPI security-requirement list.
226
+
227
+ Accepts:
228
+ @security("oauth2", scopes=["read"]) -> [{"oauth2": ["read"]}]
229
+ @security({"bearerAuth": []}) -> [{"bearerAuth": []}] (AND within one dict)
230
+ @security([{"oauth2": ["read"]}, {"apiKeyAuth": []}]) -> verbatim (OR across dicts)
231
+ @security("public") / @security([]) -> [] (explicitly no auth)
232
+ """
233
+ if scheme_or_reqs in ("public", "none", None) and not scopes:
234
+ return []
235
+ if isinstance(scheme_or_reqs, str):
236
+ return [{scheme_or_reqs: list(scopes or [])}]
237
+ if isinstance(scheme_or_reqs, dict):
238
+ return [{k: list(v or []) for k, v in scheme_or_reqs.items()}]
239
+ if isinstance(scheme_or_reqs, list):
240
+ return [{k: list(v or []) for k, v in req.items()} for req in scheme_or_reqs]
241
+ return []
242
+
243
+
244
+ def security(scheme_or_reqs="bearerAuth", scopes=None):
245
+ """Declare the security requirement(s) for a route (overrides the default).
246
+
247
+ @security("bearerAuth") # default bearer, no scopes
248
+ @security("oauth2", scopes=["read:users"]) # oauth2 scheme + scopes
249
+ @security({"apiKeyAuth": []}) # a registered API-key scheme
250
+ @security([{"oauth2": ["read"]}, {"apiKeyAuth": []}]) # either (OR)
251
+ @security("public") # explicitly public (no security)
252
+
253
+ Scopes are meaningful only for ``oauth2``/``openIdConnect`` schemes; for
254
+ ``http``/``apiKey`` schemes OpenAPI requires an empty scope array, so the
255
+ generator drops any scopes you pass on those (keeping the spec valid).
256
+ """
257
+ reqs = _normalize_security(scheme_or_reqs, scopes)
258
+ def decorator(fn):
259
+ fn._swagger_security = reqs
260
+ @functools.wraps(fn)
261
+ def wrapper(*args, **kwargs):
262
+ return fn(*args, **kwargs)
263
+ wrapper._swagger_security = reqs
264
+ _carry(fn, wrapper)
265
+ return wrapper
266
+ return decorator
267
+
268
+
269
+ def request_schema(name: str, content_type: str = "application/json"):
270
+ """Reference a registered component schema as the request body.
271
+
272
+ Swagger.add_schema("CreateUser", {...})
273
+ @request_schema("CreateUser")
274
+ """
275
+ def decorator(fn):
276
+ fn._swagger_request_schema = (name, content_type)
277
+ @functools.wraps(fn)
278
+ def wrapper(*args, **kwargs):
279
+ return fn(*args, **kwargs)
280
+ wrapper._swagger_request_schema = (name, content_type)
281
+ _carry(fn, wrapper)
282
+ return wrapper
283
+ return decorator
284
+
285
+
286
+ def response_schema(name: str, status: int = 200, is_list: bool = False):
287
+ """Reference a registered component schema as a response body (per status).
288
+
289
+ @response_schema("User", status=200)
290
+ @response_schema("Error", status=404)
291
+ @response_schema("User", status=200, is_list=True) # array of User
292
+ """
293
+ def decorator(fn):
294
+ existing = dict(getattr(fn, "_swagger_response_schemas", {}) or {})
295
+ existing[int(status)] = (name, bool(is_list))
296
+ fn._swagger_response_schemas = existing
297
+ @functools.wraps(fn)
298
+ def wrapper(*args, **kwargs):
299
+ return fn(*args, **kwargs)
300
+ wrapper._swagger_response_schemas = existing
301
+ _carry(fn, wrapper)
302
+ return wrapper
303
+ return decorator
304
+
305
+
202
306
  # ── Swagger Generator ──────────────────────────────────────────
203
307
 
204
308
  def _swagger_truthy(val) -> bool:
205
309
  return str(val or "").strip().lower() in ("true", "1", "yes", "on")
206
310
 
207
311
 
312
+ def _csv(val) -> list[str]:
313
+ """Split a comma-separated env value into a clean list."""
314
+ return [p.strip() for p in str(val or "").split(",") if p.strip()]
315
+
316
+
317
+ def _resolve_openapi_version(val) -> str:
318
+ """Resolve TINA4_SWAGGER_OPENAPI to a concrete version string.
319
+
320
+ Default 3.0.3 (broad tool support). '3.1' / '3.1.0' -> '3.1.0'.
321
+ """
322
+ v = str(val or "").strip()
323
+ if not v:
324
+ return "3.0.3"
325
+ if v in ("3.1", "3.1.0"):
326
+ return "3.1.0"
327
+ if v in ("3.0", "3.0.3"):
328
+ return "3.0.3"
329
+ return v # honour an explicit full version verbatim
330
+
331
+
208
332
  def is_enabled() -> bool:
209
333
  """Whether Swagger UI should be served.
210
334
 
@@ -279,6 +403,99 @@ class Swagger:
279
403
  if license_name is not None
280
404
  else os.environ.get("TINA4_SWAGGER_LICENSE", "")
281
405
  )
406
+ # OpenAPI version — default 3.0.3 for broad tool compatibility; opt in to
407
+ # 3.1.0 via TINA4_SWAGGER_OPENAPI=3.1 (the schemas this generator emits
408
+ # are valid in both dialects).
409
+ self.openapi_version = _resolve_openapi_version(
410
+ os.environ.get("TINA4_SWAGGER_OPENAPI")
411
+ )
412
+ # Default bearer format (the built-in bearerAuth scheme). JWT unless an
413
+ # API uses opaque tokens / API keys as the bearer (e.g. sk_live_...).
414
+ self.bearer_format = os.environ.get("TINA4_SWAGGER_BEARER_FORMAT", "JWT")
415
+ # Optional apiKey scheme — emitted as "apiKeyAuth" when a header/query
416
+ # name is configured (e.g. X-Api-Key).
417
+ self.api_key_name = os.environ.get("TINA4_SWAGGER_API_KEY_NAME", "")
418
+ self.api_key_in = os.environ.get("TINA4_SWAGGER_API_KEY_IN", "header")
419
+ # Which scheme secured routes use by default when no @security is set.
420
+ self.default_scheme = os.environ.get("TINA4_SWAGGER_DEFAULT_SCHEME", "bearerAuth")
421
+ # Path filters (comma-separated prefixes on the raw route path).
422
+ self.include_prefixes = _csv(os.environ.get("TINA4_SWAGGER_INCLUDE", ""))
423
+ self.exclude_prefixes = _csv(os.environ.get("TINA4_SWAGGER_EXCLUDE", ""))
424
+
425
+ # ── Programmatic registries ────────────────────────────────────
426
+
427
+ @staticmethod
428
+ def add_security_scheme(name: str, definition: dict) -> None:
429
+ """Register a named OpenAPI security scheme (e.g. an oauth2 scheme with
430
+ scopes, or a custom apiKey). Call at app bootstrap, before generate().
431
+
432
+ Swagger.add_security_scheme("oauth2", {
433
+ "type": "oauth2",
434
+ "flows": {"clientCredentials": {
435
+ "tokenUrl": "https://api.example.com/oauth/token",
436
+ "scopes": {"read:users": "Read users", "write:users": "Write users"},
437
+ }},
438
+ })
439
+ """
440
+ _REGISTERED_SCHEMES[name] = definition
441
+
442
+ @staticmethod
443
+ def add_schema(name: str, schema: dict) -> None:
444
+ """Register a reusable component schema, referenceable via
445
+ @request_schema(name) / @response_schema(name) or a raw $ref."""
446
+ _REGISTERED_SCHEMAS[name] = schema
447
+
448
+ @staticmethod
449
+ def reset_registry() -> None:
450
+ """Clear the security-scheme and schema registries (test helper)."""
451
+ _REGISTERED_SCHEMES.clear()
452
+ _REGISTERED_SCHEMAS.clear()
453
+
454
+ def _security_schemes(self) -> dict:
455
+ """Resolve components.securitySchemes from defaults + env + registry."""
456
+ schemes: dict[str, dict] = {
457
+ "bearerAuth": {
458
+ "type": "http",
459
+ "scheme": "bearer",
460
+ "bearerFormat": self.bearer_format,
461
+ }
462
+ }
463
+ if self.api_key_name:
464
+ schemes["apiKeyAuth"] = {
465
+ "type": "apiKey",
466
+ "name": self.api_key_name,
467
+ "in": self.api_key_in if self.api_key_in in ("header", "query", "cookie") else "header",
468
+ }
469
+ # Registered schemes win (let an app override bearerAuth or add oauth2).
470
+ schemes.update(_REGISTERED_SCHEMES)
471
+ return schemes
472
+
473
+ def _sanitize_security(self, reqs: list, schemes: dict) -> list:
474
+ """Keep a security-requirement list spec-valid: scopes are allowed only
475
+ on oauth2/openIdConnect schemes; everything else gets an empty array."""
476
+ scope_ok = {"oauth2", "openIdConnect"}
477
+ out = []
478
+ for req in reqs:
479
+ clean = {}
480
+ for name, scopes in req.items():
481
+ stype = (schemes.get(name) or {}).get("type")
482
+ clean[name] = list(scopes) if stype in scope_ok else []
483
+ out.append(clean)
484
+ return out
485
+
486
+ def _included(self, raw_path: str) -> bool:
487
+ """Path-filter a raw route path. Framework internals are always
488
+ excluded; then TINA4_SWAGGER_INCLUDE (allow-list) / _EXCLUDE apply."""
489
+ for internal in ("/swagger", "/__dev"):
490
+ if raw_path == internal or raw_path.startswith(internal + "/"):
491
+ return False
492
+ if self.include_prefixes and not any(
493
+ raw_path == p or raw_path.startswith(p) for p in self.include_prefixes
494
+ ):
495
+ return False
496
+ if any(raw_path == p or raw_path.startswith(p) for p in self.exclude_prefixes):
497
+ return False
498
+ return True
282
499
 
283
500
  def _servers(self) -> list[dict]:
284
501
  """Resolve the servers[] block.
@@ -315,27 +532,25 @@ class Swagger:
315
532
  if self.license_name:
316
533
  info["license"] = {"name": self.license_name}
317
534
 
535
+ schemes = self._security_schemes()
318
536
  spec = {
319
- "openapi": "3.0.3",
537
+ "openapi": self.openapi_version,
320
538
  "info": info,
321
539
  "servers": self._servers(),
322
540
  "paths": {},
323
541
  "components": {
324
- "securitySchemes": {
325
- "bearerAuth": {
326
- "type": "http",
327
- "scheme": "bearer",
328
- "bearerFormat": "JWT",
329
- }
330
- }
542
+ "securitySchemes": schemes,
331
543
  },
332
544
  }
333
545
 
334
546
  models: dict[str, type] = {} # name -> ORM model class, for components.schemas
547
+ ref_schemas: set[str] = set() # registered schema names referenced by routes
335
548
  used_tags: list[str] = [] # insertion-ordered unique tags
336
549
  seen_ids: set[str] = set() # operationId de-dup
337
550
 
338
551
  for route in routes:
552
+ if not self._included(route.get("path", "")):
553
+ continue
339
554
  path = self._openapi_path(route["path"])
340
555
  method = route["method"].lower()
341
556
  handler = route.get("handler")
@@ -378,10 +593,16 @@ class Swagger:
378
593
  # an inferred schema from @example. application/json unless the
379
594
  # example declares multipart/form-data.
380
595
  if method in ("post", "put", "patch"):
596
+ req_schema = getattr(handler, "_swagger_request_schema", None)
381
597
  ct = getattr(handler, "_swagger_example_content_type", "application/json")
382
598
  ex = getattr(handler, "_swagger_example", None)
383
599
  media: dict = {}
384
- if ref is not None:
600
+ if req_schema is not None:
601
+ sname, sct = req_schema
602
+ ct = sct or ct
603
+ ref_schemas.add(sname)
604
+ media["schema"] = {"$ref": f"#/components/schemas/{sname}"}
605
+ elif ref is not None:
385
606
  media["schema"] = {"$ref": ref}
386
607
  elif ex is not None:
387
608
  media["schema"] = self._infer_schema(ex)
@@ -425,6 +646,19 @@ class Swagger:
425
646
  },
426
647
  }
427
648
 
649
+ # Registered response schemas ($ref) — explicit and authoritative.
650
+ resp_schemas = getattr(handler, "_swagger_response_schemas", None)
651
+ if resp_schemas:
652
+ for status_code, (sname, is_list) in resp_schemas.items():
653
+ ref_schemas.add(sname)
654
+ sref = f"#/components/schemas/{sname}"
655
+ schema = ({"type": "array", "items": {"$ref": sref}} if is_list
656
+ else {"$ref": sref})
657
+ operation["responses"][str(status_code)] = {
658
+ "description": "Successful response" if str(status_code).startswith("2") else "Response",
659
+ "content": {"application/json": {"schema": schema}},
660
+ }
661
+
428
662
  # Parameters: path params (+ types) then query params from @description(query=)
429
663
  params = self._extract_path_params(route["path"])
430
664
  if handler:
@@ -444,9 +678,15 @@ class Swagger:
444
678
  if params:
445
679
  operation["parameters"] = params
446
680
 
447
- # Auth
448
- if route.get("auth_required", False):
449
- operation["security"] = [{"bearerAuth": []}]
681
+ # Auth — explicit @security wins (an empty list = explicitly public);
682
+ # otherwise a secured route gets the default scheme.
683
+ sec = getattr(handler, "_swagger_security", None) if handler else None
684
+ if sec is not None:
685
+ operation["security"] = self._sanitize_security(sec, schemes) if sec else []
686
+ elif route.get("auth_required", False):
687
+ operation["security"] = self._sanitize_security(
688
+ [{self.default_scheme: []}], schemes
689
+ )
450
690
 
451
691
  spec["paths"][path][method] = operation
452
692
 
@@ -456,6 +696,13 @@ class Swagger:
456
696
  for name, model_class in models.items():
457
697
  schemas[name] = self._model_schema(model_class)
458
698
 
699
+ # Registered component schemas referenced via @request_schema/@response_schema.
700
+ if ref_schemas:
701
+ schemas = spec["components"].setdefault("schemas", {})
702
+ for name in ref_schemas:
703
+ if name in _REGISTERED_SCHEMAS and name not in schemas:
704
+ schemas[name] = _REGISTERED_SCHEMAS[name]
705
+
459
706
  # top-level tags[] (name-only is valid OpenAPI; descriptions optional)
460
707
  if used_tags:
461
708
  spec["tags"] = [{"name": t} for t in used_tags]
@@ -609,4 +856,5 @@ __all__ = [
609
856
  "Swagger", "is_enabled",
610
857
  "description", "summary", "tags",
611
858
  "example", "example_response", "deprecated",
859
+ "security", "request_schema", "response_schema",
612
860
  ]
File without changes