From a123bf4b94c57c34f6960f7b496560f10d077346 Mon Sep 17 00:00:00 2001 From: bnbong Date: Mon, 24 Nov 2025 17:11:06 +0900 Subject: [PATCH 1/3] [ADD] add python 3.14 compatability check, deprecation of python 3.8 --- .github/workflows/test.yml | 12 ++++----- requirements-docs.txt | 50 ++++++++++++-------------------------- scripts/translate.py | 16 ++---------- 3 files changed, 24 insertions(+), 54 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 672d294..f10cc78 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,12 +12,12 @@ jobs: strategy: matrix: python-version: - - "3.13" # max - - "3.12" - - "3.11" - - "3.10" - - "3.9" - - "3.8" # min + - "3.14" # max + - "3.13" + - "3.12" # min requirements + - "3.11" # supported, but full functionality not guaranteed + - "3.10" # supported, but full functionality not guaranteed + - "3.9" # supported, but full functionality not guaranteed fail-fast: false steps: - name: Checkout code diff --git a/requirements-docs.txt b/requirements-docs.txt index d0d7c27..528c377 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,56 +1,38 @@ # This file is @generated by PDM. # Please do not edit it manually. -annotated-types==0.7.0 -anthropic==0.74.0 -anyio==4.11.0 babel==2.17.0 -backrefs==6.1 -certifi==2025.11.12 -charset-normalizer==3.4.4 -click==8.3.1 +backrefs==5.9 +certifi==2025.6.15 +charset-normalizer==3.4.2 +click==8.1.8 colorama==0.4.6 cyclic==1.0.0 -distro==1.9.0 -docstring-parser==0.17.0 ghp-import==2.1.0 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -idna==3.11 +idna==3.10 jinja2==3.1.6 -jiter==0.12.0 -markdown==3.10 -markdown-it-py==4.0.0 -markupsafe==3.0.3 +markdown==3.8.2 +markdown-it-py==3.0.0 +markupsafe==3.0.2 mdurl==0.1.2 mdx-include==1.4.2 mergedeep==1.3.4 mkdocs==1.6.1 mkdocs-get-deps==0.2.0 -mkdocs-material==9.7.0 +mkdocs-material==9.6.14 mkdocs-material-extensions==1.3.1 -mkdocs-static-i18n==1.3.0 -openai==2.8.1 -packaging==25.0 +packaging==24.2 paginate==0.5.7 pathspec==0.12.1 -platformdirs==4.5.0 -pydantic==2.12.4 -pydantic-core==2.41.5 -pygments==2.19.2 -pymdown-extensions==10.17.1 +platformdirs==4.3.6 +pygments==2.19.1 +pymdown-extensions==10.16 python-dateutil==2.9.0.post0 -pyyaml==6.0.3 +pyyaml==6.0.2 pyyaml-env-tag==1.1 rcslice==1.1.0 -requests==2.32.5 -rich==14.2.0 +requests==2.32.4 +rich==13.9.4 six==1.17.0 -sniffio==1.3.1 -tqdm==4.67.1 -types-pyyaml==6.0.12.20250915 -typing-extensions==4.15.0 -typing-inspection==0.4.2 urllib3==2.5.0 watchdog==6.0.0 diff --git a/scripts/translate.py b/scripts/translate.py index 492b0fe..1794393 100755 --- a/scripts/translate.py +++ b/scripts/translate.py @@ -38,24 +38,12 @@ class TranslationConfig: target_langs: List[str] = field( default_factory=lambda: ["ko", "ja", "zh", "es", "fr", "de"] ) - _docs_dir: Optional[Path] = field(default=None, repr=False) - api_provider: str = "openai" + docs_dir: Path = Path(__file__).parent.parent / "docs" / source_lang + api_provider: str = "openai" # openai, anthropic, etc. api_key: Optional[str] = None create_pr: bool = True branch_prefix: str = "translation" - @property - def docs_dir(self) -> Path: - """Get docs directory path.""" - if self._docs_dir is None: - return Path(__file__).parent.parent / "docs" / self.source_lang - return self._docs_dir - - @docs_dir.setter - def docs_dir(self, value: Path) -> None: - """Set docs directory path.""" - self._docs_dir = value - class TranslationAPI: """Base class for translation API providers.""" From 21f01d95d685b790cb7f6af22c5a271c4a1afa14 Mon Sep 17 00:00:00 2001 From: bnbong Date: Tue, 25 Nov 2025 13:26:28 +0900 Subject: [PATCH 2/3] [TEST] add coverage testcases --- scripts/coverage-report.sh | 2 - tests/test_backends/test_package_managers.py | 819 ++++++++++++++++++- 2 files changed, 814 insertions(+), 7 deletions(-) diff --git a/scripts/coverage-report.sh b/scripts/coverage-report.sh index 0f5e7e9..432bb14 100755 --- a/scripts/coverage-report.sh +++ b/scripts/coverage-report.sh @@ -134,8 +134,6 @@ else echo -e "${GREEN}ā„¹ļø Changes detected in fastapi_project_template - running all tests${NC}" fi -echo "" - # Run tests with coverage if pytest $PYTEST_ARGS; then echo "" diff --git a/tests/test_backends/test_package_managers.py b/tests/test_backends/test_package_managers.py index cacc843..2abd4a8 100644 --- a/tests/test_backends/test_package_managers.py +++ b/tests/test_backends/test_package_managers.py @@ -4,9 +4,10 @@ # @author bnbong bbbong9@gmail.com # -------------------------------------------------------------------------- import shutil +import subprocess import tempfile from pathlib import Path -from typing import List +from typing import Any, List from unittest.mock import Mock, patch import pytest @@ -46,7 +47,14 @@ def create_virtual_environment(self) -> str: def install_dependencies(self, venv_path: str) -> None: pass - def generate_dependency_file(self, deps: List[str]) -> None: + def generate_dependency_file( + self, + dependencies: List[str], + project_name: str = "", + author: str = "", + author_email: str = "", + description: str = "", + ) -> None: pass def add_dependency(self, dep: str, dev: bool = False) -> None: @@ -74,7 +82,14 @@ def create_virtual_environment(self) -> str: def install_dependencies(self, venv_path: str) -> None: pass - def generate_dependency_file(self, deps: List[str]) -> None: + def generate_dependency_file( + self, + dependencies: List[str], + project_name: str = "", + author: str = "", + author_email: str = "", + description: str = "", + ) -> None: pass def add_dependency(self, dep: str, dev: bool = False) -> None: @@ -102,7 +117,14 @@ def create_virtual_environment(self) -> str: def install_dependencies(self, venv_path: str) -> None: pass - def generate_dependency_file(self, deps: List[str]) -> None: + def generate_dependency_file( + self, + dependencies: List[str], + project_name: str = "", + author: str = "", + author_email: str = "", + description: str = "", + ) -> None: pass def add_dependency(self, dep: str, dev: bool = False) -> None: @@ -211,7 +233,14 @@ def create_virtual_environment(self) -> str: def install_dependencies(self, venv_path: str) -> None: pass - def generate_dependency_file(self, deps: List[str]) -> None: + def generate_dependency_file( + self, + dependencies: List[str], + project_name: str = "", + author: str = "", + author_email: str = "", + description: str = "", + ) -> None: pass def add_dependency(self, dep: str, dev: bool = False) -> None: @@ -467,3 +496,783 @@ def test_pip_manager_dependency_workflow(self, mock_run: Mock) -> None: # Check it was added content = req_file.read_text() assert "pytest==7.4.0" in content + + +# ============================================================================ +# Extended Coverage Tests +# These tests run only during explicit coverage tests and CI +# ============================================================================ + + +@pytest.mark.extended +class TestPipManagerExtended: + """Extended coverage tests for PipManager.""" + + def setup_method(self) -> None: + """Set up test environment.""" + self.temp_dir = tempfile.mkdtemp() + self.manager = PipManager(self.temp_dir) + + def teardown_method(self) -> None: + """Clean up test environment.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + @patch("subprocess.run") + def test_create_virtual_environment_success(self, mock_run: Mock) -> None: + """Test successful virtual environment creation.""" + mock_run.return_value.returncode = 0 + venv_path = self.manager.create_virtual_environment() + assert venv_path == str(Path(self.temp_dir) / ".venv") + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_create_virtual_environment_failure(self, mock_run: Mock) -> None: + """Test virtual environment creation failure.""" + mock_run.side_effect = subprocess.CalledProcessError(1, "venv", stderr="Error") + with pytest.raises(BackendExceptions): + self.manager.create_virtual_environment() + + @patch("subprocess.run") + def test_create_virtual_environment_os_error(self, mock_run: Mock) -> None: + """Test virtual environment creation OS error.""" + mock_run.side_effect = OSError("Disk full") + with pytest.raises(BackendExceptions): + self.manager.create_virtual_environment() + + def test_install_dependencies_no_venv(self) -> None: + """Test installing dependencies when venv doesn't exist.""" + with patch.object( + self.manager, "create_virtual_environment" + ) as mock_create_venv: + mock_create_venv.return_value = None + with pytest.raises(BackendExceptions): + self.manager.install_dependencies("/nonexistent/venv") + + @patch("subprocess.run") + def test_install_dependencies_no_requirements_file(self, mock_run: Mock) -> None: + """Test installing dependencies when requirements.txt doesn't exist.""" + venv_path = str(Path(self.temp_dir) / ".venv") + Path(venv_path).mkdir(parents=True) + + with pytest.raises(BackendExceptions) as exc_info: + self.manager.install_dependencies(venv_path) + assert "Requirements file not found" in str(exc_info.value) + + @patch("subprocess.run") + def test_install_dependencies_success(self, mock_run: Mock) -> None: + """Test successful dependency installation.""" + venv_path = str(Path(self.temp_dir) / ".venv") + Path(venv_path).mkdir(parents=True) + + # Create requirements.txt + req_file = Path(self.temp_dir) / "requirements.txt" + req_file.write_text("fastapi==0.104.1\n") + + mock_run.return_value.returncode = 0 + self.manager.install_dependencies(venv_path) + # Should call pip upgrade and install + assert mock_run.call_count == 2 + + @patch("subprocess.run") + def test_install_dependencies_failure(self, mock_run: Mock) -> None: + """Test dependency installation failure.""" + venv_path = str(Path(self.temp_dir) / ".venv") + Path(venv_path).mkdir(parents=True) + + req_file = Path(self.temp_dir) / "requirements.txt" + req_file.write_text("fastapi==0.104.1\n") + + mock_run.side_effect = subprocess.CalledProcessError( + 1, "pip", stderr="Installation failed" + ) + with pytest.raises(BackendExceptions): + self.manager.install_dependencies(venv_path) + + def test_generate_dependency_file_error(self) -> None: + """Test dependency file generation with OS error.""" + with patch("builtins.open", side_effect=OSError("Permission denied")): + with pytest.raises(BackendExceptions): + self.manager.generate_dependency_file(["fastapi"]) + + def test_add_dependency_unicode_error(self) -> None: + """Test adding dependency with unicode decode error.""" + req_file = Path(self.temp_dir) / "requirements.txt" + req_file.write_bytes(b"\xff\xfe") # Invalid UTF-8 + + with pytest.raises(BackendExceptions): + self.manager.add_dependency("pytest") + + +@pytest.mark.extended +class TestPdmManagerExtended: + """Extended coverage tests for PdmManager.""" + + def setup_method(self) -> None: + """Set up test environment.""" + self.temp_dir = tempfile.mkdtemp() + self.manager = PdmManager(self.temp_dir) + + def teardown_method(self) -> None: + """Clean up test environment.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + @patch("subprocess.run") + def test_create_virtual_environment_success(self, mock_run: Mock) -> None: + """Test successful virtual environment creation with PDM.""" + mock_run.return_value.returncode = 0 + venv_path = self.manager.create_virtual_environment() + assert venv_path == str(Path(self.temp_dir) / ".venv") + + @patch("subprocess.run") + def test_create_virtual_environment_failure(self, mock_run: Mock) -> None: + """Test virtual environment creation failure with PDM.""" + mock_run.side_effect = subprocess.CalledProcessError( + 1, "pdm", stderr="PDM error" + ) + with pytest.raises(BackendExceptions): + self.manager.create_virtual_environment() + + @patch("subprocess.run") + def test_create_virtual_environment_os_error(self, mock_run: Mock) -> None: + """Test virtual environment creation OS error with PDM.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.create_virtual_environment() + + def test_install_dependencies_no_venv(self) -> None: + """Test installing dependencies when venv doesn't exist.""" + with patch.object( + self.manager, "create_virtual_environment" + ) as mock_create_venv: + mock_create_venv.return_value = None + with pytest.raises(BackendExceptions): + self.manager.install_dependencies("/nonexistent/venv") + + @patch("subprocess.run") + def test_install_dependencies_no_pyproject(self, mock_run: Mock) -> None: + """Test installing dependencies when pyproject.toml doesn't exist.""" + venv_path = str(Path(self.temp_dir) / ".venv") + Path(venv_path).mkdir(parents=True) + + with pytest.raises(BackendExceptions) as exc_info: + self.manager.install_dependencies(venv_path) + assert "pyproject.toml file not found" in str(exc_info.value) + + @patch("subprocess.run") + def test_install_dependencies_success(self, mock_run: Mock) -> None: + """Test successful dependency installation with PDM.""" + venv_path = str(Path(self.temp_dir) / ".venv") + Path(venv_path).mkdir(parents=True) + + # Create pyproject.toml + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + pyproject_file.write_text('[project]\nname = "test"\n') + + mock_run.return_value.returncode = 0 + self.manager.install_dependencies(venv_path) + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_install_dependencies_failure(self, mock_run: Mock) -> None: + """Test dependency installation failure with PDM.""" + venv_path = str(Path(self.temp_dir) / ".venv") + Path(venv_path).mkdir(parents=True) + + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + pyproject_file.write_text('[project]\nname = "test"\n') + + mock_run.side_effect = subprocess.CalledProcessError( + 1, "pdm", stderr="Installation failed" + ) + with pytest.raises(BackendExceptions): + self.manager.install_dependencies(venv_path) + + @patch("subprocess.run") + def test_install_dependencies_os_error(self, mock_run: Mock) -> None: + """Test dependency installation OS error with PDM.""" + venv_path = str(Path(self.temp_dir) / ".venv") + Path(venv_path).mkdir(parents=True) + + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + pyproject_file.write_text('[project]\nname = "test"\n') + + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.install_dependencies(venv_path) + + def test_generate_dependency_file_with_metadata(self) -> None: + """Test generating pyproject.toml with metadata.""" + deps = ["fastapi", "uvicorn"] + self.manager.generate_dependency_file( + deps, + project_name="test-project", + author="Test Author", + author_email="test@example.com", + description="Test description", + ) + + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + assert pyproject_file.exists() + content = pyproject_file.read_text() + assert 'name = "test-project"' in content + assert 'name = "Test Author"' in content + assert 'email = "test@example.com"' in content + assert 'description = "Test description"' in content + + def test_generate_dependency_file_error(self) -> None: + """Test pyproject.toml generation error.""" + with patch("builtins.open", side_effect=OSError("Permission denied")): + with pytest.raises(BackendExceptions): + self.manager.generate_dependency_file(["fastapi"]) + + @patch("subprocess.run") + def test_add_dependency_success(self, mock_run: Mock) -> None: + """Test successful dependency addition with PDM.""" + mock_run.return_value.returncode = 0 + self.manager.add_dependency("fastapi") + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_add_dependency_dev(self, mock_run: Mock) -> None: + """Test adding dev dependency with PDM.""" + mock_run.return_value.returncode = 0 + self.manager.add_dependency("pytest", dev=True) + args = mock_run.call_args[0][0] + assert "--dev" in args + + @patch("subprocess.run") + def test_add_dependency_failure(self, mock_run: Mock) -> None: + """Test dependency addition failure with PDM.""" + mock_run.side_effect = subprocess.CalledProcessError(1, "pdm", stderr="Error") + with pytest.raises(BackendExceptions): + self.manager.add_dependency("fastapi") + + @patch("subprocess.run") + def test_add_dependency_os_error(self, mock_run: Mock) -> None: + """Test dependency addition OS error with PDM.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.add_dependency("fastapi") + + @patch("subprocess.run") + def test_initialize_project_success(self, mock_run: Mock) -> None: + """Test successful project initialization with PDM.""" + mock_run.return_value.returncode = 0 + self.manager.initialize_project( + "test-project", "Author", "author@example.com", "Description" + ) + + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + assert pyproject_file.exists() + + @patch("subprocess.run") + def test_initialize_project_subprocess_error(self, mock_run: Mock) -> None: + """Test project initialization subprocess error.""" + mock_run.side_effect = subprocess.CalledProcessError(1, "pdm", stderr="Error") + with pytest.raises(BackendExceptions): + self.manager.initialize_project( + "test-project", "Author", "author@example.com", "Description" + ) + + @patch("subprocess.run") + def test_initialize_project_os_error(self, mock_run: Mock) -> None: + """Test project initialization OS error.""" + mock_run.return_value.returncode = 0 + with patch("builtins.open", side_effect=OSError("Permission denied")): + with pytest.raises(BackendExceptions): + self.manager.initialize_project( + "test-project", "Author", "author@example.com", "Description" + ) + + +@pytest.mark.extended +class TestPoetryManagerExtended: + """Extended coverage tests for PoetryManager.""" + + def setup_method(self) -> None: + """Set up test environment.""" + self.temp_dir = tempfile.mkdtemp() + self.manager = PoetryManager(self.temp_dir) + + def teardown_method(self) -> None: + """Clean up test environment.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + @patch("subprocess.run") + def test_create_virtual_environment_existing_venv(self, mock_run: Mock) -> None: + """Test virtual environment creation when venv already exists.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "/path/to/venv" + mock_run.return_value = mock_result + + venv_path = self.manager.create_virtual_environment() + assert venv_path == "/path/to/venv" + + @patch("subprocess.run") + def test_create_virtual_environment_create_new(self, mock_run: Mock) -> None: + """Test creating new virtual environment with Poetry.""" + + def run_side_effect(*args: Any, **kwargs: Any) -> Any: + result = Mock() + if "env" in args[0] and "info" in args[0]: + if hasattr(run_side_effect, "called"): + result.returncode = 0 + result.stdout = "/path/to/new/venv" + else: + run_side_effect.called = True # type: ignore[attr-defined] + result.returncode = 1 + result.stdout = "" + else: + result.returncode = 0 + result.stdout = "" + return result + + mock_run.side_effect = run_side_effect + venv_path = self.manager.create_virtual_environment() + assert venv_path == "/path/to/new/venv" + + @patch("subprocess.run") + def test_create_virtual_environment_failure(self, mock_run: Mock) -> None: + """Test virtual environment creation failure with Poetry.""" + mock_run.side_effect = subprocess.CalledProcessError( + 1, "poetry", stderr="Error" + ) + with pytest.raises(BackendExceptions): + self.manager.create_virtual_environment() + + @patch("subprocess.run") + def test_create_virtual_environment_os_error(self, mock_run: Mock) -> None: + """Test virtual environment creation OS error with Poetry.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.create_virtual_environment() + + @patch("subprocess.run") + def test_install_dependencies_no_pyproject(self, mock_run: Mock) -> None: + """Test installing dependencies when pyproject.toml doesn't exist.""" + with pytest.raises(BackendExceptions) as exc_info: + self.manager.install_dependencies("/some/venv") + assert "pyproject.toml file not found" in str(exc_info.value) + + @patch("subprocess.run") + def test_install_dependencies_success(self, mock_run: Mock) -> None: + """Test successful dependency installation with Poetry.""" + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + pyproject_file.write_text('[tool.poetry]\nname = "test"\n') + + mock_run.return_value.returncode = 0 + self.manager.install_dependencies("/some/venv") + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_install_dependencies_failure(self, mock_run: Mock) -> None: + """Test dependency installation failure with Poetry.""" + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + pyproject_file.write_text('[tool.poetry]\nname = "test"\n') + + mock_run.side_effect = subprocess.CalledProcessError( + 1, "poetry", stderr="Installation failed" + ) + with pytest.raises(BackendExceptions): + self.manager.install_dependencies("/some/venv") + + @patch("subprocess.run") + def test_install_dependencies_os_error(self, mock_run: Mock) -> None: + """Test dependency installation OS error with Poetry.""" + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + pyproject_file.write_text('[tool.poetry]\nname = "test"\n') + + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.install_dependencies("/some/venv") + + def test_generate_dependency_file_with_metadata(self) -> None: + """Test generating pyproject.toml with Poetry format.""" + deps = ["fastapi==0.104.1", "uvicorn"] + self.manager.generate_dependency_file( + deps, + project_name="test-project", + author="Test Author", + author_email="test@example.com", + description="Test description", + ) + + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + assert pyproject_file.exists() + content = pyproject_file.read_text() + assert "[tool.poetry]" in content + assert 'name = "test-project"' in content + assert 'fastapi = "0.104.1"' in content + assert 'uvicorn = "*"' in content + + def test_generate_dependency_file_error(self) -> None: + """Test pyproject.toml generation error with Poetry.""" + with patch("builtins.open", side_effect=OSError("Permission denied")): + with pytest.raises(BackendExceptions): + self.manager.generate_dependency_file(["fastapi"]) + + @patch("subprocess.run") + def test_add_dependency_success(self, mock_run: Mock) -> None: + """Test successful dependency addition with Poetry.""" + mock_run.return_value.returncode = 0 + self.manager.add_dependency("fastapi") + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_add_dependency_dev(self, mock_run: Mock) -> None: + """Test adding dev dependency with Poetry.""" + mock_run.return_value.returncode = 0 + self.manager.add_dependency("pytest", dev=True) + args = mock_run.call_args[0][0] + assert "--group=dev" in args + + @patch("subprocess.run") + def test_add_dependency_failure(self, mock_run: Mock) -> None: + """Test dependency addition failure with Poetry.""" + mock_run.side_effect = subprocess.CalledProcessError( + 1, "poetry", stderr="Error" + ) + with pytest.raises(BackendExceptions): + self.manager.add_dependency("fastapi") + + @patch("subprocess.run") + def test_add_dependency_os_error(self, mock_run: Mock) -> None: + """Test dependency addition OS error with Poetry.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.add_dependency("fastapi") + + @patch("subprocess.run") + def test_initialize_project_success(self, mock_run: Mock) -> None: + """Test successful project initialization with Poetry.""" + mock_run.return_value.returncode = 0 + self.manager.initialize_project( + "test-project", "Author", "author@example.com", "Description" + ) + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_initialize_project_subprocess_error(self, mock_run: Mock) -> None: + """Test project initialization subprocess error with Poetry.""" + mock_run.side_effect = subprocess.CalledProcessError( + 1, "poetry", stderr="Error" + ) + with pytest.raises(BackendExceptions): + self.manager.initialize_project( + "test-project", "Author", "author@example.com", "Description" + ) + + @patch("subprocess.run") + def test_initialize_project_os_error(self, mock_run: Mock) -> None: + """Test project initialization OS error with Poetry.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.initialize_project( + "test-project", "Author", "author@example.com", "Description" + ) + + @patch("subprocess.run") + def test_lock_dependencies_success(self, mock_run: Mock) -> None: + """Test successful lock file generation with Poetry.""" + mock_run.return_value.returncode = 0 + self.manager.lock_dependencies() + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_lock_dependencies_failure(self, mock_run: Mock) -> None: + """Test lock file generation failure with Poetry.""" + mock_run.side_effect = subprocess.CalledProcessError( + 1, "poetry", stderr="Lock error" + ) + with pytest.raises(BackendExceptions): + self.manager.lock_dependencies() + + @patch("subprocess.run") + def test_lock_dependencies_os_error(self, mock_run: Mock) -> None: + """Test lock file generation OS error with Poetry.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.lock_dependencies() + + @patch("subprocess.run") + def test_run_script_success(self, mock_run: Mock) -> None: + """Test successful script execution with Poetry.""" + mock_run.return_value.returncode = 0 + self.manager.run_script("python test.py") + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_run_script_failure(self, mock_run: Mock) -> None: + """Test script execution failure with Poetry.""" + mock_run.side_effect = subprocess.CalledProcessError( + 1, "poetry", stderr="Script error" + ) + with pytest.raises(BackendExceptions): + self.manager.run_script("python test.py") + + @patch("subprocess.run") + def test_run_script_os_error(self, mock_run: Mock) -> None: + """Test script execution OS error with Poetry.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.run_script("python test.py") + + @patch("subprocess.run") + def test_show_dependencies_success(self, mock_run: Mock) -> None: + """Test showing dependencies with Poetry.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "fastapi 0.104.1\nuvicorn 0.24.0" + mock_run.return_value = mock_result + + output = self.manager.show_dependencies() + assert "fastapi" in output + assert "uvicorn" in output + + @patch("subprocess.run") + def test_show_dependencies_failure(self, mock_run: Mock) -> None: + """Test showing dependencies failure with Poetry.""" + mock_run.side_effect = subprocess.CalledProcessError( + 1, "poetry", stderr="Show error" + ) + with pytest.raises(BackendExceptions): + self.manager.show_dependencies() + + @patch("subprocess.run") + def test_show_dependencies_os_error(self, mock_run: Mock) -> None: + """Test showing dependencies OS error with Poetry.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.show_dependencies() + + +@pytest.mark.extended +class TestUvManagerExtended: + """Extended coverage tests for UvManager.""" + + def setup_method(self) -> None: + """Set up test environment.""" + self.temp_dir = tempfile.mkdtemp() + self.manager = UvManager(self.temp_dir) + + def teardown_method(self) -> None: + """Clean up test environment.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + @patch("subprocess.run") + def test_create_virtual_environment_success(self, mock_run: Mock) -> None: + """Test successful virtual environment creation with UV.""" + mock_run.return_value.returncode = 0 + venv_path = self.manager.create_virtual_environment() + assert venv_path == str(Path(self.temp_dir) / ".venv") + + @patch("subprocess.run") + def test_create_virtual_environment_failure(self, mock_run: Mock) -> None: + """Test virtual environment creation failure with UV.""" + mock_run.side_effect = subprocess.CalledProcessError(1, "uv", stderr="Error") + with pytest.raises(BackendExceptions): + self.manager.create_virtual_environment() + + @patch("subprocess.run") + def test_create_virtual_environment_os_error(self, mock_run: Mock) -> None: + """Test virtual environment creation OS error with UV.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.create_virtual_environment() + + def test_install_dependencies_no_venv(self) -> None: + """Test installing dependencies when venv doesn't exist.""" + with patch.object( + self.manager, "create_virtual_environment" + ) as mock_create_venv: + mock_create_venv.return_value = None + with pytest.raises(BackendExceptions): + self.manager.install_dependencies("/nonexistent/venv") + + @patch("subprocess.run") + def test_install_dependencies_no_pyproject(self, mock_run: Mock) -> None: + """Test installing dependencies when pyproject.toml doesn't exist.""" + venv_path = str(Path(self.temp_dir) / ".venv") + Path(venv_path).mkdir(parents=True) + + with pytest.raises(BackendExceptions) as exc_info: + self.manager.install_dependencies(venv_path) + assert "pyproject.toml file not found" in str(exc_info.value) + + @patch("subprocess.run") + def test_install_dependencies_success(self, mock_run: Mock) -> None: + """Test successful dependency installation with UV.""" + venv_path = str(Path(self.temp_dir) / ".venv") + Path(venv_path).mkdir(parents=True) + + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + pyproject_file.write_text('[project]\nname = "test"\n') + + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "Installing dependencies..." + mock_result.stderr = "" + mock_run.return_value = mock_result + + self.manager.install_dependencies(venv_path) + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_install_dependencies_failure(self, mock_run: Mock) -> None: + """Test dependency installation failure with UV.""" + venv_path = str(Path(self.temp_dir) / ".venv") + Path(venv_path).mkdir(parents=True) + + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + pyproject_file.write_text('[project]\nname = "test"\n') + + mock_run.side_effect = subprocess.CalledProcessError( + 1, "uv", stderr="Installation failed" + ) + with pytest.raises(BackendExceptions): + self.manager.install_dependencies(venv_path) + + @patch("subprocess.run") + def test_install_dependencies_os_error(self, mock_run: Mock) -> None: + """Test dependency installation OS error with UV.""" + venv_path = str(Path(self.temp_dir) / ".venv") + Path(venv_path).mkdir(parents=True) + + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + pyproject_file.write_text('[project]\nname = "test"\n') + + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.install_dependencies(venv_path) + + def test_generate_dependency_file_with_metadata(self) -> None: + """Test generating pyproject.toml with metadata for UV.""" + deps = ["fastapi", "uvicorn"] + self.manager.generate_dependency_file( + deps, + project_name="test-project", + author="Test Author", + author_email="test@example.com", + description="Test description", + ) + + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + assert pyproject_file.exists() + content = pyproject_file.read_text() + assert 'name = "test-project"' in content + assert 'name = "Test Author"' in content + assert 'email = "test@example.com"' in content + assert 'description = "Test description"' in content + assert "[tool.uv]" in content + + def test_generate_dependency_file_error(self) -> None: + """Test pyproject.toml generation error with UV.""" + with patch("builtins.open", side_effect=OSError("Permission denied")): + with pytest.raises(BackendExceptions): + self.manager.generate_dependency_file(["fastapi"]) + + @patch("subprocess.run") + def test_add_dependency_success(self, mock_run: Mock) -> None: + """Test successful dependency addition with UV.""" + mock_run.return_value.returncode = 0 + self.manager.add_dependency("fastapi") + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_add_dependency_dev(self, mock_run: Mock) -> None: + """Test adding dev dependency with UV.""" + mock_run.return_value.returncode = 0 + self.manager.add_dependency("pytest", dev=True) + args = mock_run.call_args[0][0] + assert "--dev" in args + + @patch("subprocess.run") + def test_add_dependency_failure(self, mock_run: Mock) -> None: + """Test dependency addition failure with UV.""" + mock_run.side_effect = subprocess.CalledProcessError(1, "uv", stderr="Error") + with pytest.raises(BackendExceptions): + self.manager.add_dependency("fastapi") + + @patch("subprocess.run") + def test_add_dependency_os_error(self, mock_run: Mock) -> None: + """Test dependency addition OS error with UV.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.add_dependency("fastapi") + + @patch("subprocess.run") + def test_initialize_project_success(self, mock_run: Mock) -> None: + """Test successful project initialization with UV.""" + mock_run.return_value.returncode = 0 + self.manager.initialize_project( + "test-project", "Author", "author@example.com", "Description" + ) + + pyproject_file = Path(self.temp_dir) / "pyproject.toml" + assert pyproject_file.exists() + + @patch("subprocess.run") + def test_initialize_project_subprocess_error(self, mock_run: Mock) -> None: + """Test project initialization subprocess error with UV.""" + mock_run.side_effect = subprocess.CalledProcessError(1, "uv", stderr="Error") + with pytest.raises(BackendExceptions): + self.manager.initialize_project( + "test-project", "Author", "author@example.com", "Description" + ) + + @patch("subprocess.run") + def test_initialize_project_os_error(self, mock_run: Mock) -> None: + """Test project initialization OS error with UV.""" + mock_run.return_value.returncode = 0 + with patch("builtins.open", side_effect=OSError("Permission denied")): + with pytest.raises(BackendExceptions): + self.manager.initialize_project( + "test-project", "Author", "author@example.com", "Description" + ) + + @patch("subprocess.run") + def test_lock_dependencies_success(self, mock_run: Mock) -> None: + """Test successful lock file generation with UV.""" + mock_run.return_value.returncode = 0 + self.manager.lock_dependencies() + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_lock_dependencies_failure(self, mock_run: Mock) -> None: + """Test lock file generation failure with UV.""" + mock_run.side_effect = subprocess.CalledProcessError( + 1, "uv", stderr="Lock error" + ) + with pytest.raises(BackendExceptions): + self.manager.lock_dependencies() + + @patch("subprocess.run") + def test_lock_dependencies_os_error(self, mock_run: Mock) -> None: + """Test lock file generation OS error with UV.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.lock_dependencies() + + @patch("subprocess.run") + def test_run_script_success(self, mock_run: Mock) -> None: + """Test successful script execution with UV.""" + mock_run.return_value.returncode = 0 + self.manager.run_script("python test.py") + mock_run.assert_called_once() + + @patch("subprocess.run") + def test_run_script_failure(self, mock_run: Mock) -> None: + """Test script execution failure with UV.""" + mock_run.side_effect = subprocess.CalledProcessError( + 1, "uv", stderr="Script error" + ) + with pytest.raises(BackendExceptions): + self.manager.run_script("python test.py") + + @patch("subprocess.run") + def test_run_script_os_error(self, mock_run: Mock) -> None: + """Test script execution OS error with UV.""" + mock_run.side_effect = OSError("System error") + with pytest.raises(BackendExceptions): + self.manager.run_script("python test.py") From 59d307b4dafbce88f7ed32995d96e23ecf52e06b Mon Sep 17 00:00:00 2001 From: bnbong Date: Thu, 27 Nov 2025 17:46:26 +0900 Subject: [PATCH 3/3] [ADD] add fastkit init --interactive feature --- CHANGELOG.md | 54 ++ scripts/coverage-report.sh | 2 +- scripts/coverage.sh | 2 +- src/fastapi_fastkit/__init__.py | 2 +- .../backend/interactive/__init__.py | 51 ++ .../backend/interactive/config_builder.py | 186 ++++++ .../backend/interactive/prompts.py | 528 ++++++++++++++++ .../backend/interactive/selectors.py | 241 +++++++ .../backend/interactive/validators.py | 163 +++++ src/fastapi_fastkit/backend/main.py | 77 +++ .../backend/project_builder/__init__.py | 17 + .../project_builder/config_generator.py | 594 ++++++++++++++++++ .../project_builder/dependency_collector.py | 210 +++++++ src/fastapi_fastkit/cli.py | 221 ++++++- src/fastapi_fastkit/core/settings.py | 74 +++ .../test_interactive_config_builder.py | 437 +++++++++++++ .../test_backends/test_interactive_prompts.py | 188 ++++++ .../test_interactive_selectors.py | 231 +++++++ .../test_interactive_validators.py | 579 +++++++++++++++++ .../test_project_builder_config_generator.py | 372 +++++++++++ ...st_project_builder_dependency_collector.py | 503 +++++++++++++++ .../test_cli_interactive_integration.py | 212 +++++++ 22 files changed, 4928 insertions(+), 16 deletions(-) create mode 100644 src/fastapi_fastkit/backend/interactive/__init__.py create mode 100644 src/fastapi_fastkit/backend/interactive/config_builder.py create mode 100644 src/fastapi_fastkit/backend/interactive/prompts.py create mode 100644 src/fastapi_fastkit/backend/interactive/selectors.py create mode 100644 src/fastapi_fastkit/backend/interactive/validators.py create mode 100644 src/fastapi_fastkit/backend/project_builder/__init__.py create mode 100644 src/fastapi_fastkit/backend/project_builder/config_generator.py create mode 100644 src/fastapi_fastkit/backend/project_builder/dependency_collector.py create mode 100644 tests/test_backends/test_interactive_config_builder.py create mode 100644 tests/test_backends/test_interactive_prompts.py create mode 100644 tests/test_backends/test_interactive_selectors.py create mode 100644 tests/test_backends/test_interactive_validators.py create mode 100644 tests/test_backends/test_project_builder_config_generator.py create mode 100644 tests/test_backends/test_project_builder_dependency_collector.py create mode 100644 tests/test_cli_operations/test_cli_interactive_integration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bc3983..da33817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## v1.2.0 (2025-11-27) + +### Features + +- **(Breaking Change) Add `fastkit init --interactive` feature**: Revolutionary feature-by-feature project builder + - `fastkit init --interactive` now provides guided project setup with intelligent feature selection + - Always uses Empty project (fastapi-empty template) as base template to prevent conflicts with DynamicConfigGenerator + - Interactive project configuration with validation and compatibility warnings + - Real-time dependency collection based on selected features + - Confirmation summary before project creation + +- **Dynamic Code Generation**: Intelligent code generation based on feature selections + - Integrated DynamicConfigGenerator for automatic code scaffolding + - Generates `main.py` with selected features (auth, database, monitoring, etc.) + - Creates database configuration files for PostgreSQL, MySQL, MongoDB, SQLite + - Generates authentication setup for JWT, OAuth2, FastAPI-Users + - Auto-generates test configuration (pytest with optional coverage) + - Docker deployment files (Dockerfile, docker-compose.yml) generation + +- **Enhanced Dependency Management**: Multi-format dependency file generation + - Automatically generates both package-manager-specific files AND requirements.txt + - Ensures pip compatibility regardless of selected package manager + - Dependencies correctly reflect all selected stack features + - Smart dependency deduplication and version management + +### Improvements + +- **Interactive CLI Experience**: + - Step-by-step feature selection with descriptions. Each selection step proceeds in the following order below: + - Database selection (PostgreSQL, MySQL, MongoDB, Redis, SQLite) + - Authentication options (JWT, OAuth2, FastAPI-Users, Session-based) + - Background tasks (Celery, Dramatiq) + - Caching layer (Redis, fastapi-cache2) + - Monitoring integration (Loguru, OpenTelemetry, Prometheus) + - Testing framework (Basic, Coverage, Advanced) + - Utilities (CORS, Rate-Limiting, Pagination, WebSocket) + - Deployment configuration (Docker, docker-compose) + - Package manager selection (pip, uv, pdm, poetry) + - Custom package addition support + +### Technical + +- **Interactive Backend Architecture**: + - `InteractiveConfigBuilder`: Orchestrates full interactive flow + - `DynamicConfigGenerator`: Generates feature-specific code + - `DependencyCollector`: Intelligently collects stack dependencies + - Input validators with comprehensive error handling + - Multi-select prompts for utilities and deployment options + - Feature compatibility validation system + +### Documentation + +- Add AI translation support of user guides(docs/ folder sources that mkdocs renders) + ## v1.1.5 (2025-09-14) ### Improvements diff --git a/scripts/coverage-report.sh b/scripts/coverage-report.sh index 432bb14..977e043 100755 --- a/scripts/coverage-report.sh +++ b/scripts/coverage-report.sh @@ -12,7 +12,7 @@ NC='\033[0m' # No Color # Script options OPEN_HTML=false SHOW_MISSING=true -MIN_COVERAGE=70 +MIN_COVERAGE=80 OUTPUT_FORMAT="term" # Help function diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 42f8243..82a8e35 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -6,7 +6,7 @@ set -x echo "Running tests with coverage..." # Run tests with coverage -pytest --cov=src/fastapi_fastkit --cov-report=term-missing --cov-report=html --cov-report=xml --cov-fail-under=70 +pytest --cov=src/fastapi_fastkit --cov-report=term-missing --cov-report=html --cov-report=xml --cov-fail-under=80 coverage_exit_code=$? diff --git a/src/fastapi_fastkit/__init__.py b/src/fastapi_fastkit/__init__.py index 9b102be..c68196d 100644 --- a/src/fastapi_fastkit/__init__.py +++ b/src/fastapi_fastkit/__init__.py @@ -1 +1 @@ -__version__ = "1.1.5" +__version__ = "1.2.0" diff --git a/src/fastapi_fastkit/backend/interactive/__init__.py b/src/fastapi_fastkit/backend/interactive/__init__.py new file mode 100644 index 0000000..3cd102a --- /dev/null +++ b/src/fastapi_fastkit/backend/interactive/__init__.py @@ -0,0 +1,51 @@ +# -------------------------------------------------------------------------- +# Interactive module for FastAPI-fastkit +# +# Provides interactive prompts and configuration building for dynamic +# project creation. +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +from .config_builder import InteractiveConfigBuilder +from .prompts import ( + prompt_additional_features, + prompt_authentication_selection, + prompt_basic_info, + prompt_caching_selection, + prompt_custom_packages, + prompt_database_selection, + prompt_deployment_options, + prompt_monitoring_selection, + prompt_package_manager_selection, + prompt_template_selection, + prompt_testing_selection, + prompt_utilities_selection, +) +from .selectors import confirm_selections, multi_select_prompt, render_selection_table +from .validators import ( + sanitize_custom_packages, + validate_feature_compatibility, + validate_package_name, +) + +__all__ = [ + "InteractiveConfigBuilder", + "prompt_basic_info", + "prompt_template_selection", + "prompt_database_selection", + "prompt_authentication_selection", + "prompt_additional_features", + "prompt_testing_selection", + "prompt_deployment_options", + "prompt_custom_packages", + "prompt_caching_selection", + "prompt_monitoring_selection", + "prompt_utilities_selection", + "prompt_package_manager_selection", + "render_selection_table", + "multi_select_prompt", + "confirm_selections", + "validate_package_name", + "validate_feature_compatibility", + "sanitize_custom_packages", +] diff --git a/src/fastapi_fastkit/backend/interactive/config_builder.py b/src/fastapi_fastkit/backend/interactive/config_builder.py new file mode 100644 index 0000000..917cd8e --- /dev/null +++ b/src/fastapi_fastkit/backend/interactive/config_builder.py @@ -0,0 +1,186 @@ +# -------------------------------------------------------------------------- +# Build comprehensive project configuration from user selections +# +# Aggregates all user choices into a structured configuration dict +# that can be consumed by the project builder. +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +from typing import Any, Dict, List + +from fastapi_fastkit.utils.main import console, print_warning + +from .prompts import ( + prompt_additional_features, + prompt_basic_info, + prompt_template_selection, +) +from .selectors import confirm_selections +from .validators import sanitize_custom_packages, validate_feature_compatibility + + +class InteractiveConfigBuilder: + """ + Builds project configuration from interactive prompts. + + Orchestrates the entire interactive flow and aggregates all + user selections into a cohesive configuration dictionary. + """ + + def __init__(self, settings: Any) -> None: + """ + Initialize the config builder. + + Args: + settings: FastkitConfig instance + """ + self.settings = settings + self.config: Dict[str, Any] = {} + + def run_interactive_flow(self) -> Dict[str, Any]: + """ + Execute full interactive flow and return config. + + Returns: + Complete project configuration dictionary + """ + console.print( + "\n[bold magenta]⚔ FastAPI-fastkit Interactive Project Setup ⚔[/bold magenta]\n" + ) + + # Step 1: Basic information + self._collect_basic_info() + + # Step 2: Always use Empty template as base for interactive mode + # (Feature selection will build the project incrementally) + self.config["base_template"] = None # None = Empty project + + # Step 3: Feature selections + self._collect_feature_selections() + + # Step 4: Build final configuration + final_config = self._build_final_config() + + # Step 5: Validate compatibility + is_valid, warning = validate_feature_compatibility(final_config) + if warning: + print_warning(warning, title="Feature Compatibility") + + # Step 6: Confirm selections + if confirm_selections(final_config): + return final_config + else: + print_warning("Project creation cancelled by user.") + return {} + + def _collect_basic_info(self) -> None: + """Collect basic project information.""" + basic_info = prompt_basic_info() + self.config.update(basic_info) + + def _collect_template_selection(self) -> None: + """Collect template selection.""" + template = prompt_template_selection(self.settings) + self.config["base_template"] = template + + def _collect_feature_selections(self) -> None: + """Collect all feature selections.""" + features = prompt_additional_features(self.settings) + self.config.update(features) + + def _build_final_config(self) -> Dict[str, Any]: + """ + Build and validate final configuration. + + Returns: + Complete configuration dictionary with collected dependencies + """ + # Collect all dependencies + all_deps = self._collect_all_dependencies() + + # Add to config + self.config["all_dependencies"] = all_deps + + return self.config + + def _collect_all_dependencies(self) -> List[str]: + """ + Collect all dependencies from selected features. + + Returns: + Deduplicated list of all package dependencies + """ + dependencies = set() + + # Always add base FastAPI dependencies + dependencies.update(["fastapi", "uvicorn", "pydantic", "pydantic-settings"]) + + # Database dependencies + db_info = self.config.get("database", {}) + if isinstance(db_info, dict) and db_info.get("packages"): + dependencies.update(db_info["packages"]) + + # Authentication dependencies + auth_type = self.config.get("authentication", "None") + if auth_type != "None": + auth_packages = self.settings.PACKAGE_CATALOG["authentication"].get( + auth_type, [] + ) + dependencies.update(auth_packages) + + # Async tasks dependencies + tasks_type = self.config.get("async_tasks", "None") + if tasks_type != "None": + task_packages = self.settings.PACKAGE_CATALOG["async_tasks"].get( + tasks_type, [] + ) + dependencies.update(task_packages) + + # Caching dependencies + cache_type = self.config.get("caching", "None") + if cache_type != "None": + cache_packages = self.settings.PACKAGE_CATALOG["caching"].get( + cache_type, [] + ) + dependencies.update(cache_packages) + + # Monitoring dependencies + monitoring_type = self.config.get("monitoring", "None") + if monitoring_type != "None": + monitoring_packages = self.settings.PACKAGE_CATALOG["monitoring"].get( + monitoring_type, [] + ) + dependencies.update(monitoring_packages) + + # Testing dependencies + testing_type = self.config.get("testing", "None") + if testing_type != "None": + testing_packages = self.settings.PACKAGE_CATALOG["testing"].get( + testing_type, [] + ) + dependencies.update(testing_packages) + + # Utilities dependencies + utilities = self.config.get("utilities", []) + for util in utilities: + if util in self.settings.PACKAGE_CATALOG["utilities"]: + util_packages = self.settings.PACKAGE_CATALOG["utilities"][util] + dependencies.update(util_packages) + + # Custom packages + custom_packages = self.config.get("custom_packages", []) + if custom_packages: + sanitized = sanitize_custom_packages(custom_packages) + dependencies.update(sanitized) + + # Convert to sorted list + return sorted(list(dependencies)) + + def get_config(self) -> Dict[str, Any]: + """ + Get the current configuration. + + Returns: + Current configuration dictionary + """ + return self.config diff --git a/src/fastapi_fastkit/backend/interactive/prompts.py b/src/fastapi_fastkit/backend/interactive/prompts.py new file mode 100644 index 0000000..1a25126 --- /dev/null +++ b/src/fastapi_fastkit/backend/interactive/prompts.py @@ -0,0 +1,528 @@ +# -------------------------------------------------------------------------- +# Interactive prompt definitions for FastAPI-fastkit CLI +# +# Provides user-facing prompts for: +# - Basic project information +# - Template selection +# - Database selection +# - Authentication method +# - Additional features +# - Testing configuration +# - Deployment options +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +from typing import Any, Dict, List, Optional, cast + +import click +from rich.panel import Panel + +from fastapi_fastkit.utils.main import console, print_error + +from .selectors import render_selection_table +from .validators import validate_email_format, validate_project_name + + +def prompt_basic_info() -> Dict[str, str]: + """ + Prompt for basic project information. + + Returns: + Dictionary with project_name, author, author_email, description + """ + console.print("\n[bold cyan]šŸ“ Project Information[/bold cyan]") + console.print( + "[dim]Let's start with some basic information about your project[/dim]\n" + ) + + # Project name + while True: + project_name = click.prompt("Project name", type=str) + is_valid, error = validate_project_name(project_name) + if is_valid: + break + print_error(error or "Invalid project name") + + # Author + author = click.prompt("Author name", type=str) + + # Email + while True: + author_email = click.prompt("Author email", type=str) + if validate_email_format(author_email): + break + print_error("Invalid email format. Please enter a valid email address.") + + # Description + description = click.prompt("Project description", type=str) + + return { + "project_name": project_name, + "author": author, + "author_email": author_email, + "description": description, + } + + +def prompt_template_selection(settings: Any) -> Optional[str]: + """ + Prompt for base template selection. + + Args: + settings: FastkitConfig instance + + Returns: + Template name or None for empty project + """ + import os + + console.print("\n[bold cyan]šŸ“¦ Base Template Selection[/bold cyan]") + console.print("[dim]Choose a base template or start from scratch[/dim]\n") + + template_dir = settings.FASTKIT_TEMPLATE_ROOT + excluded_dirs = ["__pycache__", "modules", "fastapi-empty"] + + templates = [ + d + for d in os.listdir(template_dir) + if os.path.isdir(os.path.join(template_dir, d)) and d not in excluded_dirs + ] + + # Add "Empty Project" option + options = {"Empty Project": "Start with minimal FastAPI setup"} + + # Add available templates + for template in templates: + readme_path = os.path.join(template_dir, template, "README.md-tpl") + description = "No description" + if os.path.exists(readme_path): + with open(readme_path, "r") as f: + first_line = f.readline().strip() + if first_line.startswith("# "): + description = first_line[2:] + options[template] = description + + render_selection_table("Available Templates", options) + + choice = click.prompt( + "\nSelect template", + type=click.IntRange(1, len(options)), + default=1, + ) + + selected_key = list(options.keys())[choice - 1] + + return cast( + Optional[str], selected_key if selected_key != "Empty Project" else None + ) + + +def prompt_database_selection(settings: Any) -> Dict[str, Any]: + """ + Prompt for database selection. + + Args: + settings: FastkitConfig instance + + Returns: + Dictionary with type and packages + """ + console.print("\n[bold cyan]šŸ—„ļø Database Selection[/bold cyan]") + console.print("[dim]Choose your database backend[/dim]\n") + + db_catalog = settings.PACKAGE_CATALOG["database"] + options = { + name: f"Packages: {', '.join(pkgs) if pkgs else 'None'}" + for name, pkgs in db_catalog.items() + } + + render_selection_table("Database Options", options) + + choice = click.prompt( + "\nSelect database", + type=click.IntRange(1, len(options)), + default=len(options), # Default to "None" + ) + + selected_type = list(options.keys())[choice - 1] + + return { + "type": selected_type, + "packages": db_catalog[selected_type], + } + + +def prompt_authentication_selection(settings: Any) -> str: + """ + Prompt for authentication method. + + Args: + settings: FastkitConfig instance + + Returns: + Authentication type name + """ + console.print("\n[bold cyan]šŸ” Authentication Selection[/bold cyan]") + console.print("[dim]Choose your authentication strategy[/dim]\n") + + auth_catalog = settings.PACKAGE_CATALOG["authentication"] + options = {} + + for name, pkgs in auth_catalog.items(): + if name == "None": + options[name] = "No authentication" + elif name == "JWT": + options[name] = "JSON Web Tokens (recommended for APIs)" + elif name == "OAuth2": + options[name] = "OAuth2 with authlib" + elif name == "FastAPI-Users": + options[name] = "Complete user management system (includes JWT)" + elif name == "Session-based": + options[name] = "Traditional session-based authentication" + else: + options[name] = f"Packages: {', '.join(pkgs)}" + + render_selection_table("Authentication Options", options) + + choice = click.prompt( + "\nSelect authentication method", + type=click.IntRange(1, len(options)), + default=len(options), # Default to "None" + ) + + return cast(str, list(options.keys())[choice - 1]) + + +def prompt_async_tasks_selection(settings: Any) -> str: + """ + Prompt for async task queue selection. + + Args: + settings: FastkitConfig instance + + Returns: + Task queue type name + """ + console.print("\n[bold cyan]⚔ Background Tasks Selection[/bold cyan]") + console.print("[dim]Choose a task queue for background processing[/dim]\n") + + task_catalog = settings.PACKAGE_CATALOG["async_tasks"] + options = { + name: f"Packages: {', '.join(pkgs) if pkgs else 'None'}" + for name, pkgs in task_catalog.items() + } + + render_selection_table("Task Queue Options", options) + + choice = click.prompt( + "\nSelect task queue", + type=click.IntRange(1, len(options)), + default=len(options), # Default to "None" + ) + + return cast(str, list(options.keys())[choice - 1]) + + +def prompt_caching_selection(settings: Any) -> str: + """ + Prompt for caching selection. + + Args: + settings: FastkitConfig instance + + Returns: + Caching type name + """ + console.print("\n[bold cyan]šŸ’¾ Caching Selection[/bold cyan]") + console.print("[dim]Add caching layer for better performance[/dim]\n") + + cache_catalog = settings.PACKAGE_CATALOG["caching"] + options = { + name: f"Packages: {', '.join(pkgs) if pkgs else 'None'}" + for name, pkgs in cache_catalog.items() + } + + render_selection_table("Caching Options", options) + + choice = click.prompt( + "\nSelect caching", + type=click.IntRange(1, len(options)), + default=len(options), # Default to "None" + ) + + return cast(str, list(options.keys())[choice - 1]) + + +def prompt_monitoring_selection(settings: Any) -> str: + """ + Prompt for monitoring/logging selection. + + Args: + settings: FastkitConfig instance + + Returns: + Monitoring type name + """ + console.print("\n[bold cyan]šŸ“Š Monitoring & Logging Selection[/bold cyan]") + console.print("[dim]Add monitoring and logging capabilities[/dim]\n") + + monitoring_catalog = settings.PACKAGE_CATALOG["monitoring"] + options = {} + + for name, pkgs in monitoring_catalog.items(): + if name == "None": + options[name] = "No additional monitoring" + elif name == "Loguru": + options[name] = "Enhanced logging with Loguru" + elif name == "OpenTelemetry": + options[name] = "Distributed tracing and metrics" + elif name == "Prometheus": + options[name] = "Prometheus metrics instrumentation" + else: + options[name] = f"Packages: {', '.join(pkgs)}" + + render_selection_table("Monitoring Options", options) + + choice = click.prompt( + "\nSelect monitoring", + type=click.IntRange(1, len(options)), + default=len(options), # Default to "None" + ) + + return cast(str, list(options.keys())[choice - 1]) + + +def prompt_testing_selection(settings: Any) -> str: + """ + Prompt for testing framework selection. + + Args: + settings: FastkitConfig instance + + Returns: + Testing type name + """ + console.print("\n[bold cyan]🧪 Testing Framework Selection[/bold cyan]") + console.print("[dim]Choose testing tools and coverage[/dim]\n") + + testing_catalog = settings.PACKAGE_CATALOG["testing"] + options = {} + + for name, pkgs in testing_catalog.items(): + if name == "None": + options[name] = "No testing framework" + elif name == "Basic": + options[name] = "pytest + httpx for API testing" + elif name == "Coverage": + options[name] = "Basic + code coverage" + elif name == "Advanced": + options[name] = "Coverage + faker + factory-boy for fixtures" + else: + options[name] = f"Packages: {', '.join(pkgs)}" + + render_selection_table("Testing Options", options) + + choice = click.prompt( + "\nSelect testing framework", + type=click.IntRange(1, len(options)), + default=2, # Default to "Basic" + ) + + return cast(str, list(options.keys())[choice - 1]) + + +def prompt_utilities_selection(settings: Any) -> List[str]: + """ + Prompt for utilities selection (multi-select). + + Args: + settings: FastkitConfig instance + + Returns: + List of selected utility names + """ + console.print("\n[bold cyan]šŸ› ļø Additional Utilities[/bold cyan]") + console.print( + "[dim]Select additional utilities (comma-separated numbers, e.g., 1,3,4)[/dim]\n" + ) + + utilities_catalog = settings.PACKAGE_CATALOG["utilities"] + options = [] + descriptions = {} + + for name, pkgs in utilities_catalog.items(): + if name == "None": + continue + options.append(name) + if name == "CORS": + descriptions[name] = "(Built-in, will configure in settings)" + elif name == "Rate-Limiting": + descriptions[name] = "API rate limiting with slowapi" + elif name == "Pagination": + descriptions[name] = "Response pagination utilities" + elif name == "WebSocket": + descriptions[name] = "(Built-in, will configure routes)" + else: + descriptions[name] = f"({', '.join(pkgs)})" + + for i, option in enumerate(options, 1): + desc = descriptions.get(option, "") + console.print(f" [cyan]{i}[/cyan]. {option} {desc}") + + selected_input = console.input( + "\n[cyan]Your choice (or press Enter to skip):[/cyan] " + ).strip() + + if not selected_input: + return [] + + try: + indices = [int(x.strip()) for x in selected_input.split(",") if x.strip()] + selected = [options[idx - 1] for idx in indices if 1 <= idx <= len(options)] + return selected + except (ValueError, IndexError): + console.print("[yellow]Invalid selection. Skipping utilities.[/yellow]") + return [] + + +def prompt_deployment_options() -> List[str]: + """ + Prompt for deployment configuration. + + Returns: + List of deployment options + """ + console.print("\n[bold cyan]šŸš€ Deployment Configuration[/bold cyan]") + console.print("[dim]Select deployment options (comma-separated numbers)[/dim]\n") + + options = [ + "Docker", + "docker-compose", + "None", + ] + + descriptions = { + "Docker": "Generate Dockerfile", + "docker-compose": "Generate docker-compose.yml (includes Docker)", + "None": "No deployment configuration", + } + + for i, option in enumerate(options, 1): + desc = descriptions.get(option, "") + console.print(f" [cyan]{i}[/cyan]. {option} - {desc}") + + choice = click.prompt( + "\nSelect deployment option", + type=click.IntRange(1, len(options)), + default=3, # Default to "None" + ) + + selected = options[choice - 1] + + if selected == "None": + return [] + elif selected == "docker-compose": + return ["Docker", "docker-compose"] + else: + return [selected] + + +def prompt_package_manager_selection(settings: Any) -> str: + """ + Prompt for package manager selection. + + Args: + settings: FastkitConfig instance + + Returns: + Package manager name + """ + console.print("\n[bold cyan]šŸ“¦ Package Manager Selection[/bold cyan]") + console.print("[dim]Choose your preferred package manager[/dim]\n") + + options = {} + for manager, config in settings.PACKAGE_MANAGER_CONFIG.items(): + options[manager] = config["description"] + + render_selection_table("Package Managers", options) + + # Find default index + default_idx = list(options.keys()).index(settings.DEFAULT_PACKAGE_MANAGER) + 1 + + choice = click.prompt( + "\nSelect package manager", + type=click.IntRange(1, len(options)), + default=default_idx, + ) + + return cast(str, list(options.keys())[choice - 1]) + + +def prompt_custom_packages() -> List[str]: + """ + Prompt for custom package names (manual entry). + + Returns: + List of custom package names + """ + console.print("\n[bold cyan]āž• Additional Custom Packages[/bold cyan]") + console.print( + "[dim]Enter any additional packages (comma-separated) or press Enter to skip[/dim]\n" + ) + + packages_input = console.input("[cyan]Package names:[/cyan] ").strip() + + if not packages_input: + return [] + + # Split by comma and clean + packages = [pkg.strip() for pkg in packages_input.split(",") if pkg.strip()] + + return packages + + +def prompt_additional_features(settings: Any) -> Dict[str, Any]: + """ + Prompt for all additional features in sequence. + + This is a convenience function that calls all feature prompts. + + Args: + settings: FastkitConfig instance + + Returns: + Dictionary with all feature selections + """ + features: Dict[str, Any] = {} + + # Database + features["database"] = prompt_database_selection(settings) + + # Authentication + features["authentication"] = prompt_authentication_selection(settings) + + # Async tasks + features["async_tasks"] = prompt_async_tasks_selection(settings) + + # Caching + features["caching"] = prompt_caching_selection(settings) + + # Monitoring + features["monitoring"] = prompt_monitoring_selection(settings) + + # Testing + features["testing"] = prompt_testing_selection(settings) + + # Utilities + features["utilities"] = prompt_utilities_selection(settings) + + # Deployment + features["deployment"] = prompt_deployment_options() + + # Package manager + features["package_manager"] = prompt_package_manager_selection(settings) + + # Custom packages + features["custom_packages"] = prompt_custom_packages() + + return features diff --git a/src/fastapi_fastkit/backend/interactive/selectors.py b/src/fastapi_fastkit/backend/interactive/selectors.py new file mode 100644 index 0000000..8e66d2d --- /dev/null +++ b/src/fastapi_fastkit/backend/interactive/selectors.py @@ -0,0 +1,241 @@ +# -------------------------------------------------------------------------- +# Selection logic and UI rendering for interactive mode +# +# Provides: +# - Table-based option displays using rich +# - Multi-select prompts +# - Confirmation dialogs +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +from typing import Any, Dict, List, Optional + +from rich.panel import Panel +from rich.table import Table + +from fastapi_fastkit.utils.main import console + + +def render_selection_table( + title: str, options: Dict[str, str], show_numbers: bool = True +) -> None: + """ + Render a selection table using rich. + + Args: + title: Table title + options: Dictionary of option_key: description + show_numbers: Whether to show numbered options (for single-select) + """ + table = Table(title=title, show_header=True, header_style="bold magenta") + + if show_numbers: + table.add_column("Option", style="cyan", no_wrap=True) + table.add_column("Name", style="green") + table.add_column("Description", style="yellow") + + for i, (key, description) in enumerate(options.items(), 1): + if show_numbers: + table.add_row(str(i), key, description) + else: + table.add_row(key, description) + + console.print(table) + + +def render_feature_options( + category: str, options: List[str], descriptions: Dict[str, str] +) -> None: + """ + Render feature options for multi-select. + + Args: + category: Feature category name + options: List of option names + descriptions: Dictionary of option descriptions + """ + console.print(f"\n[bold cyan]{category}[/bold cyan]") + console.print( + "[dim]Select options by entering numbers (comma-separated, e.g., 1,3,4)[/dim]\n" + ) + + for i, option in enumerate(options, 1): + desc = descriptions.get(option, "") + console.print(f" [cyan]{i}[/cyan]. {option} [dim]{desc}[/dim]") + + +def multi_select_prompt( + title: str, options: List[str], descriptions: Optional[Dict[str, str]] = None +) -> List[int]: + """ + Multi-select prompt with comma-separated input. + + Args: + title: Prompt title + options: List of options + descriptions: Optional descriptions for each option + + Returns: + List of selected indices (0-based) + """ + descriptions = descriptions or {} + + console.print(f"\n[bold]{title}[/bold]") + render_feature_options(title, options, descriptions) + + while True: + selected = console.input( + "\n[cyan]Your choice (or press Enter to skip):[/cyan] " + ).strip() + + if not selected: + return [] + + try: + # Parse comma-separated numbers + indices = [int(x.strip()) for x in selected.split(",") if x.strip()] + + # Validate indices + if all(1 <= idx <= len(options) for idx in indices): + # Convert to 0-based indices + return [idx - 1 for idx in indices] + else: + console.print( + f"[red]Invalid selection. Please enter numbers between 1 and {len(options)}[/red]" + ) + except ValueError: + console.print( + "[red]Invalid input. Please enter comma-separated numbers (e.g., 1,3,4)[/red]" + ) + + +def confirm_selections(config: Dict[str, Any]) -> bool: + """ + Display summary and confirm all selections. + + Args: + config: Complete project configuration + + Returns: + True if user confirms, False otherwise + """ + table = Table( + title="šŸ“‹ Project Configuration Summary", + show_header=True, + header_style="bold magenta", + ) + table.add_column("Setting", style="cyan", no_wrap=True) + table.add_column("Value", style="green") + + # Basic information + table.add_row("Project Name", config.get("project_name", "N/A")) + table.add_row("Author", config.get("author", "N/A")) + table.add_row("Email", config.get("author_email", "N/A")) + table.add_row("Description", config.get("description", "N/A")) + + # Template + if config.get("base_template"): + table.add_row("Base Template", config["base_template"]) + + # Database + db_info = config.get("database", {}) + if db_info.get("type") != "None": + table.add_row("Database", db_info.get("type", "None")) + + # Authentication + auth_type = config.get("authentication", "None") + if auth_type != "None": + table.add_row("Authentication", auth_type) + + # Async tasks + task_type = config.get("async_tasks", "None") + if task_type != "None": + table.add_row("Async Tasks", task_type) + + # Caching + cache_type = config.get("caching", "None") + if cache_type != "None": + table.add_row("Caching", cache_type) + + # Monitoring + monitoring_type = config.get("monitoring", "None") + if monitoring_type != "None": + table.add_row("Monitoring", monitoring_type) + + # Testing + testing_type = config.get("testing", "None") + if testing_type != "None": + table.add_row("Testing", testing_type) + + # Utilities + utilities = config.get("utilities", []) + if utilities: + table.add_row("Utilities", ", ".join(utilities)) + + # Custom packages + custom = config.get("custom_packages", []) + if custom: + table.add_row("Custom Packages", ", ".join(custom)) + + # Package manager + table.add_row("Package Manager", config.get("package_manager", "uv")) + + console.print("\n") + console.print(table) + console.print("\n") + + # Show total dependencies count + total_deps = len(config.get("all_dependencies", [])) + console.print(f"[bold cyan]Total dependencies to install:[/bold cyan] {total_deps}") + + # Ask for confirmation + response = ( + console.input( + "\n[bold yellow]Proceed with project creation? [Y/n]:[/bold yellow] " + ) + .strip() + .lower() + ) + + return response in ["y", "yes", ""] + + +def display_feature_catalog( + catalog: Dict[str, Dict[str, List[str]]], descriptions: Dict[str, str] +) -> None: + """ + Display the complete feature catalog. + + Args: + catalog: Package catalog from settings + descriptions: Feature descriptions from settings + """ + panel = Panel( + "[bold cyan]FastAPI-fastkit Feature Catalog[/bold cyan]\n\n" + "Available features and their associated packages for interactive project creation.", + title="šŸ“š Feature Catalog", + border_style="cyan", + ) + console.print(panel) + + for category, options in catalog.items(): + if category == "utilities": + continue # Skip utilities as they may have empty package lists + + desc = descriptions.get(category, category.title()) + table = Table(title=f"{desc}", show_header=True, header_style="bold yellow") + table.add_column("Option", style="cyan", no_wrap=True) + table.add_column("Packages", style="green") + + for option_name, packages in options.items(): + if option_name == "None": + continue + pkg_list = ( + ", ".join(packages) + if packages + else "[dim](No additional packages)[/dim]" + ) + table.add_row(option_name, pkg_list) + + console.print("\n") + console.print(table) diff --git a/src/fastapi_fastkit/backend/interactive/validators.py b/src/fastapi_fastkit/backend/interactive/validators.py new file mode 100644 index 0000000..015d851 --- /dev/null +++ b/src/fastapi_fastkit/backend/interactive/validators.py @@ -0,0 +1,163 @@ +# -------------------------------------------------------------------------- +# Input validation utilities for interactive mode +# +# Provides validation functions for: +# - Package names +# - Project names +# - Feature compatibility checking +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import re +from typing import Any, Dict, List, Optional, Tuple + +from fastapi_fastkit.utils.main import REGEX as EMAIL_REGEX + + +def validate_package_name(package: str) -> bool: + """ + Validate package name format. + + Checks if the package name follows basic PyPI package naming conventions. + Does not validate existence on PyPI (that will be added in v1.2.1+). + + Args: + package: Package name to validate + + Returns: + True if format is valid, False otherwise + """ + if not package or not isinstance(package, str): + return False + + # Remove version specifiers and extras for validation + package_name = re.split(r"[<>=!\[]", package)[0].strip() + + # Basic package name pattern: letters, numbers, hyphens, underscores, dots + # Must start with letter or number + pattern = r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + + return bool(re.match(pattern, package_name)) + + +def validate_project_name(name: str) -> Tuple[bool, Optional[str]]: + """ + Validate project name. + + Args: + name: Project name to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not name: + return False, "Project name cannot be empty" + + if not name.replace("_", "").replace("-", "").isalnum(): + return ( + False, + "Project name must contain only letters, numbers, hyphens, and underscores", + ) + + if name[0].isdigit(): + return False, "Project name cannot start with a number" + + # Check for Python keywords + import keyword + + if keyword.iskeyword(name): + return False, f"Project name '{name}' is a Python keyword" + + return True, None + + +def validate_email_format(email: str) -> bool: + """ + Validate email address format using utils/main.py REGEX pattern. + + Args: + email: Email address to validate + + Returns: + True if format is valid, False otherwise + """ + if not email: + return False + + return bool(re.match(EMAIL_REGEX, email)) + + +def validate_feature_compatibility( + config: Dict[str, Any], +) -> Tuple[bool, Optional[str]]: + """ + Check compatibility between selected features. + + Validates that selected features don't have conflicting requirements. + + Args: + config: Project configuration dictionary + + Returns: + Tuple of (is_compatible, warning_message) + """ + warnings = [] + + # Check if FastAPI-Users is selected with database + if config.get("authentication") == "FastAPI-Users": + db_type = config.get("database", {}).get("type", "None") + if db_type == "None": + warnings.append( + "FastAPI-Users requires a database. Consider selecting PostgreSQL or MySQL." + ) + + # Check if Celery is selected with Redis + if config.get("async_tasks") == "Celery" or config.get("async_tasks") == "Dramatiq": + # These already include Redis in their dependencies + pass + + # Check if caching Redis is selected along with task queue Redis + cache_type = config.get("caching", "None") + task_type = config.get("async_tasks", "None") + if cache_type == "Redis" and task_type in ["Celery", "Dramatiq"]: + # This is actually compatible, just a note + warnings.append( + "Info: Redis will be used for both caching and task queue (shared dependency)." + ) + + # If there are warnings, return them + if warnings: + return True, " | ".join(warnings) + + return True, None + + +def sanitize_custom_packages(packages: List[str]) -> List[str]: + """ + Clean and deduplicate custom package list. + + Args: + packages: List of package names (possibly with duplicates or empty strings) + + Returns: + Cleaned list of unique package names + """ + if not packages: + return [] + + # Remove empty strings and strip whitespace + cleaned = [pkg.strip() for pkg in packages if pkg and pkg.strip()] + + # Remove duplicates while preserving order + seen = set() + unique_packages = [] + for pkg in cleaned: + pkg_lower = pkg.lower() + if pkg_lower not in seen: + seen.add(pkg_lower) + unique_packages.append(pkg) + + # Validate each package name + valid_packages = [pkg for pkg in unique_packages if validate_package_name(pkg)] + + return valid_packages diff --git a/src/fastapi_fastkit/backend/main.py b/src/fastapi_fastkit/backend/main.py index 0c6a27f..496d90e 100644 --- a/src/fastapi_fastkit/backend/main.py +++ b/src/fastapi_fastkit/backend/main.py @@ -308,6 +308,67 @@ def _process_pyproject_file( raise BackendExceptions(f"Failed to process pyproject.toml: {e}") +def update_setup_py_dependencies(project_dir: str, dependencies: List[str]) -> None: + """ + Update setup.py file's install_requires list with new dependencies. + + This function finds and updates the install_requires list in setup.py + to match the dependencies selected during interactive mode. + + :param project_dir: Path to the project directory + :param dependencies: List of dependency specifications + """ + setup_py_path = os.path.join(project_dir, "setup.py") + + if not os.path.exists(setup_py_path): + debug_log("setup.py not found, skipping dependency update", "info") + return + + try: + with open(setup_py_path, "r", encoding="utf-8") as f: + content = f.read() + + # Build the new install_requires list + deps_str = ",\n ".join(f'"{dep}"' for dep in dependencies) + + # Try to replace existing install_requires first (with type annotation) + pattern = r"install_requires:\s*list\[str\]\s*=\s*\[(.*?)\]" + if re.search(pattern, content, re.DOTALL): + content = re.sub( + pattern, + f"install_requires: list[str] = [\n {deps_str},\n]", + content, + flags=re.DOTALL, + ) + debug_log( + "Updated install_requires with type annotation in setup.py", "info" + ) + else: + # Fallback: try without type annotation + pattern_old = r"install_requires\s*=\s*\[(.*?)\]" + if re.search(pattern_old, content, re.DOTALL): + content = re.sub( + pattern_old, + f"install_requires = [\n {deps_str},\n]", + content, + flags=re.DOTALL, + ) + debug_log("Updated install_requires in setup.py", "info") + else: + debug_log("Could not find install_requires in setup.py", "warning") + return + + # Write updated content + with open(setup_py_path, "w", encoding="utf-8") as f: + f.write(content) + + print_info(f"Updated setup.py with {len(dependencies)} dependencies") + + except (OSError, UnicodeDecodeError) as e: + debug_log(f"Error updating setup.py dependencies: {e}", "error") + print_warning(f"Could not update setup.py: {e}") + + def create_venv_with_manager(project_dir: str, manager_type: str = "pip") -> str: """ Create a virtual environment using the specified package manager. @@ -405,6 +466,22 @@ def generate_dependency_file_with_manager( package_manager.generate_dependency_file( dependencies, project_name, author, author_email, description ) + + # Also generate requirements.txt for pip compatibility (if not using pip) + if manager_type != "pip": + from pathlib import Path + + requirements_path = Path(project_dir) / "requirements.txt" + try: + with open(requirements_path, "w", encoding="utf-8") as f: + for dep in dependencies: + f.write(f"{dep}\n") + debug_log("Generated requirements.txt for pip compatibility", "info") + except (OSError, UnicodeEncodeError) as e: + debug_log( + f"Warning: Could not generate requirements.txt: {e}", "warning" + ) + except Exception as e: debug_log(f"Error generating dependency file with {manager_type}: {e}", "error") raise BackendExceptions(f"Failed to generate dependency file: {str(e)}") diff --git a/src/fastapi_fastkit/backend/project_builder/__init__.py b/src/fastapi_fastkit/backend/project_builder/__init__.py new file mode 100644 index 0000000..4c15734 --- /dev/null +++ b/src/fastapi_fastkit/backend/project_builder/__init__.py @@ -0,0 +1,17 @@ +# -------------------------------------------------------------------------- +# Project Builder module for FastAPI-fastkit +# +# Provides project building functionality including: +# - Dependency collection and management +# - Dynamic configuration file generation +# - Template merging +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +from .config_generator import DynamicConfigGenerator +from .dependency_collector import DependencyCollector + +__all__ = [ + "DependencyCollector", + "DynamicConfigGenerator", +] diff --git a/src/fastapi_fastkit/backend/project_builder/config_generator.py b/src/fastapi_fastkit/backend/project_builder/config_generator.py new file mode 100644 index 0000000..c482a15 --- /dev/null +++ b/src/fastapi_fastkit/backend/project_builder/config_generator.py @@ -0,0 +1,594 @@ +# -------------------------------------------------------------------------- +# Dynamic configuration file generation based on user selections +# +# Generates: +# - main.py with selected features +# - Database configuration files +# - Authentication setup files +# - Docker files +# - Testing configuration +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + + +class DynamicConfigGenerator: + """ + Generates configuration files based on project config. + + This class generates various configuration and setup files + dynamically based on the user's feature selections. + """ + + def __init__(self, config: Dict[str, Any], project_dir: str) -> None: + """ + Initialize config generator. + + Args: + config: Project configuration dictionary + project_dir: Path to the project directory + """ + self.config = config + self.project_dir = Path(project_dir) + + @staticmethod + def _build_header(title: str) -> list[str]: + """ + Build a standard FastAPI-fastkit header comment. + + Args: + title: Title of the file + + Returns: + List of header lines + """ + return [ + "# --------------------------------------------------------------------------", + f"# {title}", + "# Generated by FastAPI-fastkit", + "# --------------------------------------------------------------------------", + ] + + @staticmethod + def _join_lines(lines: list[str]) -> str: + """ + Join lines with newline and ensure proper ending. + + Args: + lines: List of code lines + + Returns: + Joined string with newlines + """ + return "\n".join(lines) + "\n" + + @staticmethod + def _build_code_block(indent: str, *lines: str) -> list[str]: + """ + Build a code block with consistent indentation. + + Args: + indent: Indentation string (e.g., " " for 4 spaces) + lines: Lines of code to indent + + Returns: + List of indented lines + """ + return [f"{indent}{line}" if line else "" for line in lines] + + def generate_all_files(self) -> None: + """Generate all configuration files.""" + # This method will be called by the main CLI + # Individual file generation will be handled by specific methods + pass + + def generate_main_py(self) -> str: + """ + Generate main.py with selected features. + + Returns: + Content of main.py as string + """ + imports = [] + app_config = [] + startup_code = [] + middleware_code = [] + route_includes: List[str] = [] + + # Base imports + imports.append("from fastapi import FastAPI") + imports.append("from fastapi.middleware.cors import CORSMiddleware") + + # Database imports + db_type = self.config.get("database", {}).get("type", "None") + if db_type in ["PostgreSQL", "MySQL", "SQLite"]: + imports.append( + "from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession" + ) + imports.append("from sqlalchemy.orm import sessionmaker") + startup_code.append(" # Initialize database") + startup_code.append(" # await init_db()") + elif db_type == "MongoDB": + imports.append("from motor.motor_asyncio import AsyncIOMotorClient") + startup_code.append(" # Initialize MongoDB") + startup_code.append( + " # client = AsyncIOMotorClient(settings.MONGODB_URL)" + ) + + # Authentication imports + auth_type = self.config.get("authentication", "None") + if auth_type == "JWT": + imports.append("from fastapi.security import HTTPBearer") + elif auth_type == "FastAPI-Users": + imports.append("# FastAPI-Users setup will be in separate auth module") + + # CORS configuration + utilities = self.config.get("utilities", []) + if "CORS" in utilities: + middleware_code.append("# CORS middleware") + middleware_code.append("app.add_middleware(") + middleware_code.append(" CORSMiddleware,") + middleware_code.append( + " allow_origins=['*'], # Configure appropriately" + ) + middleware_code.append(" allow_credentials=True,") + middleware_code.append(" allow_methods=['*'],") + middleware_code.append(" allow_headers=['*'],") + middleware_code.append(")") + + # Rate limiting + if "Rate-Limiting" in utilities: + imports.append("from slowapi import Limiter, _rate_limit_exceeded_handler") + imports.append("from slowapi.util import get_remote_address") + imports.append("from slowapi.errors import RateLimitExceeded") + app_config.append("limiter = Limiter(key_func=get_remote_address)") + app_config.append("app.state.limiter = limiter") + app_config.append( + "app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)" + ) + + # Monitoring + monitoring_type = self.config.get("monitoring", "None") + if monitoring_type == "Loguru": + imports.append("from loguru import logger") + elif monitoring_type == "Prometheus": + imports.append( + "from prometheus_fastapi_instrumentator import Instrumentator" + ) + startup_code.append(" # Initialize Prometheus metrics") + startup_code.append(" Instrumentator().instrument(app).expose(app)") + + # Build main.py content + content_parts = [] + + # Imports section + content_parts.append( + "# --------------------------------------------------------------------------" + ) + content_parts.append("# FastAPI Application Main Entry Point") + content_parts.append("# Generated by FastAPI-fastkit") + content_parts.append( + "# --------------------------------------------------------------------------" + ) + content_parts.extend(imports) + content_parts.append("") + + # App initialization + project_name = self.config.get("project_name", "FastAPI App") + description = self.config.get("description", "") + content_parts.append(f"app = FastAPI(") + content_parts.append(f' title="{project_name}",') + content_parts.append(f' description="{description}",') + content_parts.append(f' version="0.1.0",') + content_parts.append(f")") + content_parts.append("") + + # App configuration + if app_config: + content_parts.extend(app_config) + content_parts.append("") + + # Middleware + if middleware_code: + content_parts.extend(middleware_code) + content_parts.append("") + + # Startup event + if startup_code: + content_parts.append("@app.on_event('startup')") + content_parts.append("async def startup_event():") + content_parts.extend(startup_code) + content_parts.append("") + + # Basic health check endpoint + content_parts.append("@app.get('/', tags=['Health'])") + content_parts.append("async def root():") + content_parts.append(" return {") + content_parts.append(f" 'message': 'Welcome to {project_name}',") + content_parts.append(" 'status': 'healthy',") + content_parts.append(" }") + content_parts.append("") + + content_parts.append("@app.get('/health', tags=['Health'])") + content_parts.append("async def health_check():") + content_parts.append(" return {'status': 'ok'}") + content_parts.append("") + + # Route includes (placeholder) + if route_includes: + content_parts.extend(route_includes) + content_parts.append("") + + return "\n".join(content_parts) + + def generate_database_config(self) -> Optional[str]: + """ + Generate database configuration. + + Returns: + Content of database config file or None + """ + db_type = self.config.get("database", {}).get("type", "None") + + if db_type == "None": + return None + + if db_type in ["PostgreSQL", "MySQL", "SQLite"]: + return self._generate_sqlalchemy_config(db_type) + elif db_type == "MongoDB": + return self._generate_mongodb_config() + + return None + + def _generate_sqlalchemy_config(self, db_type: str) -> str: + """Generate SQLAlchemy database configuration.""" + content = [] + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("# Database Configuration") + content.append("# Generated by FastAPI-fastkit") + content.append( + "# --------------------------------------------------------------------------" + ) + content.append( + "from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession" + ) + content.append("from sqlalchemy.orm import sessionmaker, declarative_base") + content.append("from pydantic_settings import BaseSettings") + content.append("") + content.append("class DatabaseSettings(BaseSettings):") + content.append(' """Database configuration settings."""') + + if db_type == "PostgreSQL": + content.append( + " DATABASE_URL: str = 'postgresql+asyncpg://user:password@localhost/dbname'" + ) + elif db_type == "MySQL": + content.append( + " DATABASE_URL: str = 'mysql+aiomysql://user:password@localhost/dbname'" + ) + elif db_type == "SQLite": + content.append(" DATABASE_URL: str = 'sqlite+aiosqlite:///./app.db'") + + content.append("") + content.append(" class Config:") + content.append(" env_file = '.env'") + content.append("") + content.append("settings = DatabaseSettings()") + content.append("") + content.append("# Create async engine") + content.append("engine = create_async_engine(") + content.append(" settings.DATABASE_URL,") + content.append(" echo=True,") + content.append(" future=True,") + content.append(")") + content.append("") + content.append("# Create async session factory") + content.append("AsyncSessionLocal = sessionmaker(") + content.append(" engine,") + content.append(" class_=AsyncSession,") + content.append(" expire_on_commit=False,") + content.append(")") + content.append("") + content.append("# Create base class for models") + content.append("Base = declarative_base()") + content.append("") + content.append("async def get_db():") + content.append(' """Dependency for getting async database session."""') + content.append(" async with AsyncSessionLocal() as session:") + content.append(" try:") + content.append(" yield session") + content.append(" await session.commit()") + content.append(" except Exception:") + content.append(" await session.rollback()") + content.append(" raise") + content.append("") + + return "\n".join(content) + + def _generate_mongodb_config(self) -> str: + """Generate MongoDB configuration.""" + content = [] + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("# MongoDB Configuration") + content.append("# Generated by FastAPI-fastkit") + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("from motor.motor_asyncio import AsyncIOMotorClient") + content.append("from pydantic_settings import BaseSettings") + content.append("") + content.append("class DatabaseSettings(BaseSettings):") + content.append(' """MongoDB configuration settings."""') + content.append(" MONGODB_URL: str = 'mongodb://localhost:27017'") + content.append(" MONGODB_DB_NAME: str = 'fastapi_db'") + content.append("") + content.append(" class Config:") + content.append(" env_file = '.env'") + content.append("") + content.append("settings = DatabaseSettings()") + content.append("") + content.append("# MongoDB client") + content.append("client = AsyncIOMotorClient(settings.MONGODB_URL)") + content.append("database = client[settings.MONGODB_DB_NAME]") + content.append("") + + return "\n".join(content) + + def generate_auth_config(self) -> Optional[str]: + """ + Generate authentication configuration. + + Returns: + Content of auth config file or None + """ + auth_type = self.config.get("authentication", "None") + + if auth_type == "None": + return None + + if auth_type == "JWT": + return self._generate_jwt_config() + elif auth_type == "FastAPI-Users": + return self._generate_fastapi_users_config() + + return None + + def _generate_jwt_config(self) -> str: + """Generate JWT authentication configuration.""" + content = [] + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("# JWT Authentication Configuration") + content.append("# Generated by FastAPI-fastkit") + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("from datetime import datetime, timedelta") + content.append("from typing import Optional") + content.append("from jose import JWTError, jwt") + content.append("from passlib.context import CryptContext") + content.append("from pydantic_settings import BaseSettings") + content.append("") + content.append("class AuthSettings(BaseSettings):") + content.append(' """Authentication configuration settings."""') + content.append( + " SECRET_KEY: str = 'your-secret-key-here-change-in-production'" + ) + content.append(" ALGORITHM: str = 'HS256'") + content.append(" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30") + content.append("") + content.append(" class Config:") + content.append(" env_file = '.env'") + content.append("") + content.append("settings = AuthSettings()") + content.append("") + content.append("# Password hashing") + content.append( + "pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')" + ) + content.append("") + content.append( + "def verify_password(plain_password: str, hashed_password: str) -> bool:" + ) + content.append(' """Verify a password against a hash."""') + content.append(" return pwd_context.verify(plain_password, hashed_password)") + content.append("") + content.append("def get_password_hash(password: str) -> str:") + content.append(' """Hash a password."""') + content.append(" return pwd_context.hash(password)") + content.append("") + content.append( + "def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:" + ) + content.append(' """Create JWT access token."""') + content.append(" to_encode = data.copy()") + content.append(" if expires_delta:") + content.append(" expire = datetime.utcnow() + expires_delta") + content.append(" else:") + content.append( + " expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)" + ) + content.append(" to_encode.update({'exp': expire})") + content.append( + " encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)" + ) + content.append(" return encoded_jwt") + content.append("") + + return "\n".join(content) + + def _generate_fastapi_users_config(self) -> str: + """Generate FastAPI-Users configuration.""" + content = [] + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("# FastAPI-Users Authentication Configuration") + content.append("# Generated by FastAPI-fastkit") + content.append("#") + content.append("# For full setup guide, visit:") + content.append("# https://fastapi-users.github.io/fastapi-users/") + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("# TODO: Implement FastAPI-Users setup") + content.append( + "# This requires database models, user manager, and auth backends" + ) + content.append("# See the official documentation for complete setup") + content.append("") + + return "\n".join(content) + + def generate_docker_files(self) -> None: + """Generate Dockerfile and docker-compose.yml.""" + deployment = self.config.get("deployment", []) + + if "Docker" in deployment: + dockerfile_content = self._generate_dockerfile() + dockerfile_path = self.project_dir / "Dockerfile" + with open(dockerfile_path, "w") as f: + f.write(dockerfile_content) + + if "docker-compose" in deployment: + compose_content = self._generate_docker_compose() + compose_path = self.project_dir / "docker-compose.yml" + with open(compose_path, "w") as f: + f.write(compose_content) + + def _generate_dockerfile(self) -> str: + """Generate Dockerfile content.""" + content = [] + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("# Dockerfile") + content.append("# Generated by FastAPI-fastkit") + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("FROM python:3.11-slim") + content.append("") + content.append("WORKDIR /app") + content.append("") + content.append("# Install dependencies") + content.append("COPY requirements.txt .") + content.append("RUN pip install --no-cache-dir -r requirements.txt") + content.append("") + content.append("# Copy application") + content.append("COPY . .") + content.append("") + content.append("# Run application") + content.append( + "CMD ['uvicorn', 'src.main:app', '--host', '0.0.0.0', '--port', '8000']" + ) + content.append("") + + return "\n".join(content) + + def _generate_docker_compose(self) -> str: + """Generate docker-compose.yml content.""" + content = [] + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("# docker-compose.yml") + content.append("# Generated by FastAPI-fastkit") + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("version: '3.8'") + content.append("") + content.append("services:") + content.append(" app:") + content.append(" build: .") + content.append(" ports:") + content.append(" - '8000:8000'") + content.append(" environment:") + content.append(" - DATABASE_URL=${DATABASE_URL}") + content.append(" depends_on:") + + db_type = self.config.get("database", {}).get("type", "None") + if db_type == "PostgreSQL": + content.append(" - db") + content.append("") + content.append(" db:") + content.append(" image: postgres:15") + content.append(" environment:") + content.append(" - POSTGRES_USER=user") + content.append(" - POSTGRES_PASSWORD=password") + content.append(" - POSTGRES_DB=dbname") + content.append(" ports:") + content.append(" - '5432:5432'") + content.append(" volumes:") + content.append(" - postgres_data:/var/lib/postgresql/data") + content.append("") + content.append("volumes:") + content.append(" postgres_data:") + elif db_type == "MongoDB": + content.append(" - mongodb") + content.append("") + content.append(" mongodb:") + content.append(" image: mongo:6") + content.append(" ports:") + content.append(" - '27017:27017'") + content.append(" volumes:") + content.append(" - mongo_data:/data/db") + content.append("") + content.append("volumes:") + content.append(" mongo_data:") + else: + content.append(" # Add database service if needed") + + content.append("") + + return "\n".join(content) + + def generate_test_config(self) -> Optional[str]: + """ + Generate pytest configuration. + + Returns: + Content of pytest.ini or None + """ + testing_type = self.config.get("testing", "None") + + if testing_type == "None": + return None + + content = [] + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("# pytest configuration") + content.append("# Generated by FastAPI-fastkit") + content.append( + "# --------------------------------------------------------------------------" + ) + content.append("[pytest]") + content.append("testpaths = tests") + content.append("python_files = test_*.py") + content.append("python_classes = Test*") + content.append("python_functions = test_*") + content.append("asyncio_mode = auto") + + if "Coverage" in testing_type or "Advanced" in testing_type: + content.append("") + content.append("[coverage:run]") + content.append("source = .") + content.append("omit = ") + content.append(" tests/*") + content.append(" .venv/*") + content.append(" */__pycache__/*") + + content.append("") + + return "\n".join(content) diff --git a/src/fastapi_fastkit/backend/project_builder/dependency_collector.py b/src/fastapi_fastkit/backend/project_builder/dependency_collector.py new file mode 100644 index 0000000..a9e1f62 --- /dev/null +++ b/src/fastapi_fastkit/backend/project_builder/dependency_collector.py @@ -0,0 +1,210 @@ +# -------------------------------------------------------------------------- +# Dependency collection and management +# +# Collects dependencies from: +# - Base template (if selected) +# - Database choice +# - Authentication method +# - Additional features +# - Custom packages +# +# Handles deduplication and version conflict detection. +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +from typing import Any, Dict, List, Set + + +class DependencyCollector: + """ + Collects and manages project dependencies. + + This class is responsible for gathering all dependencies from + various sources and ensuring they are properly deduplicated. + """ + + def __init__(self, settings: Any) -> None: + """ + Initialize dependency collector. + + Args: + settings: FastkitConfig instance + """ + self.settings = settings + self.dependencies: Set[str] = set() + + def collect_from_config(self, config: Dict[str, Any]) -> List[str]: + """ + Collect all dependencies from configuration. + + Args: + config: Project configuration dictionary + + Returns: + Sorted list of all dependencies + """ + # Reset dependencies + self.dependencies = set() + + # Add base dependencies + self.add_base_dependencies() + + # Add database dependencies + db_info = config.get("database", {}) + if isinstance(db_info, dict) and db_info.get("type") != "None": + self.add_database_dependencies(db_info.get("type", "")) + + # Add authentication dependencies + auth_type = config.get("authentication", "None") + if auth_type != "None": + self.add_authentication_dependencies(auth_type) + + # Add async tasks dependencies + tasks_type = config.get("async_tasks", "None") + if tasks_type != "None": + self.add_async_tasks_dependencies(tasks_type) + + # Add caching dependencies + cache_type = config.get("caching", "None") + if cache_type != "None": + self.add_caching_dependencies(cache_type) + + # Add monitoring dependencies + monitoring_type = config.get("monitoring", "None") + if monitoring_type != "None": + self.add_monitoring_dependencies(monitoring_type) + + # Add testing dependencies + testing_type = config.get("testing", "None") + if testing_type != "None": + self.add_testing_dependencies(testing_type) + + # Add utilities dependencies + utilities = config.get("utilities", []) + for util in utilities: + self.add_utility_dependencies(util) + + # Add custom packages + custom = config.get("custom_packages", []) + if custom: + self.dependencies.update(custom) + + return self.get_final_dependencies() + + def add_base_dependencies(self) -> None: + """Add core FastAPI dependencies.""" + self.dependencies.update( + [ + "fastapi", + "uvicorn", + "pydantic", + "pydantic-settings", + ] + ) + + def add_database_dependencies(self, db_type: str) -> None: + """ + Add database-specific dependencies. + + Args: + db_type: Database type name + """ + catalog = self.settings.PACKAGE_CATALOG.get("database", {}) + packages = catalog.get(db_type, []) + self.dependencies.update(packages) + + def add_authentication_dependencies(self, auth_type: str) -> None: + """ + Add authentication dependencies. + + Args: + auth_type: Authentication type name + """ + catalog = self.settings.PACKAGE_CATALOG.get("authentication", {}) + packages = catalog.get(auth_type, []) + self.dependencies.update(packages) + + def add_async_tasks_dependencies(self, tasks_type: str) -> None: + """ + Add async tasks dependencies. + + Args: + tasks_type: Task queue type name + """ + catalog = self.settings.PACKAGE_CATALOG.get("async_tasks", {}) + packages = catalog.get(tasks_type, []) + self.dependencies.update(packages) + + def add_caching_dependencies(self, cache_type: str) -> None: + """ + Add caching dependencies. + + Args: + cache_type: Caching type name + """ + catalog = self.settings.PACKAGE_CATALOG.get("caching", {}) + packages = catalog.get(cache_type, []) + self.dependencies.update(packages) + + def add_monitoring_dependencies(self, monitoring_type: str) -> None: + """ + Add monitoring dependencies. + + Args: + monitoring_type: Monitoring type name + """ + catalog = self.settings.PACKAGE_CATALOG.get("monitoring", {}) + packages = catalog.get(monitoring_type, []) + self.dependencies.update(packages) + + def add_testing_dependencies(self, testing_type: str) -> None: + """ + Add testing dependencies. + + Args: + testing_type: Testing framework type name + """ + catalog = self.settings.PACKAGE_CATALOG.get("testing", {}) + packages = catalog.get(testing_type, []) + self.dependencies.update(packages) + + def add_utility_dependencies(self, utility: str) -> None: + """ + Add utility dependencies. + + Args: + utility: Utility name + """ + catalog = self.settings.PACKAGE_CATALOG.get("utilities", {}) + packages = catalog.get(utility, []) + self.dependencies.update(packages) + + def add_feature_dependencies(self, features: List[str]) -> None: + """ + Add dependencies for selected features. + + Args: + features: List of feature names + """ + for feature in features: + # This is a generic method that can be extended + # Currently handled by specific methods above + pass + + def get_final_dependencies(self) -> List[str]: + """ + Return deduplicated, sorted dependency list. + + Returns: + Sorted list of all dependencies + """ + return sorted(list(self.dependencies)) + + def get_dependency_count(self) -> int: + """ + Get the total number of dependencies. + + Returns: + Count of unique dependencies + """ + return len(self.dependencies) diff --git a/src/fastapi_fastkit/cli.py b/src/fastapi_fastkit/cli.py index 152d743..1972e8b 100644 --- a/src/fastapi_fastkit/cli.py +++ b/src/fastapi_fastkit/cli.py @@ -8,13 +8,15 @@ import shutil import subprocess import sys -from typing import Union +from typing import Union, cast import click from click import Command, Context from rich import print from rich.panel import Panel +from fastapi_fastkit.backend.interactive import InteractiveConfigBuilder +from fastapi_fastkit.backend.interactive.selectors import display_feature_catalog from fastapi_fastkit.backend.main import ( add_new_route, ask_create_project_folder, @@ -156,6 +158,20 @@ def list_templates(ctx: Context) -> None: console.print(table) +@fastkit_cli.command() +@click.pass_context +def list_features(ctx: Context) -> None: + """ + Display the list of available features and packages in the catalog. + + Shows all available features that can be selected during interactive + project creation, along with their associated packages. + """ + settings = ctx.obj["settings"] + + display_feature_catalog(settings.PACKAGE_CATALOG, settings.FEATURE_DESCRIPTIONS) + + @fastkit_cli.command(context_settings={"ignore_unknown_options": True}) @click.argument("template", default="fastapi-default") @click.option( @@ -296,23 +312,26 @@ def startdemo( @fastkit_cli.command(context_settings={"ignore_unknown_options": True}) @click.option( - "--project-name", - prompt="Enter the project name", - help="The name of the new FastAPI project.", + "--interactive", + is_flag=True, + default=False, + help="Enable interactive mode for guided project setup with feature selection.", ) @click.option( - "--author", prompt="Enter the author name", help="The name of the project author." + "--project-name", + default=None, + help="The name of the new FastAPI project.", ) +@click.option("--author", default=None, help="The name of the project author.") @click.option( "--author-email", - prompt="Enter the author email", + default=None, help="The email of the project author.", type=str, - callback=validate_email, ) @click.option( "--description", - prompt="Enter the project description", + default=None, help="The description of the new FastAPI project.", ) @click.option( @@ -324,6 +343,7 @@ def startdemo( @click.pass_context def init( ctx: Context, + interactive: bool, project_name: str, author: str, author_email: str, @@ -331,15 +351,190 @@ def init( package_manager: str, ) -> None: """ - Start a empty FastAPI project setup. - - This command will automatically create a new FastAPI project directory and a python virtual environment. + Start a FastAPI project setup. - Dependencies will be automatically installed based on the selected stack at venv. + Use --interactive for guided setup with dynamic feature selection. + Without --interactive, creates an empty project with predefined stacks. - Project metadata will be injected to the project files. + This command will automatically create a new FastAPI project directory + and a python virtual environment. Dependencies will be automatically + installed based on the selected features or stack. """ settings = ctx.obj["settings"] + + # Interactive mode - use InteractiveConfigBuilder + if interactive: + print_info("Starting interactive project setup...") + + builder = InteractiveConfigBuilder(settings) + config = builder.run_interactive_flow() + + # User cancelled + if not config: + return + + # Extract configuration + project_name = cast(str, config.get("project_name", "")) + author = cast(str, config.get("author", "")) + author_email = cast(str, config.get("author_email", "")) + description = cast(str, config.get("description", "")) + package_manager = config.get( + "package_manager", settings.DEFAULT_PACKAGE_MANAGER + ) + all_dependencies = config.get("all_dependencies", []) + + # Check if project already exists + project_dir = os.path.join(settings.USER_WORKSPACE, project_name) + if os.path.exists(project_dir): + print_error(f"Error: Project '{project_name}' already exists.") + return + + # Ask user whether to create a new project folder + create_project_folder = ask_create_project_folder(project_name) + + try: + user_local = settings.USER_WORKSPACE + + # Use fastapi-empty template as base + template = "fastapi-empty" + template_dir = settings.FASTKIT_TEMPLATE_ROOT + target_template = os.path.join(template_dir, template) + + if not os.path.exists(target_template): + print_error( + f"Template '{template}' does not exist in '{template_dir}'." + ) + raise CLIExceptions( + f"Template '{template}' does not exist in '{template_dir}'." + ) + + # Deploy template + project_dir, _ = deploy_template_with_folder_option( + target_template, user_local, project_name, create_project_folder + ) + + # Inject project metadata + inject_project_metadata( + project_dir, project_name, author, author_email, description + ) + + # Generate dependency file with collected dependencies + generate_dependency_file_with_manager( + project_dir, + all_dependencies, + package_manager, + project_name, + author, + author_email, + description, + ) + + # Update setup.py install_requires with selected dependencies + from fastapi_fastkit.backend.main import update_setup_py_dependencies + + update_setup_py_dependencies(project_dir, all_dependencies) + + print_success( + f"Generated dependency file with {len(all_dependencies)} packages" + ) + + # Generate stack-specific code and configurations + from fastapi_fastkit.backend.project_builder.config_generator import ( + DynamicConfigGenerator, + ) + + generator = DynamicConfigGenerator(config, project_dir) + + # Generate main.py with selected features + main_py_content = generator.generate_main_py() + main_py_path = os.path.join(project_dir, "src", "main.py") + if not os.path.exists(main_py_path): + main_py_path = os.path.join(project_dir, "main.py") + + with open(main_py_path, "w") as f: + f.write(main_py_content) + + # Generate database configuration if selected + db_info = config.get("database", {}) + if isinstance(db_info, dict) and db_info.get("type") != "None": + db_config_content = generator.generate_database_config() + if db_config_content: + db_config_path = os.path.join( + project_dir, "src", "config", "database.py" + ) + os.makedirs(os.path.dirname(db_config_path), exist_ok=True) + with open(db_config_path, "w") as f: + f.write(db_config_content) + + # Generate auth configuration if selected + auth_type = config.get("authentication", "None") + if auth_type != "None": + auth_config_content = generator.generate_auth_config() + if auth_config_content: + auth_config_path = os.path.join( + project_dir, "src", "config", "auth.py" + ) + os.makedirs(os.path.dirname(auth_config_path), exist_ok=True) + with open(auth_config_path, "w") as f: + f.write(auth_config_content) + + # Generate test configuration if testing selected + testing_type = config.get("testing", "None") + if testing_type != "None": + test_config_content = generator.generate_test_config() + if test_config_content: + test_config_path = os.path.join(project_dir, "tests", "conftest.py") + os.makedirs(os.path.dirname(test_config_path), exist_ok=True) + with open(test_config_path, "w") as f: + f.write(test_config_content) + + # Generate Docker files if deployment selected + deployment = config.get("deployment", []) + if deployment and deployment != ["None"]: + generator.generate_docker_files() + print_success(f"Generated Docker deployment files") + + print_success(f"Generated configuration files for selected stack") + + # Create virtual environment and install dependencies + venv_path = create_venv_with_manager(project_dir, package_manager) + install_dependencies_with_manager(project_dir, venv_path, package_manager) + + success_message = get_deployment_success_message( + template, project_name, user_local, create_project_folder + ) + print_success(success_message) + + print_info( + "To start your project, run 'fastkit runserver' at newly created FastAPI project directory" + ) + + except Exception as e: + if settings.DEBUG_MODE: + logger = get_logger() + logger.exception(f"Error during project creation in init: {str(e)}") + print_error(f"Error during project creation: {str(e)}") + if os.path.exists(project_dir): + shutil.rmtree(project_dir, ignore_errors=True) + + return + + # Non-interactive mode (original behavior) + # Require parameters if not interactive + if not project_name: + project_name = click.prompt("Enter the project name", type=str) + if not author: + author = click.prompt("Enter the author name", type=str) + if not author_email: + while True: + author_email = click.prompt("Enter the author email", type=str) + # Simple validation + if "@" in author_email and "." in author_email: + break + print_error("Invalid email format. Please try again.") + if not description: + description = click.prompt("Enter the project description", type=str) + project_dir = os.path.join(settings.USER_WORKSPACE, project_name) if os.path.exists(project_dir): diff --git a/src/fastapi_fastkit/core/settings.py b/src/fastapi_fastkit/core/settings.py index 577fe43..a0eaa14 100644 --- a/src/fastapi_fastkit/core/settings.py +++ b/src/fastapi_fastkit/core/settings.py @@ -94,6 +94,80 @@ class FastkitConfig: }, } + # Package Catalog for Interactive Mode (v1.2.0+) + # Based on recommendations from: https://github.com/mjhea0/awesome-fastapi + PACKAGE_CATALOG: dict[str, dict[str, list[str]]] = { + "database": { + "PostgreSQL": ["psycopg2-binary", "asyncpg", "sqlalchemy", "alembic"], + "MySQL": ["pymysql", "aiomysql", "sqlalchemy", "alembic"], + "MongoDB": ["motor", "beanie"], + "Redis": ["redis[hiredis]", "aioredis"], + "SQLite": ["sqlalchemy", "alembic"], + "None": [], + }, + "authentication": { + "JWT": ["python-jose[cryptography]", "passlib[bcrypt]"], + "OAuth2": ["authlib"], + "FastAPI-Users": [ + "fastapi-users[sqlalchemy]", + "python-jose[cryptography]", + "passlib[bcrypt]", + ], + "Session-based": ["itsdangerous"], + "None": [], + }, + "async_tasks": { + "Celery": ["celery[redis]", "redis"], + "Dramatiq": ["dramatiq[redis]", "redis"], + "None": [], + }, + "testing": { + "Basic": ["pytest", "pytest-asyncio", "httpx"], + "Coverage": ["pytest", "pytest-asyncio", "pytest-cov", "httpx"], + "Advanced": [ + "pytest", + "pytest-asyncio", + "pytest-cov", + "httpx", + "faker", + "factory-boy", + ], + "None": [], + }, + "caching": { + "Redis": ["redis[hiredis]", "fastapi-cache2"], + "None": [], + }, + "monitoring": { + "Loguru": ["loguru"], + "OpenTelemetry": [ + "opentelemetry-api", + "opentelemetry-sdk", + "opentelemetry-instrumentation-fastapi", + ], + "Prometheus": ["prometheus-client", "prometheus-fastapi-instrumentator"], + "None": [], + }, + "utilities": { + "CORS": [], # Built-in to FastAPI + "Rate-Limiting": ["slowapi"], + "Pagination": ["fastapi-pagination"], + "WebSocket": [], # Built-in to FastAPI + "None": [], + }, + } + + # Feature descriptions for display in interactive mode + FEATURE_DESCRIPTIONS: dict[str, str] = { + "database": "Database and ORM selection", + "authentication": "User authentication and authorization", + "async_tasks": "Background task processing", + "testing": "Testing framework and tools", + "caching": "Response and data caching", + "monitoring": "Application monitoring and logging", + "utilities": "Additional utilities and middleware", + } + # Testing Options TEST_SERVER_PORT: int = 8000 TEST_DEFAULT_TERMINAL_WIDTH: int = 80 diff --git a/tests/test_backends/test_interactive_config_builder.py b/tests/test_backends/test_interactive_config_builder.py new file mode 100644 index 0000000..1edab41 --- /dev/null +++ b/tests/test_backends/test_interactive_config_builder.py @@ -0,0 +1,437 @@ +# -------------------------------------------------------------------------- +# Test cases for backend/interactive/config_builder.py +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +from unittest.mock import MagicMock, call, patch + +import pytest + +from fastapi_fastkit.backend.interactive.config_builder import ( + InteractiveConfigBuilder, +) +from fastapi_fastkit.core.settings import FastkitConfig + + +class TestInteractiveConfigBuilderInitialization: + """Test cases for InteractiveConfigBuilder initialization.""" + + def test_initialization_with_settings(self) -> None: + """Test InteractiveConfigBuilder initialization.""" + # given + settings = FastkitConfig() + + # when + builder = InteractiveConfigBuilder(settings) + + # then + assert builder.settings is not None + assert isinstance(builder.config, dict) + assert len(builder.config) == 0 + + def test_initialization_config_empty(self) -> None: + """Test that config starts empty.""" + # given + settings = FastkitConfig() + + # when + builder = InteractiveConfigBuilder(settings) + + # then + assert builder.config == {} + + +class TestCollectAllDependencies: + """Test cases for _collect_all_dependencies method.""" + + def test_collect_dependencies_minimal(self) -> None: + """Test dependency collection with minimal config.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + builder.config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = builder._collect_all_dependencies() + + # then + assert "fastapi" in dependencies + assert "uvicorn" in dependencies + assert "pydantic" in dependencies + assert "pydantic-settings" in dependencies + + def test_collect_dependencies_with_database(self) -> None: + """Test dependency collection includes database packages.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + builder.config = { + "database": {"type": "PostgreSQL", "packages": ["sqlalchemy", "asyncpg"]}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = builder._collect_all_dependencies() + + # then + assert "sqlalchemy" in dependencies + assert "asyncpg" in dependencies + + def test_collect_dependencies_with_authentication(self) -> None: + """Test dependency collection includes authentication packages.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + builder.config = { + "database": {"type": "None"}, + "authentication": "JWT", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = builder._collect_all_dependencies() + + # then + assert "python-jose[cryptography]" in dependencies + assert "passlib[bcrypt]" in dependencies + + def test_collect_dependencies_deduplication(self) -> None: + """Test that dependencies are deduplicated.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + builder.config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": ["fastapi", "uvicorn"], # Duplicates base deps + } + + # when + dependencies = builder._collect_all_dependencies() + + # then + # Should only appear once + assert dependencies.count("fastapi") == 1 + assert dependencies.count("uvicorn") == 1 + + def test_collect_dependencies_sorted(self) -> None: + """Test that dependencies are sorted alphabetically.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + builder.config = { + "database": {"type": "PostgreSQL", "packages": ["sqlalchemy", "asyncpg"]}, + "authentication": "JWT", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": ["zebra", "aardvark"], + } + + # when + dependencies = builder._collect_all_dependencies() + + # then + sorted_deps = sorted(dependencies) + assert dependencies == sorted_deps + + def test_collect_dependencies_with_utilities(self) -> None: + """Test dependency collection with utilities.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + builder.config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": ["Rate-Limiting"], + "custom_packages": [], + } + + # when + dependencies = builder._collect_all_dependencies() + + # then + assert "slowapi" in dependencies + + +class TestBuildFinalConfig: + """Test cases for _build_final_config method.""" + + def test_build_final_config_adds_dependencies(self) -> None: + """Test that build_final_config adds all_dependencies to config.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + builder.config = { + "project_name": "test-project", + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + final_config = builder._build_final_config() + + # then + assert "all_dependencies" in final_config + assert isinstance(final_config["all_dependencies"], list) + assert len(final_config["all_dependencies"]) > 0 + + def test_build_final_config_preserves_existing_config(self) -> None: + """Test that build_final_config preserves existing configuration.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + builder.config = { + "project_name": "my-project", + "author": "Test Author", + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + final_config = builder._build_final_config() + + # then + assert final_config["project_name"] == "my-project" + assert final_config["author"] == "Test Author" + + +class TestRunInteractiveFlow: + """Test cases for run_interactive_flow method with mocked prompts.""" + + @patch("fastapi_fastkit.backend.interactive.config_builder.confirm_selections") + @patch( + "fastapi_fastkit.backend.interactive.config_builder.validate_feature_compatibility" + ) + @patch( + "fastapi_fastkit.backend.interactive.config_builder.prompt_additional_features" + ) + @patch( + "fastapi_fastkit.backend.interactive.config_builder.prompt_template_selection" + ) + @patch("fastapi_fastkit.backend.interactive.config_builder.prompt_basic_info") + def test_run_interactive_flow_full_journey( + self, + mock_basic_info, + mock_template, + mock_features, + mock_validate, + mock_confirm, + ) -> None: + """Test complete interactive flow from start to finish.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + + mock_basic_info.return_value = { + "project_name": "test-project", + "author": "Test Author", + "author_email": "test@example.com", + "description": "Test description", + } + mock_template.return_value = None # Empty template + mock_features.return_value = { + "database": {"type": "PostgreSQL", "packages": ["sqlalchemy", "asyncpg"]}, + "authentication": "JWT", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "Basic", + "utilities": [], + "deployment": [], + "package_manager": "uv", + "custom_packages": [], + } + mock_validate.return_value = (True, None) + mock_confirm.return_value = True + + # when + config = builder.run_interactive_flow() + + # then + assert config is not None + assert config["project_name"] == "test-project" + assert config["author"] == "Test Author" + assert config["authentication"] == "JWT" + assert "all_dependencies" in config + + @patch("fastapi_fastkit.backend.interactive.config_builder.confirm_selections") + @patch( + "fastapi_fastkit.backend.interactive.config_builder.validate_feature_compatibility" + ) + @patch( + "fastapi_fastkit.backend.interactive.config_builder.prompt_additional_features" + ) + @patch( + "fastapi_fastkit.backend.interactive.config_builder.prompt_template_selection" + ) + @patch("fastapi_fastkit.backend.interactive.config_builder.prompt_basic_info") + def test_run_interactive_flow_user_cancels( + self, + mock_basic_info, + mock_template, + mock_features, + mock_validate, + mock_confirm, + ) -> None: + """Test interactive flow when user cancels.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + + mock_basic_info.return_value = { + "project_name": "test-project", + "author": "Test Author", + "author_email": "test@example.com", + "description": "Test", + } + mock_template.return_value = None + mock_features.return_value = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "deployment": [], + "package_manager": "uv", + "custom_packages": [], + } + mock_validate.return_value = (True, None) + mock_confirm.return_value = False # User cancels + + # when + config = builder.run_interactive_flow() + + # then + assert config == {} + + @patch("fastapi_fastkit.backend.interactive.config_builder.confirm_selections") + @patch( + "fastapi_fastkit.backend.interactive.config_builder.validate_feature_compatibility" + ) + @patch( + "fastapi_fastkit.backend.interactive.config_builder.prompt_additional_features" + ) + @patch( + "fastapi_fastkit.backend.interactive.config_builder.prompt_template_selection" + ) + @patch("fastapi_fastkit.backend.interactive.config_builder.prompt_basic_info") + def test_run_interactive_flow_with_warnings( + self, + mock_basic_info, + mock_template, + mock_features, + mock_validate, + mock_confirm, + ) -> None: + """Test interactive flow with feature compatibility warnings.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + + mock_basic_info.return_value = { + "project_name": "test-project", + "author": "Test", + "author_email": "test@example.com", + "description": "Test", + } + mock_template.return_value = None + mock_features.return_value = { + "database": {"type": "None"}, + "authentication": "FastAPI-Users", # Requires database + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "deployment": [], + "package_manager": "uv", + "custom_packages": [], + } + mock_validate.return_value = (True, "FastAPI-Users requires a database") + mock_confirm.return_value = True + + # when + config = builder.run_interactive_flow() + + # then + assert config is not None + assert config["authentication"] == "FastAPI-Users" + + +class TestGetConfig: + """Test cases for get_config method.""" + + def test_get_config_returns_current_config(self) -> None: + """Test that get_config returns current configuration.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + builder.config = { + "project_name": "test", + "author": "Test Author", + } + + # when + config = builder.get_config() + + # then + assert config["project_name"] == "test" + assert config["author"] == "Test Author" + + def test_get_config_empty_initially(self) -> None: + """Test that get_config returns empty dict initially.""" + # given + settings = FastkitConfig() + builder = InteractiveConfigBuilder(settings) + + # when + config = builder.get_config() + + # then + assert config == {} diff --git a/tests/test_backends/test_interactive_prompts.py b/tests/test_backends/test_interactive_prompts.py new file mode 100644 index 0000000..005acf1 --- /dev/null +++ b/tests/test_backends/test_interactive_prompts.py @@ -0,0 +1,188 @@ +# -------------------------------------------------------------------------- +# Test cases for backend/interactive/prompts.py +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +from unittest.mock import MagicMock, patch + +import pytest + +from fastapi_fastkit.backend.interactive import prompts +from fastapi_fastkit.core.settings import FastkitConfig + + +class TestPromptBasicInfo: + """Test cases for prompt_basic_info function.""" + + @patch("fastapi_fastkit.backend.interactive.prompts.click.prompt") + @patch("fastapi_fastkit.backend.interactive.prompts.validate_project_name") + @patch("fastapi_fastkit.backend.interactive.prompts.validate_email_format") + def test_prompt_basic_info_valid_inputs( + self, mock_email_val, mock_name_val, mock_prompt + ) -> None: + """Test prompt_basic_info with valid inputs.""" + # given + mock_prompt.side_effect = [ + "test-project", + "Test Author", + "test@example.com", + "A test description", + ] + mock_name_val.return_value = (True, None) + mock_email_val.return_value = True + + # when + result = prompts.prompt_basic_info() + + # then + assert result["project_name"] == "test-project" + assert result["author"] == "Test Author" + assert result["author_email"] == "test@example.com" + assert result["description"] == "A test description" + + +class TestPromptTemplateSelection: + """Test cases for prompt_template_selection function.""" + + @patch("fastapi_fastkit.backend.interactive.prompts.click.prompt") + @patch("fastapi_fastkit.backend.interactive.prompts.render_selection_table") + def test_prompt_template_selection_empty_project( + self, mock_render, mock_prompt + ) -> None: + """Test selecting empty project template.""" + # given + settings = FastkitConfig() + mock_prompt.return_value = 1 # First option (Empty Project) + + # when + result = prompts.prompt_template_selection(settings) + + # then + # Empty project returns None + assert result is None or isinstance(result, str) + + +class TestPromptDatabaseSelection: + """Test cases for prompt_database_selection function.""" + + @patch("fastapi_fastkit.backend.interactive.prompts.click.prompt") + def test_prompt_database_selection_postgresql(self, mock_prompt) -> None: + """Test selecting PostgreSQL database.""" + # given + settings = FastkitConfig() + # Assuming PostgreSQL is first in catalog + mock_prompt.return_value = 1 + + # when + result = prompts.prompt_database_selection(settings) + + # then + assert "type" in result + assert "packages" in result + + +class TestPromptAuthenticationSelection: + """Test cases for prompt_authentication_selection function.""" + + @patch("fastapi_fastkit.backend.interactive.prompts.click.prompt") + def test_prompt_authentication_selection(self, mock_prompt) -> None: + """Test selecting authentication method.""" + # given + settings = FastkitConfig() + mock_prompt.return_value = 1 # First option + + # when + result = prompts.prompt_authentication_selection(settings) + + # then + assert isinstance(result, str) + + +class TestPromptPackageManagerSelection: + """Test cases for prompt_package_manager_selection function.""" + + @patch("fastapi_fastkit.backend.interactive.prompts.click.prompt") + def test_prompt_package_manager_selection(self, mock_prompt) -> None: + """Test selecting package manager.""" + # given + settings = FastkitConfig() + mock_prompt.return_value = 1 + + # when + result = prompts.prompt_package_manager_selection(settings) + + # then + assert isinstance(result, str) + assert result in ["pip", "uv", "pdm", "poetry"] + + +class TestPromptCustomPackages: + """Test cases for prompt_custom_packages function.""" + + @patch("fastapi_fastkit.backend.interactive.prompts.console.input") + def test_prompt_custom_packages_with_input(self, mock_input) -> None: + """Test entering custom packages.""" + # given + mock_input.return_value = "requests, aiohttp, httpx" + + # when + result = prompts.prompt_custom_packages() + + # then + assert isinstance(result, list) + assert "requests" in result + assert "aiohttp" in result + assert "httpx" in result + + @patch("fastapi_fastkit.backend.interactive.prompts.console.input") + def test_prompt_custom_packages_empty(self, mock_input) -> None: + """Test skipping custom packages.""" + # given + mock_input.return_value = "" + + # when + result = prompts.prompt_custom_packages() + + # then + assert result == [] + + +class TestPromptDeploymentOptions: + """Test cases for prompt_deployment_options function.""" + + @patch("fastapi_fastkit.backend.interactive.prompts.click.prompt") + def test_prompt_deployment_options_none(self, mock_prompt) -> None: + """Test selecting no deployment options.""" + # given + mock_prompt.return_value = 3 # "None" option + + # when + result = prompts.prompt_deployment_options() + + # then + assert result == [] + + @patch("fastapi_fastkit.backend.interactive.prompts.click.prompt") + def test_prompt_deployment_options_docker(self, mock_prompt) -> None: + """Test selecting Docker deployment.""" + # given + mock_prompt.return_value = 1 # "Docker" option + + # when + result = prompts.prompt_deployment_options() + + # then + assert "Docker" in result + + @patch("fastapi_fastkit.backend.interactive.prompts.click.prompt") + def test_prompt_deployment_options_docker_compose(self, mock_prompt) -> None: + """Test selecting docker-compose deployment.""" + # given + mock_prompt.return_value = 2 # "docker-compose" option + + # when + result = prompts.prompt_deployment_options() + + # then + assert "Docker" in result + assert "docker-compose" in result diff --git a/tests/test_backends/test_interactive_selectors.py b/tests/test_backends/test_interactive_selectors.py new file mode 100644 index 0000000..323c42d --- /dev/null +++ b/tests/test_backends/test_interactive_selectors.py @@ -0,0 +1,231 @@ +# -------------------------------------------------------------------------- +# Test cases for backend/interactive/selectors.py +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +from unittest.mock import MagicMock, patch + +import pytest + +from fastapi_fastkit.backend.interactive.selectors import ( + confirm_selections, + display_feature_catalog, + multi_select_prompt, + render_feature_options, + render_selection_table, +) +from fastapi_fastkit.core.settings import FastkitConfig + + +class TestRenderSelectionTable: + """Test cases for render_selection_table function.""" + + @patch("fastapi_fastkit.backend.interactive.selectors.console.print") + def test_render_selection_table_with_numbers(self, mock_print) -> None: + """Test rendering selection table with numbers.""" + # given + title = "Test Options" + options = {"option1": "Description 1", "option2": "Description 2"} + + # when + render_selection_table(title, options, show_numbers=True) + + # then + assert mock_print.called + + @patch("fastapi_fastkit.backend.interactive.selectors.console.print") + def test_render_selection_table_without_numbers(self, mock_print) -> None: + """Test rendering selection table without numbers.""" + # given + title = "Test Options" + options = {"option1": "Description 1"} + + # when + render_selection_table(title, options, show_numbers=False) + + # then + assert mock_print.called + + +class TestRenderFeatureOptions: + """Test cases for render_feature_options function.""" + + @patch("fastapi_fastkit.backend.interactive.selectors.console.print") + def test_render_feature_options(self, mock_print) -> None: + """Test rendering feature options.""" + # given + category = "Database" + options = ["PostgreSQL", "MySQL", "MongoDB"] + descriptions = { + "PostgreSQL": "PostgreSQL database", + "MySQL": "MySQL database", + "MongoDB": "MongoDB NoSQL", + } + + # when + render_feature_options(category, options, descriptions) + + # then + assert ( + mock_print.call_count >= 3 + ) # Category + instruction + at least one option + + +class TestMultiSelectPrompt: + """Test cases for multi_select_prompt function.""" + + @patch("fastapi_fastkit.backend.interactive.selectors.console.input") + @patch("fastapi_fastkit.backend.interactive.selectors.console.print") + def test_multi_select_prompt_single_selection(self, mock_print, mock_input) -> None: + """Test multi-select with single selection.""" + # given + title = "Select utilities" + options = ["CORS", "Rate-Limiting", "Pagination"] + descriptions = {} + mock_input.return_value = "1" + + # when + result = multi_select_prompt(title, options, descriptions) + + # then + assert result == [0] # 0-based index + + @patch("fastapi_fastkit.backend.interactive.selectors.console.input") + @patch("fastapi_fastkit.backend.interactive.selectors.console.print") + def test_multi_select_prompt_multiple_selections( + self, mock_print, mock_input + ) -> None: + """Test multi-select with multiple selections.""" + # given + title = "Select utilities" + options = ["CORS", "Rate-Limiting", "Pagination"] + descriptions = {} + mock_input.return_value = "1,3" + + # when + result = multi_select_prompt(title, options, descriptions) + + # then + assert 0 in result + assert 2 in result + + @patch("fastapi_fastkit.backend.interactive.selectors.console.input") + @patch("fastapi_fastkit.backend.interactive.selectors.console.print") + def test_multi_select_prompt_skip(self, mock_print, mock_input) -> None: + """Test multi-select with skip (empty input).""" + # given + title = "Select utilities" + options = ["CORS", "Rate-Limiting"] + descriptions = {} + mock_input.return_value = "" + + # when + result = multi_select_prompt(title, options, descriptions) + + # then + assert result == [] + + @patch("fastapi_fastkit.backend.interactive.selectors.console.input") + @patch("fastapi_fastkit.backend.interactive.selectors.console.print") + def test_multi_select_prompt_invalid_then_valid( + self, mock_print, mock_input + ) -> None: + """Test multi-select with invalid input followed by valid input.""" + # given + title = "Select utilities" + options = ["CORS", "Rate-Limiting"] + descriptions = {} + mock_input.side_effect = ["invalid", "1"] + + # when + result = multi_select_prompt(title, options, descriptions) + + # then + assert result == [0] + + +class TestConfirmSelections: + """Test cases for confirm_selections function.""" + + @patch("fastapi_fastkit.backend.interactive.selectors.console.input") + @patch("fastapi_fastkit.backend.interactive.selectors.console.print") + def test_confirm_selections_yes(self, mock_print, mock_input) -> None: + """Test confirming selections with 'y'.""" + # given + config = { + "project_name": "test-project", + "author": "Test Author", + "author_email": "test@example.com", + "description": "Test", + "database": {"type": "PostgreSQL"}, + "authentication": "JWT", + "package_manager": "uv", + "all_dependencies": ["fastapi", "uvicorn"], + } + mock_input.return_value = "y" + + # when + result = confirm_selections(config) + + # then + assert result is True + + @patch("fastapi_fastkit.backend.interactive.selectors.console.input") + @patch("fastapi_fastkit.backend.interactive.selectors.console.print") + def test_confirm_selections_empty_yes(self, mock_print, mock_input) -> None: + """Test confirming selections with empty input (default yes).""" + # given + config = { + "project_name": "test-project", + "database": {"type": "None"}, + "authentication": "None", + "package_manager": "uv", + "all_dependencies": [], + } + mock_input.return_value = "" + + # when + result = confirm_selections(config) + + # then + assert result is True + + @patch("fastapi_fastkit.backend.interactive.selectors.console.input") + @patch("fastapi_fastkit.backend.interactive.selectors.console.print") + def test_confirm_selections_no(self, mock_print, mock_input) -> None: + """Test rejecting selections with 'n'.""" + # given + config = { + "project_name": "test-project", + "database": {"type": "None"}, + "authentication": "None", + "package_manager": "uv", + "all_dependencies": [], + } + mock_input.return_value = "n" + + # when + result = confirm_selections(config) + + # then + assert result is False + + +class TestDisplayFeatureCatalog: + """Test cases for display_feature_catalog function.""" + + @patch("fastapi_fastkit.backend.interactive.selectors.console.print") + def test_display_feature_catalog(self, mock_print) -> None: + """Test displaying feature catalog.""" + # given + settings = FastkitConfig() + catalog = settings.PACKAGE_CATALOG + descriptions = settings.FEATURE_DESCRIPTIONS + + # when + display_feature_catalog(catalog, descriptions) + + # then + # Should print tables for each category + assert mock_print.called + assert mock_print.call_count > 5 # Multiple categories diff --git a/tests/test_backends/test_interactive_validators.py b/tests/test_backends/test_interactive_validators.py new file mode 100644 index 0000000..af1f97b --- /dev/null +++ b/tests/test_backends/test_interactive_validators.py @@ -0,0 +1,579 @@ +# -------------------------------------------------------------------------- +# Test cases for backend/interactive/validators.py +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import pytest + +from fastapi_fastkit.backend.interactive.validators import ( + sanitize_custom_packages, + validate_email_format, + validate_feature_compatibility, + validate_package_name, + validate_project_name, +) + + +class TestValidatePackageName: + """Test cases for validate_package_name function.""" + + def test_valid_simple_package_name(self) -> None: + """Test validation of simple valid package name.""" + # given + package_name = "fastapi" + + # when + result = validate_package_name(package_name) + + # then + assert result is True + + def test_valid_package_name_with_hyphen(self) -> None: + """Test validation of package name with hyphen.""" + # given + package_name = "fastapi-users" + + # when + result = validate_package_name(package_name) + + # then + assert result is True + + def test_valid_package_name_with_underscore(self) -> None: + """Test validation of package name with underscore.""" + # given + package_name = "fastapi_cache" + + # when + result = validate_package_name(package_name) + + # then + assert result is True + + def test_valid_package_name_with_version(self) -> None: + """Test validation of package name with version specifier.""" + # given + package_name = "fastapi>=0.100.0" + + # when + result = validate_package_name(package_name) + + # then + assert result is True + + def test_valid_package_name_with_extras(self) -> None: + """Test validation of package name with extras.""" + # given + package_name = "uvicorn[standard]" + + # when + result = validate_package_name(package_name) + + # then + assert result is True + + def test_valid_package_name_with_version_and_extras(self) -> None: + """Test validation of package name with both version and extras.""" + # given + package_name = "python-jose[cryptography]>=3.0.0" + + # when + result = validate_package_name(package_name) + + # then + assert result is True + + def test_invalid_package_name_empty_string(self) -> None: + """Test validation fails for empty string.""" + # given + package_name = "" + + # when + result = validate_package_name(package_name) + + # then + assert result is False + + def test_invalid_package_name_only_whitespace(self) -> None: + """Test validation fails for whitespace only.""" + # given + package_name = " " + + # when + result = validate_package_name(package_name) + + # then + assert result is False + + def test_invalid_package_name_starts_with_special_char(self) -> None: + """Test validation fails for package starting with special character.""" + # given + package_name = "-fastapi" + + # when + result = validate_package_name(package_name) + + # then + assert result is False + + def test_valid_package_name_with_dots(self) -> None: + """Test validation of package name with dots.""" + # given + package_name = "backports.zoneinfo" + + # when + result = validate_package_name(package_name) + + # then + assert result is True + + +class TestValidateProjectName: + """Test cases for validate_project_name function.""" + + def test_valid_simple_project_name(self) -> None: + """Test validation of simple valid project name.""" + # given + project_name = "myproject" + + # when + is_valid, error = validate_project_name(project_name) + + # then + assert is_valid is True + assert error is None + + def test_valid_project_name_with_hyphen(self) -> None: + """Test validation of project name with hyphen.""" + # given + project_name = "my-awesome-project" + + # when + is_valid, error = validate_project_name(project_name) + + # then + assert is_valid is True + assert error is None + + def test_valid_project_name_with_underscore(self) -> None: + """Test validation of project name with underscore.""" + # given + project_name = "my_project_name" + + # when + is_valid, error = validate_project_name(project_name) + + # then + assert is_valid is True + assert error is None + + def test_valid_project_name_mixed_case(self) -> None: + """Test validation of project name with mixed case.""" + # given + project_name = "MyProject" + + # when + is_valid, error = validate_project_name(project_name) + + # then + assert is_valid is True + assert error is None + + def test_invalid_project_name_empty_string(self) -> None: + """Test validation fails for empty string.""" + # given + project_name = "" + + # when + is_valid, error = validate_project_name(project_name) + + # then + assert is_valid is False + assert error is not None + assert "cannot be empty" in error + + def test_invalid_project_name_starts_with_number(self) -> None: + """Test validation fails when project name starts with number.""" + # given + project_name = "123project" + + # when + is_valid, error = validate_project_name(project_name) + + # then + assert is_valid is False + assert error is not None + assert "cannot start with a number" in error + + def test_invalid_project_name_with_special_chars(self) -> None: + """Test validation fails for special characters.""" + # given + project_name = "my@project!" + + # when + is_valid, error = validate_project_name(project_name) + + # then + assert is_valid is False + assert error is not None + assert "letters, numbers, hyphens, and underscores" in error + + def test_invalid_project_name_python_keyword(self) -> None: + """Test validation fails for Python keywords.""" + # given + project_name = "import" + + # when + is_valid, error = validate_project_name(project_name) + + # then + assert is_valid is False + assert error is not None + assert "Python keyword" in error + + def test_invalid_project_name_another_keyword(self) -> None: + """Test validation fails for another Python keyword.""" + # given + project_name = "class" + + # when + is_valid, error = validate_project_name(project_name) + + # then + assert is_valid is False + assert error is not None + assert "Python keyword" in error + + +class TestValidateEmailFormat: + """Test cases for validate_email_format function.""" + + def test_valid_simple_email(self) -> None: + """Test validation of simple valid email.""" + # given + email = "user@example.com" + + # when + result = validate_email_format(email) + + # then + assert result is True + + def test_valid_email_with_subdomain(self) -> None: + """Test validation of email with subdomain.""" + # given + email = "user@mail.example.com" + + # when + result = validate_email_format(email) + + # then + assert result is True + + def test_valid_email_with_plus(self) -> None: + """Test validation of email with plus sign.""" + # given + email = "user+tag@example.com" + + # when + result = validate_email_format(email) + + # then + assert result is True + + def test_valid_email_with_dots(self) -> None: + """Test validation of email with dots in username.""" + # given + email = "first.last@example.com" + + # when + result = validate_email_format(email) + + # then + assert result is True + + def test_valid_email_with_numbers(self) -> None: + """Test validation of email with numbers.""" + # given + email = "user123@example.com" + + # when + result = validate_email_format(email) + + # then + assert result is True + + def test_invalid_email_no_at_sign(self) -> None: + """Test validation fails for email without @ sign.""" + # given + email = "userexample.com" + + # when + result = validate_email_format(email) + + # then + assert result is False + + def test_invalid_email_no_domain(self) -> None: + """Test validation fails for email without domain.""" + # given + email = "user@" + + # when + result = validate_email_format(email) + + # then + assert result is False + + def test_invalid_email_no_username(self) -> None: + """Test validation fails for email without username.""" + # given + email = "@example.com" + + # when + result = validate_email_format(email) + + # then + assert result is False + + def test_invalid_email_empty_string(self) -> None: + """Test validation fails for empty string.""" + # given + email = "" + + # when + result = validate_email_format(email) + + # then + assert result is False + + def test_invalid_email_multiple_at_signs(self) -> None: + """Test validation fails for multiple @ signs.""" + # given + email = "user@@example.com" + + # when + result = validate_email_format(email) + + # then + assert result is False + + +class TestValidateFeatureCompatibility: + """Test cases for validate_feature_compatibility function.""" + + def test_compatible_features_basic(self) -> None: + """Test basic compatible features.""" + # given + config = { + "database": {"type": "PostgreSQL"}, + "authentication": "JWT", + "async_tasks": "None", + } + + # when + is_valid, warning = validate_feature_compatibility(config) + + # then + assert is_valid is True + assert warning is None + + def test_compatible_features_full_stack(self) -> None: + """Test full stack compatible features.""" + # given + config = { + "database": {"type": "PostgreSQL"}, + "authentication": "JWT", + "async_tasks": "Celery", + "caching": "Redis", + "monitoring": "Prometheus", + } + + # when + is_valid, warning = validate_feature_compatibility(config) + + # then + assert is_valid is True + # May have info warning about shared Redis + if warning: + assert "Redis" in warning + + def test_fastapi_users_without_database_warning(self) -> None: + """Test warning when FastAPI-Users selected without database.""" + # given + config = { + "database": {"type": "None"}, + "authentication": "FastAPI-Users", + } + + # when + is_valid, warning = validate_feature_compatibility(config) + + # then + assert is_valid is True + assert warning is not None + assert "FastAPI-Users requires a database" in warning + + def test_fastapi_users_with_database_no_warning(self) -> None: + """Test no warning when FastAPI-Users with database.""" + # given + config = { + "database": {"type": "PostgreSQL"}, + "authentication": "FastAPI-Users", + } + + # when + is_valid, warning = validate_feature_compatibility(config) + + # then + assert is_valid is True + # Should not have FastAPI-Users warning + + def test_redis_shared_for_cache_and_tasks(self) -> None: + """Test info message when Redis used for both cache and tasks.""" + # given + config = { + "caching": "Redis", + "async_tasks": "Celery", + } + + # when + is_valid, warning = validate_feature_compatibility(config) + + # then + assert is_valid is True + assert warning is not None + assert "Redis" in warning + assert "shared dependency" in warning.lower() + + def test_no_features_selected(self) -> None: + """Test validation with no features selected.""" + # given + config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + } + + # when + is_valid, warning = validate_feature_compatibility(config) + + # then + assert is_valid is True + assert warning is None + + +class TestSanitizeCustomPackages: + """Test cases for sanitize_custom_packages function.""" + + def test_sanitize_valid_packages(self) -> None: + """Test sanitization of valid package list.""" + # given + packages = ["fastapi", "uvicorn", "sqlalchemy"] + + # when + result = sanitize_custom_packages(packages) + + # then + assert len(result) == 3 + assert "fastapi" in result + assert "uvicorn" in result + assert "sqlalchemy" in result + + def test_sanitize_removes_duplicates(self) -> None: + """Test that duplicates are removed.""" + # given + packages = ["fastapi", "uvicorn", "fastapi", "sqlalchemy"] + + # when + result = sanitize_custom_packages(packages) + + # then + assert len(result) == 3 + assert result.count("fastapi") == 1 + + def test_sanitize_removes_empty_strings(self) -> None: + """Test that empty strings are removed.""" + # given + packages = ["fastapi", "", "uvicorn", " ", "sqlalchemy"] + + # when + result = sanitize_custom_packages(packages) + + # then + assert len(result) == 3 + assert "" not in result + + def test_sanitize_trims_whitespace(self) -> None: + """Test that whitespace is trimmed.""" + # given + packages = [" fastapi ", " uvicorn", "sqlalchemy "] + + # when + result = sanitize_custom_packages(packages) + + # then + assert "fastapi" in result + assert "uvicorn" in result + assert "sqlalchemy" in result + assert " fastapi " not in result + + def test_sanitize_case_insensitive_deduplication(self) -> None: + """Test case-insensitive deduplication.""" + # given + packages = ["FastAPI", "fastapi", "FASTAPI"] + + # when + result = sanitize_custom_packages(packages) + + # then + assert len(result) == 1 + assert "FastAPI" in result # Keeps first occurrence + + def test_sanitize_removes_invalid_package_names(self) -> None: + """Test that invalid package names are removed.""" + # given + packages = ["fastapi", "@invalid!", "uvicorn", "-bad-name"] + + # when + result = sanitize_custom_packages(packages) + + # then + assert "fastapi" in result + assert "uvicorn" in result + assert "@invalid!" not in result + assert "-bad-name" not in result + + def test_sanitize_empty_list(self) -> None: + """Test sanitization of empty list.""" + # given + packages = [] + + # when + result = sanitize_custom_packages(packages) + + # then + assert result == [] + + def test_sanitize_none_input(self) -> None: + """Test sanitization with None input.""" + # given + packages = None + + # when + result = sanitize_custom_packages(packages) + + # then + assert result == [] + + def test_sanitize_preserves_version_specifiers(self) -> None: + """Test that version specifiers are preserved.""" + # given + packages = ["fastapi>=0.100.0", "uvicorn[standard]"] + + # when + result = sanitize_custom_packages(packages) + + # then + assert "fastapi>=0.100.0" in result + assert "uvicorn[standard]" in result diff --git a/tests/test_backends/test_project_builder_config_generator.py b/tests/test_backends/test_project_builder_config_generator.py new file mode 100644 index 0000000..5bb2996 --- /dev/null +++ b/tests/test_backends/test_project_builder_config_generator.py @@ -0,0 +1,372 @@ +# -------------------------------------------------------------------------- +# Test cases for backend/project_builder/config_generator.py +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import pytest + +from fastapi_fastkit.backend.project_builder.config_generator import ( + DynamicConfigGenerator, +) + + +class TestDynamicConfigGeneratorInitialization: + """Test cases for DynamicConfigGenerator initialization.""" + + def test_initialization_with_config(self) -> None: + """Test DynamicConfigGenerator initialization.""" + # given + config = {"project_name": "test-project"} + project_dir = "/tmp/test-project" + + # when + generator = DynamicConfigGenerator(config, project_dir) + + # then + assert generator.config is not None + assert generator.project_dir.name == "test-project" + + +class TestGenerateMainPy: + """Test cases for generate_main_py method.""" + + def test_generate_main_py_minimal(self) -> None: + """Test generating main.py with minimal configuration.""" + # given + config = { + "project_name": "TestProject", + "description": "A test project", + "database": {"type": "None"}, + "authentication": "None", + "monitoring": "None", + "utilities": [], + } + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_main_py() + + # then + assert "from fastapi import FastAPI" in content + assert "from fastapi.middleware.cors import CORSMiddleware" in content + assert 'title="TestProject"' in content + assert 'description="A test project"' in content + assert "@app.get('/', tags=['Health'])" in content + assert "def root():" in content + + def test_generate_main_py_with_postgresql(self) -> None: + """Test generating main.py with PostgreSQL database.""" + # given + config = { + "project_name": "TestProject", + "description": "Test", + "database": {"type": "PostgreSQL"}, + "authentication": "None", + "monitoring": "None", + "utilities": [], + } + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_main_py() + + # then + assert "from sqlalchemy.ext.asyncio import create_async_engine" in content + assert "from sqlalchemy.orm import sessionmaker" in content + + def test_generate_main_py_with_mongodb(self) -> None: + """Test generating main.py with MongoDB database.""" + # given + config = { + "project_name": "TestProject", + "description": "Test", + "database": {"type": "MongoDB"}, + "authentication": "None", + "monitoring": "None", + "utilities": [], + } + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_main_py() + + # then + assert "from motor.motor_asyncio import AsyncIOMotorClient" in content + + def test_generate_main_py_with_jwt_auth(self) -> None: + """Test generating main.py with JWT authentication.""" + # given + config = { + "project_name": "TestProject", + "description": "Test", + "database": {"type": "None"}, + "authentication": "JWT", + "monitoring": "None", + "utilities": [], + } + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_main_py() + + # then + # JWT imports HTTPBearer security + assert "HTTPBearer" in content or "Security" in content + + def test_generate_main_py_with_cors_utility(self) -> None: + """Test generating main.py with CORS utility.""" + # given + config = { + "project_name": "TestProject", + "description": "Test", + "database": {"type": "None"}, + "authentication": "None", + "monitoring": "None", + "utilities": ["CORS"], + } + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_main_py() + + # then + assert "CORSMiddleware" in content + assert "allow_origins" in content + + def test_generate_main_py_with_rate_limiting(self) -> None: + """Test generating main.py with rate limiting.""" + # given + config = { + "project_name": "TestProject", + "description": "Test", + "database": {"type": "None"}, + "authentication": "None", + "monitoring": "None", + "utilities": ["Rate-Limiting"], + } + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_main_py() + + # then + assert "Limiter" in content or "slowapi" in content + + def test_generate_main_py_with_prometheus(self) -> None: + """Test generating main.py with Prometheus monitoring.""" + # given + config = { + "project_name": "TestProject", + "description": "Test", + "database": {"type": "None"}, + "authentication": "None", + "monitoring": "Prometheus", + "utilities": [], + } + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_main_py() + + # then + assert "Instrumentator" in content or "prometheus" in content.lower() + + +class TestGenerateDatabaseConfig: + """Test cases for generate_database_config method.""" + + def test_generate_database_config_none(self) -> None: + """Test that None database returns None config.""" + # given + config = {"database": {"type": "None"}} + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_database_config() + + # then + assert content is None + + def test_generate_database_config_postgresql(self) -> None: + """Test generating PostgreSQL database config.""" + # given + config = {"database": {"type": "PostgreSQL"}} + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_database_config() + + # then + assert content is not None + assert "Database Configuration" in content + assert "SQLAlchemy" in content or "sqlalchemy" in content + assert "postgresql+asyncpg" in content + assert "AsyncSession" in content + + def test_generate_database_config_mysql(self) -> None: + """Test generating MySQL database config.""" + # given + config = {"database": {"type": "MySQL"}} + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_database_config() + + # then + assert content is not None + assert "mysql+aiomysql" in content + + def test_generate_database_config_sqlite(self) -> None: + """Test generating SQLite database config.""" + # given + config = {"database": {"type": "SQLite"}} + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_database_config() + + # then + assert content is not None + assert "sqlite+aiosqlite" in content + + def test_generate_database_config_mongodb(self) -> None: + """Test generating MongoDB database config.""" + # given + config = {"database": {"type": "MongoDB"}} + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_database_config() + + # then + assert content is not None + assert "MongoDB Configuration" in content + assert "motor" in content.lower() + + +class TestGenerateAuthConfig: + """Test cases for generate_auth_config method.""" + + def test_generate_auth_config_none(self) -> None: + """Test that None authentication returns None.""" + # given + config = {"authentication": "None"} + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_auth_config() + + # then + assert content is None + + def test_generate_auth_config_jwt(self) -> None: + """Test generating JWT authentication config.""" + # given + config = {"authentication": "JWT"} + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_auth_config() + + # then + assert content is not None + assert "JWT Authentication Configuration" in content + assert "jose" in content.lower() + assert "passlib" in content or "password" in content.lower() + assert "SECRET_KEY" in content + + def test_generate_auth_config_fastapi_users(self) -> None: + """Test generating FastAPI-Users authentication config.""" + # given + config = {"authentication": "FastAPI-Users"} + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_auth_config() + + # then + assert content is not None + assert "FastAPI-Users" in content + + +class TestGenerateTestConfig: + """Test cases for generate_test_config method.""" + + def test_generate_test_config_none(self) -> None: + """Test that None testing returns None.""" + # given + config = {"testing": "None"} + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_test_config() + + # then + assert content is None + + def test_generate_test_config_basic(self) -> None: + """Test generating basic pytest config.""" + # given + config = {"testing": "Basic"} + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_test_config() + + # then + assert content is not None + assert "[pytest]" in content + assert "testpaths = tests" in content + + def test_generate_test_config_coverage(self) -> None: + """Test generating pytest config with coverage.""" + # given + config = {"testing": "Coverage"} + generator = DynamicConfigGenerator(config, "/tmp/test") + + # when + content = generator.generate_test_config() + + # then + assert content is not None + assert "[coverage:run]" in content + + +class TestHelperMethods: + """Test cases for helper methods.""" + + def test_build_header(self) -> None: + """Test _build_header static method.""" + # given + title = "Test File" + + # when + header = DynamicConfigGenerator._build_header(title) + + # then + assert isinstance(header, list) + assert "Test File" in header[1] + assert "FastAPI-fastkit" in header[2] + + def test_join_lines(self) -> None: + """Test _join_lines static method.""" + # given + lines = ["line1", "line2", "line3"] + + # when + result = DynamicConfigGenerator._join_lines(lines) + + # then + assert "line1\nline2\nline3\n" == result + + def test_build_code_block(self) -> None: + """Test _build_code_block static method.""" + # given + indent = " " + lines = ("line1", "line2") + + # when + result = DynamicConfigGenerator._build_code_block(indent, *lines) + + # then + assert result == [" line1", " line2"] diff --git a/tests/test_backends/test_project_builder_dependency_collector.py b/tests/test_backends/test_project_builder_dependency_collector.py new file mode 100644 index 0000000..d3f4c92 --- /dev/null +++ b/tests/test_backends/test_project_builder_dependency_collector.py @@ -0,0 +1,503 @@ +# -------------------------------------------------------------------------- +# Test cases for backend/project_builder/dependency_collector.py +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import pytest + +from fastapi_fastkit.backend.project_builder.dependency_collector import ( + DependencyCollector, +) +from fastapi_fastkit.core.settings import FastkitConfig + + +class TestDependencyCollectorInitialization: + """Test cases for DependencyCollector initialization.""" + + def test_initialization_with_settings(self) -> None: + """Test DependencyCollector initialization with settings.""" + # given + settings = FastkitConfig() + + # when + collector = DependencyCollector(settings) + + # then + assert collector.settings is not None + assert len(collector.dependencies) == 0 + + def test_initialization_dependencies_empty(self) -> None: + """Test that dependencies start empty.""" + # given + settings = FastkitConfig() + + # when + collector = DependencyCollector(settings) + + # then + assert isinstance(collector.dependencies, set) + assert len(collector.dependencies) == 0 + + +class TestCollectFromConfig: + """Test cases for collect_from_config method.""" + + def test_collect_minimal_config(self) -> None: + """Test dependency collection with minimal config.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + assert len(dependencies) > 0 + assert "fastapi" in dependencies + assert "uvicorn" in dependencies + assert "pydantic" in dependencies + assert "pydantic-settings" in dependencies + + def test_collect_with_postgresql(self) -> None: + """Test dependency collection with PostgreSQL.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "PostgreSQL"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + assert "sqlalchemy" in dependencies + assert "asyncpg" in dependencies + assert "fastapi" in dependencies + + def test_collect_with_mongodb(self) -> None: + """Test dependency collection with MongoDB.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "MongoDB"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + assert "motor" in dependencies + assert "fastapi" in dependencies + + def test_collect_with_jwt_authentication(self) -> None: + """Test dependency collection with JWT authentication.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "None"}, + "authentication": "JWT", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + assert "python-jose[cryptography]" in dependencies + assert "passlib[bcrypt]" in dependencies + + def test_collect_with_celery(self) -> None: + """Test dependency collection with Celery.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "Celery", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + # Celery comes with redis extra + assert any("celery" in dep for dep in dependencies) + assert any("redis" in dep for dep in dependencies) + + def test_collect_with_redis_caching(self) -> None: + """Test dependency collection with Redis caching.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "Redis", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + # Redis may come with hiredis extra + assert any("redis" in dep for dep in dependencies) + + def test_collect_with_prometheus_monitoring(self) -> None: + """Test dependency collection with Prometheus monitoring.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "Prometheus", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + assert "prometheus-fastapi-instrumentator" in dependencies + + def test_collect_with_pytest_testing(self) -> None: + """Test dependency collection with pytest testing.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "Basic", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + assert "pytest" in dependencies + assert "pytest-asyncio" in dependencies + assert "httpx" in dependencies + + def test_collect_with_custom_packages(self) -> None: + """Test dependency collection with custom packages.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": ["requests", "aiohttp"], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + assert "requests" in dependencies + assert "aiohttp" in dependencies + + def test_collect_full_stack_config(self) -> None: + """Test dependency collection with full stack configuration.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "PostgreSQL"}, + "authentication": "JWT", + "async_tasks": "Celery", + "caching": "Redis", + "monitoring": "Prometheus", + "testing": "Coverage", + "utilities": ["Rate-Limiting"], + "custom_packages": ["requests"], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + # Base dependencies + assert "fastapi" in dependencies + assert "uvicorn" in dependencies + # Database + assert "sqlalchemy" in dependencies + assert "asyncpg" in dependencies + # Authentication + assert "python-jose[cryptography]" in dependencies + # Tasks + assert any("celery" in dep for dep in dependencies) + assert any("redis" in dep for dep in dependencies) + # Monitoring + assert "prometheus-fastapi-instrumentator" in dependencies + # Testing + assert "pytest" in dependencies + assert "pytest-cov" in dependencies + # Custom + assert "requests" in dependencies + + +class TestDependencyDeduplication: + """Test cases for dependency deduplication logic.""" + + def test_deduplication_with_shared_redis(self) -> None: + """Test that Redis is not duplicated when used for both cache and tasks.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "Celery", # Includes redis + "caching": "Redis", # Also redis + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + redis_count = dependencies.count("redis") + assert redis_count == 1, "Redis should appear only once" + + def test_deduplication_with_duplicate_custom_packages(self) -> None: + """Test deduplication of duplicate custom packages.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "None"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": ["requests", "requests", "aiohttp"], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + requests_count = dependencies.count("requests") + assert requests_count == 1, "requests should appear only once" + + def test_dependencies_are_sorted(self) -> None: + """Test that returned dependencies are sorted.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config = { + "database": {"type": "PostgreSQL"}, + "authentication": "JWT", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": ["zebra", "aardvark"], + } + + # when + dependencies = collector.collect_from_config(config) + + # then + sorted_deps = sorted(dependencies) + assert dependencies == sorted_deps + + +class TestIndividualDependencyMethods: + """Test cases for individual add_*_dependencies methods.""" + + def test_add_base_dependencies(self) -> None: + """Test add_base_dependencies method.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + + # when + collector.add_base_dependencies() + + # then + assert "fastapi" in collector.dependencies + assert "uvicorn" in collector.dependencies + assert "pydantic" in collector.dependencies + assert "pydantic-settings" in collector.dependencies + + def test_add_database_dependencies_postgresql(self) -> None: + """Test add_database_dependencies for PostgreSQL.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + + # when + collector.add_database_dependencies("PostgreSQL") + + # then + assert "sqlalchemy" in collector.dependencies + assert "asyncpg" in collector.dependencies + + def test_add_database_dependencies_mysql(self) -> None: + """Test add_database_dependencies for MySQL.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + + # when + collector.add_database_dependencies("MySQL") + + # then + assert "sqlalchemy" in collector.dependencies + assert "aiomysql" in collector.dependencies + + def test_add_authentication_dependencies_oauth2(self) -> None: + """Test add_authentication_dependencies for OAuth2.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + + # when + collector.add_authentication_dependencies("OAuth2") + + # then + assert "authlib" in collector.dependencies + + def test_add_utility_dependencies_rate_limiting(self) -> None: + """Test add_utility_dependencies for Rate-Limiting.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + + # when + collector.add_utility_dependencies("Rate-Limiting") + + # then + assert "slowapi" in collector.dependencies + + +class TestGetFinalDependencies: + """Test cases for get_final_dependencies method.""" + + def test_get_final_dependencies_returns_list(self) -> None: + """Test that get_final_dependencies returns a list.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + collector.add_base_dependencies() + + # when + result = collector.get_final_dependencies() + + # then + assert isinstance(result, list) + + def test_get_dependency_count(self) -> None: + """Test get_dependency_count method.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + collector.add_base_dependencies() + + # when + count = collector.get_dependency_count() + + # then + assert count == 4 # fastapi, uvicorn, pydantic, pydantic-settings + + def test_collect_resets_dependencies(self) -> None: + """Test that collect_from_config resets dependencies each time.""" + # given + settings = FastkitConfig() + collector = DependencyCollector(settings) + config1 = { + "database": {"type": "PostgreSQL"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + config2 = { + "database": {"type": "MongoDB"}, + "authentication": "None", + "async_tasks": "None", + "caching": "None", + "monitoring": "None", + "testing": "None", + "utilities": [], + "custom_packages": [], + } + + # when + deps1 = collector.collect_from_config(config1) + deps2 = collector.collect_from_config(config2) + + # then + # Should have MongoDB dependencies, not PostgreSQL + assert "motor" in deps2 + assert "asyncpg" not in deps2 + # But PostgreSQL was in first collection + assert "asyncpg" in deps1 diff --git a/tests/test_cli_operations/test_cli_interactive_integration.py b/tests/test_cli_operations/test_cli_interactive_integration.py new file mode 100644 index 0000000..99135b7 --- /dev/null +++ b/tests/test_cli_operations/test_cli_interactive_integration.py @@ -0,0 +1,212 @@ +# -------------------------------------------------------------------------- +# Test cases for CLI interactive mode integration +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import os +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from fastapi_fastkit.cli import fastkit_cli + + +class TestCLIInteractiveMode: + """Test cases for interactive mode CLI operations.""" + + def setup_method(self) -> None: + self.runner = CliRunner() + self.current_workspace = os.getcwd() + + def teardown_method(self, console: Any) -> None: + os.chdir(self.current_workspace) + + def test_list_features_command(self, temp_dir: str) -> None: + """Test fastkit list-features command.""" + # given + os.chdir(temp_dir) + + # when + result = self.runner.invoke(fastkit_cli, ["list-features"]) + + # then + assert result.exit_code == 0 + # Should display feature categories + output_lower = result.output.lower() + assert ( + "database" in output_lower + or "authentication" in output_lower + or "catalog" in output_lower + ) + + def test_init_interactive_command_exists(self, temp_dir: str) -> None: + """Test that init --interactive command is recognized.""" + # given + os.chdir(temp_dir) + + # when - just test that command doesn't error on --help + result = self.runner.invoke(fastkit_cli, ["init", "--help"]) + + # then + assert result.exit_code == 0 + assert "--interactive" in result.output + + @patch("fastapi_fastkit.backend.package_managers.uv_manager.UvManager.is_available") + @patch("subprocess.run") + def test_init_interactive( + self, mock_subprocess: MagicMock, mock_uv_available: MagicMock, temp_dir: str + ) -> None: + """Test init --interactive with real stack selections (PostgreSQL + JWT).""" + # given + os.chdir(temp_dir) + project_name = "test-interactive-fullstack" + author = "Interactive Test" + author_email = "interactive@test.com" + description = "Full-stack project via interactive mode" + + # Mock package manager as available and subprocess calls + mock_uv_available.return_value = True + mock_subprocess.return_value.returncode = 0 + + # Mock subprocess to create venv directory when called + def mock_subprocess_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + if "venv" in str(args[0]): + venv_path = Path(temp_dir) / project_name / ".venv" + venv_path.mkdir(parents=True, exist_ok=True) + # Create Scripts/bin directory for pip path checks + if os.name == "nt": + (venv_path / "Scripts").mkdir(exist_ok=True) + else: + (venv_path / "bin").mkdir(exist_ok=True) + mock_result = MagicMock() + mock_result.returncode = 0 + return mock_result + + mock_subprocess.side_effect = mock_subprocess_side_effect + + # when + result = self.runner.invoke( + fastkit_cli, + ["init", "--interactive"], + input="\n".join( + [ + project_name, + author, + author_email, + description, + # Template selection removed - always Empty project + "1", # Database: PostgreSQL (1st option) + "1", # Authentication: JWT (1st option) + "1", # Background Tasks: Celery (1st option) + "1", # Caching: Redis (1st option) + "3", # Monitoring: Prometheus (3rd option) + "1", # Testing: Basic (1st option) + "1", # Utilities: CORS (option 1) + "1", # Deployment: Docker (1st option) + "2", # Package manager: uv (2nd option) + "", # Custom packages: skip + "Y", # Proceed with project creation + "Y", # Create project folder + ] + ), + ) + + # then + project_path = Path(temp_dir) / project_name + assert ( + project_path.exists() and project_path.is_dir() + ), f"Project directory was not created. Output: {result.output}" + + # Check setup.py for project metadata + setup_py = project_path / "setup.py" + if setup_py.exists(): + with open(setup_py, "r") as f: + setup_content = f.read() + assert project_name in setup_content + assert author in setup_content + assert author_email in setup_content + assert description in setup_content + + # Check dependency files for selected stack + dependency_file_found = False + deps_content = "" + + # Check pyproject.toml (for uv/poetry) + if (project_path / "pyproject.toml").exists(): + dependency_file_found = True + with open(project_path / "pyproject.toml", "r") as f: + deps_content = f.read() + # Check requirements.txt (for pip) + elif (project_path / "requirements.txt").exists(): + dependency_file_found = True + with open(project_path / "requirements.txt", "r") as f: + deps_content = f.read() + + assert ( + dependency_file_found + ), "No dependency file found (pyproject.toml or requirements.txt)" + + # Verify core dependencies + deps_lower = deps_content.lower() + assert "fastapi" in deps_lower, "fastapi should be in dependencies" + assert "uvicorn" in deps_lower, "uvicorn should be in dependencies" + + # Verify selected stack dependencies based on interactive choices + # Database: PostgreSQL + assert ( + "psycopg2" in deps_lower + or "asyncpg" in deps_lower + or "sqlalchemy" in deps_lower + ), "PostgreSQL dependencies (psycopg2/asyncpg/sqlalchemy) should be present" + + # Authentication: JWT + assert ( + "python-jose" in deps_lower or "jose" in deps_lower or "pyjwt" in deps_lower + ), "JWT authentication dependencies should be present" + + # Background Tasks: Celery + assert "celery" in deps_lower, "Celery should be in dependencies" + + # Caching: Redis + assert "redis" in deps_lower, "Redis should be in dependencies" + + # Monitoring: Prometheus + assert ( + "prometheus" in deps_lower or "prometheus-client" in deps_lower + ), "Prometheus monitoring dependencies should be present" + + # Testing: Basic (pytest) + assert ( + "pytest" in deps_lower or "httpx" in deps_lower + ), "Testing dependencies should be present" + + print(f"\nāœ… Interactive mode created full-stack project successfully!") + print(f"Project: {project_name}") + print(f"Stack: PostgreSQL + JWT + Celery + Redis + Prometheus") + print(f"Dependencies validated in pyproject.toml") + + # Check venv was created + venv_path = project_path / ".venv" + assert ( + venv_path.exists() and venv_path.is_dir() + ), "Virtual environment should be created" + + # Check Docker files were created (deployment option 1 = Docker) + dockerfile_path = project_path / "Dockerfile" + assert ( + dockerfile_path.exists() + ), "Dockerfile should be created when Docker deployment is selected" + + # Verify Dockerfile content + with open(dockerfile_path, "r") as f: + dockerfile_content = f.read() + assert "FROM python:" in dockerfile_content + assert "WORKDIR /app" in dockerfile_content + assert "uvicorn" in dockerfile_content + + # Verify subprocess was called for venv and installation + assert ( + mock_subprocess.call_count >= 2 + ), "subprocess should be called for venv creation and dependency installation"