tina4-python 3.13.90__tar.gz → 3.13.92__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 (174) hide show
  1. {tina4_python-3.13.90 → tina4_python-3.13.92}/PKG-INFO +1 -1
  2. {tina4_python-3.13.90 → tina4_python-3.13.92}/pyproject.toml +1 -1
  3. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/auth/__init__.py +68 -10
  4. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/dev_admin/__init__.py +13 -3
  5. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/dev_admin/metrics.py +67 -5
  6. tina4_python-3.13.92/tina4_python/dev_admin/metrics_engine.py +108 -0
  7. tina4_python-3.13.92/tina4_python/public/js/tina4-dev-admin.min.js +1127 -0
  8. tina4_python-3.13.90/tina4_python/public/js/tina4-dev-admin.min.js +0 -1127
  9. {tina4_python-3.13.90 → tina4_python-3.13.92}/.gitignore +0 -0
  10. {tina4_python-3.13.90 → tina4_python-3.13.92}/README.md +0 -0
  11. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/CLAUDE.md +0 -0
  12. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/HtmlElement.py +0 -0
  13. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/Testing.py +0 -0
  14. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/__init__.py +0 -0
  15. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/ai/__init__.py +0 -0
  16. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/api/__init__.py +0 -0
  17. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/cache/__init__.py +0 -0
  18. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/cli/__init__.py +0 -0
  19. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/container/__init__.py +0 -0
  20. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/context/__init__.py +0 -0
  21. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/context/chunker.py +0 -0
  22. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/__init__.py +0 -0
  23. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/cache.py +0 -0
  24. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/constants.py +0 -0
  25. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/events.py +0 -0
  26. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/middleware.py +0 -0
  27. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/rate_limiter.py +0 -0
  28. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/request.py +0 -0
  29. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/response.py +0 -0
  30. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/router.py +0 -0
  31. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/server.py +0 -0
  32. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/crud/__init__.py +0 -0
  33. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/__init__.py +0 -0
  34. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/adapter.py +0 -0
  35. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/connection.py +0 -0
  36. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/firebird.py +0 -0
  37. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/mongodb.py +0 -0
  38. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/mssql.py +0 -0
  39. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/mysql.py +0 -0
  40. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/odbc.py +0 -0
  41. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/postgres.py +0 -0
  42. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/sqlite.py +0 -0
  43. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/debug/__init__.py +0 -0
  44. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/debug/error_overlay.py +0 -0
  45. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/dev_admin/plan.py +0 -0
  46. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/dev_admin/project_index.py +0 -0
  47. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/docs.py +0 -0
  48. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/docstore/__init__.py +0 -0
  49. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/dotenv/__init__.py +0 -0
  50. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/env.py +0 -0
  51. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/frond/FROND.md +0 -0
  52. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/frond/__init__.py +0 -0
  53. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/frond/compiler.py +0 -0
  54. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/frond/engine.py +0 -0
  55. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/frond/parser.py +0 -0
  56. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/auth/meta.json +0 -0
  57. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  58. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/database/meta.json +0 -0
  59. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  60. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/error-overlay/meta.json +0 -0
  61. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  62. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/orm/meta.json +0 -0
  63. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  64. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  65. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/queue/meta.json +0 -0
  66. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  67. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/rest-api/meta.json +0 -0
  68. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  69. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/templates/meta.json +0 -0
  70. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  71. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  72. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/graphql/__init__.py +0 -0
  73. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/i18n/__init__.py +0 -0
  74. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/mcp/__init__.py +0 -0
  75. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/mcp/protocol.py +0 -0
  76. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/mcp/tools.py +0 -0
  77. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/messenger/__init__.py +0 -0
  78. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/migration/__init__.py +0 -0
  79. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/migration/runner.py +0 -0
  80. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/mqtt/__init__.py +0 -0
  81. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/mqtt/message.py +0 -0
  82. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/orm/__init__.py +0 -0
  83. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/orm/fields.py +0 -0
  84. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/orm/model.py +0 -0
  85. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/__feedback/widget.js +0 -0
  86. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/css/tina4.css +0 -0
  87. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/css/tina4.min.css +0 -0
  88. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/favicon.ico +0 -0
  89. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/images/logo.svg +0 -0
  90. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  91. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/js/frond.js +0 -0
  92. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/js/frond.min.js +0 -0
  93. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/js/tina4.min.js +0 -0
  94. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/js/tina4js.min.js +0 -0
  95. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/swagger/index.html +0 -0
  96. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  97. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/query_builder/__init__.py +0 -0
  98. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/__init__.py +0 -0
  99. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/job.py +0 -0
  100. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/kafka_backend.py +0 -0
  101. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/lite_backend.py +0 -0
  102. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/mongo_backend.py +0 -0
  103. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/rabbitmq_backend.py +0 -0
  104. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue_backends/__init__.py +0 -0
  105. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue_backends/kafka_backend.py +0 -0
  106. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue_backends/mongo_backend.py +0 -0
  107. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  108. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/__init__.py +0 -0
  109. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/Attachment.py +0 -0
  110. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/Channel.py +0 -0
  111. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/ChannelMember.py +0 -0
  112. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/Message.py +0 -0
  113. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/Workspace.py +0 -0
  114. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/__init__.py +0 -0
  115. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/storage.py +0 -0
  116. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/__init__.py +0 -0
  117. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  118. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_badges.scss +0 -0
  119. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  120. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_cards.scss +0 -0
  121. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_forms.scss +0 -0
  122. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_grid.scss +0 -0
  123. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_modals.scss +0 -0
  124. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_nav.scss +0 -0
  125. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_pagination.scss +0 -0
  126. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_reset.scss +0 -0
  127. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_tables.scss +0 -0
  128. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_typography.scss +0 -0
  129. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  130. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_variables.scss +0 -0
  131. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/base.scss +0 -0
  132. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/colors.scss +0 -0
  133. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/tina4.scss +0 -0
  134. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/seeder/__init__.py +0 -0
  135. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/service/__init__.py +0 -0
  136. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/session/__init__.py +0 -0
  137. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/session_handlers/__init__.py +0 -0
  138. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  139. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/session_handlers/redis_handler.py +0 -0
  140. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/session_handlers/valkey_handler.py +0 -0
  141. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/swagger/__init__.py +0 -0
  142. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/components/crud.twig +0 -0
  143. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  144. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  145. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/docker/python/Dockerfile +0 -0
  146. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  147. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/302.twig +0 -0
  148. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/401.twig +0 -0
  149. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/403.twig +0 -0
  150. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/404.twig +0 -0
  151. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/500.twig +0 -0
  152. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/502.twig +0 -0
  153. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/503.twig +0 -0
  154. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/base.twig +0 -0
  155. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/frontend/README.md +0 -0
  156. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/readme.md +0 -0
  157. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/test/__init__.py +0 -0
  158. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/test_client/__init__.py +0 -0
  159. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  160. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  161. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  162. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  163. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  164. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  165. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  166. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  167. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  168. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  169. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  170. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  171. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/validator/__init__.py +0 -0
  172. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/websocket/__init__.py +0 -0
  173. {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/websocket/backplane.py +0 -0
  174. {tina4_python-3.13.90 → tina4_python-3.13.92}/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.90
3
+ Version: 3.13.92
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.90"
3
+ version = "3.13.92"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -196,19 +196,51 @@ def _resolve_secret(secret: str = None) -> str:
196
196
  return env_secret
197
197
 
198
198
 
199
+ # Supported JWT algorithms. HMAC only — the whole family is in hashlib, so this
200
+ # stays zero-dependency. The header's "alg" is now always the one we actually sign
201
+ # with (python#105): the digest is looked up here rather than hardcoded.
202
+ _HMAC_ALGORITHMS = {
203
+ "HS256": hashlib.sha256,
204
+ "HS384": hashlib.sha384,
205
+ "HS512": hashlib.sha512,
206
+ }
207
+
208
+ # Seconds of clock skew tolerated on the "nbf" (not-before) claim. Without this a
209
+ # token minted on one host and validated on another a second behind is rejected
210
+ # for no real reason; RFC 7519 explicitly allows "a small leeway".
211
+ _JWT_LEEWAY_SECONDS = 60
212
+
213
+
214
+ def _resolve_algorithm(algorithm: str = None) -> str:
215
+ """Pick the JWT algorithm: explicit arg, else TINA4_JWT_ALGORITHM, else HS256.
216
+
217
+ Raises ValueError naming the supported set when asked for one we cannot sign
218
+ (python#106 — the env var was registered in the CLI's known_vars and then
219
+ silently ignored, so a user could set HS512 and still get HS256 tokens).
220
+ """
221
+ chosen = (algorithm or os.environ.get("TINA4_JWT_ALGORITHM") or "HS256").strip()
222
+ if chosen not in _HMAC_ALGORITHMS:
223
+ raise ValueError(
224
+ f"Unsupported JWT algorithm {chosen!r}. Tina4 signs with "
225
+ f"{', '.join(sorted(_HMAC_ALGORITHMS))} (HMAC only, zero-dependency). "
226
+ f"Set TINA4_JWT_ALGORITHM to one of those."
227
+ )
228
+ return chosen
229
+
230
+
199
231
  class Auth:
200
232
  """JWT authentication and password hashing — zero dependencies."""
201
233
 
202
- def __init__(self, secret: str = None, algorithm: str = "HS256",
234
+ def __init__(self, secret: str = None, algorithm: str = None,
203
235
  expires_in: int = None):
204
236
  """
205
237
  Args:
206
238
  secret: Signing secret (falls back to the TINA4_SECRET env var).
207
- algorithm: JWT algorithm (default HS256).
239
+ algorithm: JWT algorithm (falls back to TINA4_JWT_ALGORITHM, then HS256).
208
240
  expires_in: Token lifetime in seconds (default 3600).
209
241
  """
210
242
  self.secret = secret if secret is not None else _resolve_secret()
211
- self.algorithm = algorithm
243
+ self.algorithm = _resolve_algorithm(algorithm)
212
244
  # JWT expiry env var:
213
245
  # - TINA4_TOKEN_EXPIRES_IN (preferred — matches docs and the
214
246
  # form-token expiry env var used by Frond)
@@ -275,13 +307,29 @@ class Auth:
275
307
  return None
276
308
 
277
309
  h, p, sig = parts
310
+
311
+ # Pin the algorithm to OUR configured one instead of trusting the
312
+ # token's header. A token asking to be verified as anything else --
313
+ # "none", a weaker HMAC, or an RSA alg we do not implement -- is
314
+ # rejected before any signature work. Matches the Ruby check.
315
+ header = json.loads(_b64url_decode(h))
316
+ if header.get("alg") != self.algorithm:
317
+ return None
318
+
278
319
  expected = self._sign(f"{h}.{p}")
279
320
  if not hmac.compare_digest(sig, expected):
280
321
  return None
281
322
 
282
323
  payload = json.loads(_b64url_decode(p))
283
324
 
284
- if "exp" in payload and time.time() > payload["exp"]:
325
+ now = time.time()
326
+ if "exp" in payload and now > payload["exp"]:
327
+ return None
328
+
329
+ # "nbf" (not-before): a post-dated token is not valid yet. Was
330
+ # honoured only by Ruby, so Python/PHP/Node accepted tokens their
331
+ # issuer had explicitly marked as not-yet-usable (python#107).
332
+ if "nbf" in payload and now + _JWT_LEEWAY_SECONDS < payload["nbf"]:
285
333
  return None
286
334
 
287
335
  return payload
@@ -314,8 +362,10 @@ class Auth:
314
362
  return self.get_token(payload, expires_in=expires_in)
315
363
 
316
364
  def _sign(self, message: str) -> str:
365
+ # Digest comes from the configured algorithm, so the "alg" we advertise in
366
+ # the header is the one that actually produced this signature.
317
367
  sig = hmac.new(
318
- self.secret.encode(), message.encode(), hashlib.sha256
368
+ self.secret.encode(), message.encode(), _HMAC_ALGORITHMS[self.algorithm]
319
369
  ).digest()
320
370
  return _b64url_encode(sig)
321
371
 
@@ -424,16 +474,24 @@ class Auth:
424
474
  # ── Request Auth Helper ───────────────────────────────────────
425
475
 
426
476
  @_DualMethod
427
- def authenticate_request(self, headers: dict, secret: str = None, algorithm: str = "HS256") -> dict | None:
477
+ def authenticate_request(self, headers: dict, secret: str = None, algorithm: str = None) -> dict | None:
428
478
  """Extract and validate auth from request headers.
429
479
 
430
480
  Args:
431
481
  secret: Override signing secret (default: self.secret / TINA4_SECRET env var).
432
- algorithm: JWT algorithm override (default: "HS256").
482
+ algorithm: JWT algorithm override (default: self.algorithm / TINA4_JWT_ALGORITHM).
433
483
 
434
484
  Checks: Bearer JWT, Bearer API key, Basic auth.
435
485
  Returns payload dict on success, None on failure.
436
486
  """
487
+ # Both overrides used to be accepted and then dropped on the floor: the
488
+ # body called self.valid_token(), so a caller passing secret= or
489
+ # algorithm= silently got this instance's values instead. Honour them.
490
+ if secret is not None or algorithm is not None:
491
+ return Auth(secret=secret if secret is not None else self.secret,
492
+ algorithm=algorithm or self.algorithm,
493
+ expires_in=self.expires_in).authenticate_request(headers)
494
+
437
495
  auth_header = headers.get("authorization", "")
438
496
 
439
497
  if auth_header.startswith("Bearer "):
@@ -497,10 +555,10 @@ def refresh_token(token: str, expires_in: int = 60) -> str | None:
497
555
  return Auth.refresh_token_static(token, expires_in=expires_in)
498
556
 
499
557
 
500
- def authenticate_request(headers: dict, secret: str = None, algorithm: str = "HS256") -> dict | None:
558
+ def authenticate_request(headers: dict, secret: str = None, algorithm: str = None) -> dict | None:
501
559
  """Validate auth from request headers — reads TINA4_SECRET from env."""
502
- if secret is not None:
503
- return Auth(secret=secret).authenticate_request(headers, algorithm=algorithm)
560
+ if secret is not None or algorithm is not None:
561
+ return Auth(secret=secret, algorithm=algorithm).authenticate_request(headers)
504
562
  return Auth.authenticate_request_static(headers)
505
563
 
506
564
 
@@ -2041,9 +2041,19 @@ async def _api_metrics(request, response):
2041
2041
 
2042
2042
 
2043
2043
  async def _api_metrics_full(request, response):
2044
- """Full analysis — AST-based, cached 60s."""
2045
- from tina4_python.dev_admin.metrics import full_analysis
2046
- return response(full_analysis())
2044
+ """Full analysis — from the tina4 CLI metrics engine, else the local module.
2045
+
2046
+ The engine (ADR-0002) resolves imports to real file paths, so it is the only
2047
+ source with usable coupling data. It returns None when it is absent or too
2048
+ old to emit a field the dashboard renders, and we fall back rather than
2049
+ serve a payload with holes in it.
2050
+ """
2051
+ from tina4_python.dev_admin.metrics import full_analysis, resolve_scan_target
2052
+ from tina4_python.dev_admin.metrics_engine import engine_analysis
2053
+
2054
+ root, scan_mode = resolve_scan_target()
2055
+ from_engine = engine_analysis(root, scan_mode)
2056
+ return response(from_engine if from_engine is not None else full_analysis())
2047
2057
 
2048
2058
 
2049
2059
  async def _api_metrics_file(request, response):
@@ -23,6 +23,18 @@ _last_scan_root: str = ""
23
23
  # ── Quick Metrics ──────────────────────────────────────────────
24
24
 
25
25
 
26
+ def _is_code_line(line: str) -> bool:
27
+ """True for a line that counts toward LOC: not blank, not a comment.
28
+
29
+ The single definition of the rule. It used to be restated at each file-level
30
+ call site while function LOC ignored it entirely and returned a raw line
31
+ span, so `loc` meant two different things in one payload - see
32
+ _function_loc.
33
+ """
34
+ stripped = line.strip()
35
+ return bool(stripped) and not stripped.startswith("#")
36
+
37
+
26
38
  def _resolve_root(root: str = "src") -> str:
27
39
  """Pick the right directory to scan.
28
40
 
@@ -41,6 +53,23 @@ def _resolve_root(root: str = "src") -> str:
41
53
  return framework_dir
42
54
 
43
55
 
56
+ def resolve_scan_target(root: str = "src") -> tuple[str, str]:
57
+ """Return (directory to scan, scan_mode) for any metrics producer.
58
+
59
+ The CLI engine is language-agnostic and cannot know which directory holds a
60
+ framework package, so root resolution and the "framework" label stay here -
61
+ shared by this module and the engine adapter so the two never disagree about
62
+ what was measured.
63
+ """
64
+ resolved = _resolve_root(root)
65
+ framework_dir = str(Path(__import__("tina4_python").__file__).parent)
66
+ resolved_path = Path(resolved)
67
+ scanning_framework = (
68
+ resolved_path == Path(framework_dir) or str(resolved_path).startswith(framework_dir)
69
+ )
70
+ return resolved, "framework" if scanning_framework else "project"
71
+
72
+
44
73
  def quick_metrics(root: str = "src") -> dict:
45
74
  """Scan project files and return instant metrics."""
46
75
  root = _resolve_root(root)
@@ -224,7 +253,7 @@ def full_analysis(root: str = "src") -> dict:
224
253
  except ValueError:
225
254
  rel_path = (str(f.relative_to(root_path.parent)) if root_path.parent != f else f.name).replace("\\", "/")
226
255
  lines = source.splitlines()
227
- loc = sum(1 for l in lines if l.strip() and not l.strip().startswith("#"))
256
+ loc = sum(1 for l in lines if _is_code_line(l))
228
257
 
229
258
  # Extract imports for coupling analysis
230
259
  imports = _extract_imports(tree, rel_path)
@@ -491,7 +520,7 @@ def file_detail(file_path: str) -> dict:
491
520
 
492
521
  functions.sort(key=lambda x: x["complexity"], reverse=True)
493
522
 
494
- loc = sum(1 for l in lines if l.strip() and not l.strip().startswith("#"))
523
+ loc = sum(1 for l in lines if _is_code_line(l))
495
524
  classes = sum(1 for n in ast.walk(tree) if isinstance(n, ast.ClassDef))
496
525
  imports = _extract_imports(tree, file_path)
497
526
 
@@ -526,13 +555,37 @@ def file_detail(file_path: str) -> dict:
526
555
  # ── AST Helpers ────────────────────────────────────────────────
527
556
 
528
557
 
558
+ def _own_body_nodes(node: ast.AST):
559
+ """Yield the nodes belonging to this function's OWN body.
560
+
561
+ Nested function and class definitions are skipped: they are reported as
562
+ functions in their own right, so counting their decision points here too
563
+ would charge one branch to two different functions. That over-count grew
564
+ with nesting depth - an IIFE-style wrapper or a registrar that defines
565
+ twenty inner handlers absorbed the entire file's complexity and sat at the
566
+ top of the offenders list while the real hot spots hid below it.
567
+
568
+ A lambda is NOT skipped. Lambdas are never listed as separate functions, so
569
+ their decision points would simply vanish; they stay with the function that
570
+ encloses them.
571
+ """
572
+ stack = list(ast.iter_child_nodes(node))
573
+ while stack:
574
+ child = stack.pop()
575
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
576
+ continue
577
+ yield child
578
+ stack.extend(ast.iter_child_nodes(child))
579
+
580
+
529
581
  def _cyclomatic_complexity(node: ast.AST) -> int:
530
582
  """Calculate cyclomatic complexity for a function/method node.
531
583
 
532
584
  CC = 1 + number of decision points (if/elif/for/while/except/and/or/assert)
585
+ in the function's own body. Nested functions are measured separately.
533
586
  """
534
587
  cc = 1
535
- for child in ast.walk(node):
588
+ for child in _own_body_nodes(node):
536
589
  if isinstance(child, (ast.If, ast.IfExp)):
537
590
  cc += 1
538
591
  elif isinstance(child, ast.For):
@@ -553,9 +606,18 @@ def _cyclomatic_complexity(node: ast.AST) -> int:
553
606
 
554
607
 
555
608
  def _function_loc(node: ast.AST, lines: list) -> int:
556
- """Count lines of code in a function."""
609
+ """Count lines of CODE in a function, by the same rule as file-level LOC.
610
+
611
+ This returned `end_lineno - lineno + 1` - a raw line span - while the
612
+ docstring claimed lines of code and every file-level LOC excluded blanks and
613
+ comments. So a function's LOC and its file's LOC were different units sitting
614
+ side by side in the dashboard, and the maintainability index (which divides
615
+ by LOC) penalised a well-commented function for its comments.
616
+ """
557
617
  if hasattr(node, "end_lineno") and node.end_lineno:
558
- return node.end_lineno - node.lineno + 1
618
+ span = lines[node.lineno - 1:node.end_lineno]
619
+ # Floor of 1: a one-line body must never report 0.
620
+ return max(1, sum(1 for l in span if _is_code_line(l)))
559
621
  # Fallback: count indented lines
560
622
  start = node.lineno - 1
561
623
  count = 1
@@ -0,0 +1,108 @@
1
+ """Code metrics from the tina4 CLI engine (ADR-0002).
2
+
3
+ The metrics engine now lives in the Rust CLI: one tree-sitter implementation
4
+ covering every language, instead of a hand-rolled AST walker per framework. This
5
+ adapter shells out to it and reshapes the payload into the dashboard's contract.
6
+
7
+ It returns None whenever the CLI cannot supply a COMPLETE payload - no binary on
8
+ PATH, a non-zero exit, unparseable JSON, or an older CLI whose engine predates a
9
+ field the dashboard renders. The caller then falls back to the in-framework
10
+ module, so a stale CLI degrades quietly instead of blanking the chart.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import shutil
17
+ import subprocess
18
+ from pathlib import Path
19
+
20
+ # The dashboard reads these at the top level; the CLI nests them under "summary"
21
+ # because its own text report and the --fail-on gate group them there.
22
+ _SUMMARY_KEYS = (
23
+ "files_analyzed",
24
+ "total_functions",
25
+ "avg_complexity",
26
+ "avg_maintainability",
27
+ )
28
+
29
+ # Fields the dashboard renders that a pre-3.8.59 engine does not emit. Checking
30
+ # for the DATA is honest where checking a version string is not: a user may run
31
+ # any CLI build, and the payload tells us what it can actually do.
32
+ _REQUIRED_FILE_KEYS = ("path", "loc", "avg_complexity", "maintainability", "has_tests")
33
+ _REQUIRED_FUNCTION_KEYS = ("name", "file", "line", "complexity", "loc")
34
+
35
+ _TIMEOUT_SECONDS = 60
36
+
37
+
38
+ def engine_path() -> str | None:
39
+ """Absolute path to the tina4 CLI binary, or None when it is not installed."""
40
+ return shutil.which("tina4")
41
+
42
+
43
+ def engine_analysis(root: str, scan_mode: str = "project") -> dict | None:
44
+ """Run `tina4 metrics --json` over root and adapt it to the dashboard shape.
45
+
46
+ The framework passes --path and owns scan_mode: it knows where its own
47
+ package lives, and the engine is deliberately language-agnostic, so root
48
+ resolution stays on this side of the boundary.
49
+ """
50
+ binary = engine_path()
51
+ if binary is None:
52
+ return None
53
+
54
+ try:
55
+ proc = subprocess.run(
56
+ [binary, "metrics", "--path", str(root), "--json"],
57
+ capture_output=True,
58
+ text=True,
59
+ timeout=_TIMEOUT_SECONDS,
60
+ )
61
+ except (OSError, subprocess.SubprocessError):
62
+ return None
63
+
64
+ if proc.returncode != 0 or not proc.stdout.strip():
65
+ return None
66
+
67
+ try:
68
+ payload = json.loads(proc.stdout)
69
+ except (json.JSONDecodeError, ValueError):
70
+ return None
71
+
72
+ return adapt_payload(payload, root, scan_mode)
73
+
74
+
75
+ def adapt_payload(payload: dict, root: str, scan_mode: str = "project") -> dict | None:
76
+ """Flatten the engine payload into the dashboard contract, or None if short.
77
+
78
+ Split out from engine_analysis so the reshaping is testable without a
79
+ subprocess - the shaping is where the contract actually lives.
80
+ """
81
+ if not isinstance(payload, dict):
82
+ return None
83
+
84
+ summary = payload.get("summary")
85
+ file_metrics = payload.get("file_metrics")
86
+ functions = payload.get("most_complex_functions")
87
+ if not isinstance(summary, dict) or not isinstance(file_metrics, list):
88
+ return None
89
+ if not isinstance(functions, list):
90
+ return None
91
+
92
+ if any(key not in summary for key in _SUMMARY_KEYS):
93
+ return None
94
+ if file_metrics and any(key not in file_metrics[0] for key in _REQUIRED_FILE_KEYS):
95
+ return None
96
+ if functions and any(key not in functions[0] for key in _REQUIRED_FUNCTION_KEYS):
97
+ return None
98
+
99
+ result = {key: summary[key] for key in _SUMMARY_KEYS}
100
+ result["file_metrics"] = file_metrics
101
+ result["most_complex_functions"] = functions[:15]
102
+ result["dependency_graph"] = payload.get("dependency_graph") or {}
103
+ # The framework decides these two - the engine always reports "project"
104
+ # because it cannot know which directory is a framework package.
105
+ result["scan_mode"] = scan_mode
106
+ result["scan_root"] = str(Path(root).resolve())
107
+ result["engine"] = "tina4-cli"
108
+ return result