diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2314805..34f6c28 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,9 +1,6 @@ name: Test and Coverage on: - push: - branches: - - main pull_request: types: - opened diff --git a/CHANGELOG.md b/CHANGELOG.md index c2759f0..16a4f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## v1.1.0 (2025-01-15) + +### Features + +- **Package Manager Support**: Add comprehensive support for multiple Python package managers + - Support for UV (default), PDM, Poetry, and PIP package managers + - Interactive package manager selection in `fastkit init` and `fastkit startdemo` commands + - `--package-manager` CLI option for non-interactive usage + - Automatic generation of appropriate dependency files (`pyproject.toml` for UV/PDM/Poetry, `requirements.txt` for PIP) + - PEP 621 compliant project metadata for modern package managers + +- **Automated Template Testing System**: Revolutionary zero-configuration template testing + - Dynamic template discovery - new templates are automatically tested + - Comprehensive end-to-end testing with actual project creation + - Multi-package manager compatibility testing + - Virtual environment creation and dependency installation validation + - Project structure and FastAPI integration verification + - Parameterized testing with pytest for scalable test execution + +### Improvements + +- **Enhanced CLI Experience**: Package manager selection with interactive prompts and helpful descriptions +- **Better Template Quality Assurance**: Multi-layer quality assurance with static inspection and dynamic testing +- **Improved Developer Experience**: Zero boilerplate test configuration for template contributors +- **Cross-Platform Compatibility**: Enhanced support for different package manager workflows + +### Documentation + +- Updated all user guides with package manager selection examples +- Enhanced CLI reference with comprehensive package manager documentation +- Updated contributing guidelines with new automated testing system +- Improved template creation guide with zero-configuration testing instructions +- Enhanced template quality assurance documentation + +### Technical + +- Implemented BasePackageManager abstract class with concrete implementations +- Added PackageManagerFactory for dynamic package manager instantiation +- Enhanced project metadata injection for all package managers +- Improved test infrastructure with dynamic template discovery +- Updated CI/CD pipelines for multi-package manager testing + +### Breaking Changes + +- **Default Package Manager**: Changed from PIP to UV for better performance +- **CLI Prompts**: Added package manager selection step in interactive commands + ## v1.0.2 (2025-07-02) ### Features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afab2db..0199aa5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -273,17 +273,61 @@ template-name/ ### Testing -1. Required Tests: - - Basic CRUD operations - - Authentication/Authorization +FastAPI-fastkit includes **automated template testing** that provides comprehensive validation: + +#### โœ… Automatic Template Testing + +**Zero Configuration Required:** +- ๐Ÿš€ New templates are **automatically discovered** and tested +- โšก No manual test file creation needed +- ๐Ÿ›ก๏ธ Consistent quality standards applied + +**Comprehensive Test Coverage:** +- โœ… **Project Creation**: Template copying and metadata injection +- โœ… **Package Manager Support**: UV, PDM, Poetry, and PIP compatibility +- โœ… **Virtual Environment**: Creation and dependency installation +- โœ… **Project Structure**: File and directory validation +- โœ… **FastAPI Integration**: Project identification and functionality + +**Test Execution:** +```bash +# Test all templates automatically +$ pytest tests/test_templates/test_all_templates.py -v + +# Test your specific template +$ pytest tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[your-template-name] -v + +# Test with PDM environment +$ pdm run pytest tests/test_templates/test_all_templates.py -v +``` + +#### โœ… Template-Specific Testing + +While basic functionality is automatically tested, you should include template-specific tests: + +1. **Required Template Tests:** + - Basic CRUD operations (if applicable) + - Authentication/Authorization (if implemented) - Error handling - API endpoints - Configuration validation -2. Test Coverage: - - Minimum 80% code coverage - - Include integration tests - - API testing examples +2. **Test Coverage Goals:** + - Minimum 80% code coverage for template-specific logic + - Include integration tests for external services + - API testing examples in template documentation + +#### โœ… Package Manager Testing + +Test your template with all supported package managers: + +```bash +# Test with different package managers +$ fastkit startdemo your-template-name --package-manager uv +$ fastkit startdemo your-template-name --package-manager pdm +$ fastkit startdemo your-template-name --package-manager poetry +$ fastkit startdemo your-template-name --package-manager pip +``` ### Submission Process @@ -313,10 +357,12 @@ template-name/ - [ ] All files use .py-tpl extension - [ ] FastAPI-fastkit dependency included - [ ] Security requirements met - - [ ] Tests implemented and passing - [ ] Documentation complete - [ ] inspector.py validation passes - [ ] All make dev-check tests pass + - [ ] **Automatic template tests pass** (new templates tested automatically) + - [ ] **Package manager compatibility verified** (tested with UV, PDM, Poetry, PIP) + - [ ] **Template-specific functionality tested** (if applicable) 4. **Pull Request:** - Provide detailed description diff --git a/README.md b/README.md index d29229e..e285a8b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ This project was inspired by the `SpringBoot initializer` & Python Django's `dja - **๐Ÿ“‹ Standards-based FastAPI project templates** : All FastAPI-fastkit templates are based on Python standards and FastAPI's common use patterns - **๐Ÿ” Automated template quality assurance** : Weekly automated testing ensures all templates remain functional and up-to-date - **๐Ÿš€ Multiple project templates** : Choose from various pre-configured templates for different use cases (async CRUD, Docker, PostgreSQL, etc.) +- **๐Ÿ“ฆ Multiple package manager support** : Choose your preferred Python package manager (pip, uv, pdm, poetry) for dependency management ## Installation @@ -33,212 +34,58 @@ Install `FastAPI-fastkit` at your Python environment. ```console $ pip install FastAPI-fastkit ----> 100% ``` ## Usage -### Create a new FastAPI project workspace environment immediately - -You can now start new FastAPI project really fast with FastAPI-fastkit! - -Create a new FastAPI project workspace immediately with: +- Global options + - `--help`: Show help + - `--version`: Show version + - `--debug/--no-debug`: Toggle debug mode +### Create a new FastAPI project ```console -$ fastkit init -Enter the project name: my-awesome-project -Enter the author name: John Doe -Enter the author email: john@example.com -Enter the project description: My awesome FastAPI project - - Project Information -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Project Name โ”‚ my-awesome-project โ”‚ -โ”‚ Author โ”‚ John Doe โ”‚ -โ”‚ Author Email โ”‚ john@example.com โ”‚ -โ”‚ Description โ”‚ My awesome FastAPI project โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - -Available Stacks and Dependencies: - MINIMAL Stack -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Dependency 1 โ”‚ fastapi โ”‚ -โ”‚ Dependency 2 โ”‚ uvicorn โ”‚ -โ”‚ Dependency 3 โ”‚ pydantic โ”‚ -โ”‚ Dependency 4 โ”‚ pydantic-settings โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - - STANDARD Stack -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Dependency 1 โ”‚ fastapi โ”‚ -โ”‚ Dependency 2 โ”‚ uvicorn โ”‚ -โ”‚ Dependency 3 โ”‚ sqlalchemy โ”‚ -โ”‚ Dependency 4 โ”‚ alembic โ”‚ -โ”‚ Dependency 5 โ”‚ pytest โ”‚ -โ”‚ Dependency 6 โ”‚ pydantic โ”‚ -โ”‚ Dependency 7 โ”‚ pydantic-settings โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - - FULL Stack -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Dependency 1 โ”‚ fastapi โ”‚ -โ”‚ Dependency 2 โ”‚ uvicorn โ”‚ -โ”‚ Dependency 3 โ”‚ sqlalchemy โ”‚ -โ”‚ Dependency 4 โ”‚ alembic โ”‚ -โ”‚ Dependency 5 โ”‚ pytest โ”‚ -โ”‚ Dependency 6 โ”‚ redis โ”‚ -โ”‚ Dependency 7 โ”‚ celery โ”‚ -โ”‚ Dependency 8 โ”‚ pydantic โ”‚ -โ”‚ Dependency 9 โ”‚ pydantic-settings โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - -Select stack (minimal, standard, full): minimal -Do you want to proceed with project creation? [y/N]: y -FastAPI project will deploy at '~your-project-path~' - -โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โ„น Injected metadata into setup.py โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โ„น Injected metadata into config file โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ - - Creating Project: - my-awesome-project -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Component โ”‚ Collected โ”‚ -โ”‚ fastapi โ”‚ โœ“ โ”‚ -โ”‚ uvicorn โ”‚ โœ“ โ”‚ -โ”‚ pydantic โ”‚ โœ“ โ”‚ -โ”‚ pydantic-settings โ”‚ โœ“ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - -Creating virtual environment... - -โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โ„น venv created at โ”‚ -โ”‚ ~your-project-path~/my-awesome-project/.venv โ”‚ -โ”‚ To activate the virtual environment, run: โ”‚ -โ”‚ โ”‚ -โ”‚ source โ”‚ -โ”‚ ~your-project-path~/my-awesome-project/.venv/bin/act โ”‚ -โ”‚ ivate โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ - -Installing dependencies... -โ ™ Setting up project environment...Collecting - ----> 100% - -โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Success โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โœจ Dependencies installed successfully โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Success โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โœจ FastAPI project 'my-awesome-project' has been โ”‚ -โ”‚ created successfully and saved to โ”‚ -โ”‚ ~your-project-path~! โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โ„น To start your project, run 'fastkit runserver' at โ”‚ -โ”‚ newly created FastAPI project directory โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +fastkit init [OPTIONS] ``` +- What it does: Scaffolds an empty FastAPI project, creates a virtual environment, installs dependencies +- Key options: + - `--project-name`, `--author`, `--author-email`, `--description` + - `--package-manager` [pip|uv|pdm|poetry] + - Stack selection: `minimal` | `standard` | `full` (interactive) -This command will create a new FastAPI project workspace environment with Python virtual environment. - -### Add a new route to the FastAPI project - -`FastAPI-fastkit` makes it easy to expand your FastAPI project. - -Add a new route endpoint to your FastAPI project with: - +### Create a project from a template ```console -$ fastkit addroute my-awesome-project user - Adding New Route -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Project โ”‚ my-awesome-project โ”‚ -โ”‚ Route Name โ”‚ user โ”‚ -โ”‚ Target Directory โ”‚ ~your-project-path~ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - -Do you want to add route 'user' to project 'my-awesome-project'? [Y/n]: y - -โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โ„น Updated main.py to include the API router โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Success โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โœจ Successfully added new route 'user' to project โ”‚ -โ”‚ `my-awesome-project` โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +fastkit startdemo [TEMPLATE] [OPTIONS] ``` +- What it does: Creates a project from a template (e.g., `fastapi-default`) and installs dependencies +- Key options: + - `--project-name`, `--author`, `--author-email`, `--description` + - `--package-manager` [pip|uv|pdm|poetry] +- Tip: List available templates with `fastkit list-templates` -### Place a structured FastAPI demo project immediately - -You can also start with a structured FastAPI demo project. - -Demo projects are consist of various tech stacks with simple item CRUD endpoints implemented. - -Place a structured FastAPI demo project immediately with: +### Add a new route +```console +fastkit addroute +``` +- What it does: Adds a new API route to the specified project +### Run the development server ```console -$ fastkit startdemo -Enter the project name: my-awesome-demo -Enter the author name: John Doe -Enter the author email: john@example.com -Enter the project description: My awesome FastAPI demo -Deploying FastAPI project using 'fastapi-default' template -Template path: -/~fastapi_fastkit-package-path~/fastapi_project_template/fastapi-default - - Project Information -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Project Name โ”‚ my-awesome-demo โ”‚ -โ”‚ Author โ”‚ John Doe โ”‚ -โ”‚ Author Email โ”‚ john@example.com โ”‚ -โ”‚ Description โ”‚ My awesome FastAPI demo โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - - Template Dependencies -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Dependency 1 โ”‚ fastapi โ”‚ -โ”‚ Dependency 2 โ”‚ uvicorn โ”‚ -โ”‚ Dependency 3 โ”‚ pydantic โ”‚ -โ”‚ Dependency 4 โ”‚ pydantic-settings โ”‚ -โ”‚ Dependency 5 โ”‚ python-dotenv โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - -Do you want to proceed with project creation? [y/N]: y -FastAPI template project will deploy at '~your-project-path~' - ----> 100% - -โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Success โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โœจ Dependencies installed successfully โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ -โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Success โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ โœจ FastAPI project 'my-awesome-demo' from โ”‚ -โ”‚ 'fastapi-default' has been created and saved to โ”‚ -โ”‚ ~your-project-path~! โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +fastkit runserver [OPTIONS] ``` +- What it does: Starts the uvicorn development server +- Key options: + - `--host`, `--port`, `--reload/--no-reload`, `--workers` -To view the list of available FastAPI demos, check with: +### List templates +```console +fastkit list-templates +``` +### Delete a project ```console -$ fastkit list-templates - Available Templates -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ fastapi-custom-response โ”‚ Async Item Management API with โ”‚ -โ”‚ โ”‚ Custom Response System โ”‚ -โ”‚ fastapi-dockerized โ”‚ Dockerized FastAPI Item โ”‚ -โ”‚ โ”‚ Management API โ”‚ -โ”‚ fastapi-empty โ”‚ No description โ”‚ -โ”‚ fastapi-async-crud โ”‚ Async Item Management API Server โ”‚ -โ”‚ fastapi-psql-orm โ”‚ Dockerized FastAPI Item โ”‚ -โ”‚ โ”‚ Management API with PostgreSQL โ”‚ -โ”‚ fastapi-default โ”‚ Simple FastAPI Project โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +fastkit deleteproject ``` ## Documentation @@ -254,6 +101,9 @@ For comprehensive guides and detailed usage instructions, visit our documentatio We welcome contributions from the community! FastAPI-fastkit is designed to help newcomers to Python and FastAPI, and your contributions can make a significant impact. +
+Contributing Guide + ### Quick Start for Contributors 1. **Fork and clone the repository:** @@ -288,6 +138,8 @@ For detailed contribution guidelines, development setup, and project standards, - **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** - Project principles and community standards - **[SECURITY.md](SECURITY.md)** - Security guidelines and reporting +
+ ## Significance of FastAPI-fastkit FastAPI-fastkit aims to provide a fast and easy-to-use starter kit for new users of Python and FastAPI. diff --git a/docs/contributing/development-setup.md b/docs/contributing/development-setup.md index 598c5e3..4e03306 100644 --- a/docs/contributing/development-setup.md +++ b/docs/contributing/development-setup.md @@ -161,21 +161,6 @@ Success: no issues found in 12 source files -### Security Scanning - -**bandit** - Security issue detection: - -
- -```console -$ bandit -r src/ -[main] INFO profile include tests: None -[main] INFO profile exclude tests: None -No issues identified. -``` - -
- ## Available Make Commands The project Makefile provides convenient commands for common development tasks: diff --git a/docs/contributing/template-creation-guide.md b/docs/contributing/template-creation-guide.md index 4b67696..e6036ca 100644 --- a/docs/contributing/template-creation-guide.md +++ b/docs/contributing/template-creation-guide.md @@ -313,6 +313,60 @@ The inspector automatically validates the following items: - [ ] Dependencies installation successful - [ ] All pytest tests pass +#### โœ… Automated Template Testing + +FastAPI-fastkit includes **automated template testing** that runs comprehensive tests for all templates: + +**Test Coverage:** +- โœ… Template creation process +- โœ… Project metadata injection +- โœ… Virtual environment setup +- โœ… Dependency installation (all package managers) +- โœ… Basic project structure validation +- โœ… FastAPI project identification + +**Test Execution:** +```console +# Test all templates automatically +$ pytest tests/test_templates/test_all_templates.py -v + +# Test specific template +$ pytest tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[your-template-name] -v +``` + +**Template Test Discovery:** +New templates are **automatically discovered** and tested without manual configuration: + +1. โœ… **Zero Configuration**: Add template โ†’ automatic testing +2. โœ… **Consistent Testing**: Same quality standards for all templates +3. โœ… **Multiple Package Managers**: Tests with UV, PDM, Poetry, and PIP +4. โœ… **Comprehensive Validation**: Structure, metadata, and functionality checks + +**What This Means for You:** +- ๐Ÿš€ **No Additional Test Files**: Your template is tested automatically +- โšก **Faster Development**: Focus on template content, not test setup +- ๐Ÿ›ก๏ธ **Quality Assurance**: Consistent testing across all templates +- ๐Ÿ”„ **CI/CD Integration**: Automatic testing in pull requests + +**Manual Testing Still Required:** +- ๐Ÿงช **Template-specific functionality**: Business logic and custom features +- ๐Ÿ”ง **Integration testing**: External services and complex workflows +- ๐Ÿ“ฑ **End-to-end scenarios**: Complete user workflows + +**Testing Best Practices:** +```console +# 1. Test your template locally +$ fastkit startdemo your-template-name + +# 2. Run automated tests +$ pytest tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[your-template-name] -v + +# 3. Test with different package managers +$ fastkit startdemo your-template-name --package-manager poetry +$ fastkit startdemo your-template-name --package-manager pdm +$ fastkit startdemo your-template-name --package-manager uv +``` + ### Manual Validation Checklist In addition to automated validation, manually check the following items: diff --git a/docs/index.md b/docs/index.md index e67f91c..335bc08 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,7 @@ This project was inspired by the `SpringBoot initializer` & Python Django's `dja - **๐Ÿ“‹ Standards-based FastAPI project templates** : All FastAPI-fastkit templates are based on Python standards and FastAPI's common use patterns - **๐Ÿ” Automated template quality assurance** : Weekly automated testing ensures all templates remain functional and up-to-date - **๐Ÿš€ Multiple project templates** : Choose from various pre-configured templates for different use cases (async CRUD, Docker, PostgreSQL, etc.) +- **๐Ÿ“ฆ Multiple package manager support** : Choose your preferred Python package manager (pip, uv, pdm, poetry) for dependency management ## Installation @@ -100,6 +101,17 @@ Available Stacks and Dependencies: โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ Select stack (minimal, standard, full): minimal + +Available Package Managers: + Package Managers +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ PIP โ”‚ Standard Python package manager โ”‚ +โ”‚ UV โ”‚ Fast Python package manager โ”‚ +โ”‚ PDM โ”‚ Modern Python dependency management โ”‚ +โ”‚ POETRY โ”‚ Python dependency management and packaging โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv Do you want to proceed with project creation? [y/N]: y FastAPI project will deploy at '~your-project-path~' @@ -222,6 +234,16 @@ Template path: โ”‚ Dependency 5 โ”‚ python-dotenv โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +Available Package Managers: + Package Managers +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ PIP โ”‚ Standard Python package manager โ”‚ +โ”‚ UV โ”‚ Fast Python package manager โ”‚ +โ”‚ PDM โ”‚ Modern Python dependency management โ”‚ +โ”‚ POETRY โ”‚ Python dependency management and packaging โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv Do you want to proceed with project creation? [y/N]: y FastAPI template project will deploy at '~your-project-path~' diff --git a/docs/reference/template-quality-assurance.md b/docs/reference/template-quality-assurance.md index 22d7eae..77ea21c 100644 --- a/docs/reference/template-quality-assurance.md +++ b/docs/reference/template-quality-assurance.md @@ -1,6 +1,16 @@ # Template Quality Assurance -FastAPI-fastkit provides automated template validation to ensure all templates maintain high quality and remain functional. +FastAPI-fastkit provides comprehensive automated template validation to ensure all templates maintain high quality and remain functional across different environments and package managers. + +## Multi-Layer Quality Assurance + +FastAPI-fastkit employs **two complementary quality assurance systems**: + +### 1. Static Template Inspection +**Weekly automated validation of template structure and syntax** + +### 2. Dynamic Template Testing +**Comprehensive end-to-end testing with actual project creation** ## Automated Weekly Inspection @@ -12,6 +22,101 @@ Every Wednesday at midnight (UTC), our GitHub Actions workflow automatically ins - โœ… **FastAPI Implementation** - Verifies that templates contain proper FastAPI app initialization - โœ… **Test Execution** - Runs template tests to ensure functionality +## Automated Template Testing System + +FastAPI-fastkit includes a **revolutionary automated testing system** that provides comprehensive validation of every template: + +### Dynamic Template Discovery + +The testing system **automatically discovers all templates** without manual configuration: + +```console +# Test all templates automatically +$ pytest tests/test_templates/test_all_templates.py -v + +# Results show all discovered templates +PASSED tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[fastapi-default] +PASSED tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[fastapi-async-crud] +PASSED tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[fastapi-dockerized] +PASSED tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[fastapi-psql-orm] +``` + +### Comprehensive Test Coverage + +Each template undergoes **comprehensive end-to-end testing**: + +#### โœ… Project Creation Process +- Template copying and file transformation +- Project metadata injection (name, author, description) +- File structure validation + +#### โœ… Package Manager Compatibility +- **UV** (default): Fast Rust-based package manager +- **PDM**: Modern Python dependency management +- **Poetry**: Established dependency management +- **PIP**: Traditional Python package manager + +#### โœ… Virtual Environment Management +- Environment creation for each package manager +- Dependency installation verification +- Package manager-specific workflows + +#### โœ… Dependency Resolution +- `pyproject.toml` generation (UV, PDM, Poetry) +- `requirements.txt` generation (PIP) +- Metadata compliance (PEP 621) +- Build system configuration + +#### โœ… Project Structure Validation +- FastAPI project identification +- Required file existence +- Directory structure verification + +### Test Execution Examples + +**Run all template tests:** +```console +$ pytest tests/test_templates/test_all_templates.py -v +``` + +**Test specific template:** +```console +$ pytest tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[fastapi-default] -v +``` + +**Test with PDM environment:** +```console +$ pdm run pytest tests/test_templates/test_all_templates.py -v +``` + +### Continuous Integration + +The automated testing system runs in **CI/CD pipelines**: + +- โœ… **Pull Request Validation**: Every PR tests affected templates +- โœ… **Nightly Testing**: Complete template suite validation +- โœ… **Package Manager Testing**: Cross-validation with all managers +- โœ… **Environment Testing**: Multiple Python versions and platforms + +### Benefits for Contributors + +**Zero Configuration Testing:** +- ๐Ÿš€ Add new template โ†’ automatic testing +- โšก No manual test file creation required +- ๐Ÿ›ก๏ธ Consistent quality standards + +**Comprehensive Coverage:** +- ๐Ÿ” End-to-end project creation testing +- ๐Ÿ“ฆ Multi package manager validation +- ๐Ÿ—๏ธ Complete dependency resolution testing +- โœ… Real-world usage simulation + +**Developer Experience:** +- ๐ŸŽฏ **Focus on Template Content**: Testing is automatic +- ๐Ÿ”„ **Immediate Feedback**: Fast test execution +- ๐Ÿ“Š **Clear Results**: Detailed test reporting +- ๐Ÿšซ **No Boilerplate**: Zero test configuration needed + ## Manual Template Inspection For development and debugging purposes, you can manually inspect templates using our local inspection script or Makefile commands: diff --git a/docs/tutorial/first-project.md b/docs/tutorial/first-project.md index 123dc16..f245c42 100644 --- a/docs/tutorial/first-project.md +++ b/docs/tutorial/first-project.md @@ -76,6 +76,17 @@ Available Stacks and Dependencies: โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ Select stack (minimal, standard, full): standard + +Available Package Managers: + Package Managers +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ PIP โ”‚ Standard Python package manager โ”‚ +โ”‚ UV โ”‚ Fast Python package manager โ”‚ +โ”‚ PDM โ”‚ Modern Python dependency management โ”‚ +โ”‚ POETRY โ”‚ Python dependency management and packaging โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv Do you want to proceed with project creation? [y/N]: y โœจ FastAPI project 'blog-api' has been created successfully! diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index ee1ddd0..1fb41c3 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -15,7 +15,7 @@ Before we begin, make sure you have: First, let's install FastAPI-fastkit. We recommend using a virtual environment to keep your projects isolated. -### Option A: Using pip (Recommended) +### Option A: Using pip (Traditional)
@@ -27,7 +27,25 @@ Successfully installed fastapi-fastkit
-### Option B: Using a Virtual Environment +### Option B: Using UV (Recommended - Faster) + +UV is a fast Python package manager. If you don't have UV installed: + +
+ +```console +# Install UV first +$ curl -LsSf https://astral.sh/uv/install.sh | sh + +# Then install FastAPI-fastkit +$ uv pip install fastapi-fastkit +---> 100% +Successfully installed fastapi-fastkit +``` + +
+ +### Option C: Using a Virtual Environment
@@ -94,6 +112,17 @@ Available Stacks and Dependencies: โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ Select stack (minimal, standard, full): minimal + +Available Package Managers: + Package Managers +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ PIP โ”‚ Standard Python package manager โ”‚ +โ”‚ UV โ”‚ Fast Python package manager โ”‚ +โ”‚ PDM โ”‚ Modern Python dependency management โ”‚ +โ”‚ POETRY โ”‚ Python dependency management and packaging โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv Do you want to proceed with project creation? [y/N]: y Creating virtual environment... diff --git a/docs/user-guide/cli-reference.md b/docs/user-guide/cli-reference.md index 25632f8..370b981 100644 --- a/docs/user-guide/cli-reference.md +++ b/docs/user-guide/cli-reference.md @@ -60,6 +60,7 @@ $ fastkit init [OPTIONS] | Option | Description | Default | |--------|-------------|---------| +| `--package-manager` | Package manager to use (pip, uv, pdm, poetry) | uv | | `--help` | Show command help | - | #### Interactive Prompts @@ -71,6 +72,7 @@ The `init` command will prompt you for: 3. **Author email**: Contact email for package 4. **Project description**: Brief description of the project 5. **Stack selection**: Choose from minimal, standard, or full +6. **Package manager selection**: Choose from pip, uv, pdm, or poetry (unless specified with `--package-manager`) #### Stack Options @@ -106,6 +108,7 @@ Enter the author email: john@example.com Enter the project description: My awesome API Select stack (minimal, standard, full): standard +Select package manager (pip, uv, pdm, poetry) [uv]: uv Do you want to proceed with project creation? [y/N]: y โœจ FastAPI project 'my-api' has been created successfully! @@ -227,6 +230,7 @@ $ fastkit startdemo [OPTIONS] | Option | Description | Default | |--------|-------------|---------| +| `--package-manager` | Package manager to use (pip, uv, pdm, poetry) | uv | | `--help` | Show command help | - | #### Interactive Prompts @@ -237,7 +241,7 @@ The `startdemo` command will prompt you for: 2. **Author name**: Package author information 3. **Author email**: Contact email 4. **Project description**: Brief description -5. **Template selection**: Choose from available templates +5. **Package manager selection**: Choose from pip, uv, pdm, or poetry (unless specified with `--package-manager`) #### Available Templates @@ -255,13 +259,13 @@ The `startdemo` command will prompt you for:
```console -$ fastkit startdemo +$ fastkit startdemo fastapi-psql-orm Enter the project name: my-blog Enter the author name: Jane Smith Enter the author email: jane@example.com Enter the project description: Blog API with PostgreSQL -Select template: fastapi-psql-orm +Select package manager (pip, uv, pdm, poetry) [uv]: poetry Do you want to proceed with project creation? [y/N]: y โœจ FastAPI project 'my-blog' from 'fastapi-psql-orm' has been created! @@ -673,6 +677,135 @@ jobs: python -m pytest ``` +## Package Manager Support + +FastAPI-fastkit supports multiple Python package managers, allowing you to choose the one that best fits your workflow. + +### Supported Package Managers + +| Manager | Description | Dependency File | Best For | +|---------|-------------|----------------|----------| +| **UV** (default) | Fast Python package manager | `pyproject.toml` | Speed and performance | +| **PDM** | Modern Python dependency management | `pyproject.toml` | Advanced dependency resolution | +| **Poetry** | Python dependency management and packaging | `pyproject.toml` | Poetry-based workflows | +| **PIP** | Standard Python package manager | `requirements.txt` | Traditional Python development | + +### Specifying Package Manager + +#### Global Configuration + +You can set your preferred package manager for all projects: + +```console +# Using command line options +$ fastkit init --package-manager poetry +$ fastkit startdemo --package-manager pdm +``` + +#### Project-specific Selection + +Each project can use a different package manager. The choice is made during project creation and affects: + +- **Dependency file format**: Each manager creates its appropriate files +- **Virtual environment management**: Different activation methods +- **Dependency installation**: Manager-specific commands + +### Package Manager Features + +#### UV (Default) +- **Fast**: Rust-based, extremely fast dependency resolution +- **Compatible**: Drop-in replacement for pip and pip-tools +- **Modern**: Support for PEP 621 project metadata + +
+ +```console +$ fastkit init --package-manager uv +# Creates pyproject.toml with UV configuration +``` + +
+ +#### PDM +- **Modern**: PEP 582 and PEP 621 support +- **Advanced**: Sophisticated dependency resolution +- **Flexible**: Multiple project layouts + +
+ +```console +$ fastkit init --package-manager pdm +# Creates pyproject.toml with PDM configuration +``` + +
+ +#### Poetry +- **Established**: Mature and widely adopted +- **Integrated**: Build and publish support +- **Lockfile**: poetry.lock for reproducible builds + +
+ +```console +$ fastkit init --package-manager poetry +# Creates pyproject.toml with Poetry configuration +``` + +
+ +#### PIP +- **Standard**: Built into Python +- **Compatible**: Works everywhere +- **Simple**: Straightforward dependency management + +
+ +```console +$ fastkit init --package-manager pip +# Creates requirements.txt +``` + +
+ +### Working with Projects + +After creating a project with a specific package manager: + +#### UV Projects +```console +cd my-project +uv sync # Install dependencies +uv add requests # Add new dependency +uv run pytest # Run commands in environment +``` + +#### PDM Projects +```console +cd my-project +pdm install # Install dependencies +pdm add requests # Add new dependency +pdm run pytest # Run commands in environment +``` + +#### Poetry Projects +```console +cd my-project +poetry install # Install dependencies +poetry add requests # Add new dependency +poetry run pytest # Run commands in environment +``` + +#### PIP Projects +```console +cd my-project +source .venv/bin/activate # Linux/macOS +.venv\Scripts\activate # Windows +pip install -r requirements.txt +pip install requests +pytest +``` + ## Next Steps Now that you understand the CLI: diff --git a/docs/user-guide/creating-projects.md b/docs/user-guide/creating-projects.md index cd722e5..05df6e9 100644 --- a/docs/user-guide/creating-projects.md +++ b/docs/user-guide/creating-projects.md @@ -142,6 +142,134 @@ my-awesome-api/ โ””โ”€โ”€ README.md # Project documentation ``` +### 3. Package Manager Selection + +FastAPI-fastkit supports multiple Python package managers. Choose the one that best fits your development workflow: + +#### Available Package Managers + +
+ +```console +Available Package Managers: + Package Managers +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ PIP โ”‚ Standard Python package manager โ”‚ +โ”‚ UV โ”‚ Fast Python package manager โ”‚ +โ”‚ PDM โ”‚ Modern Python dependency management โ”‚ +โ”‚ POETRY โ”‚ Python dependency management and packaging โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +``` + +
+ +Each package manager has its advantages: + +#### UV (Default - Recommended) + +**Fast Rust-based package manager** + +- โšก **Ultra-fast**: 10-100x faster than pip +- ๐Ÿ”ง **Drop-in replacement**: Compatible with pip workflows +- ๐Ÿ“ฆ **Modern**: Full PEP 621 support +- ๐Ÿ› ๏ธ **Reliable**: Deterministic resolution + +**Generated files:** +- `pyproject.toml` (PEP 621 format) +- `uv.lock` (lockfile) + +**Usage after creation:** +```console +cd my-project +uv sync # Install dependencies +uv add requests # Add new dependency +uv run pytest # Run tests +``` + +#### PDM + +**Modern Python dependency management** + +- ๐Ÿš€ **Modern**: PEP 582 and PEP 621 support +- ๐Ÿง  **Smart**: Advanced dependency resolution +- ๐Ÿ’ผ **Professional**: Workspace and multi-project support +- ๐Ÿ“Š **Analytics**: Dependency analysis tools + +**Generated files:** +- `pyproject.toml` (PEP 621 format) +- `pdm.lock` (lockfile) + +**Usage after creation:** +```console +cd my-project +pdm install # Install dependencies +pdm add requests # Add new dependency +pdm run pytest # Run tests +``` + +#### Poetry + +**Mature dependency management and packaging** + +- โœ… **Established**: Mature and widely adopted +- ๐Ÿ“ฆ **Integrated**: Build and publish support +- ๐Ÿ”’ **Reproducible**: poetry.lock for exact versions +- ๐Ÿ—๏ธ **Complete**: Full project lifecycle management + +**Generated files:** +- `pyproject.toml` (Poetry format) +- `poetry.lock` (lockfile) + +**Usage after creation:** +```console +cd my-project +poetry install # Install dependencies +poetry add requests # Add new dependency +poetry run pytest # Run tests +``` + +#### PIP + +**Standard Python package manager** + +- ๐Ÿ  **Built-in**: Included with Python +- ๐ŸŒ **Universal**: Works everywhere +- ๐Ÿ“š **Familiar**: Most developers know it +- ๐Ÿ”ง **Simple**: Straightforward workflow + +**Generated files:** +- `requirements.txt` + +**Usage after creation:** +```console +cd my-project +source .venv/bin/activate # Linux/macOS +.venv\Scripts\activate # Windows +pip install -r requirements.txt +pip install requests +pytest +``` + +#### Specifying Package Manager + +You can specify your preferred package manager: + +**Interactive selection (default):** +```console +$ fastkit init +# ... prompts for package manager selection +``` + +**Command line option:** +```console +$ fastkit init --package-manager poetry +$ fastkit init --package-manager pdm +$ fastkit init --package-manager uv +$ fastkit init --package-manager pip +``` + ### Understanding Each Directory #### `src/` Directory diff --git a/docs/user-guide/quick-start.md b/docs/user-guide/quick-start.md index 9272695..63a41fa 100644 --- a/docs/user-guide/quick-start.md +++ b/docs/user-guide/quick-start.md @@ -57,6 +57,17 @@ Available Stacks and Dependencies: โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ Select stack (minimal, standard, full): minimal + +Available Package Managers: + Package Managers +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ PIP โ”‚ Standard Python package manager โ”‚ +โ”‚ UV โ”‚ Fast Python package manager โ”‚ +โ”‚ PDM โ”‚ Modern Python dependency management โ”‚ +โ”‚ POETRY โ”‚ Python dependency management and packaging โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv Do you want to proceed with project creation? [y/N]: y โœจ FastAPI project 'my-first-app' has been created successfully! @@ -248,7 +259,69 @@ my-first-app/ โ””โ”€โ”€ README.md # Project documentation ``` -## 8. What's Next? +## 8. Package Manager Options + +FastAPI-fastkit supports multiple Python package managers to suit your preferences: + +### Available Package Managers + +| Manager | Description | Best For | +|---------|-------------|----------| +| **UV** | Fast Python package manager (default) | Speed and performance | +| **PDM** | Modern Python dependency management | Advanced dependency resolution | +| **Poetry** | Python dependency management and packaging | Poetry-based workflows | +| **PIP** | Standard Python package manager | Traditional Python development | + +### Specifying Package Manager + +You can specify your preferred package manager in several ways: + +#### 1. Interactive Selection (Default) + +When you run `fastkit init` or `fastkit startdemo`, you'll be prompted to choose: + +
+ +```console +$ fastkit init +# ... after project details and stack selection ... + +Available Package Managers: + Package Managers +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ PIP โ”‚ Standard Python package manager โ”‚ +โ”‚ UV โ”‚ Fast Python package manager โ”‚ +โ”‚ PDM โ”‚ Modern Python dependency management โ”‚ +โ”‚ POETRY โ”‚ Python dependency management and packaging โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +``` + +
+ +#### 2. Command Line Option + +Skip the interactive prompt by specifying the package manager directly: + +
+ +```console +$ fastkit init --package-manager poetry +$ fastkit startdemo --package-manager pdm +``` + +
+ +### Dependency Files Generated + +Each package manager creates its appropriate dependency files: + +- **UV/PDM**: `pyproject.toml` (PEP 621 format) +- **Poetry**: `pyproject.toml` (Poetry format) +- **PIP**: `requirements.txt` + +## 9. What's Next? Congratulations! You've successfully: diff --git a/docs/user-guide/using-templates.md b/docs/user-guide/using-templates.md index 4d4f4f8..98de5cd 100644 --- a/docs/user-guide/using-templates.md +++ b/docs/user-guide/using-templates.md @@ -173,6 +173,16 @@ Select template (fastapi-default, fastapi-async-crud, fastapi-custom-response, f โ”‚ Dependency 7 โ”‚ pytest โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +Available Package Managers: + Package Managers +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ PIP โ”‚ Standard Python package manager โ”‚ +โ”‚ UV โ”‚ Fast Python package manager โ”‚ +โ”‚ PDM โ”‚ Modern Python dependency management โ”‚ +โ”‚ POETRY โ”‚ Python dependency management and packaging โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv Do you want to proceed with project creation? [y/N]: y โœจ FastAPI project 'my-blog-api' from 'fastapi-psql-orm' has been created successfully! diff --git a/src/fastapi_fastkit/__init__.py b/src/fastapi_fastkit/__init__.py index fa3f7bc..849114f 100644 --- a/src/fastapi_fastkit/__init__.py +++ b/src/fastapi_fastkit/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.0.2" +__version__ = "1.1.0" import os diff --git a/src/fastapi_fastkit/backend/main.py b/src/fastapi_fastkit/backend/main.py index 511778f..7dec3af 100644 --- a/src/fastapi_fastkit/backend/main.py +++ b/src/fastapi_fastkit/backend/main.py @@ -5,11 +5,10 @@ # -------------------------------------------------------------------------- import os import re -import subprocess -import sys -from typing import Dict, List, Optional +from typing import Dict, List from fastapi_fastkit import console +from fastapi_fastkit.backend.package_managers import PackageManagerFactory from fastapi_fastkit.backend.transducer import copy_and_convert_template_file from fastapi_fastkit.core.exceptions import BackendExceptions, TemplateExceptions from fastapi_fastkit.core.settings import settings @@ -246,99 +245,106 @@ def _process_config_file(config_py: str, project_name: str) -> None: raise BackendExceptions(f"Failed to process config file: {e}") +def create_venv_with_manager(project_dir: str, manager_type: str = "pip") -> str: + """ + Create a virtual environment using the specified package manager. + + :param project_dir: Path to the project directory + :param manager_type: Type of package manager to use + :return: Path to the virtual environment + :raises: BackendExceptions if virtual environment creation fails + """ + try: + package_manager = PackageManagerFactory.create_manager( + manager_type, project_dir, auto_detect=True + ) + return package_manager.create_virtual_environment() + except Exception as e: + debug_log( + f"Error creating virtual environment with {manager_type}: {e}", "error" + ) + raise BackendExceptions(f"Failed to create virtual environment: {str(e)}") + + def create_venv(project_dir: str) -> str: """ Create a Python virtual environment in the project directory. + This is a backward compatibility wrapper that uses pip by default. + :param project_dir: Path to the project directory :return: Path to the virtual environment """ - venv_path = os.path.join(project_dir, ".venv") + return create_venv_with_manager(project_dir, "pip") - try: - with console.status("[bold green]Creating virtual environment..."): - subprocess.run( - [sys.executable, "-m", "venv", venv_path], - check=True, - capture_output=True, - text=True, - ) - debug_log(f"Virtual environment created at {venv_path}", "info") - print_success("Virtual environment created successfully") - return venv_path +def install_dependencies_with_manager( + project_dir: str, venv_path: str, manager_type: str = "pip" +) -> None: + """ + Install dependencies using the specified package manager. - except subprocess.CalledProcessError as e: - debug_log(f"Error creating virtual environment: {e.stderr}", "error") - handle_exception(e, f"Error creating virtual environment: {str(e)}") - raise BackendExceptions("Failed to create venv") - except OSError as e: - debug_log(f"System error creating virtual environment: {e}", "error") - handle_exception(e, f"Error creating virtual environment: {str(e)}") - raise BackendExceptions(f"Failed to create venv: {str(e)}") + :param project_dir: Path to the project directory + :param venv_path: Path to the virtual environment + :param manager_type: Type of package manager to use + :return: None + :raises: BackendExceptions if dependency installation fails + """ + try: + package_manager = PackageManagerFactory.create_manager( + manager_type, project_dir, auto_detect=True + ) + package_manager.install_dependencies(venv_path) + except Exception as e: + debug_log(f"Error installing dependencies with {manager_type}: {e}", "error") + raise BackendExceptions(f"Failed to install dependencies: {str(e)}") def install_dependencies(project_dir: str, venv_path: str) -> None: """ Install dependencies in the virtual environment. + This is a backward compatibility wrapper that uses pip by default. + :param project_dir: Path to the project directory :param venv_path: Path to the virtual environment :return: None """ - try: - if not os.path.exists(venv_path): - debug_log( - "Virtual environment does not exist. Creating it first.", "warning" - ) - print_error("Virtual environment does not exist. Creating it first.") - venv_path = create_venv(project_dir) - if not venv_path: - raise BackendExceptions("Failed to create venv") - - requirements_path = os.path.join(project_dir, "requirements.txt") - if not os.path.exists(requirements_path): - debug_log(f"Requirements file not found at {requirements_path}", "error") - print_error(f"Requirements file not found at {requirements_path}") - raise BackendExceptions("Requirements file not found") - - # Determine pip path based on OS - if os.name == "nt": # Windows - pip_path = os.path.join(venv_path, "Scripts", "pip") - else: # Unix-based - pip_path = os.path.join(venv_path, "bin", "pip") - - # Upgrade pip first - subprocess.run( - [pip_path, "install", "--upgrade", "pip"], - check=True, - capture_output=True, - text=True, - ) + install_dependencies_with_manager(project_dir, venv_path, "pip") - # Install dependencies - with console.status("[bold green]Installing dependencies..."): - subprocess.run( - [pip_path, "install", "-r", "requirements.txt"], - cwd=project_dir, - check=True, - capture_output=True, - text=True, - ) - debug_log("Dependencies installed successfully", "info") - print_success("Dependencies installed successfully") - - except subprocess.CalledProcessError as e: - debug_log(f"Error during dependency installation: {e.stderr}", "error") - handle_exception(e, f"Error during dependency installation: {str(e)}") - if hasattr(e, "stderr"): - print_error(f"Error details: {e.stderr}") - raise BackendExceptions("Failed to install dependencies") - except OSError as e: - debug_log(f"System error during dependency installation: {e}", "error") - handle_exception(e, f"Error during dependency installation: {str(e)}") - raise BackendExceptions(f"Failed to install dependencies: {str(e)}") +def generate_dependency_file_with_manager( + project_dir: str, + dependencies: List[str], + manager_type: str = "pip", + project_name: str = "", + author: str = "", + author_email: str = "", + description: str = "", +) -> None: + """ + Generate a dependency file using the specified package manager. + + :param project_dir: Path to the project directory + :param dependencies: List of dependency specifications + :param manager_type: Type of package manager to use + :param project_name: Name of the project + :param author: Author name + :param author_email: Author email + :param description: Project description + :return: None + :raises: BackendExceptions if dependency file generation fails + """ + try: + package_manager = PackageManagerFactory.create_manager( + manager_type, project_dir, auto_detect=True + ) + package_manager.generate_dependency_file( + dependencies, project_name, author, author_email, description + ) + 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/package_managers/__init__.py b/src/fastapi_fastkit/backend/package_managers/__init__.py new file mode 100644 index 0000000..d5ecaa5 --- /dev/null +++ b/src/fastapi_fastkit/backend/package_managers/__init__.py @@ -0,0 +1,22 @@ +# -------------------------------------------------------------------------- +# Package Managers Module - FastAPI-fastkit +# Provides abstraction layer for different package managers (pip, uv, pdm, poetry) +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- + +from .base import BasePackageManager +from .factory import PackageManagerFactory +from .pdm_manager import PdmManager +from .pip_manager import PipManager +from .poetry_manager import PoetryManager +from .uv_manager import UvManager + +__all__ = [ + "BasePackageManager", + "PackageManagerFactory", + "PipManager", + "PdmManager", + "UvManager", + "PoetryManager", +] diff --git a/src/fastapi_fastkit/backend/package_managers/base.py b/src/fastapi_fastkit/backend/package_managers/base.py new file mode 100644 index 0000000..6faac60 --- /dev/null +++ b/src/fastapi_fastkit/backend/package_managers/base.py @@ -0,0 +1,150 @@ +# -------------------------------------------------------------------------- +# Base Package Manager - Abstract class for package manager implementations +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import subprocess +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict, List, Optional + + +class BasePackageManager(ABC): + """ + Abstract base class for package managers. + + All package manager implementations must inherit from this class + and implement the required abstract methods. + """ + + def __init__(self, project_dir: str): + """ + Initialize package manager for a specific project. + + :param project_dir: Path to the project directory + """ + self.project_dir = Path(project_dir) + self.name = self.__class__.__name__.replace("Manager", "").lower() + + @abstractmethod + def is_available(self) -> bool: + """ + Check if the package manager is available on the system. + + :return: True if package manager is installed and available + """ + pass + + @abstractmethod + def get_dependency_file_name(self) -> str: + """ + Get the name of the dependency file for this package manager. + + :return: Dependency file name (e.g., 'requirements.txt', 'pyproject.toml') + """ + pass + + @abstractmethod + def create_virtual_environment(self) -> str: + """ + Create a virtual environment for the project. + + :return: Path to the created virtual environment + :raises: Exception if virtual environment creation fails + """ + pass + + @abstractmethod + def install_dependencies(self, venv_path: str) -> None: + """ + Install dependencies using the package manager. + + :param venv_path: Path to the virtual environment + :raises: Exception if dependency installation fails + """ + pass + + @abstractmethod + def generate_dependency_file( + self, + dependencies: List[str], + project_name: str = "", + author: str = "", + author_email: str = "", + description: str = "", + ) -> None: + """ + Generate a dependency file with the given dependencies and metadata. + + :param dependencies: List of dependency specifications + :param project_name: Name of the project + :param author: Author name + :param author_email: Author email + :param description: Project description + """ + pass + + @abstractmethod + def add_dependency(self, dependency: str, dev: bool = False) -> None: + """ + Add a new dependency to the project. + + :param dependency: Dependency specification + :param dev: Whether this is a development dependency + """ + pass + + def get_executable_path( + self, executable_name: str, venv_path: Optional[str] = None + ) -> str: + """ + Get the full path to an executable, considering virtual environment. + + :param executable_name: Name of the executable + :param venv_path: Path to virtual environment (optional) + :return: Full path to the executable + """ + import os + + if venv_path: + if os.name == "nt": # Windows + return os.path.join(venv_path, "Scripts", f"{executable_name}.exe") + else: # Unix-based + return os.path.join(venv_path, "bin", executable_name) + else: + return executable_name + + def run_command( + self, command: List[str], **kwargs: Any + ) -> subprocess.CompletedProcess[str]: + """ + Run a command with proper error handling. + + :param command: Command to run as list of strings + :param kwargs: Additional keyword arguments for subprocess.run + :return: CompletedProcess instance + :raises: subprocess.CalledProcessError on failure + """ + default_kwargs: Dict[str, Any] = { + "check": True, + "capture_output": True, + "text": True, + "cwd": str(self.project_dir), + } + default_kwargs.update(kwargs) + + return subprocess.run(command, **default_kwargs) + + def get_dependency_file_path(self) -> Path: + """ + Get the full path to the dependency file. + + :return: Path to the dependency file + """ + return self.project_dir / self.get_dependency_file_name() + + def __str__(self) -> str: + return f"{self.__class__.__name__}({self.project_dir})" + + def __repr__(self) -> str: + return self.__str__() diff --git a/src/fastapi_fastkit/backend/package_managers/factory.py b/src/fastapi_fastkit/backend/package_managers/factory.py new file mode 100644 index 0000000..f5f9112 --- /dev/null +++ b/src/fastapi_fastkit/backend/package_managers/factory.py @@ -0,0 +1,151 @@ +# -------------------------------------------------------------------------- +# Package Manager Factory - Creates appropriate package manager instances +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +from typing import Dict, List, Optional, Type + +from fastapi_fastkit.core.exceptions import BackendExceptions +from fastapi_fastkit.utils.logging import debug_log, get_logger + +from .base import BasePackageManager +from .pdm_manager import PdmManager +from .pip_manager import PipManager +from .poetry_manager import PoetryManager +from .uv_manager import UvManager + +logger = get_logger(__name__) + + +class PackageManagerFactory: + """Factory for creating package manager instances.""" + + # Registry of available package managers + _managers: Dict[str, Type[BasePackageManager]] = { + "pip": PipManager, + "pdm": PdmManager, + "uv": UvManager, + "poetry": PoetryManager, + } + + @classmethod + def create_manager( + self, manager_type: str, project_dir: str, auto_detect: bool = False + ) -> BasePackageManager: + """ + Create a package manager instance. + + :param manager_type: Type of package manager ('pip', 'uv', 'pdm', 'poetry') + :param project_dir: Path to the project directory + :param auto_detect: If True, auto-detect available package manager when requested type is not available + :return: Package manager instance + :raises: BackendExceptions if manager type is not supported or not available + """ + manager_type = manager_type.lower() + + # Check if requested manager type is supported + if manager_type not in self._managers: + available_managers = list(self._managers.keys()) + error_msg = f"Unsupported package manager: {manager_type}. Available: {available_managers}" + debug_log(error_msg, "error") + raise BackendExceptions(error_msg) + + # Create the manager instance + manager_class = self._managers[manager_type] + manager = manager_class(project_dir) + + # Check if the manager is available on the system + if not manager.is_available(): + if auto_detect: + debug_log( + f"{manager_type} is not available, trying to auto-detect alternative", + "warning", + ) + return self._auto_detect_manager(project_dir, exclude=[manager_type]) + else: + error_msg = ( + f"Package manager '{manager_type}' is not available on the system" + ) + debug_log(error_msg, "error") + raise BackendExceptions(error_msg) + + debug_log(f"Created {manager_type} package manager for {project_dir}", "info") + return manager + + @classmethod + def _auto_detect_manager( + self, project_dir: str, exclude: Optional[List[str]] = None + ) -> BasePackageManager: + """ + Auto-detect the best available package manager. + + :param project_dir: Path to the project directory + :param exclude: List of manager types to exclude from detection + :return: Package manager instance + :raises: BackendExceptions if no package manager is available + """ + exclude = exclude or [] + + # Priority order for auto-detection + detection_order = ["uv", "pdm", "poetry", "pip"] + + for manager_type in detection_order: + if manager_type in exclude: + continue + + if manager_type not in self._managers: + continue + + manager_class = self._managers[manager_type] + manager = manager_class(project_dir) + + if manager.is_available(): + debug_log(f"Auto-detected package manager: {manager_type}", "info") + return manager + + error_msg = "No package manager is available on the system" + debug_log(error_msg, "error") + raise BackendExceptions(error_msg) + + @classmethod + def get_available_managers(self) -> list[str]: + """ + Get list of available package managers on the current system. + + :return: List of available package manager names + """ + available = [] + + for manager_type, manager_class in self._managers.items(): + # Create a temporary instance to check availability + # Using current directory as a dummy project path + temp_manager = manager_class(".") + if temp_manager.is_available(): + available.append(manager_type) + + return available + + @classmethod + def register_manager( + self, name: str, manager_class: Type[BasePackageManager] + ) -> None: + """ + Register a new package manager type. + + :param name: Name of the package manager + :param manager_class: Package manager class + """ + if not issubclass(manager_class, BasePackageManager): + raise ValueError(f"Manager class must inherit from BasePackageManager") + + self._managers[name.lower()] = manager_class + debug_log(f"Registered package manager: {name}", "info") + + @classmethod + def get_supported_managers(self) -> list[str]: + """ + Get list of all supported package manager types. + + :return: List of supported package manager names + """ + return list(self._managers.keys()) diff --git a/src/fastapi_fastkit/backend/package_managers/pdm_manager.py b/src/fastapi_fastkit/backend/package_managers/pdm_manager.py new file mode 100644 index 0000000..602564b --- /dev/null +++ b/src/fastapi_fastkit/backend/package_managers/pdm_manager.py @@ -0,0 +1,275 @@ +# -------------------------------------------------------------------------- +# PDM Package Manager Implementation +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import os +import subprocess +import sys +from typing import List + +from fastapi_fastkit import console +from fastapi_fastkit.core.exceptions import BackendExceptions +from fastapi_fastkit.utils.logging import debug_log, get_logger +from fastapi_fastkit.utils.main import handle_exception, print_error, print_success + +from .base import BasePackageManager + +logger = get_logger(__name__) + + +class PdmManager(BasePackageManager): + """PDM package manager implementation.""" + + def is_available(self) -> bool: + """Check if PDM is available on the system.""" + try: + subprocess.run( + ["pdm", "--version"], + check=True, + capture_output=True, + text=True, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + def get_dependency_file_name(self) -> str: + """Get the dependency file name for PDM.""" + return "pyproject.toml" + + def create_virtual_environment(self) -> str: + """ + Create a virtual environment using PDM. + + :return: Path to the virtual environment + :raises: BackendExceptions if virtual environment creation fails + """ + venv_path = str(self.project_dir / ".venv") + + try: + with console.status("[bold green]Creating virtual environment with PDM..."): + # PDM can create virtual environment in specific location + subprocess.run( + ["pdm", "venv", "create", "--name", ".venv", sys.executable], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log(f"Virtual environment created at {venv_path}", "info") + print_success("Virtual environment created successfully with PDM") + return venv_path + + except subprocess.CalledProcessError as e: + debug_log(f"Error creating virtual environment: {e.stderr}", "error") + handle_exception(e, f"Error creating virtual environment: {str(e)}") + raise BackendExceptions("Failed to create venv with PDM") + except OSError as e: + debug_log(f"System error creating virtual environment: {e}", "error") + handle_exception(e, f"Error creating virtual environment: {str(e)}") + raise BackendExceptions(f"Failed to create venv with PDM: {str(e)}") + + def install_dependencies(self, venv_path: str) -> None: + """ + Install dependencies using PDM. + + :param venv_path: Path to the virtual environment + :raises: BackendExceptions if dependency installation fails + """ + try: + if not os.path.exists(venv_path): + debug_log( + "Virtual environment does not exist. Creating it first.", "warning" + ) + print_error("Virtual environment does not exist. Creating it first.") + venv_path = self.create_virtual_environment() + if not venv_path: + raise BackendExceptions("Failed to create venv") + + pyproject_path = self.get_dependency_file_path() + if not pyproject_path.exists(): + debug_log(f"pyproject.toml file not found at {pyproject_path}", "error") + print_error(f"pyproject.toml file not found at {pyproject_path}") + raise BackendExceptions("pyproject.toml file not found") + + # Install dependencies using PDM + with console.status("[bold green]Installing dependencies with PDM..."): + subprocess.run( + ["pdm", "install"], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log("Dependencies installed successfully with PDM", "info") + print_success("Dependencies installed successfully with PDM") + + except subprocess.CalledProcessError as e: + debug_log(f"Error during dependency installation: {e.stderr}", "error") + handle_exception(e, f"Error during dependency installation: {str(e)}") + if hasattr(e, "stderr"): + print_error(f"Error details: {e.stderr}") + raise BackendExceptions("Failed to install dependencies with PDM") + except OSError as e: + debug_log(f"System error during dependency installation: {e}", "error") + handle_exception(e, f"Error during dependency installation: {str(e)}") + raise BackendExceptions( + f"Failed to install dependencies with PDM: {str(e)}" + ) + + def generate_dependency_file( + self, + dependencies: List[str], + project_name: str = "", + author: str = "", + author_email: str = "", + description: str = "", + ) -> None: + """ + Generate a pyproject.toml file with the given dependencies and metadata. + + :param dependencies: List of dependency specifications + :param project_name: Name of the project + :param author: Author name + :param author_email: Author email + :param description: Project description + """ + pyproject_path = self.get_dependency_file_path() + + try: + # Create dependencies list as TOML format + deps_toml = "[\n" + for dep in dependencies: + deps_toml += f' "{dep}",\n' + deps_toml += "]" + + # Create basic pyproject.toml content as string + pyproject_content = f"""[project] +name = "{project_name or 'fastapi-project'}" +version = "0.1.0" +description = "{description or 'A FastAPI project'}" +authors = [ + {{name = "{author or 'Author'}", email = "{author_email or 'author@example.com'}"}}, +] +dependencies = {deps_toml} +requires-python = ">=3.8" +readme = "README.md" +license = {{text = "MIT"}} + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.pdm] +""" + + with open(pyproject_path, "w", encoding="utf-8") as f: + f.write(pyproject_content) + + debug_log( + f"Generated {pyproject_path} with {len(dependencies)} dependencies", + "info", + ) + + except (OSError, UnicodeEncodeError) as e: + debug_log(f"Error generating pyproject.toml: {e}", "error") + raise BackendExceptions(f"Failed to generate pyproject.toml: {str(e)}") + + def add_dependency(self, dependency: str, dev: bool = False) -> None: + """ + Add a new dependency using PDM. + + :param dependency: Dependency specification + :param dev: Whether this is a development dependency + """ + try: + # Use PDM's add command to add dependency + cmd = ["pdm", "add"] + if dev: + cmd.append("--dev") + cmd.append(dependency) + + subprocess.run( + cmd, + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log( + f"Added {'dev ' if dev else ''}dependency '{dependency}' with PDM", + "info", + ) + + except subprocess.CalledProcessError as e: + debug_log(f"Error adding dependency with PDM: {e}", "error") + raise BackendExceptions(f"Failed to add dependency with PDM: {str(e)}") + except OSError as e: + debug_log(f"System error adding dependency with PDM: {e}", "error") + raise BackendExceptions(f"Failed to add dependency with PDM: {str(e)}") + + def initialize_project( + self, project_name: str, author: str, author_email: str, description: str + ) -> None: + """ + Initialize a new PDM project with metadata. + + :param project_name: Name of the project + :param author: Author name + :param author_email: Author email + :param description: Project description + """ + try: + # Use PDM init command for interactive setup + subprocess.run( + ["pdm", "init", "--non-interactive"], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + # Create or update pyproject.toml with provided metadata + pyproject_path = self.get_dependency_file_path() + + # Create basic pyproject.toml with project metadata + pyproject_content = f"""[project] +name = "{project_name}" +version = "0.1.0" +description = "{description}" +authors = [ + {{name = "{author}", email = "{author_email}"}}, +] +dependencies = [] +requires-python = ">=3.8" +readme = "README.md" +license = {{text = "MIT"}} + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.pdm] +""" + + with open(pyproject_path, "w", encoding="utf-8") as f: + f.write(pyproject_content) + + debug_log(f"Initialized PDM project: {project_name}", "info") + + except subprocess.CalledProcessError as e: + debug_log(f"Error initializing PDM project: {e}", "error") + raise BackendExceptions(f"Failed to initialize PDM project: {str(e)}") + except (OSError, UnicodeEncodeError) as e: + debug_log(f"Error updating project metadata: {e}", "error") + raise BackendExceptions(f"Failed to update project metadata: {str(e)}") diff --git a/src/fastapi_fastkit/backend/package_managers/pip_manager.py b/src/fastapi_fastkit/backend/package_managers/pip_manager.py new file mode 100644 index 0000000..0516bf4 --- /dev/null +++ b/src/fastapi_fastkit/backend/package_managers/pip_manager.py @@ -0,0 +1,205 @@ +# -------------------------------------------------------------------------- +# Pip Package Manager Implementation +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import os +import subprocess +import sys +from typing import List + +from fastapi_fastkit import console +from fastapi_fastkit.core.exceptions import BackendExceptions +from fastapi_fastkit.utils.logging import debug_log, get_logger +from fastapi_fastkit.utils.main import handle_exception, print_error, print_success + +from .base import BasePackageManager + +logger = get_logger(__name__) + + +class PipManager(BasePackageManager): + """Pip package manager implementation.""" + + def is_available(self) -> bool: + """Check if pip is available on the system.""" + try: + subprocess.run( + [sys.executable, "-m", "pip", "--version"], + check=True, + capture_output=True, + text=True, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + def get_dependency_file_name(self) -> str: + """Get the dependency file name for pip.""" + return "requirements.txt" + + def create_virtual_environment(self) -> str: + """ + Create a Python virtual environment using venv module. + + :return: Path to the virtual environment + :raises: BackendExceptions if virtual environment creation fails + """ + venv_path = str(self.project_dir / ".venv") + + try: + with console.status("[bold green]Creating virtual environment..."): + subprocess.run( + [sys.executable, "-m", "venv", venv_path], + check=True, + capture_output=True, + text=True, + ) + + debug_log(f"Virtual environment created at {venv_path}", "info") + print_success("Virtual environment created successfully") + return venv_path + + except subprocess.CalledProcessError as e: + debug_log(f"Error creating virtual environment: {e.stderr}", "error") + handle_exception(e, f"Error creating virtual environment: {str(e)}") + raise BackendExceptions("Failed to create venv") + except OSError as e: + debug_log(f"System error creating virtual environment: {e}", "error") + handle_exception(e, f"Error creating virtual environment: {str(e)}") + raise BackendExceptions(f"Failed to create venv: {str(e)}") + + def install_dependencies(self, venv_path: str) -> None: + """ + Install dependencies using pip in the virtual environment. + + :param venv_path: Path to the virtual environment + :raises: BackendExceptions if dependency installation fails + """ + try: + if not os.path.exists(venv_path): + debug_log( + "Virtual environment does not exist. Creating it first.", "warning" + ) + print_error("Virtual environment does not exist. Creating it first.") + venv_path = self.create_virtual_environment() + if not venv_path: + raise BackendExceptions("Failed to create venv") + + requirements_path = self.get_dependency_file_path() + if not requirements_path.exists(): + debug_log( + f"Requirements file not found at {requirements_path}", "error" + ) + print_error(f"Requirements file not found at {requirements_path}") + raise BackendExceptions("Requirements file not found") + + # Get pip path + pip_path = self.get_executable_path("pip", venv_path) + + # Upgrade pip first + subprocess.run( + [pip_path, "install", "--upgrade", "pip"], + check=True, + capture_output=True, + text=True, + ) + + # Install dependencies + with console.status("[bold green]Installing dependencies..."): + subprocess.run( + [pip_path, "install", "-r", str(requirements_path.name)], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log("Dependencies installed successfully", "info") + print_success("Dependencies installed successfully") + + except subprocess.CalledProcessError as e: + debug_log(f"Error during dependency installation: {e.stderr}", "error") + handle_exception(e, f"Error during dependency installation: {str(e)}") + if hasattr(e, "stderr"): + print_error(f"Error details: {e.stderr}") + raise BackendExceptions("Failed to install dependencies") + except OSError as e: + debug_log(f"System error during dependency installation: {e}", "error") + handle_exception(e, f"Error during dependency installation: {str(e)}") + raise BackendExceptions(f"Failed to install dependencies: {str(e)}") + + def generate_dependency_file( + self, + dependencies: List[str], + project_name: str = "", + author: str = "", + author_email: str = "", + description: str = "", + ) -> None: + """ + Generate a requirements.txt file with the given dependencies. + + :param dependencies: List of dependency specifications + :param project_name: Name of the project (not used for pip) + :param author: Author name (not used for pip) + :param author_email: Author email (not used for pip) + :param description: Project description (not used for pip) + """ + requirements_path = self.get_dependency_file_path() + + try: + with open(requirements_path, "w", encoding="utf-8") as f: + for dep in dependencies: + f.write(f"{dep}\n") + + debug_log( + f"Generated {requirements_path} with {len(dependencies)} dependencies", + "info", + ) + + except (OSError, UnicodeEncodeError) as e: + debug_log(f"Error generating requirements.txt: {e}", "error") + raise BackendExceptions(f"Failed to generate requirements.txt: {str(e)}") + + def add_dependency(self, dependency: str, dev: bool = False) -> None: + """ + Add a new dependency to requirements.txt. + + Note: pip doesn't have built-in support for dev dependencies, + so we'll add them to the main requirements.txt file. + + :param dependency: Dependency specification + :param dev: Whether this is a development dependency (ignored for pip) + """ + requirements_path = self.get_dependency_file_path() + + try: + # Read existing dependencies + existing_deps = [] + if requirements_path.exists(): + with open(requirements_path, "r", encoding="utf-8") as f: + existing_deps = [ + line.strip() for line in f.readlines() if line.strip() + ] + + # Add new dependency if not already present + if dependency not in existing_deps: + existing_deps.append(dependency) + + with open(requirements_path, "w", encoding="utf-8") as f: + for dep in existing_deps: + f.write(f"{dep}\n") + + debug_log( + f"Added dependency '{dependency}' to requirements.txt", "info" + ) + else: + debug_log( + f"Dependency '{dependency}' already exists in requirements.txt", + "info", + ) + + except (OSError, UnicodeEncodeError, UnicodeDecodeError) as e: + debug_log(f"Error adding dependency: {e}", "error") + raise BackendExceptions(f"Failed to add dependency: {str(e)}") diff --git a/src/fastapi_fastkit/backend/package_managers/poetry_manager.py b/src/fastapi_fastkit/backend/package_managers/poetry_manager.py new file mode 100644 index 0000000..275e256 --- /dev/null +++ b/src/fastapi_fastkit/backend/package_managers/poetry_manager.py @@ -0,0 +1,378 @@ +# -------------------------------------------------------------------------- +# Poetry Package Manager Implementation +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import subprocess +from typing import List + +from fastapi_fastkit import console +from fastapi_fastkit.core.exceptions import BackendExceptions +from fastapi_fastkit.utils.logging import debug_log, get_logger +from fastapi_fastkit.utils.main import handle_exception, print_error, print_success + +from .base import BasePackageManager + +logger = get_logger(__name__) + + +class PoetryManager(BasePackageManager): + """Poetry package manager implementation.""" + + def is_available(self) -> bool: + """Check if Poetry is available on the system.""" + try: + subprocess.run( + ["poetry", "--version"], + check=True, + capture_output=True, + text=True, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + def get_dependency_file_name(self) -> str: + """Get the dependency file name for Poetry.""" + return "pyproject.toml" + + def create_virtual_environment(self) -> str: + """ + Create a virtual environment using Poetry. + + :return: Path to the virtual environment + :raises: BackendExceptions if virtual environment creation fails + """ + try: + with console.status( + "[bold green]Creating virtual environment with Poetry..." + ): + # Poetry automatically creates virtual environment when installing + # First ensure we have a basic pyproject.toml + pyproject_path = self.get_dependency_file_path() + if not pyproject_path.exists(): + # Create minimal pyproject.toml for Poetry + self._create_minimal_pyproject() + + # Get the virtual environment path from Poetry + result = subprocess.run( + ["poetry", "env", "info", "--path"], + cwd=str(self.project_dir), + capture_output=True, + text=True, + check=False, # Don't fail if venv doesn't exist yet + ) + + if result.returncode != 0: + # Create virtual environment with Poetry + subprocess.run( + ["poetry", "install", "--no-deps"], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + # Get the path again + result = subprocess.run( + ["poetry", "env", "info", "--path"], + cwd=str(self.project_dir), + capture_output=True, + text=True, + check=True, + ) + + venv_path = result.stdout.strip() + + debug_log(f"Virtual environment created at {venv_path}", "info") + print_success("Virtual environment created successfully with Poetry") + return venv_path + + except subprocess.CalledProcessError as e: + debug_log(f"Error creating virtual environment: {e.stderr}", "error") + handle_exception(e, f"Error creating virtual environment: {str(e)}") + raise BackendExceptions("Failed to create venv with Poetry") + except OSError as e: + debug_log(f"System error creating virtual environment: {e}", "error") + handle_exception(e, f"Error creating virtual environment: {str(e)}") + raise BackendExceptions(f"Failed to create venv with Poetry: {str(e)}") + + def _create_minimal_pyproject(self) -> None: + """Create a minimal pyproject.toml for Poetry.""" + pyproject_content = """[tool.poetry] +name = "temp-project" +version = "0.1.0" +description = "" +authors = ["Author "] + +[tool.poetry.dependencies] +python = "^3.8" + +[tool.poetry.group.dev.dependencies] + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" +""" + pyproject_path = self.get_dependency_file_path() + with open(pyproject_path, "w", encoding="utf-8") as f: + f.write(pyproject_content) + + def install_dependencies(self, venv_path: str) -> None: + """ + Install dependencies using Poetry. + + :param venv_path: Path to the virtual environment + :raises: BackendExceptions if dependency installation fails + """ + try: + pyproject_path = self.get_dependency_file_path() + if not pyproject_path.exists(): + debug_log(f"pyproject.toml file not found at {pyproject_path}", "error") + print_error(f"pyproject.toml file not found at {pyproject_path}") + raise BackendExceptions("pyproject.toml file not found") + + # Install dependencies using Poetry + with console.status("[bold green]Installing dependencies with Poetry..."): + subprocess.run( + ["poetry", "install"], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log("Dependencies installed successfully with Poetry", "info") + print_success("Dependencies installed successfully with Poetry") + + except subprocess.CalledProcessError as e: + debug_log(f"Error during dependency installation: {e.stderr}", "error") + handle_exception(e, f"Error during dependency installation: {str(e)}") + if hasattr(e, "stderr"): + print_error(f"Error details: {e.stderr}") + raise BackendExceptions("Failed to install dependencies with Poetry") + except OSError as e: + debug_log(f"System error during dependency installation: {e}", "error") + handle_exception(e, f"Error during dependency installation: {str(e)}") + raise BackendExceptions( + f"Failed to install dependencies with Poetry: {str(e)}" + ) + + def generate_dependency_file( + self, + dependencies: List[str], + project_name: str = "", + author: str = "", + author_email: str = "", + description: str = "", + ) -> None: + """ + Generate a pyproject.toml file with the given dependencies and metadata. + + :param dependencies: List of dependency specifications + :param project_name: Name of the project + :param author: Author name + :param author_email: Author email + :param description: Project description + """ + pyproject_path = self.get_dependency_file_path() + + try: + # Create dependencies section for Poetry + deps_section = "" + for dep in dependencies: + # Convert pip-style to poetry-style + if "==" in dep: + name, version = dep.split("==", 1) + deps_section += f'{name} = "{version}"\n' + else: + deps_section += f'{dep} = "*"\n' + + # Create basic pyproject.toml content for Poetry + pyproject_content = f"""[tool.poetry] +name = "{project_name or 'fastapi-project'}" +version = "0.1.0" +description = "{description or 'A FastAPI project'}" +authors = ["{author or 'Author'} <{author_email or 'author@example.com'}>"] +readme = "README.md" +license = "MIT" +packages = [{{include = "src"}}] + +[tool.poetry.dependencies] +python = "^3.8" +{deps_section} + +[tool.poetry.group.dev.dependencies] + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" +""" + + with open(pyproject_path, "w", encoding="utf-8") as f: + f.write(pyproject_content) + + debug_log( + f"Generated {pyproject_path} with {len(dependencies)} dependencies", + "info", + ) + + except (OSError, UnicodeEncodeError) as e: + debug_log(f"Error generating pyproject.toml: {e}", "error") + raise BackendExceptions(f"Failed to generate pyproject.toml: {str(e)}") + + def add_dependency(self, dependency: str, dev: bool = False) -> None: + """ + Add a new dependency using Poetry. + + :param dependency: Dependency specification + :param dev: Whether this is a development dependency + """ + try: + # Use Poetry's add command to add dependency + cmd = ["poetry", "add"] + if dev: + cmd.append("--group=dev") + cmd.append(dependency) + + subprocess.run( + cmd, + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log( + f"Added {'dev ' if dev else ''}dependency '{dependency}' with Poetry", + "info", + ) + + except subprocess.CalledProcessError as e: + debug_log(f"Error adding dependency with Poetry: {e}", "error") + raise BackendExceptions(f"Failed to add dependency with Poetry: {str(e)}") + except OSError as e: + debug_log(f"System error adding dependency with Poetry: {e}", "error") + raise BackendExceptions(f"Failed to add dependency with Poetry: {str(e)}") + + def initialize_project( + self, project_name: str, author: str, author_email: str, description: str + ) -> None: + """ + Initialize a new Poetry project with metadata. + + :param project_name: Name of the project + :param author: Author name + :param author_email: Author email + :param description: Project description + """ + try: + # Use Poetry init command with non-interactive mode + subprocess.run( + [ + "poetry", + "init", + "--name", + project_name, + "--description", + description, + "--author", + f"{author} <{author_email}>", + "--license", + "MIT", + "--python", + "^3.8", + "--no-interaction", + ], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log(f"Initialized Poetry project: {project_name}", "info") + + except subprocess.CalledProcessError as e: + debug_log(f"Error initializing Poetry project: {e}", "error") + raise BackendExceptions(f"Failed to initialize Poetry project: {str(e)}") + except OSError as e: + debug_log(f"System error initializing Poetry project: {e}", "error") + raise BackendExceptions(f"Failed to initialize Poetry project: {str(e)}") + + def lock_dependencies(self) -> None: + """ + Generate Poetry lock file. + + :raises: BackendExceptions if lock generation fails + """ + try: + with console.status("[bold green]Generating Poetry lock file..."): + subprocess.run( + ["poetry", "lock"], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log("Poetry lock file generated successfully", "info") + print_success("Poetry lock file generated successfully") + + except subprocess.CalledProcessError as e: + debug_log(f"Error generating Poetry lock file: {e.stderr}", "error") + handle_exception(e, f"Error generating Poetry lock file: {str(e)}") + raise BackendExceptions("Failed to generate Poetry lock file") + except OSError as e: + debug_log(f"System error generating Poetry lock file: {e}", "error") + handle_exception(e, f"Error generating Poetry lock file: {str(e)}") + raise BackendExceptions(f"Failed to generate Poetry lock file: {str(e)}") + + def run_script(self, script_command: str) -> None: + """ + Run a script using Poetry. + + :param script_command: Command to run + :raises: BackendExceptions if script execution fails + """ + try: + subprocess.run( + ["poetry", "run"] + script_command.split(), + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log(f"Poetry script executed successfully: {script_command}", "info") + + except subprocess.CalledProcessError as e: + debug_log(f"Error running Poetry script: {e.stderr}", "error") + raise BackendExceptions(f"Failed to run Poetry script: {str(e)}") + except OSError as e: + debug_log(f"System error running Poetry script: {e}", "error") + raise BackendExceptions(f"Failed to run Poetry script: {str(e)}") + + def show_dependencies(self) -> str: + """ + Show project dependencies using Poetry. + + :return: Dependencies information + :raises: BackendExceptions if showing dependencies fails + """ + try: + result = subprocess.run( + ["poetry", "show"], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + return result.stdout + + except subprocess.CalledProcessError as e: + debug_log(f"Error showing Poetry dependencies: {e.stderr}", "error") + raise BackendExceptions(f"Failed to show Poetry dependencies: {str(e)}") + except OSError as e: + debug_log(f"System error showing Poetry dependencies: {e}", "error") + raise BackendExceptions(f"Failed to show Poetry dependencies: {str(e)}") diff --git a/src/fastapi_fastkit/backend/package_managers/uv_manager.py b/src/fastapi_fastkit/backend/package_managers/uv_manager.py new file mode 100644 index 0000000..ff2f532 --- /dev/null +++ b/src/fastapi_fastkit/backend/package_managers/uv_manager.py @@ -0,0 +1,327 @@ +# -------------------------------------------------------------------------- +# UV Package Manager Implementation +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import os +import subprocess +from typing import List + +from fastapi_fastkit import console +from fastapi_fastkit.core.exceptions import BackendExceptions +from fastapi_fastkit.utils.logging import debug_log, get_logger +from fastapi_fastkit.utils.main import handle_exception, print_error, print_success + +from .base import BasePackageManager + +logger = get_logger(__name__) + + +class UvManager(BasePackageManager): + """UV package manager implementation.""" + + def is_available(self) -> bool: + """Check if UV is available on the system.""" + try: + subprocess.run( + ["uv", "--version"], + check=True, + capture_output=True, + text=True, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + def get_dependency_file_name(self) -> str: + """Get the dependency file name for UV.""" + return "pyproject.toml" + + def create_virtual_environment(self) -> str: + """ + Create a virtual environment using UV. + + :return: Path to the virtual environment + :raises: BackendExceptions if virtual environment creation fails + """ + venv_path = str(self.project_dir / ".venv") + + try: + with console.status("[bold green]Creating virtual environment with UV..."): + # UV can create virtual environment with specific Python version + subprocess.run( + ["uv", "venv", venv_path], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log(f"Virtual environment created at {venv_path}", "info") + print_success("Virtual environment created successfully with UV") + return venv_path + + except subprocess.CalledProcessError as e: + debug_log(f"Error creating virtual environment: {e.stderr}", "error") + handle_exception(e, f"Error creating virtual environment: {str(e)}") + raise BackendExceptions("Failed to create venv with UV") + except OSError as e: + debug_log(f"System error creating virtual environment: {e}", "error") + handle_exception(e, f"Error creating virtual environment: {str(e)}") + raise BackendExceptions(f"Failed to create venv with UV: {str(e)}") + + def install_dependencies(self, venv_path: str) -> None: + """ + Install dependencies using UV. + + :param venv_path: Path to the virtual environment + :raises: BackendExceptions if dependency installation fails + """ + try: + if not os.path.exists(venv_path): + debug_log( + "Virtual environment does not exist. Creating it first.", "warning" + ) + print_error("Virtual environment does not exist. Creating it first.") + venv_path = self.create_virtual_environment() + if not venv_path: + raise BackendExceptions("Failed to create venv") + + pyproject_path = self.get_dependency_file_path() + if not pyproject_path.exists(): + debug_log(f"pyproject.toml file not found at {pyproject_path}", "error") + print_error(f"pyproject.toml file not found at {pyproject_path}") + raise BackendExceptions("pyproject.toml file not found") + + # Install dependencies using UV sync + with console.status("[bold green]Installing dependencies with UV..."): + subprocess.run( + ["uv", "sync"], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log("Dependencies installed successfully with UV", "info") + print_success("Dependencies installed successfully with UV") + + except subprocess.CalledProcessError as e: + debug_log(f"Error during dependency installation: {e.stderr}", "error") + handle_exception(e, f"Error during dependency installation: {str(e)}") + if hasattr(e, "stderr"): + print_error(f"Error details: {e.stderr}") + raise BackendExceptions("Failed to install dependencies with UV") + except OSError as e: + debug_log(f"System error during dependency installation: {e}", "error") + handle_exception(e, f"Error during dependency installation: {str(e)}") + raise BackendExceptions(f"Failed to install dependencies with UV: {str(e)}") + + def generate_dependency_file( + self, + dependencies: List[str], + project_name: str = "", + author: str = "", + author_email: str = "", + description: str = "", + ) -> None: + """ + Generate a pyproject.toml file with the given dependencies and metadata. + + :param dependencies: List of dependency specifications + :param project_name: Name of the project + :param author: Author name + :param author_email: Author email + :param description: Project description + """ + pyproject_path = self.get_dependency_file_path() + + try: + # Create dependencies list as TOML format + deps_toml = "[\n" + for dep in dependencies: + deps_toml += f' "{dep}",\n' + deps_toml += "]" + + # Create basic pyproject.toml content for UV + pyproject_content = f"""[project] +name = "{project_name or 'fastapi-project'}" +version = "0.1.0" +description = "{description or 'A FastAPI project'}" +authors = [ + {{name = "{author or 'Author'}", email = "{author_email or 'author@example.com'}"}}, +] +dependencies = {deps_toml} +requires-python = ">=3.8" +readme = "README.md" +license = {{text = "MIT"}} + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.uv] +dev-dependencies = [] +""" + + with open(pyproject_path, "w", encoding="utf-8") as f: + f.write(pyproject_content) + + debug_log( + f"Generated {pyproject_path} with {len(dependencies)} dependencies", + "info", + ) + + except (OSError, UnicodeEncodeError) as e: + debug_log(f"Error generating pyproject.toml: {e}", "error") + raise BackendExceptions(f"Failed to generate pyproject.toml: {str(e)}") + + def add_dependency(self, dependency: str, dev: bool = False) -> None: + """ + Add a new dependency using UV. + + :param dependency: Dependency specification + :param dev: Whether this is a development dependency + """ + try: + # Use UV's add command to add dependency + cmd = ["uv", "add"] + if dev: + cmd.append("--dev") + cmd.append(dependency) + + subprocess.run( + cmd, + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log( + f"Added {'dev ' if dev else ''}dependency '{dependency}' with UV", + "info", + ) + + except subprocess.CalledProcessError as e: + debug_log(f"Error adding dependency with UV: {e}", "error") + raise BackendExceptions(f"Failed to add dependency with UV: {str(e)}") + except OSError as e: + debug_log(f"System error adding dependency with UV: {e}", "error") + raise BackendExceptions(f"Failed to add dependency with UV: {str(e)}") + + def initialize_project( + self, project_name: str, author: str, author_email: str, description: str + ) -> None: + """ + Initialize a new UV project with metadata. + + :param project_name: Name of the project + :param author: Author name + :param author_email: Author email + :param description: Project description + """ + try: + # Use UV init command to initialize project + subprocess.run( + ["uv", "init", "--name", project_name], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + # Create custom pyproject.toml with provided metadata + pyproject_path = self.get_dependency_file_path() + + # Create pyproject.toml with project metadata + pyproject_content = f"""[project] +name = "{project_name}" +version = "0.1.0" +description = "{description}" +authors = [ + {{name = "{author}", email = "{author_email}"}}, +] +dependencies = [] +requires-python = ">=3.8" +readme = "README.md" +license = {{text = "MIT"}} + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.uv] +dev-dependencies = [] +""" + + with open(pyproject_path, "w", encoding="utf-8") as f: + f.write(pyproject_content) + + debug_log(f"Initialized UV project: {project_name}", "info") + + except subprocess.CalledProcessError as e: + debug_log(f"Error initializing UV project: {e}", "error") + raise BackendExceptions(f"Failed to initialize UV project: {str(e)}") + except (OSError, UnicodeEncodeError) as e: + debug_log(f"Error updating project metadata: {e}", "error") + raise BackendExceptions(f"Failed to update project metadata: {str(e)}") + + def lock_dependencies(self) -> None: + """ + Generate UV lock file. + + :raises: BackendExceptions if lock generation fails + """ + try: + with console.status("[bold green]Generating UV lock file..."): + subprocess.run( + ["uv", "lock"], + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log("UV lock file generated successfully", "info") + print_success("UV lock file generated successfully") + + except subprocess.CalledProcessError as e: + debug_log(f"Error generating UV lock file: {e.stderr}", "error") + handle_exception(e, f"Error generating UV lock file: {str(e)}") + raise BackendExceptions("Failed to generate UV lock file") + except OSError as e: + debug_log(f"System error generating UV lock file: {e}", "error") + handle_exception(e, f"Error generating UV lock file: {str(e)}") + raise BackendExceptions(f"Failed to generate UV lock file: {str(e)}") + + def run_script(self, script_command: str) -> None: + """ + Run a script using UV. + + :param script_command: Command to run + :raises: BackendExceptions if script execution fails + """ + try: + subprocess.run( + ["uv", "run"] + script_command.split(), + cwd=str(self.project_dir), + check=True, + capture_output=True, + text=True, + ) + + debug_log(f"UV script executed successfully: {script_command}", "info") + + except subprocess.CalledProcessError as e: + debug_log(f"Error running UV script: {e.stderr}", "error") + raise BackendExceptions(f"Failed to run UV script: {str(e)}") + except OSError as e: + debug_log(f"System error running UV script: {e}", "error") + raise BackendExceptions(f"Failed to run UV script: {str(e)}") diff --git a/src/fastapi_fastkit/cli.py b/src/fastapi_fastkit/cli.py index 0285b97..f338565 100644 --- a/src/fastapi_fastkit/cli.py +++ b/src/fastapi_fastkit/cli.py @@ -17,10 +17,11 @@ from fastapi_fastkit.backend.main import ( add_new_route, - create_venv, + create_venv_with_manager, find_template_core_modules, + generate_dependency_file_with_manager, inject_project_metadata, - install_dependencies, + install_dependencies_with_manager, read_template_stack, ) from fastapi_fastkit.backend.transducer import copy_and_convert_template @@ -47,11 +48,6 @@ def fastkit_cli(ctx: Context, debug: bool) -> Union["BaseCommand", None]: """ main FastAPI-fastkit CLI operation group - - :param ctx: context of passing configurations (NOT specify it at CLI) - :type ctx: - :param debug: parameter from CLI - :return: None(will be wrapped with click.core.BaseCommand via @click decorator) """ settings = FastkitConfig() ctx.ensure_object(dict) @@ -91,10 +87,6 @@ def cleanup_debug_capture() -> None: def echo(ctx: Context) -> None: """ About FastAPI-fastkit - - :param ctx: context of passing configurations (NOT specify it at CLI) - :type ctx: - :return: None """ fastkit_info = f""" โšก๏ธ FastAPI fastkit - fastest [bold]FastAPI[/bold] initializer. โšก๏ธ @@ -180,6 +172,12 @@ def list_templates(ctx: Context) -> None: prompt="Enter the project description", help="The description of the new FastAPI project.", ) +@click.option( + "--package-manager", + help="Package manager to use for the project.", + type=click.Choice(["pip", "uv", "pdm", "poetry"]), + default=None, +) @click.pass_context def startdemo( ctx: Context, @@ -188,17 +186,10 @@ def startdemo( author: str, author_email: str, description: str, + package_manager: str, ) -> None: """ Create a new FastAPI project from templates and inject metadata. - - :param ctx: Click context object - :param template: Template name - :param project_name: Project name for the new project - :param author: Author name - :param author_email: Author email - :param description: Project description - :return: None """ settings = ctx.obj["settings"] @@ -239,6 +230,27 @@ def startdemo( console.print("\n") console.print(deps_table) + # Package manager selection + if not package_manager: + console.print("\n[bold]Available Package Managers:[/bold]") + package_manager_table = create_info_table( + "Package Managers", + { + f"{manager.upper()}": config["description"] + for manager, config in settings.PACKAGE_MANAGER_CONFIG.items() + }, + ) + console.print(package_manager_table) + console.print("\n") + + package_manager = click.prompt( + "Select package manager", + type=click.Choice(settings.SUPPORTED_PACKAGE_MANAGERS), + default=settings.DEFAULT_PACKAGE_MANAGER, + show_choices=True, + show_default=True, + ) + confirm = click.confirm( "\nDo you want to proceed with project creation?", default=False ) @@ -254,13 +266,13 @@ def startdemo( copy_and_convert_template(target_template, user_local, project_name) - venv_path = create_venv(project_dir) - inject_project_metadata( project_dir, project_name, author, author_email, description ) - install_dependencies(project_dir, venv_path) + # Create virtual environment and install dependencies with selected package manager + venv_path = create_venv_with_manager(project_dir, package_manager) + install_dependencies_with_manager(project_dir, venv_path, package_manager) print_success( f"FastAPI project '{project_name}' from '{template}' has been created and saved to {user_local}!" @@ -294,22 +306,26 @@ def startdemo( prompt="Enter the project description", help="The description of the new FastAPI project.", ) +@click.option( + "--package-manager", + help="Package manager to use for the project.", + type=click.Choice(["pip", "uv", "pdm", "poetry"]), + default=None, +) @click.pass_context def init( - ctx: Context, project_name: str, author: str, author_email: str, description: str + ctx: Context, + project_name: str, + author: str, + author_email: str, + description: str, + 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. Dependencies will be automatically installed based on the selected stack at venv. Project metadata will be injected to the project files. - - :param ctx: Click context object - :param project_name: Project name for the new project - :param author: Author name - :param author_email: Author email - :param description: Project description - :return: None """ settings = ctx.obj["settings"] project_dir = os.path.join(settings.USER_WORKSPACE, project_name) @@ -348,6 +364,27 @@ def init( show_choices=True, ) + # Package manager selection + if not package_manager: + console.print("\n[bold]Available Package Managers:[/bold]") + package_manager_table = create_info_table( + "Package Managers", + { + f"{manager.upper()}": config["description"] + for manager, config in settings.PACKAGE_MANAGER_CONFIG.items() + }, + ) + console.print(package_manager_table) + console.print("\n") + + package_manager = click.prompt( + "Select package manager", + type=click.Choice(settings.SUPPORTED_PACKAGE_MANAGERS), + default=settings.DEFAULT_PACKAGE_MANAGER, + show_choices=True, + show_default=True, + ) + template = "fastapi-empty" template_dir = settings.FASTKIT_TEMPLATE_ROOT target_template = os.path.join(template_dir, template) @@ -381,15 +418,26 @@ def init( f"Creating Project: {project_name}", {"Component": "Collected"} ) - with open(os.path.join(project_dir, "requirements.txt"), "w") as f: - for dep in settings.PROJECT_STACKS[stack]: - f.write(f"{dep}\n") - deps_table.add_row(dep, "โœ“") + # Generate dependency file using selected package manager + dependencies = settings.PROJECT_STACKS[stack] + generate_dependency_file_with_manager( + project_dir, + dependencies, + package_manager, + project_name, + author, + author_email, + description, + ) + + for dep in dependencies: + deps_table.add_row(dep, "โœ“") console.print(deps_table) - venv_path = create_venv(project_dir) - install_dependencies(project_dir, venv_path) + # Create virtual environment and install dependencies with selected package manager + venv_path = create_venv_with_manager(project_dir, package_manager) + install_dependencies_with_manager(project_dir, venv_path, package_manager) print_success( f"FastAPI project '{project_name}' has been created successfully and saved to {user_local}!" @@ -415,11 +463,6 @@ def init( def addroute(ctx: Context, project_name: str, route_name: str) -> None: """ Add a new route to the FastAPI project. - - :param ctx: Click context object - :param project_name: Project name - :param route_name: Name of the new route to add - :return: None """ settings = ctx.obj["settings"] user_local = settings.USER_WORKSPACE @@ -489,10 +532,6 @@ def addroute(ctx: Context, project_name: str, route_name: str) -> None: def deleteproject(ctx: Context, project_name: str) -> None: """ Delete a FastAPI project. - - :param ctx: Click context object - :param project_name: Project name - :return: None """ settings = ctx.obj["settings"] user_local = settings.USER_WORKSPACE @@ -559,13 +598,6 @@ def runserver( ) -> None: """ Run the FastAPI server for the current project. - - :param ctx: Click context object - :param host: Host address to bind the server to - :param port: Port number to bind the server to - :param reload: Enable or disable auto-reload - :param workers: Number of worker processes - :return: None """ settings = ctx.obj["settings"] project_dir = settings.USER_WORKSPACE diff --git a/src/fastapi_fastkit/core/settings.py b/src/fastapi_fastkit/core/settings.py index 1dcac69..a2917d9 100644 --- a/src/fastapi_fastkit/core/settings.py +++ b/src/fastapi_fastkit/core/settings.py @@ -65,6 +65,32 @@ class FastkitConfig: ], } + # Package Manager Options + DEFAULT_PACKAGE_MANAGER: str = "uv" + SUPPORTED_PACKAGE_MANAGERS: list[str] = ["pip", "uv", "pdm", "poetry"] + PACKAGE_MANAGER_CONFIG: dict[str, dict[str, str]] = { + "pip": { + "dependency_file": "requirements.txt", + "executable": "pip", + "description": "Standard Python package manager", + }, + "uv": { + "dependency_file": "pyproject.toml", + "executable": "uv", + "description": "Fast Python package manager", + }, + "pdm": { + "dependency_file": "pyproject.toml", + "executable": "pdm", + "description": "Modern Python dependency management", + }, + "poetry": { + "dependency_file": "pyproject.toml", + "executable": "poetry", + "description": "Python dependency management and packaging", + }, + } + # Testing Options TEST_SERVER_PORT: int = 8000 TEST_DEFAULT_TERMINAL_WIDTH: int = 80 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.py-tpl index 8bc4372..20f4ae2 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.py-tpl @@ -3,16 +3,24 @@ from setuptools import find_packages, setup with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() +install_requires = [ + "fastapi>=0.115.8", + "fastapi-mcp>=0.3.4", + "uvicorn>=0.34.0", + "pydantic>=2.10.6", + "pydantic-settings>=2.7.1", + "python-dotenv>=1.0.1", +] + setup( - name="fastapi-mcp-project", + name="", version="0.1.0", - author="Your Name", - author_email="your.email@example.com", - description="FastAPI project with Model Context Protocol (MCP) integration", + author="", + author_email="", + description="[FastAPI-fastkit templated] ", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/yourusername/fastapi-mcp-project", - packages=find_packages(), + packages=find_packages(where="src"), classifiers=[ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -25,14 +33,7 @@ setup( "Framework :: FastAPI", ], python_requires=">=3.10", - install_requires=[ - "fastapi>=0.115.8", - "fastapi-mcp>=0.3.4", - "uvicorn>=0.34.0", - "pydantic>=2.10.6", - "pydantic-settings>=2.7.1", - "python-dotenv>=1.0.1", - ], + install_requires=install_requires, extras_require={ "dev": [ "pytest>=8.3.4", diff --git a/tests/test_backends/test_main.py b/tests/test_backends/test_main.py index a82801b..cfeb634 100644 --- a/tests/test_backends/test_main.py +++ b/tests/test_backends/test_main.py @@ -52,7 +52,8 @@ def test_create_venv_success(self, mock_subprocess: MagicMock) -> None: # then expected_venv_path = str(self.project_path / ".venv") assert result == expected_venv_path - mock_subprocess.assert_called_once() + # 2 calls: is_available() check, venv creation + assert mock_subprocess.call_count == 2 @patch("subprocess.run") def test_create_venv_failure(self, mock_subprocess: MagicMock) -> None: @@ -63,7 +64,9 @@ def test_create_venv_failure(self, mock_subprocess: MagicMock) -> None: ) # when & then - with pytest.raises(BackendExceptions, match="Failed to create venv"): + with pytest.raises( + BackendExceptions, match="Failed to create virtual environment" + ): create_venv(str(self.project_path)) @patch("subprocess.run") @@ -73,7 +76,9 @@ def test_create_venv_os_error(self, mock_subprocess: MagicMock) -> None: mock_subprocess.side_effect = OSError("Permission denied") # when & then - with pytest.raises(BackendExceptions, match="Failed to create venv"): + with pytest.raises( + BackendExceptions, match="Failed to create virtual environment" + ): create_venv(str(self.project_path)) def test_find_template_core_modules(self) -> None: @@ -132,8 +137,8 @@ def test_install_dependencies_success(self, mock_subprocess: MagicMock) -> None: install_dependencies(str(self.project_path), venv_path) # then - # Should be called twice: pip upgrade and install requirements - assert mock_subprocess.call_count == 2 + # Should be called 3 times: is_available check, pip upgrade, and install requirements + assert mock_subprocess.call_count == 3 @patch("subprocess.run") def test_install_dependencies_pip_upgrade_failure( @@ -507,3 +512,358 @@ def test_add_new_route( mock_create_route.assert_called_once() mock_handle_api.assert_called_once() mock_update_main.assert_called_once() + + def test_ensure_project_structure_success(self) -> None: + """Test _ensure_project_structure function with successful structure creation.""" + # given + src_dir = self.project_path / "src" + src_dir.mkdir() + + # when + from fastapi_fastkit.backend.main import _ensure_project_structure + + result = _ensure_project_structure(str(src_dir)) + + # then + assert "api" in result + assert "api_routes" in result + assert "crud" in result + assert "schemas" in result + + # Check directories were created + assert (src_dir / "api").exists() + assert (src_dir / "api" / "routes").exists() + assert (src_dir / "crud").exists() + assert (src_dir / "schemas").exists() + + # Check __init__.py files were created + assert (src_dir / "api" / "__init__.py").exists() + assert (src_dir / "api" / "routes" / "__init__.py").exists() + assert (src_dir / "crud" / "__init__.py").exists() + assert (src_dir / "schemas" / "__init__.py").exists() + + def test_ensure_project_structure_missing_src_dir(self) -> None: + """Test _ensure_project_structure function when src directory doesn't exist.""" + # given + nonexistent_dir = str(self.project_path / "nonexistent") + + # when & then + from fastapi_fastkit.backend.main import _ensure_project_structure + + with pytest.raises(BackendExceptions, match="Source directory not found"): + _ensure_project_structure(nonexistent_dir) + + def test_ensure_project_structure_existing_directories(self) -> None: + """Test _ensure_project_structure function when directories already exist.""" + # given + src_dir = self.project_path / "src" + src_dir.mkdir() + api_dir = src_dir / "api" + api_dir.mkdir() + (api_dir / "__init__.py").write_text("# existing") + + # when + from fastapi_fastkit.backend.main import _ensure_project_structure + + result = _ensure_project_structure(str(src_dir)) + + # then + assert result["api"] == str(api_dir) + # Should preserve existing __init__.py content + assert (api_dir / "__init__.py").read_text() == "# existing" + + @patch("fastapi_fastkit.backend.main.copy_and_convert_template_file") + def test_create_route_files_success(self, mock_copy: MagicMock) -> None: + """Test _create_route_files function with successful file creation.""" + # given + modules_dir = str(self.project_path / "modules") + target_dirs = { + "api_routes": str(self.project_path / "api" / "routes"), + "crud": str(self.project_path / "crud"), + "schemas": str(self.project_path / "schemas"), + } + route_name = "test_route" + mock_copy.return_value = True + + # Create target directories + for dir_path in target_dirs.values(): + os.makedirs(dir_path, exist_ok=True) + + # when + from fastapi_fastkit.backend.main import _create_route_files + + _create_route_files(modules_dir, target_dirs, route_name) + + # then + assert mock_copy.call_count == 3 # api/routes, crud, schemas + + @patch("fastapi_fastkit.backend.main.copy_and_convert_template_file") + def test_create_route_files_existing_file(self, mock_copy: MagicMock) -> None: + """Test _create_route_files function when target file already exists.""" + # given + modules_dir = str(self.project_path / "modules") + target_dirs = { + "api_routes": str(self.project_path / "api" / "routes"), + "crud": str(self.project_path / "crud"), + "schemas": str(self.project_path / "schemas"), + } + route_name = "test_route" + + # Create target directories and existing file + os.makedirs(target_dirs["api_routes"], exist_ok=True) + os.makedirs(target_dirs["crud"], exist_ok=True) + os.makedirs(target_dirs["schemas"], exist_ok=True) + + existing_file = Path(target_dirs["api_routes"]) / f"{route_name}.py" + existing_file.write_text("# existing") + + # when + from fastapi_fastkit.backend.main import _create_route_files + + _create_route_files(modules_dir, target_dirs, route_name) + + # then + # Only crud and schemas should be called (api_routes file exists) + assert mock_copy.call_count == 2 # Only crud and schemas, not api_routes + + @patch("fastapi_fastkit.backend.main.copy_and_convert_template_file") + def test_handle_api_router_file_no_existing_file( + self, mock_copy: MagicMock + ) -> None: + """Test _handle_api_router_file function when no api.py exists.""" + # given + target_dirs = {"api": str(self.project_path / "api")} + modules_dir = str(self.project_path / "modules") + route_name = "test_route" + mock_copy.return_value = True + + os.makedirs(target_dirs["api"], exist_ok=True) + + # Create the source template file + os.makedirs(os.path.join(modules_dir, "api"), exist_ok=True) + source_file = Path(modules_dir) / "api" / "__init__.py-tpl" + source_file.write_text( + "from fastapi import APIRouter\napi_router = APIRouter()" + ) + + # when + from fastapi_fastkit.backend.main import _handle_api_router_file + + _handle_api_router_file(target_dirs, modules_dir, route_name) + + # then + # Should be called to create api.py file + mock_copy.assert_called() + + def test_handle_api_router_file_existing_file(self) -> None: + """Test _handle_api_router_file function when api.py already exists.""" + # given + api_dir = self.project_path / "api" + api_dir.mkdir() + api_file = api_dir / "api.py" + existing_content = """ +from fastapi import APIRouter + +api_router = APIRouter() + +@api_router.get("/items") +def get_items(): + return {"items": []} +""" + api_file.write_text(existing_content) + + target_dirs = {"api": str(api_dir)} + modules_dir = str(self.project_path / "modules") + route_name = "users" + + # when + from fastapi_fastkit.backend.main import _handle_api_router_file + + _handle_api_router_file(target_dirs, modules_dir, route_name) + + # then + updated_content = api_file.read_text() + # Check for the actual import pattern used in _update_api_router + assert "from .routes import users" in updated_content + assert ( + 'api_router.include_router(users.router, prefix="/users", tags=["users"])' + in updated_content + ) + + @patch("fastapi_fastkit.backend.main.copy_and_convert_template_file") + def test_process_init_files_success(self, mock_copy: MagicMock) -> None: + """Test _process_init_files function.""" + # given + modules_dir = str(self.project_path / "modules") + # _process_init_files looks for module_base in target_dirs, not exact module_type + target_dirs = { + "api": str(self.project_path / "api"), # api/routes -> api + "crud": str(self.project_path / "crud"), + "schemas": str(self.project_path / "schemas"), + } + module_types = ["api/routes", "crud", "schemas"] + mock_copy.return_value = True + + # Create target directories + for dir_path in target_dirs.values(): + os.makedirs(dir_path, exist_ok=True) + + # Create source template files for each module_base (not module_type) + for module_type in module_types: + module_base = module_type.split("/")[0] # api/routes -> api + source_dir = Path(modules_dir) / module_base + source_dir.mkdir(parents=True, exist_ok=True) + source_file = source_dir / "__init__.py-tpl" + source_file.write_text("# init file") + + # when + from fastapi_fastkit.backend.main import _process_init_files + + _process_init_files(modules_dir, target_dirs, module_types) + + # then + # Only unique module_bases will be processed: api, crud, schemas = 3 calls + assert mock_copy.call_count == 3 + + def test_update_main_app_success(self) -> None: + """Test _update_main_app function with successful update.""" + # given + src_dir = self.project_path / "src" + src_dir.mkdir() + main_py = src_dir / "main.py" + main_content = """ +from fastapi import FastAPI + +app = FastAPI() + +@app.get("/") +def read_root(): + return {"Hello": "World"} +""" + main_py.write_text(main_content) + + # when + from fastapi_fastkit.backend.main import _update_main_app + + _update_main_app(str(src_dir), "test_route") + + # then + updated_content = main_py.read_text() + assert "from src.api.api import api_router" in updated_content + assert "app.include_router(api_router)" in updated_content + + def test_update_main_app_no_main_file(self) -> None: + """Test _update_main_app function when main.py doesn't exist.""" + # given + src_dir = self.project_path / "src" + src_dir.mkdir() + + # when + from fastapi_fastkit.backend.main import _update_main_app + + _update_main_app(str(src_dir), "test_route") + + # then + # Should complete without error (warning logged) + pass + + def test_update_main_app_no_fastapi_app(self) -> None: + """Test _update_main_app function when FastAPI app is not found.""" + # given + src_dir = self.project_path / "src" + src_dir.mkdir() + main_py = src_dir / "main.py" + main_py.write_text("print('Hello World')") + + # when + from fastapi_fastkit.backend.main import _update_main_app + + _update_main_app(str(src_dir), "test_route") + + # then + # Should complete without error (warning logged) + content = main_py.read_text() + assert "app = FastAPI" not in content + + def test_update_main_app_already_configured(self) -> None: + """Test _update_main_app function when router is already configured.""" + # given + src_dir = self.project_path / "src" + src_dir.mkdir() + main_py = src_dir / "main.py" + main_content = """ +from fastapi import FastAPI +from src.api.api import api_router + +app = FastAPI() +app.include_router(api_router) + +@app.get("/") +def read_root(): + return {"Hello": "World"} +""" + main_py.write_text(main_content) + original_content = main_content + + # when + from fastapi_fastkit.backend.main import _update_main_app + + _update_main_app(str(src_dir), "test_route") + + # then + # Should not modify the file + assert main_py.read_text() == original_content + + def test_update_main_app_file_read_error(self) -> None: + """Test _update_main_app function with file read error.""" + # given + src_dir = self.project_path / "src" + src_dir.mkdir() + main_py = src_dir / "main.py" + main_py.write_text("content") + + # when & then + with patch("builtins.open", side_effect=OSError("Permission denied")): + from fastapi_fastkit.backend.main import _update_main_app + + # Should complete without raising exception + _update_main_app(str(src_dir), "test_route") + + def test_add_new_route_with_exception(self) -> None: + """Test add_new_route function with OSError.""" + # given + project_dir = str(self.project_path) + + # when & then + with patch( + "fastapi_fastkit.backend.main._ensure_project_structure", + side_effect=OSError("Permission denied"), + ): + with pytest.raises(BackendExceptions, match="Failed to add new route"): + add_new_route(project_dir, "test_route") + + def test_add_new_route_with_backend_exception(self) -> None: + """Test add_new_route function with BackendExceptions.""" + # given + project_dir = str(self.project_path) + + # when & then + with patch( + "fastapi_fastkit.backend.main._ensure_project_structure", + side_effect=BackendExceptions("Backend error"), + ): + with pytest.raises(BackendExceptions, match="Backend error"): + add_new_route(project_dir, "test_route") + + def test_add_new_route_with_unexpected_exception(self) -> None: + """Test add_new_route function with unexpected exception.""" + # given + project_dir = str(self.project_path) + + # when & then + with patch( + "fastapi_fastkit.backend.main._ensure_project_structure", + side_effect=ValueError("Unexpected error"), + ): + with pytest.raises(BackendExceptions, match="Failed to add new route"): + add_new_route(project_dir, "test_route") diff --git a/tests/test_backends/test_package_managers.py b/tests/test_backends/test_package_managers.py new file mode 100644 index 0000000..cacc843 --- /dev/null +++ b/tests/test_backends/test_package_managers.py @@ -0,0 +1,469 @@ +# -------------------------------------------------------------------------- +# Test Package Managers Module +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import shutil +import tempfile +from pathlib import Path +from typing import List +from unittest.mock import Mock, patch + +import pytest + +from fastapi_fastkit.backend.package_managers import ( + BasePackageManager, + PackageManagerFactory, + PdmManager, + PipManager, + PoetryManager, + UvManager, +) +from fastapi_fastkit.core.exceptions import BackendExceptions + + +class TestBasePackageManager: + """Test BasePackageManager abstract class.""" + + def test_cannot_instantiate_abstract_class(self) -> None: + """Test that BasePackageManager cannot be instantiated directly.""" + with pytest.raises(TypeError): + BasePackageManager("/test/path") # type: ignore[abstract] + + def test_get_executable_path_windows(self) -> None: + """Test executable path generation for Windows.""" + + class TestManager(BasePackageManager): + def is_available(self) -> bool: + return True + + def get_dependency_file_name(self) -> str: + return "test.txt" + + def create_virtual_environment(self) -> str: + return "/test/venv" + + def install_dependencies(self, venv_path: str) -> None: + pass + + def generate_dependency_file(self, deps: List[str]) -> None: + pass + + def add_dependency(self, dep: str, dev: bool = False) -> None: + pass + + manager = TestManager("/test") + + with patch("os.name", "nt"): + path = manager.get_executable_path("pip", "/test/venv") + assert path == "/test/venv/Scripts/pip.exe" + + def test_get_executable_path_unix(self) -> None: + """Test executable path generation for Unix systems.""" + + class TestManager(BasePackageManager): + def is_available(self) -> bool: + return True + + def get_dependency_file_name(self) -> str: + return "test.txt" + + def create_virtual_environment(self) -> str: + return "/test/venv" + + def install_dependencies(self, venv_path: str) -> None: + pass + + def generate_dependency_file(self, deps: List[str]) -> None: + pass + + def add_dependency(self, dep: str, dev: bool = False) -> None: + pass + + manager = TestManager("/test") + + with patch("os.name", "posix"): + path = manager.get_executable_path("pip", "/test/venv") + assert path == "/test/venv/bin/pip" + + def test_get_dependency_file_path(self) -> None: + """Test dependency file path generation.""" + + class TestManager(BasePackageManager): + def is_available(self) -> bool: + return True + + def get_dependency_file_name(self) -> str: + return "requirements.txt" + + def create_virtual_environment(self) -> str: + return "/test/venv" + + def install_dependencies(self, venv_path: str) -> None: + pass + + def generate_dependency_file(self, deps: List[str]) -> None: + pass + + def add_dependency(self, dep: str, dev: bool = False) -> None: + pass + + manager = TestManager("/test/project") + file_path = manager.get_dependency_file_path() + assert str(file_path) == "/test/project/requirements.txt" + + +class TestPackageManagerFactory: + """Test PackageManagerFactory.""" + + def test_get_supported_managers(self) -> None: + """Test getting list of supported managers.""" + supported = PackageManagerFactory.get_supported_managers() + expected = ["pip", "pdm", "uv", "poetry"] + assert set(supported) == set(expected) + + @patch( + "fastapi_fastkit.backend.package_managers.pip_manager.PipManager.is_available" + ) + def test_create_pip_manager(self, mock_is_available: Mock) -> None: + """Test creating PIP manager.""" + mock_is_available.return_value = True + manager = PackageManagerFactory.create_manager("pip", "/test") + assert isinstance(manager, PipManager) + assert manager.get_dependency_file_name() == "requirements.txt" + + @patch( + "fastapi_fastkit.backend.package_managers.pdm_manager.PdmManager.is_available" + ) + def test_create_pdm_manager(self, mock_is_available: Mock) -> None: + """Test creating PDM manager.""" + mock_is_available.return_value = True + manager = PackageManagerFactory.create_manager("pdm", "/test") + assert isinstance(manager, PdmManager) + assert manager.get_dependency_file_name() == "pyproject.toml" + + @patch("fastapi_fastkit.backend.package_managers.uv_manager.UvManager.is_available") + def test_create_uv_manager(self, mock_is_available: Mock) -> None: + """Test creating UV manager.""" + mock_is_available.return_value = True + manager = PackageManagerFactory.create_manager("uv", "/test") + assert isinstance(manager, UvManager) + assert manager.get_dependency_file_name() == "pyproject.toml" + + @patch( + "fastapi_fastkit.backend.package_managers.poetry_manager.PoetryManager.is_available" + ) + def test_create_poetry_manager(self, mock_is_available: Mock) -> None: + """Test creating Poetry manager.""" + mock_is_available.return_value = True + manager = PackageManagerFactory.create_manager("poetry", "/test") + assert isinstance(manager, PoetryManager) + assert manager.get_dependency_file_name() == "pyproject.toml" + + def test_create_unsupported_manager(self) -> None: + """Test creating unsupported manager raises exception.""" + with pytest.raises(BackendExceptions) as exc_info: + PackageManagerFactory.create_manager("unsupported", "/test") + assert "Unsupported package manager" in str(exc_info.value) + + @patch( + "fastapi_fastkit.backend.package_managers.pip_manager.PipManager.is_available" + ) + def test_create_manager_case_insensitive(self, mock_is_available: Mock) -> None: + """Test that manager creation is case insensitive.""" + mock_is_available.return_value = True + manager = PackageManagerFactory.create_manager("PIP", "/test") + assert isinstance(manager, PipManager) + + @patch( + "fastapi_fastkit.backend.package_managers.pip_manager.PipManager.is_available" + ) + def test_create_manager_not_available_no_auto_detect( + self, mock_is_available: Mock + ) -> None: + """Test creating manager when not available and auto_detect=False.""" + mock_is_available.return_value = False + + with pytest.raises(BackendExceptions) as exc_info: + PackageManagerFactory.create_manager("pip", "/test", auto_detect=False) + assert "not available on the system" in str(exc_info.value) + + def test_get_available_managers(self) -> None: + """Test getting available managers on system.""" + available = PackageManagerFactory.get_available_managers() + assert isinstance(available, list) + # At least pip should be available in most environments + # assert "pip" in available + + def test_register_manager(self) -> None: + """Test registering a new manager type.""" + + class CustomManager(BasePackageManager): + def is_available(self) -> bool: + return True + + def get_dependency_file_name(self) -> str: + return "custom.txt" + + def create_virtual_environment(self) -> str: + return "/test/venv" + + def install_dependencies(self, venv_path: str) -> None: + pass + + def generate_dependency_file(self, deps: List[str]) -> None: + pass + + def add_dependency(self, dep: str, dev: bool = False) -> None: + pass + + PackageManagerFactory.register_manager("custom", CustomManager) + + # Test that we can create the custom manager + manager = PackageManagerFactory.create_manager("custom", "/test") + assert isinstance(manager, CustomManager) + + # Cleanup + del PackageManagerFactory._managers["custom"] + + def test_register_invalid_manager(self) -> None: + """Test registering invalid manager class raises error.""" + + class InvalidManager: + pass + + with pytest.raises(ValueError) as exc_info: + PackageManagerFactory.register_manager("invalid", InvalidManager) # type: ignore[arg-type] + assert "must inherit from BasePackageManager" in str(exc_info.value) + + +class TestPipManager: + """Test PipManager implementation.""" + + 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_is_available_true(self, mock_run: Mock) -> None: + """Test is_available returns True when pip is available.""" + mock_run.return_value.returncode = 0 + assert self.manager.is_available() is True + + @patch("subprocess.run") + def test_is_available_false(self, mock_run: Mock) -> None: + """Test is_available returns False when pip is not available.""" + mock_run.side_effect = FileNotFoundError() + assert self.manager.is_available() is False + + def test_get_dependency_file_name(self) -> None: + """Test dependency file name for pip.""" + assert self.manager.get_dependency_file_name() == "requirements.txt" + + def test_generate_dependency_file(self) -> None: + """Test generating requirements.txt file.""" + dependencies = ["fastapi==0.104.1", "uvicorn==0.24.0"] + self.manager.generate_dependency_file(dependencies) + + req_file = Path(self.temp_dir) / "requirements.txt" + assert req_file.exists() + + content = req_file.read_text() + assert "fastapi==0.104.1" in content + assert "uvicorn==0.24.0" in content + + def test_add_dependency_new(self) -> None: + """Test adding new dependency to requirements.txt.""" + # Create initial requirements.txt + req_file = Path(self.temp_dir) / "requirements.txt" + req_file.write_text("fastapi==0.104.1\n") + + self.manager.add_dependency("uvicorn==0.24.0") + + content = req_file.read_text() + assert "fastapi==0.104.1" in content + assert "uvicorn==0.24.0" in content + + def test_add_dependency_existing(self) -> None: + """Test adding existing dependency doesn't duplicate.""" + # Create initial requirements.txt + req_file = Path(self.temp_dir) / "requirements.txt" + req_file.write_text("fastapi==0.104.1\n") + + self.manager.add_dependency("fastapi==0.104.1") + + content = req_file.read_text() + # Should only appear once + assert content.count("fastapi==0.104.1") == 1 + + +class TestPdmManager: + """Test PdmManager implementation.""" + + 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_is_available_true(self, mock_run: Mock) -> None: + """Test is_available returns True when PDM is available.""" + mock_run.return_value.returncode = 0 + assert self.manager.is_available() is True + + @patch("subprocess.run") + def test_is_available_false(self, mock_run: Mock) -> None: + """Test is_available returns False when PDM is not available.""" + mock_run.side_effect = FileNotFoundError() + assert self.manager.is_available() is False + + def test_get_dependency_file_name(self) -> None: + """Test dependency file name for PDM.""" + assert self.manager.get_dependency_file_name() == "pyproject.toml" + + def test_generate_dependency_file(self) -> None: + """Test generating pyproject.toml file for PDM.""" + dependencies = ["fastapi", "uvicorn"] + self.manager.generate_dependency_file(dependencies) + + toml_file = Path(self.temp_dir) / "pyproject.toml" + assert toml_file.exists() + + content = toml_file.read_text() + assert "[project]" in content + assert '"fastapi"' in content + assert '"uvicorn"' in content + assert "[build-system]" in content + assert "pdm-backend" in content + + def test_initialize_project(self) -> None: + """Test PDM project initialization.""" + with patch("subprocess.run") as mock_run: + mock_run.return_value.returncode = 0 + + self.manager.initialize_project( + "test-project", "Test Author", "test@example.com", "Test description" + ) + + # Check if pyproject.toml was created with correct content + toml_file = Path(self.temp_dir) / "pyproject.toml" + assert toml_file.exists() + + content = toml_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 + + +class TestIntegration: + """Integration tests for package managers.""" + + def setup_method(self) -> None: + """Set up test environment.""" + self.temp_dir = tempfile.mkdtemp() + + def teardown_method(self) -> None: + """Clean up test environment.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + @patch( + "fastapi_fastkit.backend.package_managers.pip_manager.PipManager.is_available" + ) + @patch( + "fastapi_fastkit.backend.package_managers.pdm_manager.PdmManager.is_available" + ) + @patch("fastapi_fastkit.backend.package_managers.uv_manager.UvManager.is_available") + @patch( + "fastapi_fastkit.backend.package_managers.poetry_manager.PoetryManager.is_available" + ) + def test_factory_creates_all_managers( + self, mock_poetry: Mock, mock_uv: Mock, mock_pdm: Mock, mock_pip: Mock + ) -> None: + """Test that factory can create all supported managers.""" + # Mock all managers as available + mock_pip.return_value = True + mock_pdm.return_value = True + mock_uv.return_value = True + mock_poetry.return_value = True + + factory = PackageManagerFactory() + + for manager_type in factory.get_supported_managers(): + manager = factory.create_manager(manager_type, self.temp_dir) + assert manager is not None + assert hasattr(manager, "is_available") + assert hasattr(manager, "get_dependency_file_name") + assert hasattr(manager, "create_virtual_environment") + assert hasattr(manager, "install_dependencies") + assert hasattr(manager, "generate_dependency_file") + assert hasattr(manager, "add_dependency") + + @patch( + "fastapi_fastkit.backend.package_managers.pip_manager.PipManager.is_available" + ) + @patch( + "fastapi_fastkit.backend.package_managers.pdm_manager.PdmManager.is_available" + ) + @patch("fastapi_fastkit.backend.package_managers.uv_manager.UvManager.is_available") + @patch( + "fastapi_fastkit.backend.package_managers.poetry_manager.PoetryManager.is_available" + ) + def test_all_managers_implement_interface( + self, mock_poetry: Mock, mock_uv: Mock, mock_pdm: Mock, mock_pip: Mock + ) -> None: + """Test that all managers properly implement the interface.""" + # Mock all managers as available + mock_pip.return_value = True + mock_pdm.return_value = True + mock_uv.return_value = True + mock_poetry.return_value = True + + factory = PackageManagerFactory() + + for manager_type in factory.get_supported_managers(): + manager = factory.create_manager(manager_type, self.temp_dir) + + # Test that all required methods exist and are callable + assert callable(manager.is_available) + assert callable(manager.get_dependency_file_name) + assert callable(manager.create_virtual_environment) + assert callable(manager.install_dependencies) + assert callable(manager.generate_dependency_file) + assert callable(manager.add_dependency) + + # Test basic method calls don't raise unexpected errors + file_name = manager.get_dependency_file_name() + assert isinstance(file_name, str) + assert len(file_name) > 0 + + @patch("fastapi_fastkit.backend.package_managers.pip_manager.subprocess.run") + def test_pip_manager_dependency_workflow(self, mock_run: Mock) -> None: + """Test complete dependency management workflow with PIP.""" + mock_run.return_value.returncode = 0 + + manager = PipManager(self.temp_dir) + + # Generate dependency file + deps = ["fastapi==0.104.1", "uvicorn==0.24.0"] + manager.generate_dependency_file(deps) + + # Check file was created + req_file = Path(self.temp_dir) / "requirements.txt" + assert req_file.exists() + + # Add new dependency + manager.add_dependency("pytest==7.4.0") + + # Check it was added + content = req_file.read_text() + assert "pytest==7.4.0" in content diff --git a/tests/test_cli_operations/test_cli.py b/tests/test_cli_operations/test_cli.py index 7ba1f1b..6459cde 100644 --- a/tests/test_cli_operations/test_cli.py +++ b/tests/test_cli_operations/test_cli.py @@ -5,7 +5,6 @@ # @author bnbong bbbong9@gmail.com # -------------------------------------------------------------------------- import os -import subprocess from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -45,7 +44,14 @@ def test_startdemo(self, temp_dir: str) -> None: fastkit_cli, ["startdemo", "fastapi-default"], input="\n".join( - ["test-project", "bnbong", "bbbong9@gmail.com", "test project", "Y"] + [ + "test-project", + "bnbong", + "bbbong9@gmail.com", + "test project", + "uv", + "Y", + ] ), ) @@ -106,7 +112,14 @@ def test_startdemo_invalid_template(self, temp_dir: str) -> None: fastkit_cli, ["startdemo", "invalid-template"], input="\n".join( - ["test-project", "bnbong", "bbbong9@gmail.com", "test project", "Y"] + [ + "test-project", + "bnbong", + "bbbong9@gmail.com", + "test project", + "uv", + "Y", + ] ), ) @@ -123,7 +136,14 @@ def test_startdemo_cancel_confirmation(self, temp_dir: str) -> None: fastkit_cli, ["startdemo", "fastapi-default"], input="\n".join( - ["test-project", "bnbong", "bbbong9@gmail.com", "test project", "N"] + [ + "test-project", + "bnbong", + "bbbong9@gmail.com", + "test project", + "uv", + "N", + ] ), ) @@ -150,6 +170,7 @@ def test_startdemo_backend_error( "bnbong", "bbbong9@gmail.com", "test project", + "uv", "Y", ] ), @@ -168,7 +189,7 @@ def test_delete_demoproject(self, temp_dir: str) -> None: fastkit_cli, ["startdemo", "fastapi-default"], input="\n".join( - [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] + [project_name, "bnbong", "bbbong9@gmail.com", "test project", "uv", "Y"] ), ) project_path = Path(temp_dir) / project_name @@ -194,7 +215,7 @@ def test_delete_demoproject_cancel(self, temp_dir: str) -> None: fastkit_cli, ["startdemo", "fastapi-default"], input="\n".join( - [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] + [project_name, "bnbong", "bbbong9@gmail.com", "test project", "uv", "Y"] ), ) project_path = Path(temp_dir) / project_name @@ -239,7 +260,11 @@ def test_list_templates(self, temp_dir: str) -> None: assert "fastapi-default" in result.output assert "fastapi-dockerized" in result.output - def test_init_minimal(self, temp_dir: str) -> None: + @patch("fastapi_fastkit.backend.package_managers.uv_manager.UvManager.is_available") + @patch("subprocess.run") + def test_init_minimal( + self, mock_subprocess: MagicMock, mock_uv_available: MagicMock, temp_dir: str + ) -> None: # given os.chdir(temp_dir) project_name = "test-minimal" @@ -247,12 +272,32 @@ def test_init_minimal(self, temp_dir: str) -> None: author_email = "test@example.com" description = "A minimal FastAPI project" + # 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) + # Also 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"], input="\n".join( - [project_name, author, author_email, description, "minimal", "Y"] + [project_name, author, author_email, description, "minimal", "uv", "Y"] ), ) @@ -270,23 +315,34 @@ def test_init_minimal(self, temp_dir: str) -> None: assert author_email in content assert description in content - with open(project_path / "requirements.txt", "r") as f: - content = f.read() - assert "fastapi" in content - assert "uvicorn" in content - assert "sqlalchemy" not in content + # Check dependency file (pyproject.toml for uv) + if (project_path / "pyproject.toml").exists(): + with open(project_path / "pyproject.toml", "r") as f: + content = f.read() + assert "fastapi" in content + assert "uvicorn" in content + assert "sqlalchemy" not in content + else: + with open(project_path / "requirements.txt", "r") as f: + content = f.read() + assert "fastapi" in content + assert "uvicorn" in content + assert "sqlalchemy" not in content venv_path = project_path / ".venv" assert venv_path.exists() and venv_path.is_dir() - pip_list = subprocess.run( - [str(venv_path / "bin" / "pip"), "list"], capture_output=True, text=True - ) - installed_packages = pip_list.stdout.lower() - assert "fastapi" in installed_packages - assert "uvicorn" in installed_packages + # Note: Actual dependency installation is mocked in tests + # Check that subprocess.run was called for dependency installation + assert ( + mock_subprocess.call_count >= 2 + ) # venv creation + dependency installation - def test_init_full(self, temp_dir: str) -> None: + @patch("fastapi_fastkit.backend.package_managers.uv_manager.UvManager.is_available") + @patch("subprocess.run") + def test_init_full( + self, mock_subprocess: MagicMock, mock_uv_available: MagicMock, temp_dir: str + ) -> None: # given os.chdir(temp_dir) project_name = "test-full" @@ -294,12 +350,32 @@ def test_init_full(self, temp_dir: str) -> None: author_email = "test@example.com" description = "A full FastAPI project" + # 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) + # Also 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"], input="\n".join( - [project_name, author, author_email, description, "full", "Y"] + [project_name, author, author_email, description, "full", "uv", "Y"] ), ) @@ -317,21 +393,28 @@ def test_init_full(self, temp_dir: str) -> None: assert author_email in content assert description in content - with open(project_path / "requirements.txt", "r") as f: - content = f.read() - assert "fastapi" in content - assert "uvicorn" in content - assert "sqlalchemy" in content + # Check dependency file (pyproject.toml for uv) + if (project_path / "pyproject.toml").exists(): + with open(project_path / "pyproject.toml", "r") as f: + content = f.read() + assert "fastapi" in content + assert "uvicorn" in content + assert "sqlalchemy" in content + else: + with open(project_path / "requirements.txt", "r") as f: + content = f.read() + assert "fastapi" in content + assert "uvicorn" in content + assert "sqlalchemy" in content venv_path = project_path / ".venv" assert venv_path.exists() and venv_path.is_dir() - pip_list = subprocess.run( - [str(venv_path / "bin" / "pip"), "list"], capture_output=True, text=True - ) - installed_packages = pip_list.stdout.lower() - assert "fastapi" in installed_packages - assert "uvicorn" in installed_packages + # Note: Actual dependency installation is mocked in tests + # Check that subprocess.run was called for dependency installation + assert ( + mock_subprocess.call_count >= 2 + ) # venv creation + dependency installation def test_init_cancel_confirmation(self, temp_dir: str) -> None: # given @@ -377,6 +460,7 @@ def test_init_backend_error( "email@example.com", "description", "minimal", + "uv", "Y", ] ), @@ -423,7 +507,7 @@ def test_is_fastkit_project_function(self, temp_dir: str) -> None: fastkit_cli, ["startdemo", "fastapi-default"], input="\n".join( - [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] + [project_name, "bnbong", "bbbong9@gmail.com", "test project", "uv", "Y"] ), ) project_path = Path(temp_dir) / project_name @@ -456,7 +540,7 @@ def test_runserver_command(self, temp_dir: str) -> None: fastkit_cli, ["startdemo", "fastapi-default"], input="\n".join( - [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] + [project_name, "bnbong", "bbbong9@gmail.com", "test project", "uv", "Y"] ), ) project_path = Path(temp_dir) / project_name @@ -497,7 +581,7 @@ def test_addroute_command(self, temp_dir: str) -> None: fastkit_cli, ["startdemo", "fastapi-default"], input="\n".join( - [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] + [project_name, "bnbong", "bbbong9@gmail.com", "test project", "uv", "Y"] ), ) project_path = Path(temp_dir) / project_name @@ -537,7 +621,7 @@ def test_addroute_cancel_confirmation(self, temp_dir: str) -> None: fastkit_cli, ["startdemo", "fastapi-default"], input="\n".join( - [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] + [project_name, "bnbong", "bbbong9@gmail.com", "test project", "uv", "Y"] ), ) project_path = Path(temp_dir) / project_name diff --git a/tests/test_cli_operations/test_cli_extended.py b/tests/test_cli_operations/test_cli_extended.py index bc03af0..75054a7 100644 --- a/tests/test_cli_operations/test_cli_extended.py +++ b/tests/test_cli_operations/test_cli_extended.py @@ -4,11 +4,9 @@ # @author bnbong bbbong9@gmail.com # -------------------------------------------------------------------------- import os -import tempfile from pathlib import Path from unittest.mock import MagicMock, patch -import pytest from click.testing import CliRunner from fastapi_fastkit.cli import fastkit_cli @@ -44,22 +42,36 @@ def test_version_command(self) -> None: # Version command might not exist, but shouldn't crash assert result.exit_code in [0, 2] # 0 for success, 2 for no such option - @patch("fastapi_fastkit.backend.main.inject_project_metadata") - @patch("fastapi_fastkit.backend.transducer.copy_and_convert_template_file") - @patch("fastapi_fastkit.backend.main.create_venv") - @patch("fastapi_fastkit.backend.main.install_dependencies") + @patch("subprocess.run") + @patch("fastapi_fastkit.backend.package_managers.uv_manager.UvManager.is_available") def test_init_standard_stack( self, - mock_install: MagicMock, - mock_venv: MagicMock, - mock_copy: MagicMock, - mock_inject: MagicMock, + mock_uv_available: MagicMock, + mock_subprocess: MagicMock, temp_dir: str, ) -> None: """Test init command with standard stack.""" # given os.chdir(temp_dir) - mock_venv.return_value = "/fake/venv" + mock_uv_available.return_value = True + + # Mock subprocess.run to simulate venv creation and dependency installation + def mock_subprocess_side_effect(*args, **kwargs) -> MagicMock: # type: ignore + cmd = args[0] if args else kwargs.get("args", []) + if isinstance(cmd, list) and len(cmd) > 0: + # Simulate venv creation by creating the directory + if cmd[0] == "uv" and "venv" in cmd: + venv_path = Path(temp_dir) / "test-standard" / ".venv" + venv_path.mkdir(parents=True, exist_ok=True) + # Create bin directory for Unix-like systems + (venv_path / "bin").mkdir(exist_ok=True) + (venv_path / "Scripts").mkdir( + exist_ok=True + ) # For Windows compatibility + + return MagicMock(returncode=0, stdout="", stderr="") + + mock_subprocess.side_effect = mock_subprocess_side_effect # when result = self.runner.invoke( @@ -72,6 +84,7 @@ def test_init_standard_stack( "test@example.com", "Standard FastAPI project", "standard", + "uv", "Y", ] ), @@ -82,6 +95,9 @@ def test_init_standard_stack( assert project_path.exists() assert "Success" in result.output + # Verify that subprocess.run was called for venv and dependency operations + assert mock_subprocess.call_count >= 2 + def test_addroute_command(self, temp_dir: str) -> None: """Test addroute command behavior.""" # given diff --git a/tests/test_core.py b/tests/test_core.py index 70f5b67..33179df 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,10 +3,13 @@ # # @author bnbong bbbong9@gmail.com # -------------------------------------------------------------------------- +import os +from unittest.mock import patch + import pytest from fastapi_fastkit.core.exceptions import BackendExceptions, TemplateExceptions -from fastapi_fastkit.core.settings import settings +from fastapi_fastkit.core.settings import FastkitConfig, settings class TestCoreExceptions: @@ -187,3 +190,156 @@ def test_settings_debug_mode_toggle(self) -> None: finally: # Reset to original state settings.set_debug_mode(original_debug) + + def test_settings_config_files_structure(self) -> None: + """Test that config files structure is properly defined.""" + # given & when + config_files = settings.TEMPLATE_PATHS["config"]["files"] # type: ignore + config_paths = settings.TEMPLATE_PATHS["config"]["paths"] # type: ignore + + # then + assert "config.py" in config_files + assert "settings.py" in config_files + assert "src/core" in config_paths + assert "src" in config_paths + assert "" in config_paths + + def test_settings_project_stacks_completeness(self) -> None: + """Test that all project stacks have required packages.""" + # given + required_packages = ["fastapi", "uvicorn", "pydantic"] + + # when & then + for stack_name, packages in settings.PROJECT_STACKS.items(): + for required_pkg in required_packages: + assert any( + required_pkg in pkg for pkg in packages + ), f"{required_pkg} missing in {stack_name}" + + def test_settings_test_configuration_values(self) -> None: + """Test test configuration values are reasonable.""" + # given & when & then + assert settings.TEST_SERVER_PORT > 0 + assert settings.TEST_SERVER_PORT < 65536 + assert settings.TEST_DEFAULT_TERMINAL_WIDTH > 0 + assert settings.TEST_MAX_TERMINAL_WIDTH >= settings.TEST_DEFAULT_TERMINAL_WIDTH + + def test_settings_debug_mode_default_value(self) -> None: + """Test debug mode default value.""" + # given & when & then + # Default should be False + config = FastkitConfig() + assert config.DEBUG_MODE is False + + def test_settings_logging_level_default_value(self) -> None: + """Test logging level default value.""" + # given & when & then + assert settings.LOGGING_LEVEL == "DEBUG" + + def test_settings_immutable_constants(self) -> None: + """Test that certain settings are properly defined as constants.""" + # given & when & then + assert hasattr(settings, "TEMPLATE_PATHS") + assert hasattr(settings, "PROJECT_STACKS") + assert isinstance(settings.TEMPLATE_PATHS, dict) + assert isinstance(settings.PROJECT_STACKS, dict) + + def test_settings_template_root_exists(self) -> None: + """Test that template root directory exists and is valid.""" + # given & when + template_root = settings.FASTKIT_TEMPLATE_ROOT + + # then + assert template_root is not None + assert len(template_root) > 0 + assert os.path.exists(template_root) + + def test_settings_project_root_exists(self) -> None: + """Test that project root directory exists and is valid.""" + # given & when + project_root = settings.FASTKIT_PROJECT_ROOT + + # then + assert project_root is not None + assert len(project_root) > 0 + assert os.path.exists(project_root) + + def test_settings_set_debug_mode_true(self) -> None: + """Test set_debug_mode with True value.""" + # given + original_debug = settings.DEBUG_MODE + + try: + # when + settings.set_debug_mode(True) + + # then + assert settings.DEBUG_MODE is True + finally: + settings.set_debug_mode(original_debug) + + def test_settings_set_debug_mode_false(self) -> None: + """Test set_debug_mode with False value.""" + # given + original_debug = settings.DEBUG_MODE + + try: + # when + settings.set_debug_mode(False) + + # then + assert settings.DEBUG_MODE is False + finally: + settings.set_debug_mode(original_debug) + + def test_settings_set_debug_mode_without_parameter(self) -> None: + """Test set_debug_mode without parameter (defaults to True).""" + # given + original_debug = settings.DEBUG_MODE + + try: + # when + settings.set_debug_mode() + + # then + assert settings.DEBUG_MODE is True + finally: + settings.set_debug_mode(original_debug) + + def test_settings_log_file_path_creation(self) -> None: + """Test that log file path is properly set.""" + # given & when + log_path = settings.LOG_FILE_PATH + + # then + assert log_path is not None + assert "fastkit.log" in log_path + + def test_settings_user_workspace_initialization(self) -> None: + """Test user workspace initialization.""" + # given & when + workspace = settings.USER_WORKSPACE + + # then + assert workspace is not None + assert len(workspace) > 0 + assert os.path.exists(workspace) + + def test_settings_error_handling_for_missing_template_files(self) -> None: + """Test error handling when template files are missing.""" + # given + from fastapi_fastkit.core.settings import FastkitConfig + + # Mock pathlib to simulate missing files + with patch("pathlib.Path.exists", return_value=False): + with patch("fastapi_fastkit.core.settings.Path") as mock_path: + mock_path.return_value.exists.return_value = False + + # when & then - should handle gracefully + try: + config = FastkitConfig() + # Should not raise exception even with missing template files + assert config is not None + except Exception: + # If exception occurs, it should be handled gracefully + pass diff --git a/tests/test_rich/test_rich.py b/tests/test_rich/test_rich.py index f86e16b..48f011d 100644 --- a/tests/test_rich/test_rich.py +++ b/tests/test_rich/test_rich.py @@ -1,30 +1,43 @@ # -------------------------------------------------------------------------- -# Testcases of Rich library operations. +# Testcases of rich console operations. # # @author bnbong bbbong9@gmail.com # -------------------------------------------------------------------------- +import logging import os import sys from io import StringIO +from typing import Any +from unittest.mock import MagicMock, patch -from click.testing import CliRunner +import pytest +from rich.console import Console + +from fastapi_fastkit.utils.logging import ( + DebugFileHandler, + DebugOutputCapture, + clear_logger_cache, + debug_log, + get_logger, + setup_logging, +) class TestCLI: def setup_method(self) -> None: - self.runner = CliRunner() + self.runner = MagicMock() # CliRunner is not used in this test file, so mock it self.current_workspace = os.getcwd() self.stdout = StringIO() self.old_stdout = sys.stdout sys.stdout = self.stdout - def teardown_method(self, console) -> None: + def teardown_method(self, console: Any) -> None: os.chdir(self.current_workspace) sys.stdout = self.old_stdout - def test_success_message(self, console) -> None: + def test_success_message(self, console: Any) -> None: # given - from src.fastapi_fastkit.utils.main import print_success + from fastapi_fastkit.utils.main import print_success test_message = "this is success test" @@ -35,9 +48,9 @@ def test_success_message(self, console) -> None: # then assert "Success" in output and test_message in output - def test_error_message(self, console) -> None: + def test_error_message(self, console: Any) -> None: # given - from src.fastapi_fastkit.utils.main import print_error + from fastapi_fastkit.utils.main import print_error test_message = "this is error test" @@ -48,9 +61,9 @@ def test_error_message(self, console) -> None: # then assert "Error" in output and test_message in output - def test_warning_message(self, console) -> None: + def test_warning_message(self, console: Any) -> None: # given - from src.fastapi_fastkit.utils.main import print_warning + from fastapi_fastkit.utils.main import print_warning test_message = "this is warning test" @@ -61,9 +74,9 @@ def test_warning_message(self, console) -> None: # then assert "Warning" in output and test_message in output - def test_info_message(self, console) -> None: + def test_info_message(self, console: Any) -> None: # given - from src.fastapi_fastkit.utils.main import print_info + from fastapi_fastkit.utils.main import print_info test_message = "this is info test" @@ -73,3 +86,255 @@ def test_info_message(self, console) -> None: # then assert "Info" in output and test_message in output + + +class TestDebugFileHandler: + """Test cases for DebugFileHandler class.""" + + def test_debug_file_handler_init(self, temp_dir: str) -> None: + """Test DebugFileHandler initialization.""" + # given + log_file_path = os.path.join(temp_dir, "logs", "test.log") + + # when + handler = DebugFileHandler(log_file_path) + + # then + assert handler.log_file_path == log_file_path + assert os.path.exists(os.path.dirname(log_file_path)) + + def test_debug_file_handler_emit_success(self, temp_dir: str) -> None: + """Test DebugFileHandler emit method with successful write.""" + # given + log_file_path = os.path.join(temp_dir, "logs", "test.log") + handler = DebugFileHandler(log_file_path) + + # Create a log record + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="Test message", + args=(), + exc_info=None, + ) + + # when + handler.emit(record) + + # then + assert os.path.exists(log_file_path) + with open(log_file_path, "r") as f: + content = f.read() + assert "Test message" in content + + def test_debug_file_handler_emit_permission_error(self, temp_dir: str) -> None: + """Test DebugFileHandler emit method with permission error.""" + # given + log_file_path = os.path.join(temp_dir, "readonly", "test.log") + handler = DebugFileHandler(log_file_path) + + # Create readonly directory + os.makedirs(os.path.dirname(log_file_path), exist_ok=True) + if os.name != "nt": # Skip on Windows + os.chmod(os.path.dirname(log_file_path), 0o444) + + # Create a log record + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="Test message", + args=(), + exc_info=None, + ) + + try: + # when & then + # Should not raise exception, but handle it gracefully + handler.emit(record) + finally: + if os.name != "nt": + os.chmod(os.path.dirname(log_file_path), 0o755) + + +class TestDebugOutputCapture: + """Test cases for DebugOutputCapture class.""" + + def test_debug_output_capture_init(self, temp_dir: str) -> None: + """Test DebugOutputCapture initialization.""" + # given + log_file_path = os.path.join(temp_dir, "debug.log") + + # when + capture = DebugOutputCapture(log_file_path) + + # then + assert capture.log_file_path == log_file_path + # Note: original_stdout and original_stderr are not set until __enter__ is called + + def test_debug_output_capture_context_manager(self, temp_dir: str) -> None: + """Test DebugOutputCapture as context manager.""" + # given + log_file_path = os.path.join(temp_dir, "debug.log") + capture = DebugOutputCapture(log_file_path) + original_stdout = sys.stdout + original_stderr = sys.stderr + + # when & then + with capture: + print("Test stdout") + print("Test stderr", file=sys.stderr) + + # After exiting context, should restore original streams + assert sys.stdout == original_stdout + assert sys.stderr == original_stderr + + def test_debug_output_capture_enter_exit(self, temp_dir: str) -> None: + """Test DebugOutputCapture enter and exit methods directly.""" + # given + log_file_path = os.path.join(temp_dir, "debug.log") + capture = DebugOutputCapture(log_file_path) + original_stdout = sys.stdout + original_stderr = sys.stderr + + # when + capture.__enter__() + + # then + assert capture.original_stdout == original_stdout + assert capture.original_stderr == original_stderr + + # when + capture.__exit__(None, None, None) + + # then + assert sys.stdout == original_stdout + assert sys.stderr == original_stderr + + +class TestLoggingFunctions: + """Test cases for logging utility functions.""" + + def test_get_logger_function(self) -> None: + """Test get_logger function.""" + # given & when + logger = get_logger("test-logger") + + # then + assert logger.name == "test-logger" + assert isinstance(logger, logging.Logger) + + def test_get_logger_function_caching(self) -> None: + """Test get_logger function caching behavior.""" + # given & when + logger1 = get_logger("cached-logger") + logger2 = get_logger("cached-logger") + + # then + assert logger1 is logger2 # Should be the same instance due to caching + + def test_debug_log_function_debug_mode_on(self) -> None: + """Test debug_log function when debug mode is enabled.""" + # given + from fastapi_fastkit.core.settings import settings + + original_debug = settings.DEBUG_MODE + + try: + settings.set_debug_mode(True) + + # when & then + # Should not raise exception + debug_log("Test debug message", "info") + debug_log("Test warning message", "warning") + debug_log("Test error message", "error") + + finally: + settings.set_debug_mode(original_debug) + + def test_debug_log_function_debug_mode_off(self) -> None: + """Test debug_log function when debug mode is disabled.""" + # given + from fastapi_fastkit.core.settings import settings + + original_debug = settings.DEBUG_MODE + + try: + settings.set_debug_mode(False) + + # when & then + # Should not raise exception and should not log anything + debug_log("Test debug message", "info") + + finally: + settings.set_debug_mode(original_debug) + + def test_debug_log_function_invalid_level(self) -> None: + """Test debug_log function with invalid log level.""" + # given + from fastapi_fastkit.core.settings import settings + + original_debug = settings.DEBUG_MODE + + try: + settings.set_debug_mode(True) + + # when & then + # Should fall back to info level and not raise exception + debug_log("Test message", "invalid_level") + + finally: + settings.set_debug_mode(original_debug) + + def test_clear_logger_cache_function(self) -> None: + """Test clear_logger_cache function.""" + # given + logger1 = get_logger("cache-test-logger") + + # Verify cache info before clearing + cache_info_before = get_logger.cache_info() + + # when + clear_logger_cache() + + # then + cache_info_after = get_logger.cache_info() + # Cache should be cleared (hits and misses reset, currsize should be 0) + assert cache_info_after.currsize == 0 + + # Getting the same logger name should create a cache miss + logger2 = get_logger("cache-test-logger") + assert isinstance(logger2, logging.Logger) + + def test_setup_logging_function_debug_mode(self, temp_dir: str) -> None: + """Test setup_logging function with debug mode.""" + # given + from fastapi_fastkit.core.settings import FastkitConfig + + test_settings = FastkitConfig() + test_settings.set_debug_mode(True) + test_settings.LOG_FILE_PATH = os.path.join(temp_dir, "test.log") + + # when + capture = setup_logging(settings=test_settings) + + # then + assert capture is not None + assert isinstance(capture, DebugOutputCapture) + + def test_setup_logging_function_no_debug_mode(self) -> None: + """Test setup_logging function without debug mode.""" + # given + from fastapi_fastkit.core.settings import FastkitConfig + + test_settings = FastkitConfig() + test_settings.set_debug_mode(False) + + # when + capture = setup_logging(settings=test_settings) + + # then + assert capture is None diff --git a/tests/test_templates/template_configs.yaml b/tests/test_templates/template_configs.yaml new file mode 100644 index 0000000..b5f7c89 --- /dev/null +++ b/tests/test_templates/template_configs.yaml @@ -0,0 +1,122 @@ +# NOTE: I'm on decision making to adjust this with test_config_based_templates.py for better opensource contribution +# # Template test configurations +# # This file defines test parameters for each FastAPI template + +# templates: +# fastapi-default: +# description: "Simple CRUD API application using FastAPI" +# expected_files: +# - "setup.py" +# - "README.md" +# - "src/main.py" +# - "src/api/api.py" +# - "src/schemas/items.py" +# - "src/crud/items.py" +# - "requirements.txt" +# required_dirs: +# - "src" +# - "tests" +# - "src/api" +# - "src/schemas" +# - "src/crud" +# package_manager: "uv" +# test_endpoints: +# - "/items/" +# - "/docs" + +# fastapi-async-crud: +# description: "Async CRUD operations with FastAPI" +# expected_files: +# - "setup.py" +# - "README.md" +# - "src/main.py" +# - "src/database.py" +# - "src/models.py" +# required_dirs: +# - "src" +# - "tests" +# package_manager: "uv" + +# fastapi-custom-response: +# description: "FastAPI with customized response handling" +# expected_files: +# - "setup.py" +# - "README.md" +# - "src/main.py" +# - "src/response_models.py" +# required_dirs: +# - "src" +# - "tests" +# package_manager: "uv" + +# fastapi-dockerized: +# description: "FastAPI with Docker support" +# expected_files: +# - "setup.py" +# - "README.md" +# - "src/main.py" +# - "Dockerfile" +# - "docker-compose.yml" +# - ".dockerignore" +# required_dirs: +# - "src" +# - "tests" +# package_manager: "uv" +# docker_enabled: true + +# fastapi-empty: +# description: "Minimal FastAPI template for rapid prototyping" +# expected_files: +# - "setup.py" +# - "README.md" +# - "src/main.py" +# - "src/core/config.py" +# required_dirs: +# - "src" +# - "tests" +# - "src/core" +# package_manager: "uv" + +# fastapi-mcp: +# description: "FastAPI with Model Context Protocol integration" +# expected_files: +# - "setup.py" +# - "README.md" +# - "src/main.py" +# - "src/mcp" +# - "src/auth" +# required_dirs: +# - "src" +# - "tests" +# - "src/mcp" +# - "src/auth" +# package_manager: "uv" +# has_auth: true + +# fastapi-psql-orm: +# description: "FastAPI with PostgreSQL and ORM" +# expected_files: +# - "setup.py" +# - "README.md" +# - "src/main.py" +# - "alembic.ini" +# - "src/database.py" +# - "src/models" +# required_dirs: +# - "src" +# - "tests" +# - "alembic" +# - "src/models" +# package_manager: "uv" +# has_database: true +# database_type: "postgresql" + +# # Global test settings +# global_settings: +# default_package_manager: "uv" +# test_timeout: 30 +# default_author: "test-author" +# default_email: "test@example.com" +# excluded_dirs: +# - "__pycache__" +# - "modules" # modules is not a regular template diff --git a/tests/test_templates/test_all_templates.py b/tests/test_templates/test_all_templates.py new file mode 100644 index 0000000..9bb01a6 --- /dev/null +++ b/tests/test_templates/test_all_templates.py @@ -0,0 +1,225 @@ +# -------------------------------------------------------------------------- +# Dynamic template testing for all FastAPI templates +# +# @author bnbong bbbong9@gmail.com +# -------------------------------------------------------------------------- +import os +from pathlib import Path +from typing import Any, Dict, Generator, List + +import pytest +from click.testing import CliRunner + +from fastapi_fastkit.cli import fastkit_cli +from fastapi_fastkit.core.settings import FastkitConfig +from fastapi_fastkit.utils.main import is_fastkit_project + + +class TemplateTestConfig: + """Template test configuration and discovery""" + + @classmethod + def discover_templates(cls) -> List[str]: + """Dynamically discover all available templates""" + settings = FastkitConfig() + template_dir = Path(settings.FASTKIT_TEMPLATE_ROOT) + + # Exclude non-template directories + excluded_dirs = {"__pycache__", "modules"} + + templates = [ + d.name + for d in template_dir.iterdir() + if d.is_dir() and d.name not in excluded_dirs + ] + + return sorted(templates) # Sort for consistent test order + + @classmethod + def get_template_metadata(cls, template_name: str) -> Dict[str, Any]: + """Get template-specific test metadata""" + # You can customize test parameters per template here + metadata = { + "expected_files": ["setup.py", "README.md", "src/main.py"], + "required_dirs": ["src", "tests"], + "package_manager": "uv", # Default package manager + } + + # Template-specific customizations + template_configs = { + "fastapi-dockerized": { + "expected_files": list(metadata["expected_files"]) + ["Dockerfile"], + }, + "fastapi-psql-orm": { + "expected_files": list(metadata["expected_files"]) + ["alembic.ini"], + "required_dirs": list(metadata["required_dirs"]) + ["src/alembic"], + }, + "fastapi-mcp": { + "expected_files": list(metadata["expected_files"]), + }, + } + + if template_name in template_configs: + metadata.update(template_configs[template_name]) + + return metadata + + +class TestAllTemplates: + """Unified test class for all FastAPI templates""" + + runner: CliRunner = CliRunner() + + @pytest.fixture + def temp_dir(self, tmpdir: Any) -> Generator[str, None, None]: + """Temporary directory fixture""" + original_cwd = os.getcwd() + os.chdir(str(tmpdir)) + yield str(tmpdir) + os.chdir(original_cwd) + + @pytest.mark.parametrize("template_name", TemplateTestConfig.discover_templates()) + def test_template_creation(self, template_name: str, temp_dir: str) -> None: + """Test template creation for all discovered templates""" + # Given + project_name = f"test-{template_name}" + author = "test-author" + author_email = "test@example.com" + description = f"A test FastAPI project with {template_name} template" + + metadata = TemplateTestConfig.get_template_metadata(template_name) + + # When + result = self.runner.invoke( + fastkit_cli, + ["startdemo", template_name], + input="\n".join( + [ + project_name, + author, + author_email, + description, + metadata["package_manager"], + "Y", + ] + ), + ) + + # Then + project_path = Path(temp_dir) / project_name + + # Basic assertions + assert ( + project_path.exists() + ), f"Project directory was not created for {template_name}" + assert ( + result.exit_code == 0 + ), f"CLI command failed for {template_name}: {result.output}" + assert ( + "Success" in result.output + ), f"Success message not found for {template_name}" + + # Template identification + assert is_fastkit_project( + str(project_path) + ), f"Not identified as fastkit project: {template_name}" + + # Check expected files + for expected_file in metadata["expected_files"]: + file_path = project_path / expected_file + assert ( + file_path.exists() + ), f"Expected file missing in {template_name}: {expected_file}" + + # Check required directories + for required_dir in metadata["required_dirs"]: + dir_path = project_path / required_dir + assert ( + dir_path.exists() + ), f"Required directory missing in {template_name}: {required_dir}" + + @pytest.mark.parametrize("template_name", TemplateTestConfig.discover_templates()) + def test_template_metadata_injection( + self, template_name: str, temp_dir: str + ) -> None: + """Test that project metadata is properly injected for all templates""" + # Given + project_name = f"metadata-test-{template_name}" + author = "Metadata Author" + author_email = "metadata@example.com" + description = f"Metadata test for {template_name}" + + metadata = TemplateTestConfig.get_template_metadata(template_name) + + # When + result = self.runner.invoke( + fastkit_cli, + ["startdemo", template_name], + input="\n".join( + [ + project_name, + author, + author_email, + description, + metadata["package_manager"], + "Y", + ] + ), + ) + + # Then + project_path = Path(temp_dir) / project_name + assert result.exit_code == 0 + + # Check setup.py contains injected metadata + setup_py = project_path / "setup.py" + if setup_py.exists(): + setup_content = setup_py.read_text() + assert ( + project_name in setup_content + ), f"Project name not found in setup.py for {template_name}" + assert ( + author in setup_content + ), f"Author not found in setup.py for {template_name}" + assert ( + author_email in setup_content + ), f"Author email not found in setup.py for {template_name}" + + def test_template_discovery(self) -> None: + """Test that template discovery works correctly""" + templates = TemplateTestConfig.discover_templates() + + # Should have discovered templates + assert len(templates) > 0, "No templates discovered" + + # Should not include excluded directories + excluded = {"__pycache__", "modules"} + for template in templates: + assert ( + template not in excluded + ), f"Excluded directory found in templates: {template}" + + # Should be sorted + assert templates == sorted( + templates + ), "Templates should be sorted for consistent test order" + + @pytest.mark.parametrize("template_name", TemplateTestConfig.discover_templates()) + def test_template_structure_validation(self, template_name: str) -> None: + """Test that template directories have required structure""" + settings = FastkitConfig() + template_path = Path(settings.FASTKIT_TEMPLATE_ROOT) / template_name + + # Template directory should exist + assert template_path.exists(), f"Template directory not found: {template_name}" + assert ( + template_path.is_dir() + ), f"Template path is not directory: {template_name}" + + # Should have README.md-tpl + readme_path = template_path / "README.md-tpl" + assert readme_path.exists(), f"README.md-tpl not found in {template_name}" + + # Should have setup.py-tpl + setup_path = template_path / "setup.py-tpl" + assert setup_path.exists(), f"setup.py-tpl not found in {template_name}" diff --git a/tests/test_templates/test_config_based_templates.py b/tests/test_templates/test_config_based_templates.py new file mode 100644 index 0000000..13de32d --- /dev/null +++ b/tests/test_templates/test_config_based_templates.py @@ -0,0 +1,213 @@ +# # -------------------------------------------------------------------------- +# # Configuration-based template testing +# # +# # @author bnbong bbbong9@gmail.com +# # -------------------------------------------------------------------------- +# import os +# from pathlib import Path +# from typing import Any, Dict, Generator, List + +# import pytest +# import yaml +# from click.testing import CliRunner + +# from fastapi_fastkit.cli import fastkit_cli +# from fastapi_fastkit.utils.main import is_fastkit_project + + +# class ConfigBasedTemplateTest: +# """Configuration-based template testing using YAML config""" + +# runner: CliRunner = CliRunner() + +# @classmethod +# def load_template_configs(cls) -> Dict[str, Any]: +# """Load template configurations from YAML file""" +# config_path = Path(__file__).parent / "template_configs.yaml" + +# try: +# with open(config_path, "r", encoding="utf-8") as f: +# config = yaml.safe_load(f) +# return config +# except FileNotFoundError: +# pytest.skip(f"Template config file not found: {config_path}") +# except yaml.YAMLError as e: +# pytest.fail(f"Failed to parse template config: {e}") + +# @classmethod +# def get_template_names(cls) -> List[str]: +# """Get all template names from configuration""" +# config = cls.load_template_configs() +# return list(config.get("templates", {}).keys()) + +# @pytest.fixture +# def temp_dir(self, tmpdir: Any) -> Generator[str, None, None]: +# """Temporary directory fixture""" +# original_cwd = os.getcwd() +# os.chdir(str(tmpdir)) +# yield str(tmpdir) +# os.chdir(original_cwd) + +# @pytest.fixture +# def template_config(self) -> Dict[str, Any]: +# """Template configuration fixture""" +# return self.load_template_configs() + +# @pytest.mark.parametrize("template_name", get_template_names.__func__()) +# def test_template_creation_from_config( +# self, template_name: str, temp_dir: str, template_config: Dict[str, Any] +# ) -> None: +# """Test template creation using configuration""" +# # Given +# template_info = template_config["templates"][template_name] +# global_settings = template_config.get("global_settings", {}) + +# project_name = f"config-test-{template_name}" +# author = global_settings.get("default_author", "test-author") +# author_email = global_settings.get("default_email", "test@example.com") +# description = template_info.get( +# "description", f"Test project for {template_name}" +# ) +# package_manager = template_info.get( +# "package_manager", global_settings.get("default_package_manager", "uv") +# ) + +# # When +# result = self.runner.invoke( +# fastkit_cli, +# ["startdemo", template_name], +# input="\n".join( +# [project_name, author, author_email, description, package_manager, "Y"] +# ), +# ) + +# # Then +# project_path = Path(temp_dir) / project_name + +# # Basic assertions +# assert ( +# project_path.exists() +# ), f"Project directory was not created for {template_name}" +# assert ( +# result.exit_code == 0 +# ), f"CLI command failed for {template_name}: {result.output}" +# assert ( +# "Success" in result.output +# ), f"Success message not found for {template_name}" + +# # Template identification +# assert is_fastkit_project( +# str(project_path) +# ), f"Not identified as fastkit project: {template_name}" + +# # Check expected files from config +# expected_files = template_info.get("expected_files", []) +# for expected_file in expected_files: +# file_path = project_path / expected_file +# assert ( +# file_path.exists() +# ), f"Expected file missing in {template_name}: {expected_file}" + +# # Check required directories from config +# required_dirs = template_info.get("required_dirs", []) +# for required_dir in required_dirs: +# dir_path = project_path / required_dir +# assert ( +# dir_path.exists() +# ), f"Required directory missing in {template_name}: {required_dir}" + +# @pytest.mark.parametrize("template_name", get_template_names.__func__()) +# def test_template_specific_features( +# self, template_name: str, temp_dir: str, template_config: Dict[str, Any] +# ) -> None: +# """Test template-specific features based on configuration""" +# template_info = template_config["templates"][template_name] + +# # Skip if no specific features to test +# features_to_test = { +# "docker_enabled", +# "has_auth", +# "has_database", +# "test_endpoints", +# } + +# if not any(feature in template_info for feature in features_to_test): +# pytest.skip(f"No specific features to test for {template_name}") + +# # Create project first +# project_name = f"feature-test-{template_name}" +# # ... (create project using same logic as above) + +# project_path = Path(temp_dir) / project_name + +# # Test Docker features +# if template_info.get("docker_enabled", False): +# docker_files = ["Dockerfile", "docker-compose.yml"] +# for docker_file in docker_files: +# assert ( +# project_path / docker_file +# ).exists(), f"Docker file missing: {docker_file}" + +# # Test Authentication features +# if template_info.get("has_auth", False): +# auth_dir = project_path / "src" / "auth" +# assert auth_dir.exists(), "Authentication directory missing" + +# # Test Database features +# if template_info.get("has_database", False): +# db_type = template_info.get("database_type", "") +# if db_type == "postgresql": +# assert ( +# project_path / "alembic.ini" +# ).exists(), "Alembic config missing for PostgreSQL" + +# def test_config_file_validity(self, template_config: Dict[str, Any]) -> None: +# """Test that the configuration file is valid and complete""" +# # Check required sections +# assert "templates" in template_config, "Missing 'templates' section in config" +# assert ( +# "global_settings" in template_config +# ), "Missing 'global_settings' section in config" + +# templates = template_config["templates"] +# assert len(templates) > 0, "No templates defined in config" + +# # Check each template has required fields +# required_fields = ["description", "expected_files", "required_dirs"] +# for template_name, template_info in templates.items(): +# for field in required_fields: +# assert ( +# field in template_info +# ), f"Missing required field '{field}' in template '{template_name}'" + +# # Check that expected_files and required_dirs are lists +# assert isinstance( +# template_info["expected_files"], list +# ), f"expected_files must be a list in {template_name}" +# assert isinstance( +# template_info["required_dirs"], list +# ), f"required_dirs must be a list in {template_name}" + +# @pytest.mark.parametrize("template_name", get_template_names.__func__()) +# def test_template_consistency( +# self, template_name: str, template_config: Dict[str, Any] +# ) -> None: +# """Test template consistency with actual file system""" +# from fastapi_fastkit.core.settings import FastkitConfig + +# # Check if template actually exists in filesystem +# settings = FastkitConfig() +# template_path = Path(settings.FASTKIT_TEMPLATE_ROOT) / template_name + +# assert template_path.exists(), f"Template directory not found: {template_name}" +# assert ( +# template_path.is_dir() +# ), f"Template path is not a directory: {template_name}" + +# # Check that required template files exist +# required_template_files = ["README.md-tpl", "setup.py-tpl"] +# for required_file in required_template_files: +# file_path = template_path / required_file +# assert ( +# file_path.exists() +# ), f"Required template file missing in {template_name}: {required_file}" diff --git a/tests/test_templates/test_fastapi-async-crud.py b/tests/test_templates/test_fastapi-async-crud.py deleted file mode 100644 index 6187cb1..0000000 --- a/tests/test_templates/test_fastapi-async-crud.py +++ /dev/null @@ -1,67 +0,0 @@ -# -------------------------------------------------------------------------- -# Testcases of fastapi-async-crud template. -# -# @author bnbong bbbong9@gmail.com -# -------------------------------------------------------------------------- -import os -import shutil -from pathlib import Path -from typing import Any, Generator - -import pytest -from click.testing import CliRunner - -from fastapi_fastkit.cli import fastkit_cli -from fastapi_fastkit.utils.main import is_fastkit_project - - -class TestFastAPIAsyncCRUD: - runner: CliRunner = CliRunner() - - @pytest.fixture - def temp_dir(self, tmpdir: Any) -> Generator[str, None, None]: - os.chdir(str(tmpdir)) - yield str(tmpdir) - # Clean up - os.chdir(os.path.expanduser("~")) - - def test_startdemo_fastapi_async_crud(self, temp_dir: str) -> None: - # given - project_name = "test-async-crud" - author = "test-author" - author_email = "test@example.com" - description = "A test FastAPI project with async CRUD operations" - - # when - result = self.runner.invoke( - fastkit_cli, - ["startdemo", "fastapi-async-crud"], - input="\n".join([project_name, author, author_email, description, "Y"]), - ) - - # then - project_path = Path(temp_dir) / project_name - - assert project_path.exists(), "Project directory was not created" - assert result.exit_code == 0, f"CLI command failed: {result.output}" - assert "Success" in result.output, "Success message not found in output" - - assert is_fastkit_project( - str(project_path) - ), "Not identified as a fastkit project" - - assert (project_path / "setup.py").exists(), "setup.py not found" - assert (project_path / "src" / "main.py").exists(), "main.py not found" - assert ( - project_path / "requirements.txt" - ).exists(), "requirements.txt not found" - - assert (project_path / "src" / "api").exists(), "API module not found" - assert (project_path / "src" / "schemas").exists(), "Schemas module not found" - assert (project_path / "src" / "crud").exists(), "CRUD module not found" - - with open(project_path / "setup.py", "r") as f: - setup_content = f.read() - assert project_name in setup_content, "Project name not injected" - assert author in setup_content, "Author not injected" - assert description in setup_content, "Description not injected" diff --git a/tests/test_templates/test_fastapi-customized-response.py b/tests/test_templates/test_fastapi-customized-response.py deleted file mode 100644 index a56f4ea..0000000 --- a/tests/test_templates/test_fastapi-customized-response.py +++ /dev/null @@ -1,66 +0,0 @@ -# -------------------------------------------------------------------------- -# Testcases of fastapi-customized-response template. -# -# @author bnbong bbbong9@gmail.com -# -------------------------------------------------------------------------- -import os -from pathlib import Path -from typing import Any, Generator - -import pytest -from click.testing import CliRunner - -from fastapi_fastkit.cli import fastkit_cli -from fastapi_fastkit.utils.main import is_fastkit_project - - -class TestFastAPICustomizedResponse: - runner: CliRunner = CliRunner() - - @pytest.fixture - def temp_dir(self, tmpdir: Any) -> Generator[str, None, None]: - os.chdir(str(tmpdir)) - yield str(tmpdir) - # Clean up - os.chdir(os.path.expanduser("~")) - - def test_startdemo_fastapi_customized_response(self, temp_dir: str) -> None: - # given - project_name = "test-custom-response" - author = "test-author" - author_email = "test@example.com" - description = "A test FastAPI project with customized response handling" - - # when - result = self.runner.invoke( - fastkit_cli, - ["startdemo", "fastapi-custom-response"], - input="\n".join([project_name, author, author_email, description, "Y"]), - ) - - # then - project_path = Path(temp_dir) / project_name - - assert project_path.exists(), "Project directory was not created" - assert result.exit_code == 0, f"CLI command failed: {result.output}" - assert "Success" in result.output, "Success message not found in output" - - assert is_fastkit_project( - str(project_path) - ), "Not identified as a fastkit project" - - assert (project_path / "setup.py").exists(), "setup.py not found" - assert (project_path / "src" / "main.py").exists(), "main.py not found" - assert ( - project_path / "requirements.txt" - ).exists(), "requirements.txt not found" - - assert ( - project_path / "src" / "schemas" / "base.py" - ).exists(), "Custom responses module not found" - - with open(project_path / "setup.py", "r") as f: - setup_content = f.read() - assert project_name in setup_content, "Project name not injected" - assert author in setup_content, "Author not injected" - assert description in setup_content, "Description not injected" diff --git a/tests/test_templates/test_fastapi-default.py b/tests/test_templates/test_fastapi-default.py deleted file mode 100644 index 2ae0851..0000000 --- a/tests/test_templates/test_fastapi-default.py +++ /dev/null @@ -1,63 +0,0 @@ -# -------------------------------------------------------------------------- -# Testcases of fastapi-default template. -# -# @author bnbong bbbong9@gmail.com -# -------------------------------------------------------------------------- -import os -from pathlib import Path -from typing import Any, Generator - -import pytest -from click.testing import CliRunner - -from fastapi_fastkit.cli import fastkit_cli -from fastapi_fastkit.utils.main import is_fastkit_project - - -class TestFastAPIDefault: - runner: CliRunner = CliRunner() - - @pytest.fixture - def temp_dir(self, tmpdir: Any) -> Generator[str, None, None]: - os.chdir(str(tmpdir)) - yield str(tmpdir) - # Clean up - os.chdir(os.path.expanduser("~")) - - def test_startdemo_fastapi_default(self, temp_dir: str) -> None: - # given - project_name = "test-default" - author = "test-author" - author_email = "test@example.com" - description = "A test FastAPI project with default template" - - # when - result = self.runner.invoke( - fastkit_cli, - ["startdemo", "fastapi-default"], - input="\n".join([project_name, author, author_email, description, "Y"]), - ) - - # then - project_path = Path(temp_dir) / project_name - - assert project_path.exists(), "Project directory was not created" - assert result.exit_code == 0, f"CLI command failed: {result.output}" - assert "Success" in result.output, "Success message not found in output" - - assert is_fastkit_project( - str(project_path) - ), "Not identified as a fastkit project" - - assert (project_path / "setup.py").exists(), "setup.py not found" - assert (project_path / "src" / "main.py").exists(), "main.py not found" - assert ( - project_path / "requirements.txt" - ).exists(), "requirements.txt not found" - assert (project_path / ".venv").exists(), "Virtual environment not created" - - with open(project_path / "setup.py", "r") as f: - setup_content = f.read() - assert project_name in setup_content, "Project name not injected" - assert author in setup_content, "Author not injected" - assert description in setup_content, "Description not injected" diff --git a/tests/test_templates/test_fastapi-dockerized.py b/tests/test_templates/test_fastapi-dockerized.py deleted file mode 100644 index 8101a44..0000000 --- a/tests/test_templates/test_fastapi-dockerized.py +++ /dev/null @@ -1,64 +0,0 @@ -# -------------------------------------------------------------------------- -# Testcases of fastapi-dockerized template. -# -# @author bnbong bbbong9@gmail.com -# -------------------------------------------------------------------------- -import os -from pathlib import Path -from typing import Any, Generator - -import pytest -from click.testing import CliRunner - -from fastapi_fastkit.cli import fastkit_cli -from fastapi_fastkit.utils.main import is_fastkit_project - - -class TestFastAPIDockerized: - runner: CliRunner = CliRunner() - - @pytest.fixture - def temp_dir(self, tmpdir: Any) -> Generator[str, None, None]: - os.chdir(str(tmpdir)) - yield str(tmpdir) - # Clean up - os.chdir(os.path.expanduser("~")) - - def test_startdemo_fastapi_dockerized(self, temp_dir: str) -> None: - # given - project_name = "test-dockerized" - author = "test-author" - author_email = "test@example.com" - description = "A test FastAPI project with Docker support" - - # when - result = self.runner.invoke( - fastkit_cli, - ["startdemo", "fastapi-dockerized"], - input="\n".join([project_name, author, author_email, description, "Y"]), - ) - - # then - project_path = Path(temp_dir) / project_name - - assert project_path.exists(), "Project directory was not created" - assert result.exit_code == 0, f"CLI command failed: {result.output}" - assert "Success" in result.output, "Success message not found in output" - - assert is_fastkit_project( - str(project_path) - ), "Not identified as a fastkit project" - - assert (project_path / "setup.py").exists(), "setup.py not found" - assert (project_path / "src" / "main.py").exists(), "main.py not found" - assert ( - project_path / "requirements.txt" - ).exists(), "requirements.txt not found" - - assert (project_path / "Dockerfile").exists(), "Dockerfile not found" - - with open(project_path / "setup.py", "r") as f: - setup_content = f.read() - assert project_name in setup_content, "Project name not injected" - assert author in setup_content, "Author not injected" - assert description in setup_content, "Description not injected" diff --git a/tests/test_utils.py b/tests/test_utils.py index 2ad309d..59a8aaf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -148,3 +148,168 @@ def test_setup_logging_with_terminal_width(self) -> None: # Just check that logging was configured mock_get_logger.assert_called() mock_logger.setLevel.assert_called_once_with("DEBUG") + + def test_is_fastkit_project_function_with_setup_py(self, temp_dir: str) -> None: + """Test is_fastkit_project function with setup.py containing fastkit metadata.""" + # given + from fastapi_fastkit.utils.main import is_fastkit_project + + project_path = Path(temp_dir) / "fastkit-project" + project_path.mkdir() + setup_py = project_path / "setup.py" + setup_py.write_text( + """ +from setuptools import setup +setup( + name="test-project", + description="Created with FastAPI-fastkit" +) +""" + ) + + # when & then + assert is_fastkit_project(str(project_path)) is True + + def test_is_fastkit_project_function_invalid_path(self) -> None: + """Test is_fastkit_project function with invalid path.""" + # given + from fastapi_fastkit.utils.main import is_fastkit_project + + nonexistent_path = "/nonexistent/path" + + # when & then + assert is_fastkit_project(nonexistent_path) is False + + def test_print_error_with_traceback(self) -> None: + """Test print_error function with traceback enabled.""" + # given + import io + + from rich.console import Console + + from fastapi_fastkit.core.settings import settings + from fastapi_fastkit.utils.main import print_error + + # Setup console to capture output + string_io = io.StringIO() + test_console = Console(file=string_io, force_terminal=True, width=80) + original_debug = settings.DEBUG_MODE + + try: + settings.set_debug_mode(True) + + # when + print_error("Test error message", console=test_console, show_traceback=True) + + # then + output = string_io.getvalue() + assert "Test error message" in output + + finally: + settings.set_debug_mode(original_debug) + + def test_print_success_function(self) -> None: + """Test print_success function.""" + # given + import io + + from rich.console import Console + + from fastapi_fastkit.utils.main import print_success + + # Setup console to capture output + string_io = io.StringIO() + test_console = Console(file=string_io, force_terminal=True, width=80) + + # when + print_success("Test success message", console=test_console) + + # then + output = string_io.getvalue() + assert "Test success message" in output + + def test_print_warning_function(self) -> None: + """Test print_warning function.""" + # given + import io + + from rich.console import Console + + from fastapi_fastkit.utils.main import print_warning + + # Setup console to capture output + string_io = io.StringIO() + test_console = Console(file=string_io, force_terminal=True, width=80) + + # when + print_warning("Test warning message", console=test_console) + + # then + output = string_io.getvalue() + assert "Test warning message" in output + + def test_print_info_function(self) -> None: + """Test print_info function.""" + # given + import io + + from rich.console import Console + + from fastapi_fastkit.utils.main import print_info + + # Setup console to capture output + string_io = io.StringIO() + test_console = Console(file=string_io, force_terminal=True, width=80) + + # when + print_info("Test info message", console=test_console) + + # then + output = string_io.getvalue() + assert "Test info message" in output + + def test_handle_exception_function(self) -> None: + """Test handle_exception function.""" + # given + from fastapi_fastkit.utils.main import handle_exception + + # when & then + # Should not raise exception + handle_exception(ValueError("Test exception"), "Custom error message") + + def test_handle_exception_function_no_message(self) -> None: + """Test handle_exception function without custom message.""" + # given + from fastapi_fastkit.utils.main import handle_exception + + # when & then + # Should not raise exception + handle_exception(ValueError("Test exception")) + + def test_create_info_table_function(self) -> None: + """Test create_info_table function.""" + # given + from fastapi_fastkit.utils.main import create_info_table + + # when + table = create_info_table( + "Test Information", + { + "Key 1": "Value 1", + "Key 2": "Value 2", + }, + ) + + # then + assert table.title == "Test Information" + + def test_create_info_table_function_empty_data(self) -> None: + """Test create_info_table function with empty data.""" + # given + from fastapi_fastkit.utils.main import create_info_table + + # when + table = create_info_table("Empty Table", {}) + + # then + assert table.title == "Empty Table"