tina4-python 3.13.116__tar.gz → 3.13.118__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 (170) hide show
  1. {tina4_python-3.13.116 → tina4_python-3.13.118}/PKG-INFO +1 -1
  2. {tina4_python-3.13.116 → tina4_python-3.13.118}/pyproject.toml +1 -1
  3. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/__init__.py +10 -1
  4. tina4_python-3.13.118/tina4_python/_import_helper.py +140 -0
  5. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/cli/__init__.py +274 -3
  6. {tina4_python-3.13.116 → tina4_python-3.13.118}/.gitignore +0 -0
  7. {tina4_python-3.13.116 → tina4_python-3.13.118}/README.md +0 -0
  8. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/CLAUDE.md +0 -0
  9. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/HtmlElement.py +0 -0
  10. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/Testing.py +0 -0
  11. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/ai/__init__.py +0 -0
  12. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/ai/client.py +0 -0
  13. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/api/__init__.py +0 -0
  14. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/auth/__init__.py +0 -0
  15. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/cache/__init__.py +0 -0
  16. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/container/__init__.py +0 -0
  17. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/context/__init__.py +0 -0
  18. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/context/chunker.py +0 -0
  19. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/core/__init__.py +0 -0
  20. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/core/cache.py +0 -0
  21. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/core/constants.py +0 -0
  22. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/core/events.py +0 -0
  23. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/core/middleware.py +0 -0
  24. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/core/port_takeover.py +0 -0
  25. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/core/rate_limiter.py +0 -0
  26. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/core/request.py +0 -0
  27. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/core/response.py +0 -0
  28. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/core/router.py +0 -0
  29. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/core/server.py +0 -0
  30. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/crud/__init__.py +0 -0
  31. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/__init__.py +0 -0
  32. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/adapter.py +0 -0
  33. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/connection.py +0 -0
  34. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/database_url.py +0 -0
  35. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/firebird.py +0 -0
  36. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/mongodb.py +0 -0
  37. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/mssql.py +0 -0
  38. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/mysql.py +0 -0
  39. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/odbc.py +0 -0
  40. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/postgres.py +0 -0
  41. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/sql_translator.py +0 -0
  42. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/database/sqlite.py +0 -0
  43. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/debug/__init__.py +0 -0
  44. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/debug/error_overlay.py +0 -0
  45. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/dev_admin/__init__.py +0 -0
  46. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/dev_admin/metrics.py +0 -0
  47. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/dev_admin/plan.py +0 -0
  48. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/dev_admin/project_index.py +0 -0
  49. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/docs.py +0 -0
  50. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/docstore/__init__.py +0 -0
  51. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/dotenv/__init__.py +0 -0
  52. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/env.py +0 -0
  53. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/frond/FROND.md +0 -0
  54. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/frond/__init__.py +0 -0
  55. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/frond/compiler.py +0 -0
  56. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/frond/engine.py +0 -0
  57. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/frond/parser.py +0 -0
  58. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/auth/meta.json +0 -0
  59. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  60. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/database/meta.json +0 -0
  61. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  62. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/error-overlay/meta.json +0 -0
  63. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  64. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/orm/meta.json +0 -0
  65. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  66. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  67. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/queue/meta.json +0 -0
  68. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  69. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/rest-api/meta.json +0 -0
  70. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  71. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/templates/meta.json +0 -0
  72. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  73. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  74. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/graph/__init__.py +0 -0
  75. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/graph/adapter.py +0 -0
  76. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/graph/adapters/__init__.py +0 -0
  77. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/graph/adapters/arango.py +0 -0
  78. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/graph/adapters/bolt.py +0 -0
  79. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/graph/adapters/ultipa.py +0 -0
  80. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/graph/graph_url.py +0 -0
  81. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/graphql/__init__.py +0 -0
  82. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/i18n/__init__.py +0 -0
  83. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/mcp/__init__.py +0 -0
  84. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/mcp/protocol.py +0 -0
  85. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/mcp/tools.py +0 -0
  86. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/messenger/__init__.py +0 -0
  87. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/migration/__init__.py +0 -0
  88. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/migration/runner.py +0 -0
  89. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/mqtt/__init__.py +0 -0
  90. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/mqtt/message.py +0 -0
  91. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/orm/__init__.py +0 -0
  92. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/orm/fields.py +0 -0
  93. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/orm/model.py +0 -0
  94. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/orm/point.py +0 -0
  95. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/__feedback/widget.js +0 -0
  96. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/css/tina4.css +0 -0
  97. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/css/tina4.min.css +0 -0
  98. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/favicon.ico +0 -0
  99. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/images/logo.svg +0 -0
  100. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  101. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/js/frond.js +0 -0
  102. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/js/frond.min.js +0 -0
  103. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  104. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/js/tina4.min.js +0 -0
  105. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/js/tina4js.min.js +0 -0
  106. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/swagger/index.html +0 -0
  107. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  108. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/query_builder/__init__.py +0 -0
  109. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/queue/__init__.py +0 -0
  110. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/queue/amqp_url.py +0 -0
  111. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/queue/job.py +0 -0
  112. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/queue/kafka_backend.py +0 -0
  113. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/queue/lite_backend.py +0 -0
  114. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/queue/mongo_backend.py +0 -0
  115. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/queue/rabbitmq_backend.py +0 -0
  116. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/queue_backends/__init__.py +0 -0
  117. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/queue_backends/kafka_backend.py +0 -0
  118. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/queue_backends/mongo_backend.py +0 -0
  119. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  120. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/realtime/__init__.py +0 -0
  121. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/realtime/models/Attachment.py +0 -0
  122. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/realtime/models/Channel.py +0 -0
  123. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/realtime/models/ChannelMember.py +0 -0
  124. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/realtime/models/Message.py +0 -0
  125. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/realtime/models/Workspace.py +0 -0
  126. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/realtime/models/__init__.py +0 -0
  127. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/realtime/storage.py +0 -0
  128. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/seeder/__init__.py +0 -0
  129. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/service/__init__.py +0 -0
  130. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/session/__init__.py +0 -0
  131. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/session_handlers/__init__.py +0 -0
  132. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/session_handlers/memcached_handler.py +0 -0
  133. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  134. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/session_handlers/redis_handler.py +0 -0
  135. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/session_handlers/valkey_handler.py +0 -0
  136. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/sso.py +0 -0
  137. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/swagger/__init__.py +0 -0
  138. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/components/crud.twig +0 -0
  139. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  140. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  141. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/docker/python/Dockerfile +0 -0
  142. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  143. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/errors/302.twig +0 -0
  144. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/errors/401.twig +0 -0
  145. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/errors/403.twig +0 -0
  146. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/errors/404.twig +0 -0
  147. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/errors/500.twig +0 -0
  148. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/errors/502.twig +0 -0
  149. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/errors/503.twig +0 -0
  150. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/errors/base.twig +0 -0
  151. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/frontend/README.md +0 -0
  152. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/templates/readme.md +0 -0
  153. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/test/__init__.py +0 -0
  154. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/test_client/__init__.py +0 -0
  155. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  156. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  157. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  158. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  159. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  160. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  161. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  162. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  163. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  164. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  165. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  166. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  167. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/validator/__init__.py +0 -0
  168. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/websocket/__init__.py +0 -0
  169. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/websocket/backplane.py +0 -0
  170. {tina4_python-3.13.116 → tina4_python-3.13.118}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tina4-python
3
- Version: 3.13.116
3
+ Version: 3.13.118
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.116"
3
+ version = "3.13.118"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.116"
54
+ return "3.13.118"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -221,3 +221,12 @@ def _preload_from_manifest() -> None:
221
221
 
222
222
 
223
223
  _preload_from_manifest()
224
+
225
+
226
+ # ── Import-hint fallback (ADR-0062, agent-experience contract) ──
227
+ # Registered LAST so Python's normal resolution runs first — this finder is
228
+ # only consulted for `tina4_python.<X>` names that every other finder already
229
+ # failed on, which is what keeps it from ever masking a legitimate ImportError
230
+ # raised inside a module that DOES exist. See tina4_python/_import_helper.py.
231
+ from tina4_python._import_helper import install as _install_import_helper # noqa: E402
232
+ _install_import_helper()
@@ -0,0 +1,140 @@
1
+ """
2
+ tina4_python._import_helper — turn a wrong `tina4_python.<X>` import into a
3
+ helpful ModuleNotFoundError that names the closest real module in the tree.
4
+
5
+ The finder registers LAST on sys.meta_path, so Python's normal resolution runs
6
+ first. We're only consulted for names that all other finders already gave up
7
+ on — which means we never mask a legitimate ImportError raised deeper inside
8
+ a module that DOES exist. If we're being asked, the module truly does not
9
+ exist under this name.
10
+
11
+ The suggestion is derived from the REAL installed tree, read from DISK, so a
12
+ rename in the framework updates the hint automatically. There is no
13
+ hand-maintained wrong-guess list.
14
+
15
+ The tree is enumerated with the filesystem rather than pkgutil.walk_packages()
16
+ on purpose: walk_packages IMPORTS every package it descends into, because
17
+ recursing needs each package's __path__. Doing that here — from
18
+ tina4_python/__init__.py at import time — has two effects, and both are bugs:
19
+ it defeats the lazy subsystem loading, and it binds every subpackage as an
20
+ attribute on tina4_python, which shadows the callables __getattr__ exists to
21
+ provide. That second one is why `realtime` resolved to a module and stopped
22
+ being callable. See tests/test_import_helper_does_not_import_the_tree.py.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import importlib
27
+ import pkgutil
28
+ import sys
29
+ from pathlib import Path
30
+ from difflib import get_close_matches
31
+ from importlib.abc import MetaPathFinder
32
+ from typing import Sequence
33
+
34
+
35
+ _PREFIX = "tina4_python."
36
+
37
+
38
+ class _Tina4ModuleFinder(MetaPathFinder):
39
+ """Last-resort finder that turns tina4_python.X misses into hints."""
40
+
41
+ def __init__(self) -> None:
42
+ # Snapshot the tree once at install time. If a plugin registers a new
43
+ # submodule after install, that plugin's own module load succeeds via
44
+ # the normal finders BEFORE we're consulted, so the snapshot only
45
+ # matters for the "did-you-mean" list. A missing plugin module still
46
+ # produces a helpful hint, just without the plugin's name in it.
47
+ self._known: tuple[str, ...] = tuple(self._walk())
48
+
49
+ @staticmethod
50
+ def _walk() -> Sequence[str]:
51
+ try:
52
+ pkg = importlib.import_module("tina4_python")
53
+ names: list[str] = []
54
+ for root in pkg.__path__:
55
+ base = Path(root)
56
+ for path in base.rglob("*"):
57
+ if path.name.startswith((".", "_")) and path.name != "__init__.py":
58
+ continue
59
+ if path.is_dir():
60
+ if (path / "__init__.py").is_file():
61
+ names.append(_PREFIX + ".".join(path.relative_to(base).parts))
62
+ elif path.suffix == ".py" and path.name != "__init__.py":
63
+ rel = path.relative_to(base).with_suffix("")
64
+ names.append(_PREFIX + ".".join(rel.parts))
65
+ if names:
66
+ return sorted(set(names))
67
+ # Not a plain directory tree (zipimport, a namespace package): fall
68
+ # back to the non-importing pkgutil call. Top level only, but
69
+ # correct. Never walk_packages, which would import.
70
+ return sorted(
71
+ name for _, name, _ in pkgutil.iter_modules(pkg.__path__, prefix=_PREFIX)
72
+ )
73
+ except Exception: # noqa: BLE001 — never let the helper break import
74
+ return ()
75
+
76
+ def find_spec(self, fullname, path, target=None): # noqa: D401
77
+ # Only intervene for tina4_python.<X> — everything else defers to
78
+ # whatever finder comes next, but since we appended ourselves last
79
+ # and this call reached us, no next finder exists.
80
+ if not fullname.startswith(_PREFIX):
81
+ return None
82
+
83
+ # Compute the hint. Any failure here degrades to a plain
84
+ # ModuleNotFoundError rather than raising the helper's own bug.
85
+ try:
86
+ hint = self._hint(fullname)
87
+ except Exception: # noqa: BLE001
88
+ hint = ""
89
+
90
+ message = f"No module named {fullname!r}"
91
+ if hint:
92
+ message = f"{message}. {hint}"
93
+ raise ModuleNotFoundError(message, name=fullname)
94
+
95
+ def _hint(self, fullname: str) -> str:
96
+ tail = fullname[len(_PREFIX):]
97
+ last = tail.rsplit(".", 1)[-1]
98
+
99
+ # 1) Exact-suffix match — e.g. miss "route" surfaces every real
100
+ # module whose last component contains "route" ("core.router",
101
+ # "routing_x", "route_helper"). Cheap and reads well.
102
+ suffix_matches = [
103
+ m for m in self._known
104
+ if m != fullname and last in m.rsplit(".", 1)[-1]
105
+ ]
106
+
107
+ # 2) Fuzzy fallback — Levenshtein-style close matches on the
108
+ # FULL dotted name, so "tina4_python.route" surfaces
109
+ # "tina4_python.core.router" even though "route" is not a
110
+ # substring of "router".
111
+ fuzzy = get_close_matches(fullname, list(self._known), n=5, cutoff=0.55)
112
+
113
+ # Merge preserving order, cap at 5.
114
+ seen: set[str] = set()
115
+ merged: list[str] = []
116
+ for name in (*suffix_matches, *fuzzy):
117
+ if name in seen:
118
+ continue
119
+ seen.add(name)
120
+ merged.append(name)
121
+ if len(merged) >= 5:
122
+ break
123
+
124
+ if not merged:
125
+ return ""
126
+ if len(merged) == 1:
127
+ return f"Did you mean {merged[0]!r}?"
128
+ return "Did you mean one of: " + ", ".join(repr(m) for m in merged) + "?"
129
+
130
+
131
+ _installed: bool = False
132
+
133
+
134
+ def install() -> None:
135
+ """Idempotent: append the finder to sys.meta_path exactly once."""
136
+ global _installed
137
+ if _installed:
138
+ return
139
+ sys.meta_path.append(_Tina4ModuleFinder())
140
+ _installed = True
@@ -129,7 +129,7 @@ def _parse_flags(args: list[str]) -> tuple[dict, list[str]]:
129
129
  """Parse --key value and --flag from args. Returns (flags, positional)."""
130
130
  # Boolean-only flags that never take a value argument
131
131
  boolean_flags = {"no-browser", "no-reload", "no-kill", "production", "managed", "all", "clear",
132
- "json", "public", "no-migration", "once"}
132
+ "json", "public", "no-migration", "once", "dry-run", "quote"}
133
133
 
134
134
  flags = {}
135
135
  positional = []
@@ -965,6 +965,254 @@ def _ai(args):
965
965
  install_selected(".", selection)
966
966
 
967
967
 
968
+ # ── Generate resolution envelope (ADR-0062, agent-experience contract) ─────
969
+ #
970
+ # Every `generate model/route/migration/middleware` call ALSO exposes the
971
+ # mapping the framework decided to build — the class name, the (maybe
972
+ # pluralized) table name, every file it will write, and every transformation
973
+ # it applied. An agent (or a human) sees WHAT was chosen without having to
974
+ # reverse-engineer it from the generated files. Emitted as a JSON envelope
975
+ # on `--json`, as a human block on stderr for bare invocations, and skipping
976
+ # every write on `--dry-run` (composable with `--json`).
977
+ #
978
+ # The envelope shape is the SAME across all four language backends and is
979
+ # advertised as `resolution_contract` on the `commands --json` manifest so a
980
+ # caller can programmatically discover the schema version.
981
+
982
+ # The four generators wired for the envelope. Others (crud, service, queue,
983
+ # validator, seeder, websocket, listener, auth, test, form, view) keep their
984
+ # existing behaviour — this is the minimum surface the plan calls out.
985
+ _RESOLUTION_TARGETS = {"model", "route", "migration", "middleware"}
986
+
987
+ # Envelope schema version. Bump when the SHAPE changes; adding an optional
988
+ # key on the resolution object is not a break.
989
+ _RESOLUTION_ENVELOPE_VERSION = "1"
990
+
991
+
992
+ def _input_fields(flags: dict):
993
+ """Normalise `--fields` back into the envelope's `input.fields` field.
994
+
995
+ A missing / bare / boolean `--fields` maps to null so an agent can rely on
996
+ the shape; a real "name:string,price:float" value is preserved as-is (the
997
+ raw text the caller actually supplied).
998
+ """
999
+ value = flags.get("fields")
1000
+ if not isinstance(value, str) or not value:
1001
+ return None
1002
+ return value
1003
+
1004
+
1005
+ def _resolve_generation(target: str, name: str, flags: dict,
1006
+ timestamp: str | None = None) -> dict:
1007
+ """Compute the resolution mapping for a generate call. Pure — no writes.
1008
+
1009
+ Called BEFORE the generator runs, so the same envelope drives both --json
1010
+ (emit + write) and --dry-run (emit + skip) without a second walk of the
1011
+ tree. Timestamps for migration filenames are locked at the call site and
1012
+ threaded through the underlying generator via the private `_timestamp`
1013
+ flag, so the envelope path always equals the on-disk filename.
1014
+ """
1015
+ ts = timestamp or datetime.now().strftime("%Y%m%d%H%M%S")
1016
+
1017
+ if target == "model":
1018
+ raw_table = _to_snake(name)
1019
+ table = _to_table(name)
1020
+ transformations = []
1021
+ if raw_table in SQL_RESERVED_TABLE_NAMES and raw_table != table:
1022
+ transformations.append({
1023
+ "kind": "reserved_word_pluralize",
1024
+ "from": raw_table,
1025
+ "to": table,
1026
+ "reason": f"SQL reserved word '{raw_table}' would break CREATE TABLE",
1027
+ "override": (
1028
+ f"--table {raw_table} --quote (requires quoted-identifier "
1029
+ "mode, not yet implemented)"
1030
+ ),
1031
+ })
1032
+ migration_filename = f"{ts}_create_{table}.sql"
1033
+ return {
1034
+ "class_name": name,
1035
+ "table_name": table,
1036
+ "file_path": f"src/orm/{name}.py",
1037
+ "migration_path": f"migrations/{migration_filename}",
1038
+ "transformations": transformations,
1039
+ "routes": [],
1040
+ "test_paths": [f"tests/test_{table}_model.py"],
1041
+ }
1042
+
1043
+ if target == "route":
1044
+ route_path = name.lstrip("/")
1045
+ return {
1046
+ "class_name": None,
1047
+ "table_name": None,
1048
+ "file_path": f"src/routes/{route_path}.py",
1049
+ "migration_path": None,
1050
+ "transformations": [],
1051
+ "routes": [
1052
+ f"/api/{route_path}",
1053
+ f"/api/{route_path}/{{id:int}}",
1054
+ ],
1055
+ "test_paths": [f"tests/test_{route_path}.py"],
1056
+ }
1057
+
1058
+ if target == "migration":
1059
+ table = name.removeprefix("create_").removeprefix("add_").removeprefix("drop_")
1060
+ table = _to_snake(table)
1061
+ filename = f"{ts}_{name}.sql"
1062
+ is_create = name.startswith("create_")
1063
+ return {
1064
+ "class_name": None,
1065
+ "table_name": table,
1066
+ "file_path": f"migrations/{filename}",
1067
+ "migration_path": f"migrations/{filename}",
1068
+ "transformations": [],
1069
+ "routes": [],
1070
+ "test_paths": [f"tests/test_{table}_migration.py"] if is_create else [],
1071
+ }
1072
+
1073
+ if target == "middleware":
1074
+ snake = _to_snake(name)
1075
+ return {
1076
+ "class_name": name,
1077
+ "table_name": None,
1078
+ "file_path": f"src/middleware/{snake}.py",
1079
+ "migration_path": None,
1080
+ "transformations": [],
1081
+ "routes": [],
1082
+ "test_paths": [f"tests/test_{snake}.py"],
1083
+ }
1084
+
1085
+ return {} # never reached; guarded by _RESOLUTION_TARGETS
1086
+
1087
+
1088
+ def _actions_from_resolution(target: str, resolution: dict) -> list[str]:
1089
+ """Flat "wrote X" list mirroring what the generators actually write.
1090
+
1091
+ The generators print ` ✓ Created <path>` per file; this reconstructs the
1092
+ same list from the resolution so the envelope's `actions_taken` matches
1093
+ the on-disk effect without having to shadow the generator's own I/O.
1094
+ """
1095
+ actions: list[str] = []
1096
+ file_path = resolution.get("file_path")
1097
+ if file_path:
1098
+ actions.append(f"wrote {file_path}")
1099
+
1100
+ # Migration + its matching .down.sql — _gen_migration writes both.
1101
+ if target == "model" and resolution.get("migration_path"):
1102
+ migration = resolution["migration_path"]
1103
+ actions.append(f"wrote {migration}")
1104
+ actions.append(f"wrote {migration[:-len('.sql')]}.down.sql")
1105
+ if target == "migration" and file_path and file_path.endswith(".sql"):
1106
+ actions.append(f"wrote {file_path[:-len('.sql')]}.down.sql")
1107
+
1108
+ for test_path in resolution.get("test_paths", []):
1109
+ actions.append(f"wrote {test_path}")
1110
+
1111
+ return actions
1112
+
1113
+
1114
+ def _format_resolution_block(name: str, target: str, resolution: dict,
1115
+ dry_run: bool = False) -> str:
1116
+ """Render the human-readable resolution block for stderr on bare calls.
1117
+
1118
+ Same shape as the plan / ADR-0062 example. A generator with no route/table
1119
+ footprint (middleware, route without a matching table) simply omits the
1120
+ lines that don't apply — the block is honest about what was chosen.
1121
+ """
1122
+ suffix = " (dry-run — nothing written)" if dry_run else ""
1123
+ lines = [f"Generated {target} {name}{suffix}"]
1124
+
1125
+ class_name = resolution.get("class_name")
1126
+ file_path = resolution.get("file_path")
1127
+ if class_name and file_path:
1128
+ lines.append(f" class {class_name} (in {file_path})")
1129
+ elif file_path:
1130
+ lines.append(f" file {file_path}")
1131
+
1132
+ pluralize = next(
1133
+ (t for t in resolution.get("transformations", [])
1134
+ if t.get("kind") == "reserved_word_pluralize"),
1135
+ None,
1136
+ )
1137
+ if resolution.get("table_name"):
1138
+ note = ""
1139
+ if pluralize:
1140
+ note = f" (auto-pluralized: '{pluralize['from']}' is a SQL reserved word)"
1141
+ lines.append(f" table {resolution['table_name']}{note}")
1142
+
1143
+ if resolution.get("routes"):
1144
+ lines.append(f" routes {', '.join(resolution['routes'])}")
1145
+ if target != "migration" and resolution.get("migration_path"):
1146
+ lines.append(f" migration {resolution['migration_path']}")
1147
+
1148
+ if pluralize:
1149
+ lines.append("")
1150
+ lines.append(f" To keep the raw name '{pluralize['from']}' as the table:")
1151
+ lines.append(
1152
+ f" tina4 generate {target} {name} "
1153
+ f"--table {pluralize['from']} --quote "
1154
+ "(opt-in, ADR-0062 forthcoming)"
1155
+ )
1156
+
1157
+ return "\n".join(lines) + "\n"
1158
+
1159
+
1160
+ def _emit_resolution(target: str, name: str, flags: dict) -> None:
1161
+ """Handle the --json / --dry-run / bare paths for the 4 wired generators.
1162
+
1163
+ Contract shared by every language (ADR-0062):
1164
+ - --json → envelope on stdout; nothing else on stdout.
1165
+ - --dry-run → skip every write; envelope with dry_run=true.
1166
+ - --json + --dry-run → both compose (envelope with actions_taken=[]).
1167
+ - bare → normal write, then human resolution block on stderr.
1168
+ """
1169
+ import contextlib
1170
+ import io
1171
+ import json as _json
1172
+
1173
+ dry_run = bool(flags.get("dry-run"))
1174
+ want_json = bool(flags.get("json"))
1175
+
1176
+ # Lock the timestamp so the envelope's migration_path and the on-disk
1177
+ # filename agree byte-for-byte (threaded through via `_timestamp` flag).
1178
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
1179
+ resolution = _resolve_generation(target, name, flags, timestamp=timestamp)
1180
+
1181
+ actions_taken: list[str] = []
1182
+ if not dry_run:
1183
+ # Thread the locked-in timestamp through the underlying generator,
1184
+ # then run it. When --json we swallow the generator's stdout so the
1185
+ # JSON envelope is the SOLE thing on stdout; otherwise we let its
1186
+ # "Created X" lines through as before.
1187
+ generator_flags = dict(flags)
1188
+ generator_flags["_timestamp"] = timestamp
1189
+ handler = GENERATORS[target]["handler"]
1190
+
1191
+ stdout_sink = io.StringIO() if want_json else sys.stdout
1192
+ with contextlib.redirect_stdout(stdout_sink):
1193
+ handler(name, generator_flags)
1194
+
1195
+ actions_taken = _actions_from_resolution(target, resolution)
1196
+
1197
+ envelope = {
1198
+ "command": "generate",
1199
+ "target": target,
1200
+ "input": {"name": name, "fields": _input_fields(flags)},
1201
+ "resolution": resolution,
1202
+ "actions_taken": actions_taken,
1203
+ "dry_run": dry_run,
1204
+ }
1205
+
1206
+ if want_json:
1207
+ print(_json.dumps(envelope, indent=2))
1208
+ else:
1209
+ # Human resolution block always lands on STDERR (per plan): stdout
1210
+ # keeps whatever the generator printed on the bare path, and the
1211
+ # block never contends with a caller parsing stdout.
1212
+ sys.stderr.write(_format_resolution_block(name, target, resolution,
1213
+ dry_run=dry_run))
1214
+
1215
+
968
1216
  # ── Generate (rich scaffolding) ───────────────────────────────────────
969
1217
 
970
1218
  def _generate(args):
@@ -1004,6 +1252,15 @@ def _generate(args):
1004
1252
  name = args[1] if len(args) > 1 else ""
1005
1253
  flags, _ = _parse_flags(args[2:] if len(args) > 2 else [])
1006
1254
 
1255
+ # ADR-0062: the four wired generators expose a JSON envelope and a
1256
+ # stderr resolution block on every call, so an agent can see WHAT the
1257
+ # framework decided to build with its input. --json emits the envelope;
1258
+ # --dry-run computes it without writing; bare calls print the human
1259
+ # block to stderr AFTER the normal writes complete.
1260
+ if what in _RESOLUTION_TARGETS:
1261
+ _emit_resolution(what, name, flags)
1262
+ return
1263
+
1007
1264
  # Dispatch from the module-level GENERATORS registry (single source of truth
1008
1265
  # for the generate subcommands; also feeds `_help` and the manifest).
1009
1266
  gen_spec = GENERATORS.get(what)
@@ -1385,7 +1642,10 @@ def _gen_migration(name: str, flags: dict = None, *,
1385
1642
  """
1386
1643
  flags = flags or {}
1387
1644
  now = datetime.now()
1388
- timestamp = now.strftime("%Y%m%d%H%M%S")
1645
+ # ADR-0062: the resolution envelope pre-computes the migration path from a
1646
+ # snapshotted timestamp; if it was supplied via the private `_timestamp`
1647
+ # flag, honour it so envelope and disk agree byte-for-byte.
1648
+ timestamp = flags.get("_timestamp") or now.strftime("%Y%m%d%H%M%S")
1389
1649
  target = Path("migrations")
1390
1650
  target.mkdir(parents=True, exist_ok=True)
1391
1651
 
@@ -3111,7 +3371,18 @@ def _commands_manifest() -> dict:
3111
3371
  if spec.get("args"):
3112
3372
  entry["args"] = list(spec["args"])
3113
3373
  commands.append(entry)
3114
- return {"framework": "python", "version": __version__, "commands": commands}
3374
+ return {
3375
+ "framework": "python",
3376
+ "version": __version__,
3377
+ "commands": commands,
3378
+ # ADR-0062: agents discover the generate resolution envelope shape
3379
+ # programmatically. A version bump here is a break; adding an
3380
+ # optional envelope key is not.
3381
+ "resolution_contract": {
3382
+ "version": _RESOLUTION_ENVELOPE_VERSION,
3383
+ "envelope": "generate_v1",
3384
+ },
3385
+ }
3115
3386
 
3116
3387
 
3117
3388
  def _commands(args=None):