tina4-python 3.13.28__tar.gz → 3.13.30__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 (157) hide show
  1. {tina4_python-3.13.28 → tina4_python-3.13.30}/PKG-INFO +1 -1
  2. {tina4_python-3.13.28 → tina4_python-3.13.30}/pyproject.toml +1 -1
  3. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/CLAUDE.md +1 -1
  4. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/core/router.py +48 -7
  6. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/docs.py +117 -31
  7. {tina4_python-3.13.28 → tina4_python-3.13.30}/.gitignore +0 -0
  8. {tina4_python-3.13.28 → tina4_python-3.13.30}/README.md +0 -0
  9. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/HtmlElement.py +0 -0
  10. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/Testing.py +0 -0
  11. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/ai/__init__.py +0 -0
  12. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/api/__init__.py +0 -0
  13. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/auth/__init__.py +0 -0
  14. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/cache/__init__.py +0 -0
  15. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/cli/__init__.py +0 -0
  16. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/container/__init__.py +0 -0
  17. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/core/__init__.py +0 -0
  18. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/core/cache.py +0 -0
  19. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/core/constants.py +0 -0
  20. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/core/events.py +0 -0
  21. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/core/middleware.py +0 -0
  22. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/core/rate_limiter.py +0 -0
  23. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/core/request.py +0 -0
  24. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/core/response.py +0 -0
  25. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/core/server.py +0 -0
  26. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/crud/__init__.py +0 -0
  27. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/database/__init__.py +0 -0
  28. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/database/adapter.py +0 -0
  29. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/database/connection.py +0 -0
  30. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/database/firebird.py +0 -0
  31. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/database/mongodb.py +0 -0
  32. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/database/mssql.py +0 -0
  33. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/database/mysql.py +0 -0
  34. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/database/odbc.py +0 -0
  35. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/database/postgres.py +0 -0
  36. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/database/sqlite.py +0 -0
  37. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/debug/__init__.py +0 -0
  38. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/debug/error_overlay.py +0 -0
  39. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/dev_admin/__init__.py +0 -0
  40. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/dev_admin/metrics.py +0 -0
  41. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/dev_admin/plan.py +0 -0
  42. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/dev_admin/project_index.py +0 -0
  43. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/dotenv/__init__.py +0 -0
  44. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/env.py +0 -0
  45. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/frond/FROND.md +0 -0
  46. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/frond/__init__.py +0 -0
  47. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/frond/engine.py +0 -0
  48. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/auth/meta.json +0 -0
  49. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  50. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/database/meta.json +0 -0
  51. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  52. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/error-overlay/meta.json +0 -0
  53. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  54. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/orm/meta.json +0 -0
  55. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  56. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  57. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/queue/meta.json +0 -0
  58. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  59. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/rest-api/meta.json +0 -0
  60. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  61. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/templates/meta.json +0 -0
  62. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  63. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  64. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/graphql/__init__.py +0 -0
  65. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/i18n/__init__.py +0 -0
  66. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/mcp/__init__.py +0 -0
  67. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/mcp/protocol.py +0 -0
  68. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/mcp/tools.py +0 -0
  69. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/messenger/__init__.py +0 -0
  70. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/migration/__init__.py +0 -0
  71. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/migration/runner.py +0 -0
  72. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/orm/__init__.py +0 -0
  73. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/orm/fields.py +0 -0
  74. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/orm/model.py +0 -0
  75. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/__feedback/widget.js +0 -0
  76. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/css/tina4.css +0 -0
  77. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/css/tina4.min.css +0 -0
  78. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/favicon.ico +0 -0
  79. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/images/logo.svg +0 -0
  80. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  81. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/js/frond.js +0 -0
  82. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/js/frond.min.js +0 -0
  83. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  84. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  85. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/js/tina4.min.js +0 -0
  86. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/js/tina4js.min.js +0 -0
  87. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/swagger/index.html +0 -0
  88. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  89. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/query_builder/__init__.py +0 -0
  90. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/queue/__init__.py +0 -0
  91. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/queue/job.py +0 -0
  92. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/queue/kafka_backend.py +0 -0
  93. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/queue/lite_backend.py +0 -0
  94. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.28 → tina4_python-3.13.30}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.28 → tina4_python-3.13.30}/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.28
3
+ Version: 3.13.30
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.28"
3
+ version = "3.13.30"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -529,7 +529,7 @@ TINA4_SESSION_SAMESITE=Lax # SameSite attribute for session cookie
529
529
 
530
530
  ### Authentication & Security
531
531
  - Use `Auth.hash_password()` from `tina4_python.auth` to hash passwords — never use hashlib directly.
532
- - Use `Auth.check_password(hash, password)` from `tina4_python.auth` to verify passwords.
532
+ - Use `Auth.check_password(password, hash)` from `tina4_python.auth` to verify passwords.
533
533
 
534
534
  ## Templates (Twig)
535
535
 
@@ -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.28"
11
+ __version__ = "3.13.30"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -171,11 +171,12 @@ class Router:
171
171
  - ``event`` is ``"open"``, ``"message"``, or ``"close"``
172
172
  - ``data`` is the message payload (str for message, None for open/close)
173
173
  """
174
- pattern, param_names = _compile_pattern(path)
174
+ pattern, param_names, param_types = _compile_pattern(path)
175
175
  route = {
176
176
  "path": path,
177
177
  "pattern": pattern,
178
178
  "param_names": param_names,
179
+ "param_types": param_types,
179
180
  "handler": handler,
180
181
  }
181
182
  _ws_routes.append(route)
@@ -188,8 +189,9 @@ class Router:
188
189
  m = route["pattern"].match(path)
189
190
  if m:
190
191
  params = {}
192
+ _types = route.get("param_types", {})
191
193
  for i, name in enumerate(route["param_names"]):
192
- params[name] = m.group(i + 1)
194
+ params[name] = _cast_param(m.group(i + 1), _types.get(name))
193
195
  return route, params
194
196
  return None, {}
195
197
 
@@ -303,7 +305,7 @@ class Router:
303
305
  combined_mw = list(cls._group_middleware) + list(handler_mw) + route_mw
304
306
  effective_middleware = combined_mw or []
305
307
 
306
- pattern, param_names = _compile_pattern(path)
308
+ pattern, param_names, param_types = _compile_pattern(path)
307
309
 
308
310
  # Auth default: GET=public, writes=secured (unless custom middleware handles auth)
309
311
  m = method.upper()
@@ -329,6 +331,7 @@ class Router:
329
331
  "path": path,
330
332
  "pattern": pattern,
331
333
  "param_names": param_names,
334
+ "param_types": param_types,
332
335
  "handler": handler,
333
336
  "middleware": effective_middleware,
334
337
  "auth_required": auth_required,
@@ -359,8 +362,9 @@ class Router:
359
362
  m = route["pattern"].match(path)
360
363
  if m:
361
364
  params = {}
365
+ _types = route.get("param_types", {})
362
366
  for i, name in enumerate(route["param_names"]):
363
- params[name] = m.group(i + 1)
367
+ params[name] = _cast_param(m.group(i + 1), _types.get(name))
364
368
  return route, params
365
369
 
366
370
  # Second pass: HEAD auto-fallback to GET when no HEAD route registered
@@ -371,8 +375,9 @@ class Router:
371
375
  m = route["pattern"].match(path)
372
376
  if m:
373
377
  params = {}
378
+ _types = route.get("param_types", {})
374
379
  for i, name in enumerate(route["param_names"]):
375
- params[name] = m.group(i + 1)
380
+ params[name] = _cast_param(m.group(i + 1), _types.get(name))
376
381
  return route, params
377
382
 
378
383
  return None, {}
@@ -454,8 +459,36 @@ _TYPE_PATTERNS = {
454
459
  ".*": ".+",
455
460
  }
456
461
 
462
+ # Type names whose captured value is coerced from str to a Python scalar before
463
+ # it reaches the handler. Mirrors Ruby's ``cast_param`` (lib/tina4/router.rb):
464
+ # ``int``/``integer`` → ``int``, ``float``/``number`` → ``float``. Every other
465
+ # type (string, alpha, alnum, slug, uuid, path) and untyped params stay ``str``.
466
+ _TYPE_CASTS = {
467
+ "int": int,
468
+ "integer": int,
469
+ "float": float,
470
+ "number": float,
471
+ }
472
+
457
473
 
458
- def _compile_pattern(path: str) -> tuple[re.Pattern, list[str]]:
474
+ def _cast_param(value: str, type_hint: str | None):
475
+ """Coerce a captured route param to its declared Python type.
476
+
477
+ The URL regex already guarantees the segment matches the type's pattern
478
+ (e.g. ``{id:int}`` only matches digits), so the cast normally can't fail.
479
+ We still guard it: a coercion failure must never crash routing — fall back
480
+ to the raw string, exactly as Ruby leaves unknown types untouched.
481
+ """
482
+ caster = _TYPE_CASTS.get(type_hint)
483
+ if caster is None:
484
+ return value
485
+ try:
486
+ return caster(value)
487
+ except (TypeError, ValueError):
488
+ return value
489
+
490
+
491
+ def _compile_pattern(path: str) -> tuple[re.Pattern, list[str], dict[str, str]]:
459
492
  """Convert a route path to a regex pattern.
460
493
 
461
494
  Supports:
@@ -468,9 +501,16 @@ def _compile_pattern(path: str) -> tuple[re.Pattern, list[str]]:
468
501
  /api/files/{p:path} → greedy (matches remaining path)
469
502
  /api/docs/* → bare-wildcard catch-all (key "*")
470
503
 
504
+ Returns ``(pattern, param_names, param_types)`` where ``param_types`` maps
505
+ each declared name to its type hint (``"int"``, ``"float"``, …). Untyped
506
+ params and the ``*`` wildcard are absent from the map. The map drives
507
+ coercion in :meth:`Router.match` so typed params arrive at the handler as
508
+ Python scalars (mirrors Ruby's ``cast_param``).
509
+
471
510
  Unknown type names raise ``ValueError`` at route registration time.
472
511
  """
473
512
  param_names = []
513
+ param_types: dict[str, str] = {}
474
514
  regex_parts = []
475
515
 
476
516
  segments = path.strip("/").split("/")
@@ -490,6 +530,7 @@ def _compile_pattern(path: str) -> tuple[re.Pattern, list[str]]:
490
530
  f"Valid types: {', '.join(sorted(k for k in _TYPE_PATTERNS if k != '.*'))}."
491
531
  )
492
532
  regex_parts.append("(" + _TYPE_PATTERNS[type_hint] + ")")
533
+ param_types[name] = type_hint
493
534
  else:
494
535
  name = inner
495
536
  regex_parts.append("([^/]+)")
@@ -498,7 +539,7 @@ def _compile_pattern(path: str) -> tuple[re.Pattern, list[str]]:
498
539
  regex_parts.append(re.escape(segment))
499
540
 
500
541
  pattern_str = "^/" + "/".join(regex_parts) + "/?$"
501
- return re.compile(pattern_str), param_names
542
+ return re.compile(pattern_str), param_names, param_types
502
543
 
503
544
 
504
545
  # Decorator functions — the public API
@@ -139,6 +139,62 @@ def _safe_signature(obj) -> str:
139
139
  return "(...)"
140
140
 
141
141
 
142
+ # Leading parameter names that are receivers, never passed by callers — dropped
143
+ # from public signatures (covers self/cls and the (cls, instance) pair used by
144
+ # dual class/instance descriptors).
145
+ _RECEIVER_PARAMS = {"self", "cls", "mcs", "metacls", "instance", "owner"}
146
+
147
+
148
+ def _render_signature(func, display_name: str) -> str:
149
+ """Public signature for `display_name`, dropping leading receiver params."""
150
+ try:
151
+ sig = inspect.signature(func)
152
+ except (TypeError, ValueError):
153
+ return f"{display_name}(...)"
154
+ params = list(sig.parameters.values())
155
+ while params and params[0].name in _RECEIVER_PARAMS and params[0].kind in (
156
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
157
+ inspect.Parameter.POSITIONAL_ONLY,
158
+ ):
159
+ params = params[1:]
160
+ try:
161
+ sig = sig.replace(parameters=params)
162
+ except (TypeError, ValueError):
163
+ pass
164
+ return f"{display_name}{sig}"
165
+
166
+
167
+ def _unwrap_callable(raw, owner, mname: str):
168
+ """Resolve a raw class-dict value to (func, is_static, is_class).
169
+
170
+ `func` is the function to introspect for signature/doc/source, or `None`
171
+ for a non-callable class attribute (which the caller skips). Handles plain
172
+ functions, @staticmethod/@classmethod, properties, and custom descriptors
173
+ such as Frond's dual class/instance method (whose wrapper's __qualname__
174
+ would otherwise hide it from the owner check)."""
175
+ if isinstance(raw, staticmethod):
176
+ return raw.__func__, True, False
177
+ if isinstance(raw, classmethod):
178
+ return raw.__func__, False, True
179
+ if isinstance(raw, property):
180
+ return (raw.fget, False, False) if raw.fget else (None, False, False)
181
+ if inspect.isfunction(raw):
182
+ return raw, False, False
183
+ # Custom descriptor — prefer the function it wraps.
184
+ for attr in ("func", "__func__", "__wrapped__"):
185
+ f = getattr(raw, attr, None)
186
+ if inspect.isfunction(f):
187
+ return f, False, False
188
+ # Descriptor that only yields a callable through __get__ (last resort).
189
+ try:
190
+ bound = getattr(owner, mname)
191
+ if inspect.isfunction(bound) or inspect.ismethod(bound):
192
+ return bound, False, False
193
+ except Exception:
194
+ pass
195
+ return None, False, False
196
+
197
+
142
198
  def _is_private_name(name: str) -> bool:
143
199
  """Dunder stays hidden always. Single-underscore is private (opt-in)."""
144
200
  if name.startswith("__") and name.endswith("__"):
@@ -226,32 +282,16 @@ def _reflect_framework(version: str) -> list[_Entity]:
226
282
  source="framework",
227
283
  ))
228
284
 
229
- # Methods both regular functions and @classmethod/@staticmethod wrappers.
230
- for mname, mobj in inspect.getmembers(obj):
285
+ # Methods defined DIRECTLY on this class (inherited ones are skipped).
286
+ # Membership in obj.__dict__ is the reliable owner test — comparing
287
+ # __qualname__ breaks for methods built by custom descriptors (e.g.
288
+ # Frond's dual class/instance methods), which would silently vanish.
289
+ for mname, raw in list(obj.__dict__.items()):
231
290
  if _is_private_name(mname):
232
291
  continue
233
- is_static = False
234
- is_class = False
235
- # Pick real callable
236
- raw = obj.__dict__.get(mname, None)
237
- if isinstance(raw, staticmethod):
238
- is_static = True
239
- func = raw.__func__
240
- elif isinstance(raw, classmethod):
241
- is_class = True
242
- func = raw.__func__
243
- elif inspect.isfunction(mobj) or inspect.ismethod(mobj):
244
- func = mobj
245
- else:
246
- continue
247
-
248
- # Only include methods actually defined on this class (not inherited).
249
- if func.__qualname__.split(".")[0] != obj.__qualname__.split(".")[-1]:
250
- # Heuristic: compare the first qualname segment to class name.
251
- # If the method's __qualname__ doesn't start with this class, skip.
252
- owner = func.__qualname__.rsplit(".", 1)[0]
253
- if owner != obj.__qualname__:
254
- continue
292
+ func, is_static, is_class = _unwrap_callable(raw, obj, mname)
293
+ if func is None:
294
+ continue # non-callable class attribute
255
295
 
256
296
  try:
257
297
  mfile = inspect.getsourcefile(func) or src_file
@@ -262,7 +302,7 @@ def _reflect_framework(version: str) -> list[_Entity]:
262
302
 
263
303
  mdoc = inspect.getdoc(func) or ""
264
304
  msummary = _summary_from_doc(mdoc)
265
- sig = _safe_signature(func)
305
+ sig = _render_signature(func, mname)
266
306
 
267
307
  out.append(_Entity(
268
308
  fqn=f"{fqn_class}.{mname}",
@@ -478,6 +518,24 @@ def _score(entity: _Entity, tokens: list[str], raw_query: str) -> float:
478
518
  for tok in tokens:
479
519
  if tok in name_lower:
480
520
  score += 0.5
521
+ # Class-qualified queries ("Frond.add_test", "ORM save"): score the owning
522
+ # class so the qualifier actually steers ranking instead of being dead weight.
523
+ parent_lower = (entity.parent_class or "").lower()
524
+ rq = raw_query.strip().lower()
525
+ if parent_lower:
526
+ # Exact "Class.method" intent — the strongest signal we have.
527
+ if rq == f"{parent_lower}.{name_lower}":
528
+ score += 6.0
529
+ for tok in tokens:
530
+ if tok == parent_lower:
531
+ score += 2.5
532
+ elif parent_lower.startswith(tok):
533
+ score += 1.0
534
+ # Any token that is a whole segment of the fqn (module / class / name).
535
+ fqn_segs = set(re.split(r"[.\s]+", entity.fqn.lower()))
536
+ for tok in tokens:
537
+ if tok in fqn_segs:
538
+ score += 1.0
481
539
  if entity.source == "user":
482
540
  score *= 1.2
483
541
  return score
@@ -587,14 +645,40 @@ class Docs:
587
645
  scored.sort(key=lambda p: (-p[1], p[0].fqn))
588
646
  return [e.as_hit(s) for (e, s) in scored[:k]]
589
647
 
648
+ def _resolve_class(self, given: str) -> _Entity | None:
649
+ """Resolve a class by exact FQN, public import path, or bare name.
650
+
651
+ Callers naturally use the documented import path (`tina4_python.database
652
+ .Database`) or a bare name (`Database`), but the index stores the deep
653
+ defining-module FQN (`tina4_python.database.connection.Database`). Match
654
+ exactly first, then by class name, disambiguating by requiring the given
655
+ dotted segments to appear in the stored FQN (framework + shortest wins)."""
656
+ classes = [
657
+ e for e in (self._framework + self._user) if e.kind == "class"
658
+ ]
659
+ for e in classes: # 1. exact
660
+ if e.fqn == given:
661
+ return e
662
+ gsegs = [s for s in given.split(".") if s]
663
+ gname = gsegs[-1] if gsegs else given
664
+ cands = [e for e in classes if e.fqn.split(".")[-1] == gname]
665
+ if len(cands) == 1:
666
+ return cands[0]
667
+ if cands: # 2. disambiguate by segment subset
668
+ subset = [
669
+ e for e in cands
670
+ if all(s in e.fqn.split(".") for s in gsegs)
671
+ ]
672
+ pool = subset or cands
673
+ pool.sort(key=lambda e: (e.source != "framework", len(e.fqn), e.fqn))
674
+ return pool[0]
675
+ return None
676
+
590
677
  def class_spec(self, fqn: str) -> dict[str, Any] | None:
591
678
  """Full reflection of a class — `None` if not found."""
592
679
  self._ensure_index()
593
680
  all_entities = self._framework + self._user
594
- klass = next(
595
- (e for e in all_entities if e.kind == "class" and e.fqn == fqn),
596
- None,
597
- )
681
+ klass = self._resolve_class(fqn)
598
682
  if klass is None:
599
683
  return None
600
684
  methods = [
@@ -610,7 +694,7 @@ class Docs:
610
694
  "source": m.source,
611
695
  }
612
696
  for m in all_entities
613
- if m.kind == "method" and m.class_fqn == fqn
697
+ if m.kind == "method" and m.class_fqn == klass.fqn
614
698
  ]
615
699
  return {
616
700
  "fqn": klass.fqn,
@@ -629,8 +713,10 @@ class Docs:
629
713
  self, class_fqn: str, method_name: str,
630
714
  ) -> dict[str, Any] | None:
631
715
  self._ensure_index()
716
+ klass = self._resolve_class(class_fqn)
717
+ resolved = klass.fqn if klass else class_fqn
632
718
  for e in self._framework + self._user:
633
- if e.kind == "method" and e.class_fqn == class_fqn and e.name == method_name:
719
+ if e.kind == "method" and e.class_fqn == resolved and e.name == method_name:
634
720
  return {
635
721
  "fqn": e.fqn,
636
722
  "name": e.name,
File without changes