Skip to content

Commit c9d1d5d

Browse files
committed
[FIX] fix fastkit init flow, add failure takeover logic
1 parent 9aea174 commit c9d1d5d

14 files changed

Lines changed: 331 additions & 47 deletions

File tree

docs/en/reference/faq.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,9 @@ Interactive mode allows you to select from a comprehensive feature catalog:
194194
The interactive mode automatically generates:
195195

196196
- `main.py` with selected features integrated
197-
- Database and authentication configuration files
198-
- Docker deployment files (Dockerfile, docker-compose.yml)
199-
- Test configuration (pytest with coverage)
197+
- Database and authentication configuration files when the selected options support code generation (e.g. PostgreSQL/MySQL/SQLite/MongoDB for databases, JWT/FastAPI-Users for authentication); other options install the necessary packages only
198+
- Deployment files matching the selected deployment option (`Dockerfile` when `Docker` is selected, `docker-compose.yml` when `docker-compose` is selected)
199+
- Test configuration based on the selected testing option (coverage settings are included only when `Coverage` or `Advanced` is selected)
200200

201201
### Q: How do I see available features for interactive mode?
202202

src/fastapi_fastkit/backend/main.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ def inject_project_metadata(
185185
description,
186186
)
187187
_process_config_file(core_modules.get("config", ""), project_name)
188+
_process_main_file(core_modules.get("main", ""), project_name)
188189

189190
print_success("Project metadata injected successfully")
190191

@@ -235,6 +236,40 @@ def _process_setup_file(
235236
raise BackendExceptions(f"Failed to process setup.py: {e}")
236237

237238

239+
def _process_main_file(main_py: str, project_name: str) -> None:
240+
"""
241+
Replace project name placeholders in the template main.py file.
242+
243+
Some templates (e.g. fastapi-single-module) inline the project name directly
244+
in main.py rather than reading it from a settings module, so the placeholder
245+
must be substituted here as well.
246+
247+
:param main_py: Path to main.py file
248+
:param project_name: Project name
249+
"""
250+
if not main_py or not os.path.exists(main_py):
251+
return
252+
253+
try:
254+
with open(main_py, "r", encoding="utf-8") as f:
255+
content = f.read()
256+
257+
if "<project_name>" not in content:
258+
return
259+
260+
content = content.replace("<project_name>", project_name)
261+
262+
with open(main_py, "w", encoding="utf-8") as f:
263+
f.write(content)
264+
265+
debug_log("Injected project name into main.py", "info")
266+
print_info("Injected project name into main.py")
267+
268+
except (OSError, UnicodeDecodeError) as e:
269+
debug_log(f"Error processing main.py: {e}", "error")
270+
raise BackendExceptions(f"Failed to process main.py: {e}")
271+
272+
238273
def _process_config_file(config_py: str, project_name: str) -> None:
239274
"""
240275
Process config file and inject project name.

src/fastapi_fastkit/backend/package_managers/poetry_manager.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,35 @@ def generate_dependency_file(
185185
# Create dependencies section for Poetry
186186
deps_section = ""
187187
for dep in dependencies:
188-
# Convert pip-style to poetry-style
188+
# Split off version specifier (only == is converted to Poetry's pinned
189+
# form; other specifiers fall back to "*").
189190
if "==" in dep:
190-
name, version = dep.split("==", 1)
191-
deps_section += f'{name} = "{version}"\n'
191+
name_part, version = dep.split("==", 1)
192+
version_str = version.strip()
192193
else:
193-
deps_section += f'{dep} = "*"\n'
194+
name_part, version_str = dep, "*"
195+
196+
name_part = name_part.strip()
197+
198+
# Extract optional extras, e.g. "redis[hiredis]" -> ("redis", ["hiredis"]).
199+
extras: list[str] = []
200+
if "[" in name_part and name_part.endswith("]"):
201+
bare_name, _, extras_part = name_part.partition("[")
202+
extras = [
203+
extra.strip()
204+
for extra in extras_part[:-1].split(",")
205+
if extra.strip()
206+
]
207+
name_part = bare_name.strip()
208+
209+
if extras:
210+
extras_str = ", ".join(f'"{extra}"' for extra in extras)
211+
deps_section += (
212+
f'{name_part} = {{version = "{version_str}", '
213+
f"extras = [{extras_str}]}}\n"
214+
)
215+
else:
216+
deps_section += f'{name_part} = "{version_str}"\n'
194217

195218
# Create basic pyproject.toml content for Poetry
196219
pyproject_content = f"""[tool.poetry]

src/fastapi_fastkit/backend/project_builder/config_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,7 @@ def _generate_dockerfile(self) -> str:
488488
content.append("")
489489
content.append("# Run application")
490490
content.append(
491-
"CMD ['uvicorn', 'src.main:app', '--host', '0.0.0.0', '--port', '8000']"
491+
'CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]'
492492
)
493493
content.append("")
494494

src/fastapi_fastkit/cli.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,28 @@
4343
validate_email,
4444
)
4545

46+
from . import __version__
47+
4648
console = utils_console
4749

48-
from . import __version__
50+
51+
def _cleanup_failed_project(
52+
project_dir: str, user_workspace: str, create_project_folder: bool
53+
) -> None:
54+
"""
55+
Clean up a partially created project after an error.
56+
57+
Only deletes a freshly created project folder. When the project was deployed
58+
in-place (create_project_folder=False), project_dir equals the user's workspace,
59+
and removing it would destroy unrelated files, so no cleanup is performed.
60+
"""
61+
if not create_project_folder:
62+
return
63+
if not project_dir or not os.path.exists(project_dir):
64+
return
65+
if os.path.abspath(project_dir) == os.path.abspath(user_workspace):
66+
return
67+
shutil.rmtree(project_dir, ignore_errors=True)
4968

5069

5170
@click.group()
@@ -483,18 +502,17 @@ def init(
483502
if testing_type != "None":
484503
test_config_content = generator.generate_test_config()
485504
if test_config_content:
486-
test_config_path = os.path.join(project_dir, "tests", "conftest.py")
487-
os.makedirs(os.path.dirname(test_config_path), exist_ok=True)
505+
test_config_path = os.path.join(project_dir, "pytest.ini")
488506
with open(test_config_path, "w") as f:
489507
f.write(test_config_content)
490508

491509
# Generate Docker files if deployment selected
492510
deployment = config.get("deployment", [])
493511
if deployment and deployment != ["None"]:
494512
generator.generate_docker_files()
495-
print_success(f"Generated Docker deployment files")
513+
print_success("Generated Docker deployment files")
496514

497-
print_success(f"Generated configuration files for selected stack")
515+
print_success("Generated configuration files for selected stack")
498516

499517
# Create virtual environment and install dependencies
500518
venv_path = create_venv_with_manager(project_dir, package_manager)
@@ -514,8 +532,9 @@ def init(
514532
logger = get_logger()
515533
logger.exception(f"Error during project creation in init: {str(e)}")
516534
print_error(f"Error during project creation: {str(e)}")
517-
if os.path.exists(project_dir):
518-
shutil.rmtree(project_dir, ignore_errors=True)
535+
_cleanup_failed_project(
536+
project_dir, settings.USER_WORKSPACE, create_project_folder
537+
)
519538

520539
return
521540

@@ -662,8 +681,9 @@ def init(
662681
logger = get_logger()
663682
logger.exception(f"Error during project creation in init: {str(e)}")
664683
print_error(f"Error during project creation: {str(e)}")
665-
if os.path.exists(project_dir):
666-
shutil.rmtree(project_dir, ignore_errors=True)
684+
_cleanup_failed_project(
685+
project_dir, settings.USER_WORKSPACE, create_project_folder
686+
)
667687

668688

669689
@fastkit_cli.command()
@@ -930,9 +950,9 @@ def runserver(
930950
logger.exception(f"FileNotFoundError when starting server: {e}")
931951
if venv_python:
932952
print_error(
933-
f"Failed to run Python from the virtual environment. Make sure uvicorn is installed in the project's virtual environment."
953+
"Failed to run Python from the virtual environment. Make sure uvicorn is installed in the project's virtual environment."
934954
)
935955
else:
936956
print_error(
937-
f"uvicorn not found. Make sure it's installed in your system Python."
957+
"uvicorn not found. Make sure it's installed in your system Python."
938958
)

src/fastapi_fastkit/core/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ class FastkitConfig:
102102
"MySQL": ["pymysql", "aiomysql", "sqlalchemy", "alembic"],
103103
"MongoDB": ["motor", "beanie"],
104104
"Redis": ["redis[hiredis]", "aioredis"],
105-
"SQLite": ["sqlalchemy", "alembic"],
105+
"SQLite": ["sqlalchemy", "aiosqlite", "alembic"],
106106
"None": [],
107107
},
108108
"authentication": {

tests/test_backends/test_main.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,7 @@ def test_inject_project_metadata(self) -> None:
186186
# given
187187
# Create setup.py file
188188
setup_py = self.project_path / "setup.py"
189-
setup_py.write_text(
190-
"""
189+
setup_py.write_text("""
191190
from setuptools import setup
192191
193192
setup(
@@ -196,8 +195,7 @@ def test_inject_project_metadata(self) -> None:
196195
author_email="<author_email>",
197196
description="<description>",
198197
)
199-
"""
200-
)
198+
""")
201199

202200
# Create config file
203201
config_py = self.project_path / "config.py"
@@ -222,6 +220,30 @@ def test_inject_project_metadata(self) -> None:
222220
config_content = config_py.read_text()
223221
assert 'PROJECT_NAME = "test-project"' in config_content
224222

223+
def test_inject_project_metadata_replaces_placeholder_in_main(self) -> None:
224+
"""Main module placeholders (single-module template) must be substituted."""
225+
# given
226+
src_dir = self.project_path / "src"
227+
src_dir.mkdir()
228+
main_py = src_dir / "main.py"
229+
main_py.write_text(
230+
'from fastapi import FastAPI\n\napp = FastAPI(title="<project_name>")\n'
231+
)
232+
233+
# when
234+
inject_project_metadata(
235+
str(self.project_path),
236+
"my-single-module",
237+
"Test Author",
238+
"test@example.com",
239+
"desc",
240+
)
241+
242+
# then
243+
main_content = main_py.read_text()
244+
assert "<project_name>" not in main_content
245+
assert 'title="my-single-module"' in main_content
246+
225247
@patch("fastapi_fastkit.backend.main.find_template_core_modules")
226248
def test_inject_project_metadata_with_exception(
227249
self, mock_find_modules: MagicMock
@@ -248,8 +270,7 @@ def test_read_template_stack(self) -> None:
248270
template_path = Path(tempfile.mkdtemp())
249271
try:
250272
setup_py_tpl = template_path / "setup.py-tpl"
251-
setup_py_tpl.write_text(
252-
"""
273+
setup_py_tpl.write_text("""
253274
from setuptools import setup
254275
255276
install_requires: list[str] = [
@@ -262,8 +283,7 @@ def test_read_template_stack(self) -> None:
262283
name="test",
263284
install_requires=install_requires,
264285
)
265-
"""
266-
)
286+
""")
267287

268288
# when
269289
result = read_template_stack(str(template_path))
@@ -393,16 +413,14 @@ def test_process_setup_file_success(self) -> None:
393413
"""Test _process_setup_file function with successful processing."""
394414
# given
395415
setup_py = self.project_path / "setup.py"
396-
setup_py.write_text(
397-
"""
416+
setup_py.write_text("""
398417
setup(
399418
name="<project_name>",
400419
author="<author>",
401420
author_email="<author_email>",
402421
description="<description>",
403422
)
404-
"""
405-
)
423+
""")
406424

407425
# when
408426
_process_setup_file(

tests/test_backends/test_package_managers.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,44 @@ def test_generate_dependency_file_error(self) -> None:
912912
with pytest.raises(BackendExceptions):
913913
self.manager.generate_dependency_file(["fastapi"])
914914

915+
def test_generate_dependency_file_with_extras_is_valid_toml(self) -> None:
916+
"""Dependencies with extras must be emitted as inline tables, not bare keys."""
917+
import tomllib
918+
919+
deps = [
920+
"redis[hiredis]",
921+
"python-jose[cryptography]==3.3.0",
922+
"fastapi-users[sqlalchemy]",
923+
"fastapi==0.104.1",
924+
"uvicorn",
925+
]
926+
self.manager.generate_dependency_file(
927+
deps,
928+
project_name="test-project",
929+
author="Test Author",
930+
author_email="test@example.com",
931+
description="Test description",
932+
)
933+
934+
pyproject_file = Path(self.temp_dir) / "pyproject.toml"
935+
content = pyproject_file.read_text()
936+
937+
# Must parse as valid TOML - this is what previously broke poetry install.
938+
parsed = tomllib.loads(content)
939+
deps_table = parsed["tool"]["poetry"]["dependencies"]
940+
941+
assert deps_table["redis"] == {"version": "*", "extras": ["hiredis"]}
942+
assert deps_table["python-jose"] == {
943+
"version": "3.3.0",
944+
"extras": ["cryptography"],
945+
}
946+
assert deps_table["fastapi-users"] == {
947+
"version": "*",
948+
"extras": ["sqlalchemy"],
949+
}
950+
assert deps_table["fastapi"] == "0.104.1"
951+
assert deps_table["uvicorn"] == "*"
952+
915953
@patch("subprocess.run")
916954
def test_add_dependency_success(self, mock_run: Mock) -> None:
917955
"""Test successful dependency addition with Poetry."""

tests/test_backends/test_project_builder_config_generator.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
#
44
# @author bnbong bbbong9@gmail.com
55
# --------------------------------------------------------------------------
6+
import json
7+
import tempfile
8+
from pathlib import Path
9+
610
import pytest
711

812
from fastapi_fastkit.backend.project_builder.config_generator import (
@@ -332,6 +336,41 @@ def test_generate_test_config_coverage(self) -> None:
332336
assert "[coverage:run]" in content
333337

334338

339+
class TestGenerateDockerFiles:
340+
"""Test cases for generate_docker_files method."""
341+
342+
def test_generated_dockerfile_uses_exec_form_cmd(self) -> None:
343+
"""Dockerfile CMD must be JSON exec form, not shell form with single quotes."""
344+
# given
345+
with tempfile.TemporaryDirectory() as tmp:
346+
config = {"deployment": ["Docker"]}
347+
generator = DynamicConfigGenerator(config, tmp)
348+
349+
# when
350+
generator.generate_docker_files()
351+
352+
# then
353+
dockerfile = Path(tmp) / "Dockerfile"
354+
assert dockerfile.exists()
355+
content = dockerfile.read_text()
356+
357+
# Find the CMD line and validate it parses as JSON array (exec form)
358+
cmd_lines = [
359+
line for line in content.splitlines() if line.startswith("CMD ")
360+
]
361+
assert len(cmd_lines) == 1
362+
cmd_payload = cmd_lines[0][len("CMD ") :]
363+
364+
# Single quotes are Docker shell form; exec form requires double quotes.
365+
assert "'" not in cmd_payload, (
366+
"Generated Dockerfile CMD still uses single quotes (shell form): "
367+
f"{cmd_payload!r}"
368+
)
369+
parsed = json.loads(cmd_payload)
370+
assert parsed[0] == "uvicorn"
371+
assert "src.main:app" in parsed
372+
373+
335374
class TestHelperMethods:
336375
"""Test cases for helper methods."""
337376

0 commit comments

Comments
 (0)