FastAPI-fastkit 1.3.0__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 (335) 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 -1400
  15. fastapi_fastkit/backend/interactive/__init__.py +6 -0
  16. fastapi_fastkit/backend/interactive/config_builder.py +45 -63
  17. fastapi_fastkit/backend/interactive/prompts.py +155 -6
  18. fastapi_fastkit/backend/interactive/selectors.py +18 -2
  19. fastapi_fastkit/backend/main.py +887 -99
  20. fastapi_fastkit/backend/package_managers/base.py +172 -0
  21. fastapi_fastkit/backend/package_managers/factory.py +5 -4
  22. fastapi_fastkit/backend/package_managers/pdm_manager.py +29 -82
  23. fastapi_fastkit/backend/package_managers/pip_manager.py +31 -85
  24. fastapi_fastkit/backend/package_managers/poetry_manager.py +70 -110
  25. fastapi_fastkit/backend/package_managers/uv_manager.py +43 -112
  26. fastapi_fastkit/backend/project_builder/__init__.py +8 -1
  27. fastapi_fastkit/backend/project_builder/config_generator.py +806 -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 +254 -28
  31. fastapi_fastkit/backend/scaffolder.py +578 -0
  32. fastapi_fastkit/backend/transducer.py +54 -21
  33. fastapi_fastkit/cli.py +434 -311
  34. fastapi_fastkit/core/settings.py +232 -43
  35. fastapi_fastkit/fastapi_project_template/README.md +26 -10
  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 +21 -22
  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 +21 -22
  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 +18 -19
  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 +21 -22
  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/pyproject.toml-tpl +13 -14
  126. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/requirements.txt-tpl +36 -10
  127. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/router.py-tpl +3 -0
  128. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/core/config.py-tpl +4 -4
  129. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/db/memory.py-tpl +4 -3
  130. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/repository.py-tpl +4 -5
  131. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/router.py-tpl +2 -3
  132. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/service.py-tpl +2 -3
  133. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/main.py-tpl +3 -0
  134. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/conftest.py-tpl +0 -1
  135. fastapi_fastkit/fastapi_project_template/fastapi-empty/pyproject.toml-tpl +14 -15
  136. fastapi_fastkit/fastapi_project_template/fastapi-empty/requirements.txt-tpl +36 -10
  137. fastapi_fastkit/fastapi_project_template/fastapi-empty/src/core/config.py-tpl +5 -6
  138. fastapi_fastkit/fastapi_project_template/fastapi-empty/src/main.py-tpl +3 -1
  139. fastapi_fastkit/fastapi_project_template/fastapi-empty/tests/conftest.py-tpl +0 -1
  140. fastapi_fastkit/fastapi_project_template/fastapi-empty/tests/test_main.py-tpl +0 -1
  141. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/.env-tpl +15 -0
  142. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/.gitignore-tpl +31 -0
  143. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/Dockerfile-tpl +18 -0
  144. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/README.md-tpl +193 -0
  145. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/pyproject.toml-tpl +76 -0
  146. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/requirements.txt-tpl +15 -0
  147. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/scripts/chat.sh-tpl +10 -0
  148. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/scripts/format.sh-tpl +5 -0
  149. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/scripts/lint.sh-tpl +6 -0
  150. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/scripts/run-server.sh-tpl +8 -0
  151. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/scripts/test.sh-tpl +6 -0
  152. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/__init__.py-tpl +0 -0
  153. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/__init__.py-tpl +0 -0
  154. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/api/__init__.py-tpl +0 -0
  155. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/api/chat.py-tpl +62 -0
  156. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/api/deps.py-tpl +23 -0
  157. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/api/health.py-tpl +11 -0
  158. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/api/router.py-tpl +13 -0
  159. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/core/__init__.py-tpl +0 -0
  160. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/core/config.py-tpl +67 -0
  161. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/llm/__init__.py-tpl +0 -0
  162. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/llm/agent.py-tpl +136 -0
  163. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/llm/client.py-tpl +30 -0
  164. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/llm/tools.py-tpl +131 -0
  165. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/main.py-tpl +53 -0
  166. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/memory/__init__.py-tpl +0 -0
  167. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/memory/base.py-tpl +27 -0
  168. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/memory/in_memory.py-tpl +32 -0
  169. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/schemas/__init__.py-tpl +0 -0
  170. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/src/app/schemas/chat.py-tpl +46 -0
  171. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/template-config.yml-tpl +16 -0
  172. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/__init__.py-tpl +0 -0
  173. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/conftest.py-tpl +158 -0
  174. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/test_chat.py-tpl +160 -0
  175. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/test_health.py-tpl +12 -0
  176. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/test_memory.py-tpl +38 -0
  177. fastapi_fastkit/fastapi_project_template/fastapi-llm-agent/tests/test_tools.py-tpl +37 -0
  178. fastapi_fastkit/fastapi_project_template/fastapi-mcp/README.md-tpl +1 -1
  179. fastapi_fastkit/fastapi_project_template/fastapi-mcp/pyproject.toml-tpl +25 -25
  180. fastapi_fastkit/fastapi_project_template/fastapi-mcp/requirements.txt-tpl +68 -39
  181. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/api.py-tpl +3 -0
  182. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/routes/auth.py-tpl +4 -5
  183. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/routes/items.py-tpl +3 -4
  184. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/auth/dependencies.py-tpl +4 -5
  185. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/core/config.py-tpl +4 -5
  186. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/main.py-tpl +4 -1
  187. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/{mcp → mcp_server}/router.py-tpl +3 -5
  188. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/schemas/items.py-tpl +6 -7
  189. fastapi_fastkit/fastapi_project_template/fastapi-mcp/tests/conftest.py-tpl +1 -1
  190. fastapi_fastkit/fastapi_project_template/fastapi-mcp/tests/test_auth.py-tpl +0 -1
  191. fastapi_fastkit/fastapi_project_template/fastapi-mcp/tests/test_items.py-tpl +0 -1
  192. fastapi_fastkit/fastapi_project_template/fastapi-mcp/tests/test_mcp.py-tpl +0 -1
  193. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/Dockerfile-tpl +1 -1
  194. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/pyproject.toml-tpl +22 -23
  195. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/requirements.txt-tpl +44 -41
  196. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/env.py-tpl +1 -3
  197. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/versions/bedcdc35b64a_first_alembic.py-tpl +5 -6
  198. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/api.py-tpl +2 -0
  199. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/routes/items.py-tpl +2 -4
  200. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/config.py-tpl +1 -2
  201. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/db.py-tpl +1 -1
  202. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/crud/items.py-tpl +4 -6
  203. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/main.py-tpl +9 -0
  204. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/schemas/items.py-tpl +4 -5
  205. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/conftest.py-tpl +1 -5
  206. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/test_items.py-tpl +0 -2
  207. fastapi_fastkit/fastapi_project_template/fastapi-single-module/.env-tpl +1 -0
  208. fastapi_fastkit/fastapi_project_template/fastapi-single-module/.gitignore-tpl +30 -0
  209. fastapi_fastkit/fastapi_project_template/fastapi-single-module/pyproject.toml-tpl +3 -4
  210. fastapi_fastkit/fastapi_project_template/fastapi-single-module/requirements.txt-tpl +26 -6
  211. fastapi_fastkit/fastapi_project_template/fastapi-single-module/src/main.py-tpl +3 -0
  212. fastapi_fastkit/fastapi_project_template/fastapi-single-module/tests/__init__.py-tpl +0 -0
  213. fastapi_fastkit/fastapi_project_template/fastapi-single-module/tests/conftest.py-tpl +0 -1
  214. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/.env-tpl +7 -0
  215. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/.gitignore-tpl +35 -0
  216. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/Dockerfile-tpl +18 -0
  217. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/README.md-tpl +166 -0
  218. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/alembic.ini-tpl +44 -0
  219. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/pyproject.toml-tpl +82 -0
  220. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/requirements.txt-tpl +21 -0
  221. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/scripts/format.sh-tpl +5 -0
  222. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/scripts/lint.sh-tpl +6 -0
  223. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/scripts/migrate.sh-tpl +7 -0
  224. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/scripts/run-server.sh-tpl +8 -0
  225. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/scripts/test.sh-tpl +6 -0
  226. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/__init__.py-tpl +0 -0
  227. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/__init__.py-tpl +0 -0
  228. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/alembic/README-tpl +9 -0
  229. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/alembic/env.py-tpl +72 -0
  230. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/alembic/script.py.mako-tpl +27 -0
  231. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/alembic/versions/0001_create_items_table.py-tpl +38 -0
  232. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/api/__init__.py-tpl +0 -0
  233. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/api/health.py-tpl +11 -0
  234. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/api/router.py-tpl +15 -0
  235. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/core/__init__.py-tpl +0 -0
  236. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/core/config.py-tpl +62 -0
  237. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/crud/__init__.py-tpl +0 -0
  238. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/crud/base.py-tpl +62 -0
  239. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/crud/pagination.py-tpl +21 -0
  240. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/db/__init__.py-tpl +0 -0
  241. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/db/base.py-tpl +12 -0
  242. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/db/session.py-tpl +50 -0
  243. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/domains/__init__.py-tpl +0 -0
  244. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/domains/items/__init__.py-tpl +0 -0
  245. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/domains/items/crud.py-tpl +26 -0
  246. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/domains/items/models.py-tpl +70 -0
  247. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/domains/items/router.py-tpl +69 -0
  248. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/src/app/main.py-tpl +43 -0
  249. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/template-config.yml-tpl +16 -0
  250. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/tests/__init__.py-tpl +0 -0
  251. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/tests/conftest.py-tpl +60 -0
  252. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/tests/test_crud_base.py-tpl +43 -0
  253. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/tests/test_health.py-tpl +12 -0
  254. fastapi_fastkit/fastapi_project_template/fastapi-sqlmodel/tests/test_items.py-tpl +110 -0
  255. fastapi_fastkit/fastapi_project_template/modules/api/routes/new_route.py-tpl +2 -2
  256. fastapi_fastkit/fragments/README.md +90 -0
  257. fastapi_fastkit/fragments/auth/fastapi_users.py.j2 +8 -0
  258. fastapi_fastkit/fragments/auth/jwt.py.j2 +44 -0
  259. fastapi_fastkit/fragments/auth/oauth2.py.j2 +105 -0
  260. fastapi_fastkit/fragments/auth/session.py.j2 +67 -0
  261. fastapi_fastkit/fragments/db/mongodb.py.j2 +19 -0
  262. fastapi_fastkit/fragments/db/redis.py.j2 +26 -0
  263. fastapi_fastkit/fragments/db/sqlalchemy.py.j2 +45 -0
  264. fastapi_fastkit/fragments/docker/docker_compose.j2 +84 -0
  265. fastapi_fastkit/fragments/docker/dockerfile.j2 +19 -0
  266. fastapi_fastkit/fragments/features/cache.py.j2 +26 -0
  267. fastapi_fastkit/fragments/features/pagination.py.j2 +31 -0
  268. fastapi_fastkit/fragments/features/tasks_celery.py.j2 +50 -0
  269. fastapi_fastkit/fragments/features/tasks_dramatiq.py.j2 +59 -0
  270. fastapi_fastkit/fragments/features/websocket.py.j2 +66 -0
  271. fastapi_fastkit/fragments/header.j2 +4 -0
  272. fastapi_fastkit/fragments/logging/structured.py.j2 +88 -0
  273. fastapi_fastkit/fragments/main/imports/async_tasks.j2 +1 -0
  274. fastapi_fastkit/fragments/main/imports/caching.j2 +5 -0
  275. fastapi_fastkit/fragments/main/imports/fastapi_users.j2 +1 -0
  276. fastapi_fastkit/fragments/main/imports/jwt.j2 +1 -0
  277. fastapi_fastkit/fragments/main/imports/loguru.j2 +1 -0
  278. fastapi_fastkit/fragments/main/imports/mongodb.j2 +1 -0
  279. fastapi_fastkit/fragments/main/imports/oauth2.j2 +4 -0
  280. fastapi_fastkit/fragments/main/imports/opentelemetry.j2 +6 -0
  281. fastapi_fastkit/fragments/main/imports/pagination.j2 +3 -0
  282. fastapi_fastkit/fragments/main/imports/prometheus.j2 +1 -0
  283. fastapi_fastkit/fragments/main/imports/rate_limiting.j2 +3 -0
  284. fastapi_fastkit/fragments/main/imports/session_auth.j2 +4 -0
  285. fastapi_fastkit/fragments/main/imports/sqlalchemy.j2 +2 -0
  286. fastapi_fastkit/fragments/main/imports/structured_logging.j2 +1 -0
  287. fastapi_fastkit/fragments/main/imports/websocket.j2 +1 -0
  288. fastapi_fastkit/fragments/main/lifespan/caching.j2 +7 -0
  289. fastapi_fastkit/fragments/main/lifespan/mongodb.j2 +2 -0
  290. fastapi_fastkit/fragments/main/lifespan/prometheus.j2 +2 -0
  291. fastapi_fastkit/fragments/main/lifespan/sqlalchemy.j2 +2 -0
  292. fastapi_fastkit/fragments/main/setup/async_tasks.j2 +1 -0
  293. fastapi_fastkit/fragments/main/setup/caching.j2 +1 -0
  294. fastapi_fastkit/fragments/main/setup/cors.j2 +8 -0
  295. fastapi_fastkit/fragments/main/setup/oauth2.j2 +3 -0
  296. fastapi_fastkit/fragments/main/setup/opentelemetry.j2 +19 -0
  297. fastapi_fastkit/fragments/main/setup/pagination.j2 +2 -0
  298. fastapi_fastkit/fragments/main/setup/rate_limiting.j2 +3 -0
  299. fastapi_fastkit/fragments/main/setup/session_auth.j2 +6 -0
  300. fastapi_fastkit/fragments/main/setup/structured_logging.j2 +2 -0
  301. fastapi_fastkit/fragments/main/setup/websocket.j2 +1 -0
  302. fastapi_fastkit/fragments/main.py.j2 +57 -0
  303. fastapi_fastkit/fragments/migrations/alembic_ini.j2 +42 -0
  304. fastapi_fastkit/fragments/migrations/env.py.j2 +76 -0
  305. fastapi_fastkit/fragments/migrations/initial_revision.py.j2 +27 -0
  306. fastapi_fastkit/fragments/migrations/migrate_sh.j2 +34 -0
  307. fastapi_fastkit/fragments/migrations/script.py.mako.j2 +26 -0
  308. fastapi_fastkit/fragments/tasks/celery_worker.py.j2 +49 -0
  309. fastapi_fastkit/fragments/tasks/dramatiq_worker.py.j2 +41 -0
  310. fastapi_fastkit/fragments/test/factories.py.j2 +68 -0
  311. fastapi_fastkit/fragments/test/pytest_ini.j2 +16 -0
  312. fastapi_fastkit/fragments/test/test_factories.py.j2 +27 -0
  313. fastapi_fastkit/fragments/test/test_main.py.j2 +46 -0
  314. fastapi_fastkit/fragments/tooling/devcontainer.j2 +26 -0
  315. fastapi_fastkit/fragments/tooling/github_actions.j2 +37 -0
  316. fastapi_fastkit/fragments/tooling/makefile.j2 +40 -0
  317. fastapi_fastkit/fragments/tooling/pre_commit.j2 +36 -0
  318. fastapi_fastkit/fragments/tooling/ruff_toml.j2 +16 -0
  319. fastapi_fastkit/utils/config_file.py +263 -0
  320. fastapi_fastkit/utils/main.py +38 -0
  321. {fastapi_fastkit-1.3.0.dist-info → fastapi_fastkit-1.4.0.dist-info}/METADATA +26 -2
  322. fastapi_fastkit-1.4.0.dist-info/RECORD +484 -0
  323. {fastapi_fastkit-1.3.0.dist-info → fastapi_fastkit-1.4.0.dist-info}/WHEEL +1 -1
  324. fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.py-tpl +0 -25
  325. fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.py-tpl +0 -27
  326. fastapi_fastkit/fastapi_project_template/fastapi-default/setup.py-tpl +0 -22
  327. fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.py-tpl +0 -22
  328. fastapi_fastkit/fastapi_project_template/fastapi-empty/setup.py-tpl +0 -22
  329. fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.py-tpl +0 -51
  330. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/setup.py-tpl +0 -25
  331. fastapi_fastkit/fastapi_project_template/fastapi-single-module/setup.py-tpl +0 -17
  332. fastapi_fastkit-1.3.0.dist-info/RECORD +0 -287
  333. /fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/{mcp → mcp_server}/__init__.py-tpl +0 -0
  334. {fastapi_fastkit-1.3.0.dist-info → fastapi_fastkit-1.4.0.dist-info}/entry_points.txt +0 -0
  335. {fastapi_fastkit-1.3.0.dist-info → fastapi_fastkit-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,20 +3,28 @@
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
11
+ import tokenize
8
12
  import tomllib
9
- from typing import Dict, List
13
+ from typing import Any, Dict, Iterable, List, Optional
10
14
 
11
15
  import click
12
16
 
13
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
+ )
14
21
  from fastapi_fastkit.backend.transducer import (
15
22
  copy_and_convert_template,
16
23
  copy_and_convert_template_file,
17
24
  )
18
25
  from fastapi_fastkit.core.exceptions import BackendExceptions
19
26
  from fastapi_fastkit.core.settings import settings
27
+ from fastapi_fastkit.utils.config_file import format_toml_value
20
28
  from fastapi_fastkit.utils.logging import debug_log, get_logger
21
29
  from fastapi_fastkit.utils.main import (
22
30
  FASTKIT_DESCRIPTION_MARKER,
@@ -25,8 +33,15 @@ from fastapi_fastkit.utils.main import (
25
33
  print_info,
26
34
  print_success,
27
35
  print_warning,
36
+ read_fastkit_metadata,
28
37
  )
29
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
+
30
45
  logger = get_logger(__name__)
31
46
 
32
47
 
@@ -194,15 +209,258 @@ def _parse_pyproject_dependencies(pyproject_path: str) -> List[str]:
194
209
  # ------------------------------------------------------------
195
210
 
196
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
+
197
444
  def inject_project_metadata(
198
445
  target_dir: str,
199
446
  project_name: str,
200
447
  author: str,
201
448
  author_email: str,
202
449
  description: str,
450
+ files: Optional[Iterable[str]] = None,
203
451
  ) -> None:
204
452
  """
205
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.
206
464
  """
207
465
  try:
208
466
  core_modules = find_template_core_modules(target_dir)
@@ -223,8 +481,31 @@ def inject_project_metadata(
223
481
  _process_config_file(core_modules.get("config", ""), project_name)
224
482
  _process_main_file(core_modules.get("main", ""), project_name)
225
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
+
226
505
  print_success("Project metadata injected successfully")
227
506
 
507
+ except BackendExceptions:
508
+ raise
228
509
  except Exception as e:
229
510
  debug_log(f"Failed to inject project metadata: {e}", "error")
230
511
  handle_exception(e, "Failed to inject project metadata")
@@ -258,8 +539,7 @@ def _process_setup_file(
258
539
  "<description>": description,
259
540
  }
260
541
 
261
- for placeholder, value in replacements.items():
262
- content = content.replace(placeholder, value)
542
+ content = _apply_escaped_replacements(content, replacements, setup_py)
263
543
 
264
544
  with open(setup_py, "w", encoding="utf-8") as f:
265
545
  f.write(content)
@@ -293,7 +573,9 @@ def _process_main_file(main_py: str, project_name: str) -> None:
293
573
  if "<project_name>" not in content:
294
574
  return
295
575
 
296
- content = content.replace("<project_name>", project_name)
576
+ content = content.replace(
577
+ "<project_name>", escape_placeholder_value(project_name, main_py)
578
+ )
297
579
 
298
580
  with open(main_py, "w", encoding="utf-8") as f:
299
581
  f.write(content)
@@ -321,7 +603,9 @@ def _process_config_file(config_py: str, project_name: str) -> None:
321
603
  content = f.read()
322
604
 
323
605
  # Replace project name placeholder
324
- content = content.replace("<project_name>", project_name)
606
+ content = content.replace(
607
+ "<project_name>", escape_placeholder_value(project_name, config_py)
608
+ )
325
609
 
326
610
  with open(config_py, "w", encoding="utf-8") as f:
327
611
  f.write(content)
@@ -365,8 +649,7 @@ def _process_pyproject_file(
365
649
  "<description>": description,
366
650
  }
367
651
 
368
- for placeholder, value in replacements.items():
369
- content = content.replace(placeholder, value)
652
+ content = _apply_escaped_replacements(content, replacements, pyproject_toml)
370
653
 
371
654
  content = _ensure_pyproject_fastkit_markers(content)
372
655
 
@@ -387,14 +670,106 @@ _DESCRIPTION_LINE_PATTERN = re.compile(
387
670
  )
388
671
 
389
672
 
390
- def _ensure_pyproject_fastkit_markers(content: str) -> str:
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:
391
765
  """Idempotently ensure pyproject content carries FastAPI-fastkit markers.
392
766
 
393
767
  Adds the canonical description marker if the ``description`` field does
394
- not already include it (case-insensitive check), and appends a
395
- ``[tool.fastapi-fastkit]`` section with ``managed = true`` if one is not
396
- already present. Safe to run on already-marked content — no duplicates
397
- are introduced.
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.
398
773
  """
399
774
  marker = FASTKIT_DESCRIPTION_MARKER
400
775
  match = _DESCRIPTION_LINE_PATTERN.search(content)
@@ -408,28 +783,169 @@ def _ensure_pyproject_fastkit_markers(content: str) -> str:
408
783
  )
409
784
 
410
785
  tool_header = f"[tool.{FASTKIT_TOOL_SECTION}]"
411
- if tool_header not in content:
412
- if not content.endswith("\n"):
413
- content += "\n"
414
- content += f"\n{tool_header}\nmanaged = true\n"
786
+ has_section = tool_header in content
415
787
 
416
- return content
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
417
913
 
418
914
 
419
915
  def update_setup_py_dependencies(project_dir: str, dependencies: List[str]) -> None:
420
916
  """
421
- Update setup.py file's install_requires list with new dependencies.
917
+ Update the project's declared dependencies with the selected packages.
422
918
 
423
- This function finds and updates the install_requires list in setup.py
424
- 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.
425
926
 
426
927
  :param project_dir: Path to the project directory
427
928
  :param dependencies: List of dependency specifications
428
929
  """
429
- setup_py_path = os.path.join(project_dir, "setup.py")
930
+ updated_any = False
430
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")
431
943
  if not os.path.exists(setup_py_path):
432
- 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
+ )
433
949
  return
434
950
 
435
951
  try:
@@ -600,6 +1116,250 @@ def generate_dependency_file_with_manager(
600
1116
  # ------------------------------------------------------------
601
1117
 
602
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
+
603
1363
  def _ensure_project_structure(src_dir: str) -> Dict[str, str]:
604
1364
  """
605
1365
  Ensure the project structure exists for adding a new route.
@@ -634,16 +1394,28 @@ def _ensure_project_structure(src_dir: str) -> Dict[str, str]:
634
1394
 
635
1395
 
636
1396
  def _create_route_files(
637
- 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",
638
1401
  ) -> None:
639
1402
  """
640
1403
  Create route files from templates.
641
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
+
642
1410
  :param modules_dir: Path to the modules directory
643
1411
  :param target_dirs: Dictionary with paths to target directories
644
1412
  :param route_name: Name of the route to create
1413
+ :param package_module: Dotted package holding ``api``/``crud``/``schemas``
645
1414
  """
646
- replacements = {"<new_route>": route_name}
1415
+ replacements = {
1416
+ "<new_route>": route_name,
1417
+ "<package_root>": package_module or "src",
1418
+ }
647
1419
  module_types = ["api/routes", "crud", "schemas"]
648
1420
 
649
1421
  for module_type in module_types:
@@ -668,7 +1440,10 @@ def _create_route_files(
668
1440
 
669
1441
 
670
1442
  def _handle_api_router_file(
671
- 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 = "",
672
1447
  ) -> None:
673
1448
  """
674
1449
  Handle API router file creation or update.
@@ -676,12 +1451,15 @@ def _handle_api_router_file(
676
1451
  :param target_dirs: Dictionary with paths to target directories
677
1452
  :param modules_dir: Path to the modules directory
678
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.
679
1456
  """
680
1457
  api_dir = target_dirs["api"]
681
- 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")
682
1460
 
683
1461
  if not os.path.exists(api_router_file):
684
- # Create api.py if it doesn't exist
1462
+ # Create the router module if it doesn't exist
685
1463
  api_source = os.path.join(modules_dir, "api", "__init__.py-tpl")
686
1464
  if os.path.exists(api_source):
687
1465
  copy_and_convert_template_file(api_source, api_router_file)
@@ -695,6 +1473,10 @@ def _update_api_router(api_router_file: str, route_name: str) -> None:
695
1473
  """
696
1474
  Update API router file to include new route.
697
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
+
698
1480
  :param api_router_file: Path to API router file
699
1481
  :param route_name: Name of the route
700
1482
  """
@@ -702,46 +1484,20 @@ def _update_api_router(api_router_file: str, route_name: str) -> None:
702
1484
  with open(api_router_file, "r", encoding="utf-8") as f:
703
1485
  content = f.read()
704
1486
 
705
- # Check if route is already included
706
1487
  route_import = f"from .routes import {route_name}"
707
- 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
+ )
708
1492
 
709
- 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
+ ):
710
1497
  return # Already included
711
1498
 
712
- # Add import if not present
713
- if route_import not in content:
714
- # Find where to add the import
715
- lines = content.split("\n")
716
- last_import_idx = -1
717
- for i, line in enumerate(lines):
718
- if line.startswith("from .routes import") or line.startswith(
719
- "from routes import"
720
- ):
721
- last_import_idx = i
722
-
723
- if last_import_idx >= 0:
724
- lines.insert(last_import_idx + 1, route_import)
725
- else:
726
- # Add at the end of imports section
727
- for i, line in enumerate(lines):
728
- if (
729
- not line.startswith("from")
730
- and not line.startswith("import")
731
- and line.strip()
732
- ):
733
- lines.insert(i, route_import)
734
- break
735
-
736
- content = "\n".join(lines)
737
-
738
- # Add router inclusion if not present
739
- if route_include not in content:
740
- # Add before the last line or at the end
741
- if content.strip():
742
- content += f'\napi_router.include_router({route_name}.router, prefix="/{route_name}", tags=["{route_name}"])\n'
743
- else:
744
- 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)
745
1501
 
746
1502
  with open(api_router_file, "w", encoding="utf-8") as f:
747
1503
  f.write(content)
@@ -776,15 +1532,28 @@ def _process_init_files(
776
1532
  copy_and_convert_template_file(init_source, init_target)
777
1533
 
778
1534
 
779
- 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:
780
1541
  """
781
1542
  Update the main application file to include the API router.
782
- Adds the router import and include statement at the end of the file.
783
1543
 
784
- :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)
785
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``.
786
1551
  """
787
- 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
+
788
1557
  if not os.path.exists(main_py_path):
789
1558
  print_warning("main.py not found. Please manually add the API router.")
790
1559
  return
@@ -794,7 +1563,7 @@ def _update_main_app(src_dir: str, route_name: str) -> None:
794
1563
  main_content = f.read()
795
1564
 
796
1565
  # Check if router is already imported or included
797
- router_import = "from src.api.api import api_router"
1566
+ router_import = f"from {router_module} import api_router"
798
1567
  router_include = "app.include_router(api_router"
799
1568
 
800
1569
  if router_import in main_content and router_include in main_content:
@@ -808,30 +1577,12 @@ def _update_main_app(src_dir: str, route_name: str) -> None:
808
1577
  )
809
1578
  return
810
1579
 
811
- # Add the router import if needed
812
- if router_import not in main_content:
813
- # Add import at the end of imports section or beginning of file
814
- lines = main_content.split("\n")
815
-
816
- # Find last import line
817
- last_import_idx = -1
818
- for i, line in enumerate(lines):
819
- if line.startswith("import ") or line.startswith("from "):
820
- last_import_idx = i
1580
+ main_content = insert_import_line(main_content, router_import)
821
1581
 
822
- if last_import_idx >= 0:
823
- # Insert after the last import
824
- lines.insert(last_import_idx + 1, router_import)
825
- else:
826
- # Insert at the beginning
827
- lines.insert(0, router_import)
828
-
829
- main_content = "\n".join(lines)
830
-
831
- # Add router inclusion if needed
832
1582
  if router_include not in main_content:
833
- # Add at the end of the file
834
- main_content += "\napp.include_router(api_router)\n"
1583
+ main_content = insert_statement_line(
1584
+ main_content, "app.include_router(api_router)"
1585
+ )
835
1586
 
836
1587
  with open(main_py_path, "w", encoding="utf-8") as f:
837
1588
  f.write(main_content)
@@ -847,6 +1598,10 @@ def add_new_route(project_dir: str, route_name: str) -> None:
847
1598
  """
848
1599
  Add a new API route to an existing FastAPI project.
849
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
+
850
1605
  :param project_dir: Path to the project directory
851
1606
  :param route_name: Name of the new route to add
852
1607
  :raises BackendExceptions: If route addition fails
@@ -854,23 +1609,33 @@ def add_new_route(project_dir: str, route_name: str) -> None:
854
1609
  try:
855
1610
  # Setup paths
856
1611
  modules_dir = os.path.join(settings.FASTKIT_TEMPLATE_ROOT, "modules")
857
- src_dir = os.path.join(project_dir, "src")
1612
+ layout = resolve_project_layout(project_dir)
1613
+ src_dir = layout["package_dir"]
858
1614
 
859
1615
  # Ensure project structure exists
860
1616
  target_dirs = _ensure_project_structure(src_dir)
861
1617
 
862
1618
  # Create route files
863
- _create_route_files(modules_dir, target_dirs, route_name)
1619
+ _create_route_files(
1620
+ modules_dir, target_dirs, route_name, layout["package_module"]
1621
+ )
864
1622
 
865
1623
  # Handle API router file
866
- _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
+ )
867
1627
 
868
1628
  # Process init files
869
1629
  module_types = ["api/routes", "crud", "schemas"]
870
1630
  _process_init_files(modules_dir, target_dirs, module_types)
871
1631
 
872
1632
  # Update main application
873
- _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
+ )
874
1639
 
875
1640
  debug_log(f"Successfully added new route: {route_name}", "info")
876
1641
  print_success(f"Route '{route_name}' has been added successfully!")
@@ -908,9 +1673,9 @@ def ask_create_project_folder(project_name: str) -> bool:
908
1673
  )
909
1674
 
910
1675
 
911
- def deploy_template_with_folder_option(
1676
+ def deploy_template_files(
912
1677
  target_template: str, user_local: str, project_name: str, create_folder: bool
913
- ) -> tuple[str, str]:
1678
+ ) -> tuple[str, str, List[str]]:
914
1679
  """
915
1680
  Deploy template based on folder creation option.
916
1681
 
@@ -918,20 +1683,43 @@ def deploy_template_with_folder_option(
918
1683
  :param user_local: User's workspace directory
919
1684
  :param project_name: Name of the project
920
1685
  :param create_folder: Whether to create a new folder
921
- :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.
922
1689
  """
923
1690
  if create_folder:
924
1691
  project_dir = os.path.join(user_local, project_name)
925
1692
  deployment_message = f"FastAPI template project will deploy at '{user_local}' in folder '{project_name}'"
926
- copy_and_convert_template(target_template, user_local, project_name)
1693
+ copied = copy_and_convert_template(target_template, user_local, project_name)
927
1694
  else:
928
1695
  project_dir = user_local
929
1696
  deployment_message = (
930
1697
  f"FastAPI template project will deploy directly at '{user_local}'"
931
1698
  )
932
- copy_and_convert_template(target_template, user_local, "")
1699
+ copied = copy_and_convert_template(target_template, user_local, "")
933
1700
 
934
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
+ )
935
1723
  return project_dir, deployment_message
936
1724
 
937
1725