tina4-python 3.11.14__tar.gz → 3.11.16__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 (155) hide show
  1. {tina4_python-3.11.14 → tina4_python-3.11.16}/PKG-INFO +1 -1
  2. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/__init__.py +1 -1
  3. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/dev_admin/__init__.py +190 -0
  4. tina4_python-3.11.16/tina4_python/dev_admin/plan.py +454 -0
  5. tina4_python-3.11.16/tina4_python/dev_admin/project_index.py +417 -0
  6. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/mcp/__init__.py +19 -0
  7. tina4_python-3.11.16/tina4_python/mcp/tools.py +742 -0
  8. tina4_python-3.11.16/tina4_python/public/js/tina4-dev-admin.js +1279 -0
  9. tina4_python-3.11.16/tina4_python/public/js/tina4-dev-admin.min.js +1279 -0
  10. tina4_python-3.11.14/tina4_python/mcp/tools.py +0 -348
  11. tina4_python-3.11.14/tina4_python/public/js/tina4-dev-admin.js +0 -565
  12. tina4_python-3.11.14/tina4_python/public/js/tina4-dev-admin.min.js +0 -565
  13. {tina4_python-3.11.14 → tina4_python-3.11.16}/.gitignore +0 -0
  14. {tina4_python-3.11.14 → tina4_python-3.11.16}/README.md +0 -0
  15. {tina4_python-3.11.14 → tina4_python-3.11.16}/pyproject.toml +0 -0
  16. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/CLAUDE.md +0 -0
  17. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/HtmlElement.py +0 -0
  18. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/Testing.py +0 -0
  19. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/ai/__init__.py +0 -0
  20. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/api/__init__.py +0 -0
  21. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/auth/__init__.py +0 -0
  22. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/cache/__init__.py +0 -0
  23. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/cli/__init__.py +0 -0
  24. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/container/__init__.py +0 -0
  25. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/__init__.py +0 -0
  26. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/cache.py +0 -0
  27. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/constants.py +0 -0
  28. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/events.py +0 -0
  29. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/middleware.py +0 -0
  30. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/rate_limiter.py +0 -0
  31. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/request.py +0 -0
  32. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/response.py +0 -0
  33. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/router.py +0 -0
  34. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/core/server.py +0 -0
  35. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/crud/__init__.py +0 -0
  36. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/__init__.py +0 -0
  37. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/adapter.py +0 -0
  38. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/connection.py +0 -0
  39. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/firebird.py +0 -0
  40. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/mongodb.py +0 -0
  41. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/mssql.py +0 -0
  42. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/mysql.py +0 -0
  43. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/odbc.py +0 -0
  44. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/postgres.py +0 -0
  45. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/database/sqlite.py +0 -0
  46. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/debug/__init__.py +0 -0
  47. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/debug/error_overlay.py +0 -0
  48. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/dev_admin/metrics.py +0 -0
  49. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/dotenv/__init__.py +0 -0
  50. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/frond/FROND.md +0 -0
  51. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/frond/__init__.py +0 -0
  52. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/frond/engine.py +0 -0
  53. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/auth/meta.json +0 -0
  54. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  55. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/database/meta.json +0 -0
  56. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  57. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/error-overlay/meta.json +0 -0
  58. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  59. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/orm/meta.json +0 -0
  60. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  61. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  62. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/queue/meta.json +0 -0
  63. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  64. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/rest-api/meta.json +0 -0
  65. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  66. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/templates/meta.json +0 -0
  67. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  68. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  69. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/graphql/__init__.py +0 -0
  70. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/i18n/__init__.py +0 -0
  71. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/mcp/protocol.py +0 -0
  72. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/messenger/__init__.py +0 -0
  73. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/migration/__init__.py +0 -0
  74. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/migration/runner.py +0 -0
  75. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/orm/__init__.py +0 -0
  76. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/orm/fields.py +0 -0
  77. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/orm/model.py +0 -0
  78. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/css/tina4.css +0 -0
  79. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/css/tina4.min.css +0 -0
  80. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/favicon.ico +0 -0
  81. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/images/logo.svg +0 -0
  82. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  83. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/js/frond.min.js +0 -0
  84. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/js/tina4.min.js +0 -0
  85. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/js/tina4js.min.js +0 -0
  86. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/swagger/index.html +0 -0
  87. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  88. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/query_builder/__init__.py +0 -0
  89. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/__init__.py +0 -0
  90. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/job.py +0 -0
  91. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/kafka_backend.py +0 -0
  92. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/lite_backend.py +0 -0
  93. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/mongo_backend.py +0 -0
  94. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue/rabbitmq_backend.py +0 -0
  95. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue_backends/__init__.py +0 -0
  96. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue_backends/kafka_backend.py +0 -0
  97. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue_backends/mongo_backend.py +0 -0
  98. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  99. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/__init__.py +0 -0
  100. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  101. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_badges.scss +0 -0
  102. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  103. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_cards.scss +0 -0
  104. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_forms.scss +0 -0
  105. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_grid.scss +0 -0
  106. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_modals.scss +0 -0
  107. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_nav.scss +0 -0
  108. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_reset.scss +0 -0
  109. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_tables.scss +0 -0
  110. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_typography.scss +0 -0
  111. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  112. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/_variables.scss +0 -0
  113. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/base.scss +0 -0
  114. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/colors.scss +0 -0
  115. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/scss/tina4css/tina4.scss +0 -0
  116. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/seeder/__init__.py +0 -0
  117. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/service/__init__.py +0 -0
  118. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/session/__init__.py +0 -0
  119. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/session_handlers/__init__.py +0 -0
  120. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  121. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/session_handlers/redis_handler.py +0 -0
  122. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/session_handlers/valkey_handler.py +0 -0
  123. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/swagger/__init__.py +0 -0
  124. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/components/crud.twig +0 -0
  125. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  126. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  127. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/docker/python/Dockerfile +0 -0
  128. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  129. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/302.twig +0 -0
  130. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/401.twig +0 -0
  131. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/403.twig +0 -0
  132. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/404.twig +0 -0
  133. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/500.twig +0 -0
  134. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/502.twig +0 -0
  135. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/503.twig +0 -0
  136. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/errors/base.twig +0 -0
  137. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/frontend/README.md +0 -0
  138. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/templates/readme.md +0 -0
  139. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/test_client/__init__.py +0 -0
  140. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  141. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  142. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/validator/__init__.py +0 -0
  153. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/websocket/__init__.py +0 -0
  154. {tina4_python-3.11.14 → tina4_python-3.11.16}/tina4_python/websocket/backplane.py +0 -0
  155. {tina4_python-3.11.14 → tina4_python-3.11.16}/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.11.14
3
+ Version: 3.11.16
4
4
  Summary: Tina4 for Python — 54 built-in features, zero dependencies
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -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.11.14"
11
+ __version__ = "3.11.16"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -348,6 +348,20 @@ def get_api_handlers() -> dict:
348
348
  "/__dev/api/deps/search": ("GET", _api_deps_search),
349
349
  "/__dev/api/deps/install": ("POST", _api_deps_install),
350
350
  "/__dev/api/git/status": ("GET", _api_git_status),
351
+ # ── MCP REST shim ──
352
+ # Dev-admin speaks a REST flavour of MCP (plain GET/POST with
353
+ # JSON bodies) rather than the JSON-RPC SSE protocol used by
354
+ # Claude Desktop et al. Both surfaces share the same
355
+ # `_default_server` tool registry, so tools registered via the
356
+ # @mcp_tool decorator appear in both immediately.
357
+ "/__dev/api/mcp/tools": ("GET", _api_mcp_tools),
358
+ "/__dev/api/mcp/call": ("POST", _api_mcp_call),
359
+ # ── Scaffold REST shim ──
360
+ # Wraps the tina4python CLI's `generate <kind> <name>` so the
361
+ # + Route / + Model / + Migration / + Middleware buttons work
362
+ # without shelling out from the browser.
363
+ "/__dev/api/scaffold": ("GET", _api_scaffold_list),
364
+ "/__dev/api/scaffold/run": ("POST", _api_scaffold_run),
351
365
  }
352
366
 
353
367
 
@@ -2056,5 +2070,181 @@ async def _api_git_status(request, response):
2056
2070
  return response(result)
2057
2071
 
2058
2072
 
2073
+ # ─── MCP REST shim ─────────────────────────────────────────────────
2074
+ #
2075
+ # Exposes the framework's MCP tool registry (`_default_server`) over
2076
+ # plain GET/POST JSON so the dev-admin browser panel and any other
2077
+ # REST client can enumerate and invoke tools without speaking the full
2078
+ # JSON-RPC 2.0 over SSE protocol.
2079
+ #
2080
+ # The JSON-RPC endpoint at `/__dev/mcp/{message,sse}` stays live for
2081
+ # proper MCP clients (Claude Desktop et al.) — the two surfaces share
2082
+ # the same registry, so tools registered via the `@mcp_tool` decorator
2083
+ # show up on both immediately.
2084
+
2085
+ async def _api_mcp_tools(request, response):
2086
+ """GET — return the MCP tool registry as a plain JSON list.
2087
+
2088
+ Shape matches what dev-admin's `listMcpTools()` expects:
2089
+ {"tools": [{"name": "...", "description": "...", "schema": {...}}, ...]}
2090
+ """
2091
+ try:
2092
+ from tina4_python.mcp import _get_default_server
2093
+ server = _get_default_server()
2094
+ tools = [
2095
+ {
2096
+ "name": t["name"],
2097
+ "description": t.get("description", ""),
2098
+ "schema": t.get("inputSchema") or {"type": "object", "properties": {}},
2099
+ }
2100
+ for t in server._tools.values()
2101
+ ]
2102
+ return response({"tools": tools})
2103
+ except Exception as exc:
2104
+ return response({"tools": [], "error": str(exc)}, 500)
2105
+
2106
+
2107
+ async def _api_mcp_call(request, response):
2108
+ """POST — invoke an MCP tool by name.
2109
+
2110
+ Request: {"name": "tool_name", "arguments": {...}}
2111
+ Response: {"ok": true, "name": "...", "result": ...}
2112
+ or {"ok": false, "error": "..."}
2113
+
2114
+ The wrapper uses the tool's handler directly rather than routing
2115
+ through `handle_message` — we already know the name and args, no
2116
+ need to round-trip through JSON-RPC framing.
2117
+ """
2118
+ body = request.body or {}
2119
+ if not isinstance(body, dict):
2120
+ return response({"ok": False, "error": "body must be a JSON object"}, 400)
2121
+
2122
+ name = body.get("name")
2123
+ if not name or not isinstance(name, str):
2124
+ return response({"ok": False, "error": "missing 'name'"}, 400)
2125
+
2126
+ args = body.get("arguments") or body.get("args") or {}
2127
+ if not isinstance(args, dict):
2128
+ return response({"ok": False, "error": "'arguments' must be an object"}, 400)
2129
+
2130
+ try:
2131
+ from tina4_python.mcp import _get_default_server
2132
+ server = _get_default_server()
2133
+ tool = server._tools.get(name)
2134
+ if tool is None:
2135
+ return response({"ok": False, "error": f"unknown tool: {name}"}, 404)
2136
+
2137
+ handler = tool["handler"]
2138
+ # Tools are registered as regular functions or coroutines;
2139
+ # await the result when the handler returns an awaitable.
2140
+ import inspect
2141
+ if inspect.iscoroutinefunction(handler):
2142
+ result = await handler(**args)
2143
+ else:
2144
+ result = handler(**args)
2145
+
2146
+ return response({"ok": True, "name": name, "result": result})
2147
+ except TypeError as exc:
2148
+ # Bad args shape — surface the Python error cleanly rather
2149
+ # than returning a 500. Callers see "argument X missing" etc.
2150
+ return response({"ok": False, "name": name, "error": f"argument error: {exc}"}, 400)
2151
+ except Exception as exc:
2152
+ return response({"ok": False, "name": name, "error": str(exc)}, 500)
2153
+
2154
+
2155
+ # ─── Scaffold REST shim ────────────────────────────────────────────
2156
+ #
2157
+ # Wraps the tina4python `generate <kind> <name>` CLI commands so the
2158
+ # + Route / + Model / + Migration / + Middleware buttons in dev-admin
2159
+ # can call them without shelling out from the browser. The handlers
2160
+ # import the generator functions directly rather than shelling out —
2161
+ # avoids spawning a subprocess per click and surfaces errors as JSON.
2162
+
2163
+ _SCAFFOLD_KINDS = [
2164
+ {"kind": "route", "label": "+ Route", "needs_name": True},
2165
+ {"kind": "model", "label": "+ Model", "needs_name": True},
2166
+ {"kind": "migration", "label": "+ Migration", "needs_name": True},
2167
+ {"kind": "middleware", "label": "+ Middleware", "needs_name": True},
2168
+ ]
2169
+
2170
+
2171
+ async def _api_scaffold_list(request, response):
2172
+ """GET — list the scaffold kinds this framework knows how to emit.
2173
+
2174
+ Dev-admin renders one button per item. Each carries a `kind` the
2175
+ /scaffold/run endpoint expects and a human `label` for the UI.
2176
+ """
2177
+ return response({"kinds": _SCAFFOLD_KINDS})
2178
+
2179
+
2180
+ async def _api_scaffold_run(request, response):
2181
+ """POST — invoke a generator.
2182
+
2183
+ Request: {"kind": "route", "name": "contact"}
2184
+ Response: {"ok": true, "files": ["src/routes/contact.py"]}
2185
+ or {"ok": false, "error": "..."}
2186
+
2187
+ Uses tina4_python.cli's module-level generator functions rather
2188
+ than shelling out via subprocess — faster, no path/env lookup.
2189
+ """
2190
+ body = request.body or {}
2191
+ if not isinstance(body, dict):
2192
+ return response({"ok": False, "error": "body must be a JSON object"}, 400)
2193
+
2194
+ kind = (body.get("kind") or "").strip().lower()
2195
+ name = (body.get("name") or "").strip()
2196
+ if not kind:
2197
+ return response({"ok": False, "error": "missing 'kind'"}, 400)
2198
+ if not name and kind != "auth":
2199
+ return response({"ok": False, "error": "missing 'name'"}, 400)
2200
+
2201
+ # Guard against path traversal / shell-metacharacter injection in
2202
+ # the name — generator functions pass it into file paths.
2203
+ import re
2204
+ if not re.match(r"^[A-Za-z][A-Za-z0-9_\-]*$", name):
2205
+ return response({"ok": False, "error": "name must match [A-Za-z][A-Za-z0-9_-]*"}, 400)
2206
+
2207
+ generator_map = {
2208
+ "route": "generate_route",
2209
+ "model": "generate_model",
2210
+ "migration": "generate_migration",
2211
+ "middleware": "generate_middleware",
2212
+ }
2213
+ fn_name = generator_map.get(kind)
2214
+ if fn_name is None:
2215
+ return response({"ok": False, "error": f"unknown scaffold kind: {kind}"}, 400)
2216
+
2217
+ try:
2218
+ from tina4_python import cli as cli_module
2219
+ fn = getattr(cli_module, fn_name, None)
2220
+ if fn is None:
2221
+ # Fall back to shelling out — keeps the endpoint useful
2222
+ # even if the generator function names drift.
2223
+ import subprocess
2224
+ cp = subprocess.run(
2225
+ ["tina4python", "generate", kind, name],
2226
+ capture_output=True, text=True, timeout=30,
2227
+ )
2228
+ if cp.returncode != 0:
2229
+ return response({"ok": False, "error": cp.stderr.strip() or cp.stdout.strip()}, 500)
2230
+ return response({"ok": True, "output": cp.stdout.strip()})
2231
+
2232
+ # Invoke the generator directly. The CLI functions typically
2233
+ # print to stdout + write files; we don't capture their
2234
+ # output here — the file tree will refresh and show the new
2235
+ # files, which is what the user actually cares about.
2236
+ result = fn(name) if fn.__code__.co_argcount == 1 else fn(name, None)
2237
+
2238
+ # Most generators return a path or list of paths; normalise.
2239
+ files: list[str] = []
2240
+ if isinstance(result, str):
2241
+ files = [result]
2242
+ elif isinstance(result, list):
2243
+ files = [str(p) for p in result]
2244
+ return response({"ok": True, "kind": kind, "name": name, "files": files})
2245
+ except Exception as exc:
2246
+ return response({"ok": False, "error": str(exc)}, 500)
2247
+
2248
+
2059
2249
  __all__ = ["MessageLog", "RequestInspector", "BrokenTracker",
2060
2250
  "get_api_handlers", "render_dev_toolbar"]
@@ -0,0 +1,454 @@
1
+ """Project plan management — persistent, human-readable task state.
2
+
3
+ A "plan" is a markdown file under ``plan/`` at the project root with a
4
+ lightweight structure the AI (and the human) can both read and edit:
5
+
6
+ # Add user authentication
7
+
8
+ Goal: JWT login/registration with refresh tokens.
9
+
10
+ ## Steps
11
+
12
+ - [x] Create User model in src/orm/User.py
13
+ - [x] Add auth middleware in src/app/middleware.py
14
+ - [ ] Add /api/login route
15
+ - [ ] Add /api/register route
16
+
17
+ ## Notes
18
+
19
+ Use tina4_python.auth.get_token, 60-minute tokens.
20
+
21
+ At any moment exactly one plan is "current" — recorded by the filename
22
+ stored in ``plan/.current``. That plan's contents are injected into
23
+ every chat turn's system prompt so the AI keeps working on the same
24
+ thing across conversations, and the step list gives a clear "done /
25
+ not done" snapshot.
26
+
27
+ This module is the storage + manipulation layer. MCP tools in
28
+ ``tina4_python.mcp.tools`` expose it to the AI; the dev-admin UI surfaces
29
+ it to the human.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import re
37
+ import time
38
+ from pathlib import Path
39
+
40
+ PLAN_DIR = "plan"
41
+ CURRENT_FILE = ".current"
42
+ ARCHIVE_SUBDIR = "done"
43
+
44
+
45
+ def _project_root() -> Path:
46
+ return Path(os.getcwd()).resolve()
47
+
48
+
49
+ def _plan_dir() -> Path:
50
+ p = _project_root() / PLAN_DIR
51
+ p.mkdir(exist_ok=True)
52
+ return p
53
+
54
+
55
+ def _current_pointer() -> Path:
56
+ return _plan_dir() / CURRENT_FILE
57
+
58
+
59
+ def _archive_dir() -> Path:
60
+ p = _plan_dir() / ARCHIVE_SUBDIR
61
+ p.mkdir(exist_ok=True)
62
+ return p
63
+
64
+
65
+ def _slugify(title: str) -> str:
66
+ """File-system-safe filename stem for a plan title."""
67
+ slug = re.sub(r"[^A-Za-z0-9_-]+", "-", title.strip().lower()).strip("-")
68
+ return slug[:80] or f"plan-{int(time.time())}"
69
+
70
+
71
+ # ── Plan file shape ──────────────────────────────────────────────────
72
+
73
+ _STEP_RE = re.compile(r"^\s*[-*]\s*\[(?P<box>[ xX])\]\s*(?P<text>.+?)\s*$")
74
+
75
+
76
+ def _parse(text: str) -> dict:
77
+ """Parse a plan markdown file into a structured dict.
78
+
79
+ The parser is tolerant — missing sections are fine; unknown sections
80
+ are preserved verbatim under ``extra``. We only pick apart what we
81
+ need to manipulate programmatically (title, goal, steps).
82
+ """
83
+ lines = text.splitlines()
84
+ title = ""
85
+ goal = ""
86
+ steps: list = []
87
+ notes_lines: list = []
88
+ section = None # None | "steps" | "notes"
89
+
90
+ for raw in lines:
91
+ line = raw.rstrip()
92
+ if not title and line.startswith("# "):
93
+ title = line[2:].strip()
94
+ continue
95
+ low = line.strip().lower()
96
+ if low.startswith("goal:") and not goal:
97
+ goal = line.split(":", 1)[1].strip()
98
+ continue
99
+ if low == "## steps":
100
+ section = "steps"
101
+ continue
102
+ if low == "## notes":
103
+ section = "notes"
104
+ continue
105
+ if line.startswith("## "):
106
+ section = "other"
107
+ continue
108
+ if section == "steps":
109
+ m = _STEP_RE.match(line)
110
+ if m:
111
+ steps.append({
112
+ "text": m.group("text").strip(),
113
+ "done": m.group("box").lower() == "x",
114
+ })
115
+ elif section == "notes" and line.strip():
116
+ notes_lines.append(line)
117
+
118
+ return {
119
+ "title": title,
120
+ "goal": goal,
121
+ "steps": steps,
122
+ "notes": "\n".join(notes_lines).strip(),
123
+ }
124
+
125
+
126
+ def _render(plan: dict) -> str:
127
+ """Serialise a parsed dict back to canonical markdown."""
128
+ parts: list = [f"# {plan.get('title', 'Untitled plan')}", ""]
129
+ goal = plan.get("goal")
130
+ if goal:
131
+ parts += [f"Goal: {goal}", ""]
132
+ parts.append("## Steps")
133
+ parts.append("")
134
+ for step in plan.get("steps", []):
135
+ box = "x" if step.get("done") else " "
136
+ parts.append(f"- [{box}] {step.get('text', '').strip()}")
137
+ notes = (plan.get("notes") or "").strip()
138
+ if notes:
139
+ parts += ["", "## Notes", "", notes]
140
+ return "\n".join(parts) + "\n"
141
+
142
+
143
+ # ── Public API ───────────────────────────────────────────────────────
144
+
145
+
146
+ def list_plans() -> list:
147
+ """All plan files at the root of plan/ (excludes done/)."""
148
+ d = _plan_dir()
149
+ current = current_name() or ""
150
+ out: list = []
151
+ for p in sorted(d.glob("*.md")):
152
+ parsed = _parse(p.read_text(encoding="utf-8", errors="replace"))
153
+ total = len(parsed["steps"])
154
+ done = sum(1 for s in parsed["steps"] if s["done"])
155
+ out.append({
156
+ "name": p.name,
157
+ "title": parsed["title"] or p.stem,
158
+ "steps_total": total,
159
+ "steps_done": done,
160
+ "is_current": p.name == current,
161
+ })
162
+ return out
163
+
164
+
165
+ def current_name() -> str:
166
+ """Filename of the current plan, or '' when none is set."""
167
+ ptr = _current_pointer()
168
+ if not ptr.exists():
169
+ return ""
170
+ return ptr.read_text(encoding="utf-8").strip()
171
+
172
+
173
+ def set_current(name: str) -> dict:
174
+ """Point .current at the given plan file. Creates plan/ if needed."""
175
+ name = name.strip()
176
+ if not name.endswith(".md"):
177
+ name += ".md"
178
+ plan_path = _plan_dir() / name
179
+ if not plan_path.exists():
180
+ return {"ok": False, "error": f"No such plan: {name}"}
181
+ _current_pointer().write_text(name, encoding="utf-8")
182
+ return {"ok": True, "current": name}
183
+
184
+
185
+ def clear_current() -> dict:
186
+ """Unset the current plan (e.g. when everything's done and nothing's queued)."""
187
+ p = _current_pointer()
188
+ if p.exists():
189
+ p.unlink()
190
+ return {"ok": True}
191
+
192
+
193
+ def current() -> dict:
194
+ """The whole current plan: title, goal, steps (with index + done), notes,
195
+ and a cheap 'next' pointer to the first unchecked step. Returns
196
+ {current: null} when no plan is active — the AI treats that as
197
+ 'no specific plan, proceed normally'."""
198
+ name = current_name()
199
+ if not name:
200
+ return {"current": None}
201
+ path = _plan_dir() / name
202
+ if not path.exists():
203
+ # Pointer is dangling — clean up, report.
204
+ clear_current()
205
+ return {"current": None, "warning": f"Current pointer referenced missing file: {name}"}
206
+ parsed = _parse(path.read_text(encoding="utf-8", errors="replace"))
207
+ indexed_steps = [
208
+ {"index": i, "text": s["text"], "done": s["done"]}
209
+ for i, s in enumerate(parsed["steps"])
210
+ ]
211
+ next_step = next((s for s in indexed_steps if not s["done"]), None)
212
+ return {
213
+ "current": name,
214
+ "title": parsed["title"],
215
+ "goal": parsed["goal"],
216
+ "steps": indexed_steps,
217
+ "next_step": next_step,
218
+ "notes": parsed["notes"],
219
+ "progress": {
220
+ "done": sum(1 for s in indexed_steps if s["done"]),
221
+ "total": len(indexed_steps),
222
+ },
223
+ # Attach the execution summary so callers get the full picture
224
+ # in one call — no second round-trip needed just to find out
225
+ # which files this plan has already touched.
226
+ "execution": summarise_execution(name),
227
+ }
228
+
229
+
230
+ def read(name: str) -> dict:
231
+ """Full structured view of any plan by filename."""
232
+ if not name.endswith(".md"):
233
+ name += ".md"
234
+ path = _plan_dir() / name
235
+ if not path.exists():
236
+ return {"error": f"No such plan: {name}"}
237
+ return _parse(path.read_text(encoding="utf-8", errors="replace")) | {"name": name}
238
+
239
+
240
+ def create(title: str, goal: str = "", steps: list | None = None, make_current: bool = True) -> dict:
241
+ """Write a new plan file and (by default) make it the active one.
242
+ ``steps`` is a list of strings — all start unchecked."""
243
+ title = (title or "").strip()
244
+ if not title:
245
+ return {"ok": False, "error": "title is required"}
246
+ stem = _slugify(title)
247
+ name = f"{stem}.md"
248
+ path = _plan_dir() / name
249
+ if path.exists():
250
+ return {"ok": False, "error": f"Plan already exists: {name}. Pick a different title or edit the existing one."}
251
+ plan = {
252
+ "title": title,
253
+ "goal": goal.strip(),
254
+ "steps": [{"text": s.strip(), "done": False} for s in (steps or []) if s.strip()],
255
+ "notes": "",
256
+ }
257
+ path.write_text(_render(plan), encoding="utf-8")
258
+ if make_current:
259
+ _current_pointer().write_text(name, encoding="utf-8")
260
+ return {"ok": True, "name": name, "title": title, "is_current": make_current}
261
+
262
+
263
+ def _load_parsed(name: str) -> tuple[Path, dict]:
264
+ if not name.endswith(".md"):
265
+ name += ".md"
266
+ path = _plan_dir() / name
267
+ if not path.exists():
268
+ raise FileNotFoundError(f"No such plan: {name}")
269
+ return path, _parse(path.read_text(encoding="utf-8", errors="replace"))
270
+
271
+
272
+ def complete_step(index: int, name: str = "") -> dict:
273
+ """Tick a step (by 0-based index) on the current plan (or a named one).
274
+ Returns the updated plan summary + what's next."""
275
+ name = name or current_name()
276
+ if not name:
277
+ return {"ok": False, "error": "No current plan and no name given"}
278
+ try:
279
+ path, plan = _load_parsed(name)
280
+ except FileNotFoundError as e:
281
+ return {"ok": False, "error": str(e)}
282
+ if not 0 <= index < len(plan["steps"]):
283
+ return {"ok": False, "error": f"Step index {index} out of range (0..{len(plan['steps']) - 1})"}
284
+ plan["steps"][index]["done"] = True
285
+ path.write_text(_render(plan), encoding="utf-8")
286
+ remaining = [i for i, s in enumerate(plan["steps"]) if not s["done"]]
287
+ return {
288
+ "ok": True,
289
+ "completed": plan["steps"][index]["text"],
290
+ "remaining": len(remaining),
291
+ "next_step": plan["steps"][remaining[0]]["text"] if remaining else None,
292
+ }
293
+
294
+
295
+ def uncomplete_step(index: int, name: str = "") -> dict:
296
+ """Uncheck a step — useful when a change turns out to have regressed."""
297
+ name = name or current_name()
298
+ if not name:
299
+ return {"ok": False, "error": "No current plan and no name given"}
300
+ try:
301
+ path, plan = _load_parsed(name)
302
+ except FileNotFoundError as e:
303
+ return {"ok": False, "error": str(e)}
304
+ if not 0 <= index < len(plan["steps"]):
305
+ return {"ok": False, "error": f"Step index {index} out of range"}
306
+ plan["steps"][index]["done"] = False
307
+ path.write_text(_render(plan), encoding="utf-8")
308
+ return {"ok": True, "step": plan["steps"][index]["text"]}
309
+
310
+
311
+ def add_step(text: str, name: str = "") -> dict:
312
+ """Append a new unchecked step."""
313
+ text = (text or "").strip()
314
+ if not text:
315
+ return {"ok": False, "error": "text is required"}
316
+ name = name or current_name()
317
+ if not name:
318
+ return {"ok": False, "error": "No current plan and no name given"}
319
+ try:
320
+ path, plan = _load_parsed(name)
321
+ except FileNotFoundError as e:
322
+ return {"ok": False, "error": str(e)}
323
+ plan["steps"].append({"text": text, "done": False})
324
+ path.write_text(_render(plan), encoding="utf-8")
325
+ return {"ok": True, "step": text, "index": len(plan["steps"]) - 1}
326
+
327
+
328
+ def append_note(text: str, name: str = "") -> dict:
329
+ """Add a line to the plan's Notes section — great for 'here's a
330
+ gotcha I hit' breadcrumbs the AI drops as it works."""
331
+ text = (text or "").strip()
332
+ if not text:
333
+ return {"ok": False, "error": "text is required"}
334
+ name = name or current_name()
335
+ if not name:
336
+ return {"ok": False, "error": "No current plan and no name given"}
337
+ try:
338
+ path, plan = _load_parsed(name)
339
+ except FileNotFoundError as e:
340
+ return {"ok": False, "error": str(e)}
341
+ existing = (plan.get("notes") or "").strip()
342
+ stamp = time.strftime("%Y-%m-%d %H:%M")
343
+ plan["notes"] = (existing + f"\n- [{stamp}] {text}").strip()
344
+ path.write_text(_render(plan), encoding="utf-8")
345
+ return {"ok": True, "appended": text}
346
+
347
+
348
+ # ── Execution ledger — "what has this plan touched so far?" ──────────
349
+ #
350
+ # Every file_write / file_patch / migration_create that lands while a
351
+ # plan is current gets recorded here. The AI reads the *summary* at the
352
+ # top of each chat turn (via `summarise_execution()`) so it doesn't
353
+ # re-create files it already made — that's the "two migrations for the
354
+ # same table" bug we saw.
355
+ #
356
+ # Stored next to the plan file as `<plan-stem>.log.json` to keep all
357
+ # plan state in one place. Not committed (plan/.gitignore covers it).
358
+
359
+
360
+ def _ledger_path(name: str = "") -> Path | None:
361
+ name = name or current_name()
362
+ if not name:
363
+ return None
364
+ if not name.endswith(".md"):
365
+ name += ".md"
366
+ return _plan_dir() / f"{name[:-3]}.log.json"
367
+
368
+
369
+ def record_action(action: str, path: str, note: str = "") -> None:
370
+ """Append an entry to the current plan's ledger. Silently no-ops
371
+ when no plan is active — tools call this unconditionally."""
372
+ lp = _ledger_path()
373
+ if lp is None:
374
+ return
375
+ entries: list = []
376
+ if lp.exists():
377
+ try:
378
+ entries = json.loads(lp.read_text(encoding="utf-8"))
379
+ except (OSError, json.JSONDecodeError):
380
+ entries = []
381
+ entries.append({
382
+ "t": int(time.time()),
383
+ "action": action, # "created" | "patched" | "migration"
384
+ "path": path,
385
+ "note": note,
386
+ })
387
+ # Bound the log so it can't grow forever on long-running plans.
388
+ if len(entries) > 500:
389
+ entries = entries[-500:]
390
+ try:
391
+ lp.write_text(json.dumps(entries, indent=2), encoding="utf-8")
392
+ except OSError:
393
+ pass # ledger is best-effort
394
+
395
+
396
+ def summarise_execution(name: str = "") -> dict:
397
+ """Grouped view of what the current plan has touched — small
398
+ enough to inline into every system prompt. Groups:
399
+ - `created`: files written for the first time
400
+ - `patched`: files edited
401
+ - `migrations`: migration files generated
402
+ Each bucket is de-duped and capped at 20 paths."""
403
+ lp = _ledger_path(name)
404
+ if lp is None or not lp.exists():
405
+ return {"created": [], "patched": [], "migrations": [], "total": 0}
406
+ try:
407
+ entries = json.loads(lp.read_text(encoding="utf-8"))
408
+ except (OSError, json.JSONDecodeError):
409
+ return {"created": [], "patched": [], "migrations": [], "total": 0}
410
+
411
+ created: list[str] = []
412
+ patched: list[str] = []
413
+ migrations: list[str] = []
414
+ for e in entries:
415
+ action = e.get("action")
416
+ path = e.get("path")
417
+ if not path:
418
+ continue
419
+ bucket = (
420
+ migrations if action == "migration"
421
+ else created if action == "created"
422
+ else patched if action == "patched"
423
+ else None
424
+ )
425
+ if bucket is not None and path not in bucket:
426
+ bucket.append(path)
427
+ return {
428
+ "created": created[-20:],
429
+ "patched": patched[-20:],
430
+ "migrations": migrations[-20:],
431
+ "total": len(entries),
432
+ }
433
+
434
+
435
+ def archive(name: str = "") -> dict:
436
+ """Move a plan to plan/done/ and clear the current pointer if it
437
+ was pointing at this plan. A no-op + warning if it's already
438
+ archived."""
439
+ name = name or current_name()
440
+ if not name:
441
+ return {"ok": False, "error": "No current plan and no name given"}
442
+ if not name.endswith(".md"):
443
+ name += ".md"
444
+ src = _plan_dir() / name
445
+ if not src.exists():
446
+ return {"ok": False, "error": f"No such plan: {name}"}
447
+ dest = _archive_dir() / name
448
+ # Handle name collisions — prefix with timestamp on conflict
449
+ if dest.exists():
450
+ dest = _archive_dir() / f"{int(time.time())}-{name}"
451
+ src.rename(dest)
452
+ if current_name() == name:
453
+ clear_current()
454
+ return {"ok": True, "archived_to": str(dest.relative_to(_project_root()))}