FastAPI-fastkit 1.2.1__py3-none-any.whl → 1.4.0__py3-none-any.whl

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 (356) hide show
  1. fastapi_fastkit/__init__.py +1 -1
  2. fastapi_fastkit/backend/inspection/__init__.py +26 -0
  3. fastapi_fastkit/backend/inspection/checks.py +349 -0
  4. fastapi_fastkit/backend/inspection/consistency.py +125 -0
  5. fastapi_fastkit/backend/inspection/context.py +67 -0
  6. fastapi_fastkit/backend/inspection/core.py +347 -0
  7. fastapi_fastkit/backend/inspection/docker.py +185 -0
  8. fastapi_fastkit/backend/inspection/freshness.py +145 -0
  9. fastapi_fastkit/backend/inspection/fsutils.py +139 -0
  10. fastapi_fastkit/backend/inspection/lint.py +89 -0
  11. fastapi_fastkit/backend/inspection/report.py +58 -0
  12. fastapi_fastkit/backend/inspection/smoke.py +327 -0
  13. fastapi_fastkit/backend/inspection/strategies.py +336 -0
  14. fastapi_fastkit/backend/inspector.py +18 -1279
  15. fastapi_fastkit/backend/interactive/__init__.py +8 -0
  16. fastapi_fastkit/backend/interactive/config_builder.py +66 -65
  17. fastapi_fastkit/backend/interactive/prompts.py +205 -5
  18. fastapi_fastkit/backend/interactive/selectors.py +28 -2
  19. fastapi_fastkit/backend/main.py +956 -95
  20. fastapi_fastkit/backend/package_managers/base.py +172 -0
  21. fastapi_fastkit/backend/package_managers/factory.py +6 -5
  22. fastapi_fastkit/backend/package_managers/pdm_manager.py +32 -85
  23. fastapi_fastkit/backend/package_managers/pip_manager.py +31 -85
  24. fastapi_fastkit/backend/package_managers/poetry_manager.py +73 -113
  25. fastapi_fastkit/backend/package_managers/uv_manager.py +46 -115
  26. fastapi_fastkit/backend/project_builder/__init__.py +10 -0
  27. fastapi_fastkit/backend/project_builder/config_generator.py +813 -450
  28. fastapi_fastkit/backend/project_builder/config_schema.py +384 -0
  29. fastapi_fastkit/backend/project_builder/dependency_collector.py +105 -66
  30. fastapi_fastkit/backend/project_builder/preset_layout.py +429 -0
  31. fastapi_fastkit/backend/scaffolder.py +578 -0
  32. fastapi_fastkit/backend/transducer.py +54 -22
  33. fastapi_fastkit/cli.py +452 -284
  34. fastapi_fastkit/core/settings.py +248 -43
  35. fastapi_fastkit/fastapi_project_template/README.md +88 -23
  36. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/README.md-tpl +3 -0
  37. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/pyproject.toml-tpl +25 -23
  38. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/requirements.txt-tpl +39 -36
  39. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/api.py-tpl +3 -0
  40. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/routes/items.py-tpl +1 -2
  41. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/core/config.py-tpl +5 -6
  42. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/items.py-tpl +1 -2
  43. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/main.py-tpl +9 -1
  44. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/items.py-tpl +1 -2
  45. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/conftest.py-tpl +0 -1
  46. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/.env-tpl +21 -0
  47. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/.gitignore-tpl +33 -0
  48. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/Dockerfile-tpl +18 -0
  49. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/README.md-tpl +210 -0
  50. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/alembic.ini-tpl +119 -0
  51. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/pyproject.toml-tpl +86 -0
  52. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/requirements.txt-tpl +23 -0
  53. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/scripts/format.sh-tpl +5 -0
  54. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/scripts/lint.sh-tpl +6 -0
  55. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/scripts/run-server.sh-tpl +8 -0
  56. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/scripts/test.sh-tpl +6 -0
  57. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/__init__.py-tpl +0 -0
  58. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/__init__.py-tpl +0 -0
  59. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/alembic/README-tpl +1 -0
  60. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/alembic/env.py-tpl +51 -0
  61. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/alembic/script.py.mako-tpl +26 -0
  62. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/alembic/versions/0001_initial_auth_schema.py-tpl +62 -0
  63. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/api/__init__.py-tpl +0 -0
  64. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/api/deps.py-tpl +91 -0
  65. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/api/health.py-tpl +11 -0
  66. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/api/router.py-tpl +17 -0
  67. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/core/__init__.py-tpl +0 -0
  68. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/core/config.py-tpl +64 -0
  69. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/core/security.py-tpl +100 -0
  70. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/db/__init__.py-tpl +0 -0
  71. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/db/session.py-tpl +36 -0
  72. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/__init__.py-tpl +0 -0
  73. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/auth/__init__.py-tpl +0 -0
  74. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/auth/models.py-tpl +25 -0
  75. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/auth/repository.py-tpl +41 -0
  76. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/auth/router.py-tpl +82 -0
  77. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/auth/schemas.py-tpl +14 -0
  78. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/auth/service.py-tpl +65 -0
  79. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/users/__init__.py-tpl +0 -0
  80. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/users/models.py-tpl +35 -0
  81. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/users/repository.py-tpl +33 -0
  82. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/users/router.py-tpl +71 -0
  83. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/users/schemas.py-tpl +29 -0
  84. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/domains/users/service.py-tpl +55 -0
  85. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/src/app/main.py-tpl +69 -0
  86. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/template-config.yml-tpl +16 -0
  87. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/tests/__init__.py-tpl +0 -0
  88. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/tests/conftest.py-tpl +73 -0
  89. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/tests/test_auth.py-tpl +201 -0
  90. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/tests/test_health.py-tpl +15 -0
  91. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/tests/test_users.py-tpl +77 -0
  92. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/pyproject.toml-tpl +25 -23
  93. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/requirements.txt-tpl +39 -36
  94. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/api.py-tpl +3 -0
  95. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/routes/items.py-tpl +3 -3
  96. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/core/config.py-tpl +5 -6
  97. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/crud/items.py-tpl +1 -2
  98. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/helper/pagination.py-tpl +8 -8
  99. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/main.py-tpl +9 -0
  100. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/base.py-tpl +1 -1
  101. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/items.py-tpl +1 -2
  102. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/conftest.py-tpl +0 -1
  103. fastapi_fastkit/fastapi_project_template/fastapi-default/pyproject.toml-tpl +22 -20
  104. fastapi_fastkit/fastapi_project_template/fastapi-default/requirements.txt-tpl +37 -34
  105. fastapi_fastkit/fastapi_project_template/fastapi-default/src/api/api.py-tpl +3 -0
  106. fastapi_fastkit/fastapi_project_template/fastapi-default/src/api/routes/items.py-tpl +1 -2
  107. fastapi_fastkit/fastapi_project_template/fastapi-default/src/core/config.py-tpl +5 -6
  108. fastapi_fastkit/fastapi_project_template/fastapi-default/src/crud/items.py-tpl +1 -2
  109. fastapi_fastkit/fastapi_project_template/fastapi-default/src/main.py-tpl +9 -1
  110. fastapi_fastkit/fastapi_project_template/fastapi-default/src/schemas/items.py-tpl +1 -2
  111. fastapi_fastkit/fastapi_project_template/fastapi-default/tests/conftest.py-tpl +2 -3
  112. fastapi_fastkit/fastapi_project_template/fastapi-default/tests/test_items.py-tpl +0 -2
  113. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/Dockerfile-tpl +1 -1
  114. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/README.md-tpl +3 -0
  115. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/pyproject.toml-tpl +25 -23
  116. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/requirements.txt-tpl +41 -36
  117. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/api.py-tpl +3 -0
  118. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/routes/items.py-tpl +1 -2
  119. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/core/config.py-tpl +5 -6
  120. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/crud/items.py-tpl +1 -2
  121. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/main.py-tpl +9 -1
  122. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/schemas/items.py-tpl +1 -2
  123. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/conftest.py-tpl +2 -3
  124. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/test_items.py-tpl +0 -2
  125. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/.env-tpl +2 -0
  126. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/.gitignore-tpl +31 -0
  127. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/README.md-tpl +128 -0
  128. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/pyproject.toml-tpl +69 -0
  129. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/requirements.txt-tpl +37 -0
  130. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/scripts/format.sh-tpl +5 -0
  131. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/scripts/lint.sh-tpl +6 -0
  132. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/scripts/run-server.sh-tpl +8 -0
  133. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/scripts/test.sh-tpl +6 -0
  134. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/__init__.py-tpl +0 -0
  135. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/__init__.py-tpl +0 -0
  136. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/__init__.py-tpl +0 -0
  137. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/health.py-tpl +11 -0
  138. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/router.py-tpl +15 -0
  139. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/core/__init__.py-tpl +0 -0
  140. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/core/config.py-tpl +49 -0
  141. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/db/__init__.py-tpl +0 -0
  142. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/db/memory.py-tpl +49 -0
  143. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/__init__.py-tpl +0 -0
  144. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/__init__.py-tpl +9 -0
  145. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/models.py-tpl +17 -0
  146. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/repository.py-tpl +47 -0
  147. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/router.py-tpl +58 -0
  148. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/schemas.py-tpl +23 -0
  149. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/service.py-tpl +51 -0
  150. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/main.py-tpl +28 -0
  151. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/__init__.py-tpl +0 -0
  152. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/conftest.py-tpl +23 -0
  153. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/test_health.py-tpl +11 -0
  154. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/test_items.py-tpl +77 -0
  155. fastapi_fastkit/fastapi_project_template/fastapi-empty/README.md-tpl +2 -2
  156. fastapi_fastkit/fastapi_project_template/fastapi-empty/pyproject.toml-tpl +18 -16
  157. fastapi_fastkit/fastapi_project_template/fastapi-empty/requirements.txt-tpl +36 -10
  158. fastapi_fastkit/fastapi_project_template/fastapi-empty/src/core/config.py-tpl +5 -6
  159. fastapi_fastkit/fastapi_project_template/fastapi-empty/src/main.py-tpl +3 -1
  160. fastapi_fastkit/fastapi_project_template/fastapi-empty/tests/conftest.py-tpl +0 -1
  161. fastapi_fastkit/fastapi_project_template/fastapi-empty/tests/test_main.py-tpl +0 -1
  162. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/.env-tpl +15 -0
  163. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/.gitignore-tpl +31 -0
  164. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/Dockerfile-tpl +18 -0
  165. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/README.md-tpl +193 -0
  166. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/pyproject.toml-tpl +76 -0
  167. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/requirements.txt-tpl +15 -0
  168. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/scripts/chat.sh-tpl +10 -0
  169. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/scripts/format.sh-tpl +5 -0
  170. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/scripts/lint.sh-tpl +6 -0
  171. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/scripts/run-server.sh-tpl +8 -0
  172. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/scripts/test.sh-tpl +6 -0
  173. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/__init__.py-tpl +0 -0
  174. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/__init__.py-tpl +0 -0
  175. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/api/__init__.py-tpl +0 -0
  176. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/api/chat.py-tpl +62 -0
  177. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/api/deps.py-tpl +23 -0
  178. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/api/health.py-tpl +11 -0
  179. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/api/router.py-tpl +13 -0
  180. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/core/__init__.py-tpl +0 -0
  181. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/core/config.py-tpl +67 -0
  182. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/llm/__init__.py-tpl +0 -0
  183. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/llm/agent.py-tpl +136 -0
  184. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/llm/client.py-tpl +30 -0
  185. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/llm/tools.py-tpl +131 -0
  186. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/main.py-tpl +53 -0
  187. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/memory/__init__.py-tpl +0 -0
  188. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/memory/base.py-tpl +27 -0
  189. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/memory/in_memory.py-tpl +32 -0
  190. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/schemas/__init__.py-tpl +0 -0
  191. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/schemas/chat.py-tpl +46 -0
  192. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/template-config.yml-tpl +16 -0
  193. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/__init__.py-tpl +0 -0
  194. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/conftest.py-tpl +158 -0
  195. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/test_chat.py-tpl +160 -0
  196. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/test_health.py-tpl +12 -0
  197. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/test_memory.py-tpl +38 -0
  198. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/test_tools.py-tpl +37 -0
  199. fastapi_fastkit/fastapi_project_template/fastapi-mcp/README.md-tpl +1 -1
  200. fastapi_fastkit/fastapi_project_template/fastapi-mcp/pyproject.toml-tpl +29 -26
  201. fastapi_fastkit/fastapi_project_template/fastapi-mcp/requirements.txt-tpl +68 -39
  202. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/api.py-tpl +3 -0
  203. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/routes/auth.py-tpl +4 -5
  204. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/routes/items.py-tpl +3 -4
  205. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/auth/dependencies.py-tpl +4 -5
  206. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/core/config.py-tpl +4 -5
  207. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/main.py-tpl +4 -1
  208. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/{mcp → mcp_server}/router.py-tpl +3 -5
  209. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/schemas/items.py-tpl +6 -7
  210. fastapi_fastkit/fastapi_project_template/fastapi-mcp/tests/conftest.py-tpl +1 -1
  211. fastapi_fastkit/fastapi_project_template/fastapi-mcp/tests/test_auth.py-tpl +0 -1
  212. fastapi_fastkit/fastapi_project_template/fastapi-mcp/tests/test_items.py-tpl +0 -1
  213. fastapi_fastkit/fastapi_project_template/fastapi-mcp/tests/test_mcp.py-tpl +0 -1
  214. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/Dockerfile-tpl +1 -1
  215. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/pyproject.toml-tpl +26 -24
  216. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/requirements.txt-tpl +44 -41
  217. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/env.py-tpl +1 -3
  218. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/versions/bedcdc35b64a_first_alembic.py-tpl +5 -6
  219. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/api.py-tpl +2 -0
  220. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/routes/items.py-tpl +2 -4
  221. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/config.py-tpl +1 -2
  222. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/db.py-tpl +1 -1
  223. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/crud/items.py-tpl +4 -6
  224. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/main.py-tpl +9 -0
  225. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/schemas/items.py-tpl +4 -5
  226. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/conftest.py-tpl +1 -5
  227. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/test_items.py-tpl +0 -2
  228. fastapi_fastkit/fastapi_project_template/fastapi-single-module/.env-tpl +1 -0
  229. fastapi_fastkit/fastapi_project_template/fastapi-single-module/.gitignore-tpl +30 -0
  230. fastapi_fastkit/fastapi_project_template/fastapi-single-module/pyproject.toml-tpl +7 -5
  231. fastapi_fastkit/fastapi_project_template/fastapi-single-module/requirements.txt-tpl +26 -6
  232. fastapi_fastkit/fastapi_project_template/fastapi-single-module/src/main.py-tpl +3 -0
  233. fastapi_fastkit/fastapi_project_template/fastapi-single-module/tests/__init__.py-tpl +0 -0
  234. fastapi_fastkit/fastapi_project_template/fastapi-single-module/tests/conftest.py-tpl +0 -1
  235. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/.env-tpl +7 -0
  236. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/.gitignore-tpl +35 -0
  237. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/Dockerfile-tpl +18 -0
  238. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/README.md-tpl +166 -0
  239. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/alembic.ini-tpl +44 -0
  240. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/pyproject.toml-tpl +82 -0
  241. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/requirements.txt-tpl +21 -0
  242. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/scripts/format.sh-tpl +5 -0
  243. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/scripts/lint.sh-tpl +6 -0
  244. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/scripts/migrate.sh-tpl +7 -0
  245. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/scripts/run-server.sh-tpl +8 -0
  246. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/scripts/test.sh-tpl +6 -0
  247. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/__init__.py-tpl +0 -0
  248. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/__init__.py-tpl +0 -0
  249. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/alembic/README-tpl +9 -0
  250. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/alembic/env.py-tpl +72 -0
  251. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/alembic/script.py.mako-tpl +27 -0
  252. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/alembic/versions/0001_create_items_table.py-tpl +38 -0
  253. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/api/__init__.py-tpl +0 -0
  254. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/api/health.py-tpl +11 -0
  255. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/api/router.py-tpl +15 -0
  256. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/core/__init__.py-tpl +0 -0
  257. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/core/config.py-tpl +62 -0
  258. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/crud/__init__.py-tpl +0 -0
  259. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/crud/base.py-tpl +62 -0
  260. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/crud/pagination.py-tpl +21 -0
  261. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/db/__init__.py-tpl +0 -0
  262. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/db/base.py-tpl +12 -0
  263. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/db/session.py-tpl +50 -0
  264. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/domains/__init__.py-tpl +0 -0
  265. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/domains/items/__init__.py-tpl +0 -0
  266. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/domains/items/crud.py-tpl +26 -0
  267. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/domains/items/models.py-tpl +70 -0
  268. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/domains/items/router.py-tpl +69 -0
  269. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/main.py-tpl +43 -0
  270. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/template-config.yml-tpl +16 -0
  271. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/tests/__init__.py-tpl +0 -0
  272. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/tests/conftest.py-tpl +60 -0
  273. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/tests/test_crud_base.py-tpl +43 -0
  274. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/tests/test_health.py-tpl +12 -0
  275. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/tests/test_items.py-tpl +110 -0
  276. fastapi_fastkit/fastapi_project_template/modules/api/routes/new_route.py-tpl +2 -2
  277. fastapi_fastkit/fragments/README.md +90 -0
  278. fastapi_fastkit/fragments/auth/fastapi_users.py.j2 +8 -0
  279. fastapi_fastkit/fragments/auth/jwt.py.j2 +44 -0
  280. fastapi_fastkit/fragments/auth/oauth2.py.j2 +105 -0
  281. fastapi_fastkit/fragments/auth/session.py.j2 +67 -0
  282. fastapi_fastkit/fragments/db/mongodb.py.j2 +19 -0
  283. fastapi_fastkit/fragments/db/redis.py.j2 +26 -0
  284. fastapi_fastkit/fragments/db/sqlalchemy.py.j2 +45 -0
  285. fastapi_fastkit/fragments/docker/docker_compose.j2 +84 -0
  286. fastapi_fastkit/fragments/docker/dockerfile.j2 +19 -0
  287. fastapi_fastkit/fragments/features/cache.py.j2 +26 -0
  288. fastapi_fastkit/fragments/features/pagination.py.j2 +31 -0
  289. fastapi_fastkit/fragments/features/tasks_celery.py.j2 +50 -0
  290. fastapi_fastkit/fragments/features/tasks_dramatiq.py.j2 +59 -0
  291. fastapi_fastkit/fragments/features/websocket.py.j2 +66 -0
  292. fastapi_fastkit/fragments/header.j2 +4 -0
  293. fastapi_fastkit/fragments/logging/structured.py.j2 +88 -0
  294. fastapi_fastkit/fragments/main/imports/async_tasks.j2 +1 -0
  295. fastapi_fastkit/fragments/main/imports/caching.j2 +5 -0
  296. fastapi_fastkit/fragments/main/imports/fastapi_users.j2 +1 -0
  297. fastapi_fastkit/fragments/main/imports/jwt.j2 +1 -0
  298. fastapi_fastkit/fragments/main/imports/loguru.j2 +1 -0
  299. fastapi_fastkit/fragments/main/imports/mongodb.j2 +1 -0
  300. fastapi_fastkit/fragments/main/imports/oauth2.j2 +4 -0
  301. fastapi_fastkit/fragments/main/imports/opentelemetry.j2 +6 -0
  302. fastapi_fastkit/fragments/main/imports/pagination.j2 +3 -0
  303. fastapi_fastkit/fragments/main/imports/prometheus.j2 +1 -0
  304. fastapi_fastkit/fragments/main/imports/rate_limiting.j2 +3 -0
  305. fastapi_fastkit/fragments/main/imports/session_auth.j2 +4 -0
  306. fastapi_fastkit/fragments/main/imports/sqlalchemy.j2 +2 -0
  307. fastapi_fastkit/fragments/main/imports/structured_logging.j2 +1 -0
  308. fastapi_fastkit/fragments/main/imports/websocket.j2 +1 -0
  309. fastapi_fastkit/fragments/main/lifespan/caching.j2 +7 -0
  310. fastapi_fastkit/fragments/main/lifespan/mongodb.j2 +2 -0
  311. fastapi_fastkit/fragments/main/lifespan/prometheus.j2 +2 -0
  312. fastapi_fastkit/fragments/main/lifespan/sqlalchemy.j2 +2 -0
  313. fastapi_fastkit/fragments/main/setup/async_tasks.j2 +1 -0
  314. fastapi_fastkit/fragments/main/setup/caching.j2 +1 -0
  315. fastapi_fastkit/fragments/main/setup/cors.j2 +8 -0
  316. fastapi_fastkit/fragments/main/setup/oauth2.j2 +3 -0
  317. fastapi_fastkit/fragments/main/setup/opentelemetry.j2 +19 -0
  318. fastapi_fastkit/fragments/main/setup/pagination.j2 +2 -0
  319. fastapi_fastkit/fragments/main/setup/rate_limiting.j2 +3 -0
  320. fastapi_fastkit/fragments/main/setup/session_auth.j2 +6 -0
  321. fastapi_fastkit/fragments/main/setup/structured_logging.j2 +2 -0
  322. fastapi_fastkit/fragments/main/setup/websocket.j2 +1 -0
  323. fastapi_fastkit/fragments/main.py.j2 +57 -0
  324. fastapi_fastkit/fragments/migrations/alembic_ini.j2 +42 -0
  325. fastapi_fastkit/fragments/migrations/env.py.j2 +76 -0
  326. fastapi_fastkit/fragments/migrations/initial_revision.py.j2 +27 -0
  327. fastapi_fastkit/fragments/migrations/migrate_sh.j2 +34 -0
  328. fastapi_fastkit/fragments/migrations/script.py.mako.j2 +26 -0
  329. fastapi_fastkit/fragments/tasks/celery_worker.py.j2 +49 -0
  330. fastapi_fastkit/fragments/tasks/dramatiq_worker.py.j2 +41 -0
  331. fastapi_fastkit/fragments/test/factories.py.j2 +68 -0
  332. fastapi_fastkit/fragments/test/pytest_ini.j2 +16 -0
  333. fastapi_fastkit/fragments/test/test_factories.py.j2 +27 -0
  334. fastapi_fastkit/fragments/test/test_main.py.j2 +46 -0
  335. fastapi_fastkit/fragments/tooling/devcontainer.j2 +26 -0
  336. fastapi_fastkit/fragments/tooling/github_actions.j2 +37 -0
  337. fastapi_fastkit/fragments/tooling/makefile.j2 +40 -0
  338. fastapi_fastkit/fragments/tooling/pre_commit.j2 +36 -0
  339. fastapi_fastkit/fragments/tooling/ruff_toml.j2 +16 -0
  340. fastapi_fastkit/utils/config_file.py +263 -0
  341. fastapi_fastkit/utils/main.py +111 -6
  342. {fastapi_fastkit-1.2.1.dist-info → fastapi_fastkit-1.4.0.dist-info}/METADATA +33 -6
  343. fastapi_fastkit-1.4.0.dist-info/RECORD +484 -0
  344. {fastapi_fastkit-1.2.1.dist-info → fastapi_fastkit-1.4.0.dist-info}/WHEEL +1 -1
  345. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.py-tpl +0 -25
  346. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.py-tpl +0 -27
  347. fastapi_fastkit/fastapi_project_template/fastapi-default/setup.py-tpl +0 -22
  348. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.py-tpl +0 -22
  349. fastapi_fastkit/fastapi_project_template/fastapi-empty/setup.py-tpl +0 -22
  350. fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.py-tpl +0 -51
  351. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/setup.py-tpl +0 -25
  352. fastapi_fastkit/fastapi_project_template/fastapi-single-module/setup.py-tpl +0 -17
  353. fastapi_fastkit-1.2.1.dist-info/RECORD +0 -256
  354. /fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/{mcp → mcp_server}/__init__.py-tpl +0 -0
  355. {fastapi_fastkit-1.2.1.dist-info → fastapi_fastkit-1.4.0.dist-info}/entry_points.txt +0 -0
  356. {fastapi_fastkit-1.2.1.dist-info → fastapi_fastkit-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,28 +3,45 @@
3
3
  #
4
4
  # @author bnbong bbbong9@gmail.com
5
5
  # --------------------------------------------------------------------------
6
+ import ast
7
+ import io
8
+ import json
6
9
  import os
7
10
  import re
8
- from typing import Dict, List
11
+ import tokenize
12
+ import tomllib
13
+ from typing import Any, Dict, Iterable, List, Optional
9
14
 
10
15
  import click
11
16
 
12
17
  from fastapi_fastkit.backend.package_managers import PackageManagerFactory
18
+ from fastapi_fastkit.backend.project_builder.preset_layout import (
19
+ app_module_from_main_path,
20
+ )
13
21
  from fastapi_fastkit.backend.transducer import (
14
22
  copy_and_convert_template,
15
23
  copy_and_convert_template_file,
16
24
  )
17
25
  from fastapi_fastkit.core.exceptions import BackendExceptions
18
26
  from fastapi_fastkit.core.settings import settings
27
+ from fastapi_fastkit.utils.config_file import format_toml_value
19
28
  from fastapi_fastkit.utils.logging import debug_log, get_logger
20
29
  from fastapi_fastkit.utils.main import (
30
+ FASTKIT_DESCRIPTION_MARKER,
31
+ FASTKIT_TOOL_SECTION,
21
32
  handle_exception,
22
- print_error,
23
33
  print_info,
24
34
  print_success,
25
35
  print_warning,
36
+ read_fastkit_metadata,
26
37
  )
27
38
 
39
+ # Explicit insertion points templates may declare so ``addroute`` never has
40
+ # to guess where generated code belongs. See the route anchor contract in
41
+ # the project docs.
42
+ IMPORT_ANCHOR = "# fastkit:imports"
43
+ ROUTES_ANCHOR = "# fastkit:routes"
44
+
28
45
  logger = get_logger(__name__)
29
46
 
30
47
 
@@ -82,12 +99,19 @@ def find_template_core_modules(project_dir: str) -> Dict[str, str]:
82
99
 
83
100
  def read_template_stack(template_path: str) -> List[str]:
84
101
  """
85
- Read dependencies from template requirements.txt-tpl file or setup.py-tpl file.
102
+ Read dependencies from a template's metadata files.
103
+
104
+ Lookup order preserves legacy behaviour for templates that still ship
105
+ ``requirements.txt-tpl`` while allowing modern pyproject-first templates
106
+ to resolve their stack from ``pyproject.toml-tpl``:
107
+
108
+ 1. ``requirements.txt-tpl`` (legacy, highest precedence)
109
+ 2. ``pyproject.toml-tpl`` ``[project].dependencies``
110
+ 3. ``setup.py-tpl`` ``install_requires``
86
111
 
87
112
  :param template_path: Path to the template directory
88
- :return: List of dependencies
113
+ :return: List of PEP 508 requirement strings
89
114
  """
90
- # First try requirements.txt-tpl
91
115
  req_file = os.path.join(template_path, "requirements.txt-tpl")
92
116
  if os.path.exists(req_file):
93
117
  try:
@@ -100,7 +124,12 @@ def read_template_stack(template_path: str) -> List[str]:
100
124
  )
101
125
  return []
102
126
 
103
- # Fallback: try to read from setup.py-tpl for legacy templates
127
+ pyproject_file = os.path.join(template_path, "pyproject.toml-tpl")
128
+ if os.path.exists(pyproject_file):
129
+ deps = _parse_pyproject_dependencies(pyproject_file)
130
+ if deps:
131
+ return deps
132
+
104
133
  setup_file = os.path.join(template_path, "setup.py-tpl")
105
134
  if os.path.exists(setup_file):
106
135
  try:
@@ -153,20 +182,285 @@ def _parse_setup_dependencies(content: str) -> List[str]:
153
182
  return []
154
183
 
155
184
 
185
+ def _parse_pyproject_dependencies(pyproject_path: str) -> List[str]:
186
+ """
187
+ Parse dependencies from a ``pyproject.toml-tpl`` file.
188
+
189
+ Returns the list of PEP 508 requirement strings from
190
+ ``[project].dependencies``. Returns an empty list on parse errors so
191
+ callers can fall through to the next metadata source.
192
+ """
193
+ try:
194
+ with open(pyproject_path, "rb") as f:
195
+ data = tomllib.load(f)
196
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError) as e:
197
+ debug_log(f"Error reading pyproject.toml template: {e}", "error")
198
+ return []
199
+
200
+ project = data.get("project", {})
201
+ raw_deps = project.get("dependencies", []) or []
202
+ if not isinstance(raw_deps, list):
203
+ return []
204
+ return [dep.strip() for dep in raw_deps if isinstance(dep, str) and dep.strip()]
205
+
206
+
156
207
  # ------------------------------------------------------------
157
208
  # Project Setup Functions
158
209
  # ------------------------------------------------------------
159
210
 
160
211
 
212
+ # Placeholder tokens a template may declare anywhere in its files. Every one
213
+ # of them is substituted project-wide during generation, so a template author
214
+ # can use them in a README, a .env, a conftest.py - not just in the handful of
215
+ # "core" modules the injector used to know about.
216
+ PROJECT_PLACEHOLDERS = (
217
+ "<project_name>",
218
+ "<author>",
219
+ "<author_email>",
220
+ "<description>",
221
+ )
222
+
223
+ # Suffixes that are safe to rewrite as UTF-8 text. Anything else (images,
224
+ # fonts, compiled artifacts) is skipped without being read. Extension-less
225
+ # files (Dockerfile, Makefile, .env, .gitignore) are read too - they are the
226
+ # ones templates most often put placeholders in.
227
+ _TEXT_FILE_SUFFIXES = frozenset(
228
+ {
229
+ "",
230
+ ".cfg",
231
+ ".conf",
232
+ ".env",
233
+ ".example",
234
+ ".html",
235
+ ".in",
236
+ ".ini",
237
+ ".j2",
238
+ ".json",
239
+ ".mako",
240
+ ".md",
241
+ ".po",
242
+ ".py",
243
+ ".rst",
244
+ ".sh",
245
+ ".sql",
246
+ ".toml",
247
+ ".txt",
248
+ ".yaml",
249
+ ".yml",
250
+ }
251
+ )
252
+
253
+ # Directories never worth walking: virtualenvs and caches are not part of the
254
+ # template output, and rewriting them would be both slow and wrong.
255
+ _SKIPPED_SCAN_DIRS = frozenset(
256
+ {
257
+ ".git",
258
+ ".mypy_cache",
259
+ ".pytest_cache",
260
+ ".ruff_cache",
261
+ ".venv",
262
+ "__pycache__",
263
+ "venv",
264
+ }
265
+ )
266
+
267
+ # Guard against rewriting an accidentally huge file (a checked-in fixture,
268
+ # a lockfile dump). 2 MiB is far above any real template source file.
269
+ _MAX_SUBSTITUTION_FILE_BYTES = 2 * 1024 * 1024
270
+
271
+
272
+ #: File suffixes whose placeholder values must be escaped as TOML basic
273
+ #: string bodies, and those needing Python/JSON string escaping. A raw
274
+ #: substitution of a value containing a quote or a backslash would otherwise
275
+ #: produce a pyproject.toml or a config.py that no longer parses.
276
+ _TOML_SUFFIXES = frozenset({".toml"})
277
+ _PY_STRING_SUFFIXES = frozenset({".py", ".json"})
278
+
279
+
280
+ def escape_placeholder_value(value: str, file_path: str) -> str:
281
+ """
282
+ Escape a metadata value for safe substitution inside ``file_path``.
283
+
284
+ Placeholder tokens sit *inside* a quoted literal in the template
285
+ (``name = "<project_name>"``), so what is needed is the escaped *body*
286
+ of a string, not a fully quoted literal.
287
+
288
+ :param value: Raw metadata value entered by the user
289
+ :param file_path: Destination file the value is substituted into
290
+ :return: The value escaped for that file type (unchanged for plain text)
291
+ """
292
+ suffix = os.path.splitext(file_path)[1].lower()
293
+ if suffix in _TOML_SUFFIXES:
294
+ return format_toml_value(value)[1:-1]
295
+ if suffix in _PY_STRING_SUFFIXES:
296
+ return json.dumps(value, ensure_ascii=False)[1:-1]
297
+ return value
298
+
299
+
300
+ def _escaped_replacements(
301
+ replacements: Dict[str, str], file_path: str
302
+ ) -> Dict[str, str]:
303
+ """Escape every replacement value for the target file's syntax."""
304
+ return {
305
+ placeholder: escape_placeholder_value(value, file_path)
306
+ for placeholder, value in replacements.items()
307
+ }
308
+
309
+
310
+ def _apply_escaped_replacements(
311
+ content: str, replacements: Dict[str, str], file_path: str
312
+ ) -> str:
313
+ """Substitute ``replacements`` into ``content``, escaping per file type."""
314
+ for placeholder, value in _escaped_replacements(replacements, file_path).items():
315
+ content = content.replace(placeholder, value)
316
+ return content
317
+
318
+
319
+ def _is_substitutable_file(file_path: str) -> bool:
320
+ """Return True when a file should be scanned for placeholder tokens.
321
+
322
+ :param file_path: Absolute path to the candidate file
323
+ :return: True for reasonably sized text files
324
+ """
325
+ if os.path.islink(file_path):
326
+ return False
327
+ if os.path.splitext(file_path)[1].lower() not in _TEXT_FILE_SUFFIXES:
328
+ return False
329
+ try:
330
+ return os.path.getsize(file_path) <= _MAX_SUBSTITUTION_FILE_BYTES
331
+ except OSError:
332
+ return False
333
+
334
+
335
+ def _iter_project_text_files(project_dir: str) -> List[str]:
336
+ """List every text file in a generated project, skipping caches and venvs.
337
+
338
+ :param project_dir: Path to the generated project
339
+ :return: Absolute paths of candidate text files
340
+ """
341
+ candidates: List[str] = []
342
+ for root, dirs, files in os.walk(project_dir):
343
+ dirs[:] = [d for d in dirs if d not in _SKIPPED_SCAN_DIRS]
344
+ for file_name in files:
345
+ file_path = os.path.join(root, file_name)
346
+ if _is_substitutable_file(file_path):
347
+ candidates.append(file_path)
348
+ return candidates
349
+
350
+
351
+ def _substitution_targets(
352
+ project_dir: str, files: Optional[Iterable[str]] = None
353
+ ) -> List[str]:
354
+ """Resolve which files a rewriting pass may touch.
355
+
356
+ ``files`` is the list of paths the template deployment actually wrote.
357
+ Scoping to it is what keeps an in-place deployment (``project_dir`` is
358
+ the user's own workspace) from rewriting unrelated files that happened
359
+ to be sitting there. Only when a caller supplies nothing do we fall back
360
+ to walking the whole project.
361
+ """
362
+ if files is None:
363
+ return _iter_project_text_files(project_dir)
364
+ return [path for path in dict.fromkeys(files) if _is_substitutable_file(path)]
365
+
366
+
367
+ def replace_placeholders_in_project(
368
+ project_dir: str,
369
+ replacements: Dict[str, str],
370
+ files: Optional[Iterable[str]] = None,
371
+ ) -> List[str]:
372
+ """
373
+ Substitute placeholder tokens across every text file of a project.
374
+
375
+ Files that cannot be decoded as UTF-8 are left untouched rather than
376
+ corrupted, and a file is only rewritten when it actually changed.
377
+
378
+ :param project_dir: Path to the generated project
379
+ :param replacements: Mapping of placeholder token to replacement value
380
+ :param files: Absolute paths written by the template deployment. When
381
+ given, only these files are considered; otherwise the whole project
382
+ tree is walked.
383
+ :return: Project-relative paths of the files that were rewritten
384
+ """
385
+ rewritten: List[str] = []
386
+
387
+ for file_path in _substitution_targets(project_dir, files):
388
+ try:
389
+ with open(file_path, "r", encoding="utf-8") as f:
390
+ content = f.read()
391
+ except (OSError, UnicodeDecodeError) as e:
392
+ debug_log(f"Skipping {file_path} during substitution: {e}", "debug")
393
+ continue
394
+
395
+ updated = _apply_escaped_replacements(content, replacements, file_path)
396
+
397
+ if updated == content:
398
+ continue
399
+
400
+ try:
401
+ with open(file_path, "w", encoding="utf-8") as f:
402
+ f.write(updated)
403
+ except OSError as e:
404
+ debug_log(f"Failed to write substituted file {file_path}: {e}", "error")
405
+ raise BackendExceptions(f"Failed to substitute placeholders: {e}")
406
+
407
+ rewritten.append(os.path.relpath(file_path, project_dir))
408
+
409
+ return rewritten
410
+
411
+
412
+ def find_unsubstituted_placeholders(
413
+ project_dir: str, files: Optional[Iterable[str]] = None
414
+ ) -> Dict[str, List[str]]:
415
+ """
416
+ Report placeholder tokens that survived project generation.
417
+
418
+ A surviving token means the template declares a variable the generator
419
+ does not know how to fill - a bug in the template, and a broken file in
420
+ the user's brand new project. Callers surface it instead of failing hard
421
+ so the project is still usable.
422
+
423
+ :param project_dir: Path to the generated project
424
+ :param files: Absolute paths written by the template deployment; when
425
+ given the scan is limited to them
426
+ :return: Mapping of project-relative file path to the tokens found in it
427
+ """
428
+ residue: Dict[str, List[str]] = {}
429
+
430
+ for file_path in _substitution_targets(project_dir, files):
431
+ try:
432
+ with open(file_path, "r", encoding="utf-8") as f:
433
+ content = f.read()
434
+ except (OSError, UnicodeDecodeError):
435
+ continue
436
+
437
+ hits = [token for token in PROJECT_PLACEHOLDERS if token in content]
438
+ if hits:
439
+ residue[os.path.relpath(file_path, project_dir)] = hits
440
+
441
+ return residue
442
+
443
+
161
444
  def inject_project_metadata(
162
445
  target_dir: str,
163
446
  project_name: str,
164
447
  author: str,
165
448
  author_email: str,
166
449
  description: str,
450
+ files: Optional[Iterable[str]] = None,
167
451
  ) -> None:
168
452
  """
169
453
  Inject project metadata into template files after template conversion.
454
+
455
+ The core metadata files (setup.py, pyproject.toml, the settings module and
456
+ main.py) are handled first because they need more than a text replacement -
457
+ pyproject also gets the ``[tool.fastapi-fastkit]`` marker block. Every
458
+ remaining text file in the project is then swept for the same placeholder
459
+ tokens, so README.md, .env, tests/conftest.py and friends are covered too.
460
+
461
+ ``files`` scopes that sweep to the paths the template deployment wrote.
462
+ An in-place deployment targets the user's own workspace, and walking the
463
+ whole tree there would rewrite unrelated files.
170
464
  """
171
465
  try:
172
466
  core_modules = find_template_core_modules(target_dir)
@@ -187,8 +481,31 @@ def inject_project_metadata(
187
481
  _process_config_file(core_modules.get("config", ""), project_name)
188
482
  _process_main_file(core_modules.get("main", ""), project_name)
189
483
 
484
+ replacements = dict(
485
+ zip(
486
+ PROJECT_PLACEHOLDERS,
487
+ (project_name, author, author_email, description),
488
+ )
489
+ )
490
+ rewritten = replace_placeholders_in_project(target_dir, replacements, files)
491
+ if rewritten:
492
+ debug_log(
493
+ f"Substituted placeholders in {len(rewritten)} additional files",
494
+ "info",
495
+ )
496
+
497
+ residue = find_unsubstituted_placeholders(target_dir, files)
498
+ if residue:
499
+ for relative_path, tokens in sorted(residue.items()):
500
+ print_warning(
501
+ f"{relative_path}: {', '.join(tokens)}",
502
+ title="Unsubstituted placeholder",
503
+ )
504
+
190
505
  print_success("Project metadata injected successfully")
191
506
 
507
+ except BackendExceptions:
508
+ raise
192
509
  except Exception as e:
193
510
  debug_log(f"Failed to inject project metadata: {e}", "error")
194
511
  handle_exception(e, "Failed to inject project metadata")
@@ -222,8 +539,7 @@ def _process_setup_file(
222
539
  "<description>": description,
223
540
  }
224
541
 
225
- for placeholder, value in replacements.items():
226
- content = content.replace(placeholder, value)
542
+ content = _apply_escaped_replacements(content, replacements, setup_py)
227
543
 
228
544
  with open(setup_py, "w", encoding="utf-8") as f:
229
545
  f.write(content)
@@ -257,7 +573,9 @@ def _process_main_file(main_py: str, project_name: str) -> None:
257
573
  if "<project_name>" not in content:
258
574
  return
259
575
 
260
- content = content.replace("<project_name>", project_name)
576
+ content = content.replace(
577
+ "<project_name>", escape_placeholder_value(project_name, main_py)
578
+ )
261
579
 
262
580
  with open(main_py, "w", encoding="utf-8") as f:
263
581
  f.write(content)
@@ -285,7 +603,9 @@ def _process_config_file(config_py: str, project_name: str) -> None:
285
603
  content = f.read()
286
604
 
287
605
  # Replace project name placeholder
288
- content = content.replace("<project_name>", project_name)
606
+ content = content.replace(
607
+ "<project_name>", escape_placeholder_value(project_name, config_py)
608
+ )
289
609
 
290
610
  with open(config_py, "w", encoding="utf-8") as f:
291
611
  f.write(content)
@@ -329,8 +649,9 @@ def _process_pyproject_file(
329
649
  "<description>": description,
330
650
  }
331
651
 
332
- for placeholder, value in replacements.items():
333
- content = content.replace(placeholder, value)
652
+ content = _apply_escaped_replacements(content, replacements, pyproject_toml)
653
+
654
+ content = _ensure_pyproject_fastkit_markers(content)
334
655
 
335
656
  with open(pyproject_toml, "w", encoding="utf-8") as f:
336
657
  f.write(content)
@@ -343,20 +664,288 @@ def _process_pyproject_file(
343
664
  raise BackendExceptions(f"Failed to process pyproject.toml: {e}")
344
665
 
345
666
 
667
+ _DESCRIPTION_LINE_PATTERN = re.compile(
668
+ r'^(?P<prefix>description\s*=\s*)"(?P<value>.*?)"(?P<suffix>\s*)$',
669
+ re.MULTILINE,
670
+ )
671
+
672
+
673
+ # The ``[tool.fastapi-fastkit]`` table body cannot be matched with a regex: a
674
+ # ``features = [...]`` array contains ``[`` characters, so an "everything up to
675
+ # the next bracket" pattern stops in the middle of the table and leaves half of
676
+ # it behind. The extent of the table is found line by line instead, using the
677
+ # two patterns below (see ``_replace_fastkit_section``).
678
+ _FASTKIT_SECTION_HEADER_PATTERN = re.compile(
679
+ r"^\[tool\." + re.escape(FASTKIT_TOOL_SECTION) + r"\]\s*(?:#.*)?$"
680
+ )
681
+
682
+ #: A TOML table / array-of-tables header line, used as the section terminator.
683
+ _TOML_TABLE_HEADER_PATTERN = re.compile(r"^\[\[?[^\[\]]+\]\]?\s*(?:#.*)?$")
684
+
685
+
686
+ def _replace_fastkit_section(content: str, block: str) -> str:
687
+ """
688
+ Replace an existing ``[tool.fastapi-fastkit]`` table with ``block``.
689
+
690
+ The table runs from its header up to (but excluding) the next line that
691
+ starts a new TOML table, so a multi-line ``features`` array inside it is
692
+ replaced together with the rest of the table instead of being orphaned.
693
+
694
+ :param content: Full pyproject.toml text
695
+ :param block: Rendered replacement table, newline-terminated
696
+ :return: Updated content (unchanged when no section is present)
697
+ """
698
+ lines = content.splitlines(keepends=True)
699
+
700
+ start = next(
701
+ (
702
+ index
703
+ for index, line in enumerate(lines)
704
+ if _FASTKIT_SECTION_HEADER_PATTERN.match(line.strip())
705
+ ),
706
+ None,
707
+ )
708
+ if start is None:
709
+ return content
710
+
711
+ end = len(lines)
712
+ for index in range(start + 1, len(lines)):
713
+ if _TOML_TABLE_HEADER_PATTERN.match(lines[index].strip()):
714
+ end = index
715
+ break
716
+
717
+ # Blank lines between this table and the next one belong to the file's
718
+ # formatting, not to the table, so leave them in place.
719
+ while end - 1 > start and not lines[end - 1].strip():
720
+ end -= 1
721
+
722
+ return "".join(lines[:start]) + block + "".join(lines[end:])
723
+
724
+
725
+ # Order the ``[tool.fastapi-fastkit]`` keys are emitted in. Stable ordering
726
+ # keeps generated pyproject diffs readable and matches the documented
727
+ # metadata contract.
728
+ _FASTKIT_METADATA_KEY_ORDER = (
729
+ "managed",
730
+ "version",
731
+ "template",
732
+ "preset",
733
+ "package_manager",
734
+ "app_module",
735
+ "features",
736
+ )
737
+
738
+
739
+ def build_fastkit_metadata_block(metadata: Dict[str, Any]) -> str:
740
+ """
741
+ Render a ``[tool.fastapi-fastkit]`` TOML table from a metadata mapping.
742
+
743
+ ``managed = true`` is always emitted so project detection keeps working
744
+ even when a caller passes a partial mapping. Keys with a ``None`` value
745
+ are omitted (that is how optional entries such as ``preset`` disappear
746
+ for non-preset templates).
747
+
748
+ :param metadata: Metadata mapping (see the project metadata contract)
749
+ :return: TOML table text, newline-terminated
750
+ """
751
+ values: Dict[str, Any] = {"managed": True}
752
+ values.update({k: v for k, v in metadata.items() if v is not None})
753
+
754
+ ordered_keys = [k for k in _FASTKIT_METADATA_KEY_ORDER if k in values]
755
+ ordered_keys += [k for k in values if k not in _FASTKIT_METADATA_KEY_ORDER]
756
+
757
+ lines = [f"[tool.{FASTKIT_TOOL_SECTION}]"]
758
+ lines += [f"{key} = {format_toml_value(values[key])}" for key in ordered_keys]
759
+ return "\n".join(lines) + "\n"
760
+
761
+
762
+ def _ensure_pyproject_fastkit_markers(
763
+ content: str, metadata: Optional[Dict[str, Any]] = None
764
+ ) -> str:
765
+ """Idempotently ensure pyproject content carries FastAPI-fastkit markers.
766
+
767
+ Adds the canonical description marker if the ``description`` field does
768
+ not already include it (case-insensitive check), and writes the
769
+ ``[tool.fastapi-fastkit]`` section. Without ``metadata`` the section is
770
+ only created when missing (``managed = true``); with ``metadata`` any
771
+ existing section is replaced so re-running generation refreshes the
772
+ recorded template / preset / app module rather than duplicating it.
773
+ """
774
+ marker = FASTKIT_DESCRIPTION_MARKER
775
+ match = _DESCRIPTION_LINE_PATTERN.search(content)
776
+ if match and marker.lower() not in match.group("value").lower():
777
+ existing = match.group("value")
778
+ new_value = f"{marker} {existing}".strip() if existing else marker
779
+ content = (
780
+ content[: match.start()]
781
+ + f'{match.group("prefix")}"{new_value}"{match.group("suffix")}'
782
+ + content[match.end() :]
783
+ )
784
+
785
+ tool_header = f"[tool.{FASTKIT_TOOL_SECTION}]"
786
+ has_section = tool_header in content
787
+
788
+ if metadata is None:
789
+ if not has_section:
790
+ if not content.endswith("\n"):
791
+ content += "\n"
792
+ content += f"\n{tool_header}\nmanaged = true\n"
793
+ return content
794
+
795
+ block = build_fastkit_metadata_block(metadata)
796
+
797
+ if has_section:
798
+ return _replace_fastkit_section(content, block)
799
+
800
+ if not content.endswith("\n"):
801
+ content += "\n"
802
+ return content + "\n" + block
803
+
804
+
805
+ def write_fastkit_metadata(project_dir: str, metadata: Dict[str, Any]) -> None:
806
+ """
807
+ Write the ``[tool.fastapi-fastkit]`` block into a project's pyproject.toml.
808
+
809
+ Called at the very end of scaffolding: package managers regenerate
810
+ ``pyproject.toml`` while resolving dependencies, so the metadata block
811
+ has to be (re)stamped after that step rather than during the initial
812
+ placeholder substitution.
813
+
814
+ :param project_dir: Path to the generated project directory
815
+ :param metadata: Metadata mapping (see the project metadata contract)
816
+ """
817
+ pyproject_path = find_template_core_modules(project_dir).get("pyproject", "")
818
+ if not pyproject_path:
819
+ pyproject_path = os.path.join(project_dir, "pyproject.toml")
820
+
821
+ if not os.path.exists(pyproject_path):
822
+ debug_log("No pyproject.toml found; skipping fastkit metadata write", "warning")
823
+ return
824
+
825
+ try:
826
+ with open(pyproject_path, "r", encoding="utf-8") as f:
827
+ content = f.read()
828
+
829
+ content = _ensure_pyproject_fastkit_markers(content, metadata)
830
+
831
+ with open(pyproject_path, "w", encoding="utf-8") as f:
832
+ f.write(content)
833
+
834
+ debug_log("Wrote FastAPI-fastkit metadata into pyproject.toml", "info")
835
+
836
+ except (OSError, UnicodeDecodeError) as e:
837
+ debug_log(f"Error writing fastkit metadata: {e}", "error")
838
+ print_warning(f"Could not write fastkit metadata: {e}")
839
+
840
+
841
+ def _render_dependency_array(dependencies: List[str]) -> str:
842
+ """Render a PEP 621 ``dependencies`` array as multi-line TOML."""
843
+ if not dependencies:
844
+ return "[]"
845
+ body = "\n".join(f' "{dep}",' for dep in dependencies)
846
+ return f"[\n{body}\n]"
847
+
848
+
849
+ def _update_pyproject_dependencies(
850
+ pyproject_path: str, dependencies: List[str]
851
+ ) -> bool:
852
+ """
853
+ Replace ``[project].dependencies`` in a pyproject.toml file.
854
+
855
+ The array is located by bracket matching rather than a lazy regex so
856
+ requirement strings carrying extras (``redis[hiredis]``) don't truncate
857
+ the match.
858
+
859
+ :param pyproject_path: Path to the project's pyproject.toml
860
+ :param dependencies: Dependency specifications to write
861
+ :return: True when the file was rewritten, False when no array was found
862
+ """
863
+ try:
864
+ with open(pyproject_path, "r", encoding="utf-8") as f:
865
+ content = f.read()
866
+ except (OSError, UnicodeDecodeError) as e:
867
+ debug_log(f"Error reading pyproject.toml dependencies: {e}", "error")
868
+ return False
869
+
870
+ project_match = re.search(r"^\[project\]\s*$", content, re.MULTILINE)
871
+ if not project_match:
872
+ return False
873
+
874
+ section_start = project_match.end()
875
+ next_table = re.search(r"^\[", content[section_start:], re.MULTILINE)
876
+ section_end = section_start + next_table.start() if next_table else len(content)
877
+ section = content[section_start:section_end]
878
+
879
+ deps_match = re.search(r"^dependencies\s*=\s*\[", section, re.MULTILINE)
880
+ if not deps_match:
881
+ return False
882
+
883
+ array_start = section.rindex("[", deps_match.start(), deps_match.end())
884
+ depth = 0
885
+ array_end = -1
886
+ for index in range(array_start, len(section)):
887
+ char = section[index]
888
+ if char == "[":
889
+ depth += 1
890
+ elif char == "]":
891
+ depth -= 1
892
+ if depth == 0:
893
+ array_end = index + 1
894
+ break
895
+ if array_end == -1:
896
+ return False
897
+
898
+ new_section = (
899
+ section[: deps_match.start()]
900
+ + f"dependencies = {_render_dependency_array(dependencies)}"
901
+ + section[array_end:]
902
+ )
903
+ new_content = content[:section_start] + new_section + content[section_end:]
904
+
905
+ try:
906
+ with open(pyproject_path, "w", encoding="utf-8") as f:
907
+ f.write(new_content)
908
+ except OSError as e:
909
+ debug_log(f"Error writing pyproject.toml dependencies: {e}", "error")
910
+ return False
911
+
912
+ return True
913
+
914
+
346
915
  def update_setup_py_dependencies(project_dir: str, dependencies: List[str]) -> None:
347
916
  """
348
- Update setup.py file's install_requires list with new dependencies.
917
+ Update the project's declared dependencies with the selected packages.
349
918
 
350
- This function finds and updates the install_requires list in setup.py
351
- to match the dependencies selected during interactive mode.
919
+ ``pyproject.toml``'s ``[project].dependencies`` is the primary target —
920
+ every current template ships one, and it is what every supported package
921
+ manager installs from. ``setup.py`` is still updated when a template
922
+ happens to ship one, purely as a legacy path.
923
+
924
+ The function name is kept for backwards compatibility with existing
925
+ callers and tests.
352
926
 
353
927
  :param project_dir: Path to the project directory
354
928
  :param dependencies: List of dependency specifications
355
929
  """
356
- setup_py_path = os.path.join(project_dir, "setup.py")
930
+ updated_any = False
357
931
 
932
+ pyproject_path = find_template_core_modules(project_dir).get("pyproject", "")
933
+ if pyproject_path and os.path.exists(pyproject_path):
934
+ if _update_pyproject_dependencies(pyproject_path, dependencies):
935
+ updated_any = True
936
+ print_info(f"Updated pyproject.toml with {len(dependencies)} dependencies")
937
+ else:
938
+ debug_log(
939
+ "Could not find [project].dependencies in pyproject.toml", "warning"
940
+ )
941
+
942
+ setup_py_path = os.path.join(project_dir, "setup.py")
358
943
  if not os.path.exists(setup_py_path):
359
- debug_log("setup.py not found, skipping dependency update", "info")
944
+ if not updated_any:
945
+ debug_log(
946
+ "No pyproject.toml/setup.py dependency table found, skipping update",
947
+ "info",
948
+ )
360
949
  return
361
950
 
362
951
  try:
@@ -527,6 +1116,250 @@ def generate_dependency_file_with_manager(
527
1116
  # ------------------------------------------------------------
528
1117
 
529
1118
 
1119
+ def resolve_project_layout(project_dir: str) -> Dict[str, str]:
1120
+ """
1121
+ Resolve where a generated project keeps its entrypoint and API router.
1122
+
1123
+ Replaces the old ``src/main.py`` assumption. Resolution order:
1124
+
1125
+ 1. ``[tool.fastapi-fastkit].app_module`` in ``pyproject.toml`` — written
1126
+ at generation time, so it is authoritative for every fastkit project.
1127
+ 2. On-disk discovery via :func:`find_template_core_modules`.
1128
+ 3. A ``src/`` fallback so hand-made projects behave as they used to.
1129
+
1130
+ :param project_dir: Path to the project directory
1131
+ :return: Mapping with ``main``, ``app_module``, ``package_dir``,
1132
+ ``package_module``, ``api_dir``, ``api_router_file`` and
1133
+ ``api_router_module`` entries (``main`` is empty when no entrypoint
1134
+ could be found).
1135
+ """
1136
+ metadata = read_fastkit_metadata(project_dir)
1137
+
1138
+ main_path = ""
1139
+ recorded_module = metadata.get("app_module")
1140
+ if isinstance(recorded_module, str) and recorded_module:
1141
+ module_path = recorded_module.split(":")[0].replace(".", os.sep)
1142
+ candidate = os.path.join(project_dir, f"{module_path}.py")
1143
+ if os.path.exists(candidate):
1144
+ main_path = candidate
1145
+
1146
+ if not main_path:
1147
+ main_path = find_template_core_modules(project_dir).get("main", "")
1148
+
1149
+ if main_path:
1150
+ package_dir = os.path.dirname(main_path)
1151
+ app_module = app_module_from_main_path(project_dir, main_path)
1152
+ else:
1153
+ # Nothing discovered — keep the historical ``src/`` assumption so the
1154
+ # caller still gets a usable structure to create files in.
1155
+ package_dir = os.path.join(project_dir, "src")
1156
+ app_module = "src.main:app"
1157
+
1158
+ package_module = _dotted_module(project_dir, package_dir)
1159
+ api_dir = os.path.join(package_dir, "api")
1160
+ api_router_file = _find_api_router_file(api_dir)
1161
+ stem = os.path.splitext(os.path.basename(api_router_file))[0]
1162
+ api_router_module = (
1163
+ f"{package_module}.api"
1164
+ if stem == "__init__"
1165
+ else f"{package_module}.api.{stem}"
1166
+ )
1167
+
1168
+ return {
1169
+ "main": main_path,
1170
+ "app_module": app_module,
1171
+ "package_dir": package_dir,
1172
+ "package_module": package_module,
1173
+ "api_dir": api_dir,
1174
+ "api_router_file": api_router_file,
1175
+ "api_router_module": api_router_module,
1176
+ }
1177
+
1178
+
1179
+ def _dotted_module(project_dir: str, path: str) -> str:
1180
+ """Convert a path inside the project into a dotted module prefix."""
1181
+ relative = os.path.relpath(path, project_dir)
1182
+ if relative in (".", ""):
1183
+ return ""
1184
+ return relative.replace(os.sep, ".")
1185
+
1186
+
1187
+ # Router module filenames templates use, in precedence order. ``api.py`` is
1188
+ # the classic-layered name, ``router.py`` the domain-starter one.
1189
+ _API_ROUTER_FILENAMES = ("api.py", "router.py", "__init__.py")
1190
+
1191
+
1192
+ def _find_api_router_file(api_dir: str) -> str:
1193
+ """
1194
+ Pick the file inside ``api/`` that declares ``api_router``.
1195
+
1196
+ Falls back to ``api.py`` (the file ``addroute`` creates from the module
1197
+ template) when the package has no router file yet.
1198
+ """
1199
+ for filename in _API_ROUTER_FILENAMES:
1200
+ candidate = os.path.join(api_dir, filename)
1201
+ if not os.path.exists(candidate):
1202
+ continue
1203
+ try:
1204
+ with open(candidate, "r", encoding="utf-8") as f:
1205
+ content = f.read()
1206
+ except (OSError, UnicodeDecodeError):
1207
+ continue
1208
+ if "api_router" in content:
1209
+ return candidate
1210
+
1211
+ return os.path.join(api_dir, "api.py")
1212
+
1213
+
1214
+ def insert_import_line(content: str, import_line: str) -> str:
1215
+ """
1216
+ Insert an import statement using the ``# fastkit:imports`` anchor.
1217
+
1218
+ Templates declare the anchor to say exactly where generated imports
1219
+ belong. Without one the insertion point is computed from the module's
1220
+ AST (right after the last top-level import), which is precise where the
1221
+ previous line-prefix scan was merely approximate.
1222
+
1223
+ :param content: Source of the module being edited
1224
+ :param import_line: Import statement to add
1225
+ :return: Updated source (unchanged if the import is already present)
1226
+ """
1227
+ if import_line in content:
1228
+ return content
1229
+
1230
+ anchored = _insert_after_anchor(content, IMPORT_ANCHOR, import_line)
1231
+ if anchored is not None:
1232
+ return anchored
1233
+
1234
+ lines = content.split("\n")
1235
+ insert_at = _last_import_line(content)
1236
+ lines.insert(insert_at, import_line)
1237
+ return "\n".join(lines)
1238
+
1239
+
1240
+ def insert_statement_line(
1241
+ content: str, statement: str, anchor: str = ROUTES_ANCHOR
1242
+ ) -> str:
1243
+ """
1244
+ Insert a statement at the ``# fastkit:routes`` anchor, else append it.
1245
+
1246
+ :param content: Source of the module being edited
1247
+ :param statement: Statement to add
1248
+ :param anchor: Anchor comment to look for
1249
+ :return: Updated source (unchanged if the statement is already present)
1250
+ """
1251
+ if statement in content:
1252
+ return content
1253
+
1254
+ anchored = _insert_after_anchor(content, anchor, statement)
1255
+ if anchored is not None:
1256
+ return anchored
1257
+
1258
+ if content and not content.endswith("\n"):
1259
+ content += "\n"
1260
+ return f"{content}{statement}\n"
1261
+
1262
+
1263
+ def _anchor_line_numbers(content: str, anchor: str) -> List[int]:
1264
+ """Return the 1-based line numbers carrying ``anchor`` as a real comment.
1265
+
1266
+ Tokenising is what separates an anchor from a string that merely *looks*
1267
+ like one: a docstring or a ``"# fastkit:routes"`` literal must not be
1268
+ treated as an insertion point, because inserting a router registration
1269
+ inside a string silently produces a module that no longer registers it.
1270
+ Unparsable sources fall back to a plain scan so a template with a syntax
1271
+ error still behaves as it did before.
1272
+ """
1273
+ try:
1274
+ tokens = list(tokenize.generate_tokens(io.StringIO(content).readline))
1275
+ except (tokenize.TokenError, IndentationError, SyntaxError):
1276
+ return [
1277
+ index
1278
+ for index, line in enumerate(content.split("\n"), start=1)
1279
+ if line.strip().startswith(anchor)
1280
+ ]
1281
+
1282
+ return [
1283
+ token.start[0]
1284
+ for token in tokens
1285
+ if token.type == tokenize.COMMENT and token.string.strip().startswith(anchor)
1286
+ ]
1287
+
1288
+
1289
+ def _insert_after_anchor(content: str, anchor: str, line: str) -> Optional[str]:
1290
+ """Insert ``line`` right below ``anchor``, preserving the anchor's indent.
1291
+
1292
+ When a module declares the same anchor more than once the first one wins
1293
+ and the duplicates are reported: silently picking one of several
1294
+ insertion points makes generated code land somewhere the template author
1295
+ did not intend.
1296
+
1297
+ Returns None when the anchor isn't present so callers can fall back.
1298
+ """
1299
+ anchor_lines = _anchor_line_numbers(content, anchor)
1300
+ if not anchor_lines:
1301
+ return None
1302
+
1303
+ if len(anchor_lines) > 1:
1304
+ print_warning(
1305
+ f"Found {len(anchor_lines)} '{anchor}' anchors "
1306
+ f"(lines {', '.join(str(n) for n in anchor_lines)}); "
1307
+ "inserting at the first one.",
1308
+ title="Duplicate anchor",
1309
+ )
1310
+
1311
+ lines = content.split("\n")
1312
+ index = anchor_lines[0] - 1
1313
+ existing = lines[index]
1314
+ indent = existing[: len(existing) - len(existing.lstrip())]
1315
+ lines.insert(index + 1, f"{indent}{line}")
1316
+ return "\n".join(lines)
1317
+
1318
+
1319
+ def _last_import_line(content: str) -> int:
1320
+ """
1321
+ Return the 0-based line index just after the last top-level import.
1322
+
1323
+ Uses ``ast`` so multi-line ``from x import (a, b)`` statements and
1324
+ imports appearing inside strings or functions are handled correctly.
1325
+
1326
+ A module with no imports at all still has places an import may not go:
1327
+ a new import inserted at line 0 would land above the module docstring
1328
+ (demoting it to a plain expression) or above a ``from __future__``
1329
+ statement (which must stay first). So the fallback point is just after
1330
+ the docstring and any ``__future__`` imports.
1331
+ """
1332
+ try:
1333
+ tree = ast.parse(content)
1334
+ except SyntaxError:
1335
+ return 0
1336
+
1337
+ last_line = 0
1338
+ prelude_line = 0
1339
+ for node in tree.body:
1340
+ if isinstance(node, ast.ImportFrom) and node.module == "__future__":
1341
+ prelude_line = max(prelude_line, node.end_lineno or node.lineno)
1342
+ last_line = max(last_line, node.end_lineno or node.lineno)
1343
+ continue
1344
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
1345
+ last_line = max(last_line, node.end_lineno or node.lineno)
1346
+
1347
+ if last_line:
1348
+ return last_line
1349
+
1350
+ docstring_node = tree.body[0] if tree.body else None
1351
+ if (
1352
+ isinstance(docstring_node, ast.Expr)
1353
+ and isinstance(docstring_node.value, ast.Constant)
1354
+ and isinstance(docstring_node.value.value, str)
1355
+ ):
1356
+ prelude_line = max(
1357
+ prelude_line, docstring_node.end_lineno or docstring_node.lineno
1358
+ )
1359
+
1360
+ return prelude_line
1361
+
1362
+
530
1363
  def _ensure_project_structure(src_dir: str) -> Dict[str, str]:
531
1364
  """
532
1365
  Ensure the project structure exists for adding a new route.
@@ -554,23 +1387,35 @@ def _ensure_project_structure(src_dir: str) -> Dict[str, str]:
554
1387
  # Create __init__.py if it doesn't exist
555
1388
  init_file = os.path.join(dir_path, "__init__.py")
556
1389
  if not os.path.exists(init_file):
557
- with open(init_file, "w") as f:
1390
+ with open(init_file, "w"):
558
1391
  pass
559
1392
 
560
1393
  return target_dirs
561
1394
 
562
1395
 
563
1396
  def _create_route_files(
564
- modules_dir: str, target_dirs: Dict[str, str], route_name: str
1397
+ modules_dir: str,
1398
+ target_dirs: Dict[str, str],
1399
+ route_name: str,
1400
+ package_module: str = "src",
565
1401
  ) -> None:
566
1402
  """
567
1403
  Create route files from templates.
568
1404
 
1405
+ The route template imports its crud/schemas siblings through the
1406
+ ``<package_root>`` placeholder, so the generated imports follow the
1407
+ project's real package (``src`` for the classic layout, ``src.app`` for
1408
+ the domain-starter one) instead of a hardcoded ``src``.
1409
+
569
1410
  :param modules_dir: Path to the modules directory
570
1411
  :param target_dirs: Dictionary with paths to target directories
571
1412
  :param route_name: Name of the route to create
1413
+ :param package_module: Dotted package holding ``api``/``crud``/``schemas``
572
1414
  """
573
- replacements = {"<new_route>": route_name}
1415
+ replacements = {
1416
+ "<new_route>": route_name,
1417
+ "<package_root>": package_module or "src",
1418
+ }
574
1419
  module_types = ["api/routes", "crud", "schemas"]
575
1420
 
576
1421
  for module_type in module_types:
@@ -595,7 +1440,10 @@ def _create_route_files(
595
1440
 
596
1441
 
597
1442
  def _handle_api_router_file(
598
- target_dirs: Dict[str, str], modules_dir: str, route_name: str
1443
+ target_dirs: Dict[str, str],
1444
+ modules_dir: str,
1445
+ route_name: str,
1446
+ api_router_file: str = "",
599
1447
  ) -> None:
600
1448
  """
601
1449
  Handle API router file creation or update.
@@ -603,12 +1451,15 @@ def _handle_api_router_file(
603
1451
  :param target_dirs: Dictionary with paths to target directories
604
1452
  :param modules_dir: Path to the modules directory
605
1453
  :param route_name: Name of the route
1454
+ :param api_router_file: Router file resolved from the project layout.
1455
+ Defaults to ``<api dir>/api.py`` for callers that don't resolve one.
606
1456
  """
607
1457
  api_dir = target_dirs["api"]
608
- api_router_file = os.path.join(api_dir, "api.py")
1458
+ if not api_router_file:
1459
+ api_router_file = os.path.join(api_dir, "api.py")
609
1460
 
610
1461
  if not os.path.exists(api_router_file):
611
- # Create api.py if it doesn't exist
1462
+ # Create the router module if it doesn't exist
612
1463
  api_source = os.path.join(modules_dir, "api", "__init__.py-tpl")
613
1464
  if os.path.exists(api_source):
614
1465
  copy_and_convert_template_file(api_source, api_router_file)
@@ -622,6 +1473,10 @@ def _update_api_router(api_router_file: str, route_name: str) -> None:
622
1473
  """
623
1474
  Update API router file to include new route.
624
1475
 
1476
+ Insertion points come from the ``# fastkit:imports`` /
1477
+ ``# fastkit:routes`` anchors when the template declares them, and from
1478
+ the module's AST otherwise.
1479
+
625
1480
  :param api_router_file: Path to API router file
626
1481
  :param route_name: Name of the route
627
1482
  """
@@ -629,46 +1484,20 @@ def _update_api_router(api_router_file: str, route_name: str) -> None:
629
1484
  with open(api_router_file, "r", encoding="utf-8") as f:
630
1485
  content = f.read()
631
1486
 
632
- # Check if route is already included
633
1487
  route_import = f"from .routes import {route_name}"
634
- route_include = f"api_router.include_router({route_name}.router"
1488
+ route_include = (
1489
+ f"api_router.include_router({route_name}.router, "
1490
+ f'prefix="/{route_name}", tags=["{route_name}"])'
1491
+ )
635
1492
 
636
- if route_import in content and route_include in content:
1493
+ if (
1494
+ route_import in content
1495
+ and f"api_router.include_router({route_name}.router" in content
1496
+ ):
637
1497
  return # Already included
638
1498
 
639
- # Add import if not present
640
- if route_import not in content:
641
- # Find where to add the import
642
- lines = content.split("\n")
643
- last_import_idx = -1
644
- for i, line in enumerate(lines):
645
- if line.startswith("from .routes import") or line.startswith(
646
- "from routes import"
647
- ):
648
- last_import_idx = i
649
-
650
- if last_import_idx >= 0:
651
- lines.insert(last_import_idx + 1, route_import)
652
- else:
653
- # Add at the end of imports section
654
- for i, line in enumerate(lines):
655
- if (
656
- not line.startswith("from")
657
- and not line.startswith("import")
658
- and line.strip()
659
- ):
660
- lines.insert(i, route_import)
661
- break
662
-
663
- content = "\n".join(lines)
664
-
665
- # Add router inclusion if not present
666
- if route_include not in content:
667
- # Add before the last line or at the end
668
- if content.strip():
669
- content += f'\napi_router.include_router({route_name}.router, prefix="/{route_name}", tags=["{route_name}"])\n'
670
- else:
671
- content = f'api_router.include_router({route_name}.router, prefix="/{route_name}", tags=["{route_name}"])\n'
1499
+ content = insert_import_line(content, route_import)
1500
+ content = insert_statement_line(content, route_include)
672
1501
 
673
1502
  with open(api_router_file, "w", encoding="utf-8") as f:
674
1503
  f.write(content)
@@ -703,15 +1532,28 @@ def _process_init_files(
703
1532
  copy_and_convert_template_file(init_source, init_target)
704
1533
 
705
1534
 
706
- def _update_main_app(src_dir: str, route_name: str) -> None:
1535
+ def _update_main_app(
1536
+ src_dir: str,
1537
+ route_name: str,
1538
+ router_module: str = "",
1539
+ main_py_path: str = "",
1540
+ ) -> None:
707
1541
  """
708
1542
  Update the main application file to include the API router.
709
- Adds the router import and include statement at the end of the file.
710
1543
 
711
- :param src_dir: Source directory of the project
1544
+ :param src_dir: Package directory holding ``main.py`` (``src`` or
1545
+ ``src/app`` depending on the template layout)
712
1546
  :param route_name: Name of the route to add
1547
+ :param router_module: Dotted module exposing ``api_router``. Derived from
1548
+ ``src_dir`` when omitted, preserving the classic ``src.api.api``.
1549
+ :param main_py_path: Explicit entrypoint path; defaults to
1550
+ ``<src_dir>/main.py``.
713
1551
  """
714
- main_py_path = os.path.join(src_dir, "main.py")
1552
+ if not main_py_path:
1553
+ main_py_path = os.path.join(src_dir, "main.py")
1554
+ if not router_module:
1555
+ router_module = f"{os.path.basename(src_dir)}.api.api"
1556
+
715
1557
  if not os.path.exists(main_py_path):
716
1558
  print_warning("main.py not found. Please manually add the API router.")
717
1559
  return
@@ -721,7 +1563,7 @@ def _update_main_app(src_dir: str, route_name: str) -> None:
721
1563
  main_content = f.read()
722
1564
 
723
1565
  # Check if router is already imported or included
724
- router_import = "from src.api.api import api_router"
1566
+ router_import = f"from {router_module} import api_router"
725
1567
  router_include = "app.include_router(api_router"
726
1568
 
727
1569
  if router_import in main_content and router_include in main_content:
@@ -735,30 +1577,12 @@ def _update_main_app(src_dir: str, route_name: str) -> None:
735
1577
  )
736
1578
  return
737
1579
 
738
- # Add the router import if needed
739
- if router_import not in main_content:
740
- # Add import at the end of imports section or beginning of file
741
- lines = main_content.split("\n")
742
-
743
- # Find last import line
744
- last_import_idx = -1
745
- for i, line in enumerate(lines):
746
- if line.startswith("import ") or line.startswith("from "):
747
- last_import_idx = i
748
-
749
- if last_import_idx >= 0:
750
- # Insert after the last import
751
- lines.insert(last_import_idx + 1, router_import)
752
- else:
753
- # Insert at the beginning
754
- lines.insert(0, router_import)
1580
+ main_content = insert_import_line(main_content, router_import)
755
1581
 
756
- main_content = "\n".join(lines)
757
-
758
- # Add router inclusion if needed
759
1582
  if router_include not in main_content:
760
- # Add at the end of the file
761
- main_content += "\napp.include_router(api_router)\n"
1583
+ main_content = insert_statement_line(
1584
+ main_content, "app.include_router(api_router)"
1585
+ )
762
1586
 
763
1587
  with open(main_py_path, "w", encoding="utf-8") as f:
764
1588
  f.write(main_content)
@@ -774,6 +1598,10 @@ def add_new_route(project_dir: str, route_name: str) -> None:
774
1598
  """
775
1599
  Add a new API route to an existing FastAPI project.
776
1600
 
1601
+ The target locations come from :func:`resolve_project_layout`, so the
1602
+ command works for both the classic ``src/main.py`` layout and the
1603
+ domain-starter's ``src/app/main.py``.
1604
+
777
1605
  :param project_dir: Path to the project directory
778
1606
  :param route_name: Name of the new route to add
779
1607
  :raises BackendExceptions: If route addition fails
@@ -781,23 +1609,33 @@ def add_new_route(project_dir: str, route_name: str) -> None:
781
1609
  try:
782
1610
  # Setup paths
783
1611
  modules_dir = os.path.join(settings.FASTKIT_TEMPLATE_ROOT, "modules")
784
- src_dir = os.path.join(project_dir, "src")
1612
+ layout = resolve_project_layout(project_dir)
1613
+ src_dir = layout["package_dir"]
785
1614
 
786
1615
  # Ensure project structure exists
787
1616
  target_dirs = _ensure_project_structure(src_dir)
788
1617
 
789
1618
  # Create route files
790
- _create_route_files(modules_dir, target_dirs, route_name)
1619
+ _create_route_files(
1620
+ modules_dir, target_dirs, route_name, layout["package_module"]
1621
+ )
791
1622
 
792
1623
  # Handle API router file
793
- _handle_api_router_file(target_dirs, modules_dir, route_name)
1624
+ _handle_api_router_file(
1625
+ target_dirs, modules_dir, route_name, layout["api_router_file"]
1626
+ )
794
1627
 
795
1628
  # Process init files
796
1629
  module_types = ["api/routes", "crud", "schemas"]
797
1630
  _process_init_files(modules_dir, target_dirs, module_types)
798
1631
 
799
1632
  # Update main application
800
- _update_main_app(src_dir, route_name)
1633
+ _update_main_app(
1634
+ src_dir,
1635
+ route_name,
1636
+ router_module=layout["api_router_module"],
1637
+ main_py_path=layout["main"],
1638
+ )
801
1639
 
802
1640
  debug_log(f"Successfully added new route: {route_name}", "info")
803
1641
  print_success(f"Route '{route_name}' has been added successfully!")
@@ -835,9 +1673,9 @@ def ask_create_project_folder(project_name: str) -> bool:
835
1673
  )
836
1674
 
837
1675
 
838
- def deploy_template_with_folder_option(
1676
+ def deploy_template_files(
839
1677
  target_template: str, user_local: str, project_name: str, create_folder: bool
840
- ) -> tuple[str, str]:
1678
+ ) -> tuple[str, str, List[str]]:
841
1679
  """
842
1680
  Deploy template based on folder creation option.
843
1681
 
@@ -845,20 +1683,43 @@ def deploy_template_with_folder_option(
845
1683
  :param user_local: User's workspace directory
846
1684
  :param project_name: Name of the project
847
1685
  :param create_folder: Whether to create a new folder
848
- :return: Tuple of (project_dir, deployment_message)
1686
+ :return: Tuple of (project_dir, deployment_message, copied file paths). The
1687
+ file list lets callers keep later rewriting passes scoped to what this
1688
+ deployment actually wrote.
849
1689
  """
850
1690
  if create_folder:
851
1691
  project_dir = os.path.join(user_local, project_name)
852
1692
  deployment_message = f"FastAPI template project will deploy at '{user_local}' in folder '{project_name}'"
853
- copy_and_convert_template(target_template, user_local, project_name)
1693
+ copied = copy_and_convert_template(target_template, user_local, project_name)
854
1694
  else:
855
1695
  project_dir = user_local
856
1696
  deployment_message = (
857
1697
  f"FastAPI template project will deploy directly at '{user_local}'"
858
1698
  )
859
- copy_and_convert_template(target_template, user_local, "")
1699
+ copied = copy_and_convert_template(target_template, user_local, "")
860
1700
 
861
1701
  click.echo(deployment_message)
1702
+ return project_dir, deployment_message, copied
1703
+
1704
+
1705
+ def deploy_template_with_folder_option(
1706
+ target_template: str, user_local: str, project_name: str, create_folder: bool
1707
+ ) -> tuple[str, str]:
1708
+ """
1709
+ Deploy template based on folder creation option.
1710
+
1711
+ Thin wrapper over :func:`deploy_template_files` kept for callers that do
1712
+ not need the list of copied files.
1713
+
1714
+ :param target_template: Path to template directory
1715
+ :param user_local: User's workspace directory
1716
+ :param project_name: Name of the project
1717
+ :param create_folder: Whether to create a new folder
1718
+ :return: Tuple of (project_dir, deployment_message)
1719
+ """
1720
+ project_dir, deployment_message, _ = deploy_template_files(
1721
+ target_template, user_local, project_name, create_folder
1722
+ )
862
1723
  return project_dir, deployment_message
863
1724
 
864
1725