diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d47d790..cce4eab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,8 @@ jobs: - "3.10" - "3.11" - "3.12" + - "3.13" + - "3.14" fail-fast: false steps: - name: Dump GitHub context @@ -57,6 +59,8 @@ jobs: if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }} with: limit-access-to-actor: true + - name: Install uv + uses: astral-sh/setup-uv@v5 - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: pip install -r requirements-tests.txt diff --git a/pyproject.toml b/pyproject.toml index 1ce12e6..b94dba2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ dependencies = [ [project.optional-dependencies] standard = ["uvicorn[standard] >= 0.15.0"] +[project.scripts] +fastapi-cloud = "fastapi_cloud_cli.cli:main" + [project.urls] Homepage = "https://github.com/fastapilabs/fastapi-cloud-cli" Documentation = "https://fastapi.tiangolo.com/fastapi-cloud-cli/" diff --git a/src/fastapi_cloud_cli/cli.py b/src/fastapi_cloud_cli/cli.py index e5a2f2e..b7f6b36 100644 --- a/src/fastapi_cloud_cli/cli.py +++ b/src/fastapi_cloud_cli/cli.py @@ -4,6 +4,7 @@ from .commands.env import env_app from .commands.login import login from .commands.logout import logout +from .commands.new import new from .commands.unlink import unlink from .commands.whoami import whoami from .logging import setup_logging @@ -20,6 +21,9 @@ app.command()(deploy) app.command()(login) app.command()(logout) +app.command( + context_settings={"allow_extra_args": True, "ignore_unknown_options": True} +)(new) app.command()(whoami) app.command()(unlink) diff --git a/src/fastapi_cloud_cli/commands/new.py b/src/fastapi_cloud_cli/commands/new.py new file mode 100644 index 0000000..41017be --- /dev/null +++ b/src/fastapi_cloud_cli/commands/new.py @@ -0,0 +1,244 @@ +import pathlib +import shutil +import subprocess +from dataclasses import dataclass, field +from typing import List, Optional + +import typer +from rich_toolkit import RichToolkit +from typing_extensions import Annotated + +from fastapi_cloud_cli.utils.cli import get_rich_toolkit + +# TODO: Add ability to fetch different templates in the future via --template option +TEMPLATE_CONTENT = """from fastapi import FastAPI +app = FastAPI() + +@app.get("/") +def main(): + return {"message": "Hello World"} +""" + + +@dataclass +class ProjectConfig: + name: str + path: pathlib.Path + extra_args: List[str] = field(default_factory=list) + + +def _generate_readme(project_name: str) -> str: + return f"""# {project_name} + +A project created with FastAPI Cloud CLI. + +## Quick Start + +Start the development server: + +```bash +uv run fastapi dev +``` + +Visit http://localhost:8000 + +Deploy to FastAPI Cloud: + +```bash +uv run fastapi login +uv run fastapi deploy +``` + +## Project Structure + +- `main.py` - Your FastAPI application +- `pyproject.toml` - Project dependencies + +## Learn More + +- [FastAPI Documentation](https://fastapi.tiangolo.com) +- [FastAPI Cloud](https://fastapicloud.com) +""" + + +def _exit_with_error(toolkit: RichToolkit, error_msg: str) -> None: + toolkit.print(f"[bold red]Error:[/bold red] {error_msg}", tag="error") + raise typer.Exit(code=1) + + +def _validate_python_version_in_args(extra_args: List[str]) -> Optional[str]: + """ + Check if --python is specified in extra_args and validate it's >= 3.8. + Returns error message if < 3.8, None otherwise. + Let uv handle malformed versions or versions it can't find. + """ + if not extra_args: + return None + + for i, arg in enumerate(extra_args): + if arg in ("--python", "-p") and i + 1 < len(extra_args): + version_str = extra_args[i + 1] + try: + parts = version_str.split(".") + if len(parts) < 2: + return None # Let uv handle malformed version + major, minor = int(parts[0]), int(parts[1]) + + if major < 3 or (major == 3 and minor < 8): + return f"Python {version_str} is not supported. FastAPI requires Python 3.8 or higher." + return None + except (ValueError, IndexError): + # Malformed version - let uv handle the error + return None + return None + + +def _setup(toolkit: RichToolkit, config: ProjectConfig) -> None: + error = _validate_python_version_in_args(config.extra_args) + if error: + _exit_with_error(toolkit, error) + + msg = "Setting up environment with uv" + + if config.extra_args: + msg += f" ({' '.join(config.extra_args)})" + + toolkit.print(msg, tag="env") + + # If config.name is provided, create in subdirectory; otherwise init in current dir + # uv will infer the project name from the directory name + if config.path == pathlib.Path.cwd(): + init_cmd = ["uv", "init"] + else: + init_cmd = ["uv", "init", config.name] + + if config.extra_args: + init_cmd.extend(config.extra_args) + + try: + subprocess.run(init_cmd, check=True, capture_output=True) + except subprocess.CalledProcessError as e: + stderr = e.stderr.decode() if e.stderr else "No details available" + _exit_with_error(toolkit, f"Failed to initialize project with uv. {stderr}") + + +def _install_dependencies(toolkit: RichToolkit, config: ProjectConfig) -> None: + toolkit.print("Installing dependencies...", tag="deps") + + try: + subprocess.run( + ["uv", "add", "fastapi[standard]"], + check=True, + capture_output=True, + cwd=config.path, + ) + except subprocess.CalledProcessError as e: + stderr = e.stderr.decode() if e.stderr else "No details available" + _exit_with_error(toolkit, f"Failed to install dependencies. {stderr}") + + +def _write_template_files(toolkit: RichToolkit, config: ProjectConfig) -> None: + toolkit.print("Writing template files...", tag="template") + readme_content = _generate_readme(config.name) + + try: + (config.path / "main.py").write_text(TEMPLATE_CONTENT) + (config.path / "README.md").write_text(readme_content) + except Exception as e: + _exit_with_error(toolkit, f"Failed to write template files. {str(e)}") + + +def new( + ctx: typer.Context, + project_name: Annotated[ + Optional[str], + typer.Argument( + help="The name of the new FastAPI Cloud project. If not provided, initializes in the current directory.", + ), + ] = None, +) -> None: + if project_name: + name = project_name + path = pathlib.Path.cwd() / project_name + else: + name = pathlib.Path.cwd().name + path = pathlib.Path.cwd() + + config = ProjectConfig( + name=name, + path=path, + extra_args=ctx.args if hasattr(ctx, "args") else [], + ) + + with get_rich_toolkit() as toolkit: + toolkit.print_title("Creating a new project 🚀", tag="FastAPI") + + toolkit.print_line() + + if not project_name: + toolkit.print( + f"[yellow]⚠️ No project name provided. Initializing in current directory: {path}[/yellow]", + tag="warning", + ) + toolkit.print_line() + + # Check if project directory already exists (only for new subdirectory) + if project_name and config.path.exists(): + _exit_with_error(toolkit, f"Directory '{project_name}' already exists.") + + if shutil.which("uv") is None: + _exit_with_error( + toolkit, + "uv is required to create new projects. Install it from https://uv.run/docs/installation/", + ) + + _setup(toolkit, config) + + toolkit.print_line() + + _install_dependencies(toolkit, config) + + toolkit.print_line() + + _write_template_files(toolkit, config) + + toolkit.print_line() + + # Print success message + if project_name: + toolkit.print( + f"[bold green]✨ Success![/bold green] Created FastAPI project: [cyan]{project_name}[/cyan]", + tag="success", + ) + + toolkit.print_line() + + toolkit.print("[bold]Next steps:[/bold]") + toolkit.print(f" [dim]$[/dim] cd {project_name}") + toolkit.print(" [dim]$[/dim] uv run fastapi dev") + else: + toolkit.print( + "[bold green]✨ Success![/bold green] Initialized FastAPI project in current directory", + tag="success", + ) + + toolkit.print_line() + + toolkit.print("[bold]Next steps:[/bold]") + toolkit.print(" [dim]$[/dim] uv run fastapi dev") + + toolkit.print_line() + + toolkit.print("Visit [blue]http://localhost:8000[/blue]") + + toolkit.print_line() + + toolkit.print("[bold]Deploy to FastAPI Cloud:[/bold]") + toolkit.print(" [dim]$[/dim] uv run fastapi login") + toolkit.print(" [dim]$[/dim] uv run fastapi deploy") + + toolkit.print_line() + + toolkit.print( + "[dim]💡 Tip: Use 'uv run' to automatically use the project's environment[/dim]" + ) diff --git a/tests/test_cli_new.py b/tests/test_cli_new.py new file mode 100644 index 0000000..54965fd --- /dev/null +++ b/tests/test_cli_new.py @@ -0,0 +1,195 @@ +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner + +from fastapi_cloud_cli.cli import app + +runner = CliRunner() + + +@pytest.fixture +def temp_project_dir(tmp_path: Path, monkeypatch: Any) -> Path: + """Create a temporary directory and cd into it.""" + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture(autouse=True) +def check_uv_installed() -> None: + """Skip tests if uv is not installed.""" + if not shutil.which("uv"): + pytest.skip("uv is not installed") # pragma: no cover + + +class TestNewCommand: + def _assert_project_created( + self, project_path: Path, check_version_file: bool = False + ) -> None: + assert (project_path / "main.py").exists() + assert (project_path / "README.md").exists() + assert (project_path / "pyproject.toml").exists() + if check_version_file: + assert (project_path / ".python-version").exists() + + def test_creates_project_successfully(self, temp_project_dir: Path) -> None: + result = runner.invoke(app, ["new", "my_fastapi_project"]) + + assert result.exit_code == 0 + project_path = temp_project_dir / "my_fastapi_project" + self._assert_project_created(project_path) + assert "Success!" in result.output + assert "my_fastapi_project" in result.output + + def test_creates_project_with_python_version(self, temp_project_dir: Path) -> None: + # Test long form + result = runner.invoke(app, ["new", "project_long", "--python", "3.12"]) + assert result.exit_code == 0 + project_path = temp_project_dir / "project_long" + self._assert_project_created(project_path, check_version_file=True) + assert "3.12" in (project_path / ".python-version").read_text() + + # Test short form + result = runner.invoke(app, ["new", "project_short", "-p", "3.9"]) + assert result.exit_code == 0 + project_path = temp_project_dir / "project_short" + assert "3.9" in (project_path / ".python-version").read_text() + + def test_creates_project_with_extra_uv_flags(self, temp_project_dir: Path) -> None: + """Test that extra flags are passed through to uv.""" + result = runner.invoke( + app, ["new", "my_fastapi_project", "--python", "3.12", "--lib"] + ) + + assert result.exit_code == 0 + project_path = temp_project_dir / "my_fastapi_project" + self._assert_project_created(project_path) + + def test_validates_template_file_contents(self, temp_project_dir: Path) -> None: + result = runner.invoke(app, ["new", "sample_project"]) + assert result.exit_code == 0 + + project_path = temp_project_dir / "sample_project" + + main_py_content = (project_path / "main.py").read_text() + assert "from fastapi import FastAPI" in main_py_content + assert "app = FastAPI()" in main_py_content + + # Check README.md + readme_content = (project_path / "README.md").read_text() + assert "# sample_project" in readme_content + assert "A project created with FastAPI Cloud CLI." in readme_content + + # Check pyproject.toml + pyproject_content = (project_path / "pyproject.toml").read_text() + assert 'name = "sample-project"' in pyproject_content + assert "fastapi[standard]" in pyproject_content + + def test_initializes_in_current_directory(self, temp_project_dir: Path) -> None: + result = runner.invoke(app, ["new"]) + + assert result.exit_code == 0 + assert "No project name provided" in result.output + assert "Initializing in current directory" in result.output + self._assert_project_created(temp_project_dir) + + def test_rejects_existing_directory(self, temp_project_dir: Path) -> None: + existing_dir = temp_project_dir / "existing_project" + existing_dir.mkdir() + + result = runner.invoke(app, ["new", "existing_project"]) + assert result.exit_code == 1 + assert "Directory 'existing_project' already exists." in result.output + + def test_rejects_python_below_3_8(self, temp_project_dir: Path) -> None: + result = runner.invoke(app, ["new", "test_project", "--python", "3.7"]) + assert result.exit_code == 1 + assert "Python 3.7 is not supported" in result.output + assert "FastAPI requires Python 3.8" in result.output + + def test_passes_single_digit_python_version_to_uv( + self, temp_project_dir: Path + ) -> None: + result = runner.invoke(app, ["new", "test_project", "--python", "3"]) + assert result.exit_code == 0 + project_path = temp_project_dir / "test_project" + self._assert_project_created(project_path) + + def test_passes_malformed_python_version_to_uv( + self, temp_project_dir: Path + ) -> None: + result = runner.invoke(app, ["new", "test_project", "--python", "abc.def"]) + # uv will reject this, we just verify we don't crash during validation + assert result.exit_code == 1 + + def test_creates_project_without_python_flag(self, temp_project_dir: Path) -> None: + result = runner.invoke(app, ["new", "test_project"]) + assert result.exit_code == 0 + project_path = temp_project_dir / "test_project" + self._assert_project_created(project_path) + + def test_creates_project_with_other_uv_flags_no_python( + self, temp_project_dir: Path + ) -> None: + result = runner.invoke(app, ["new", "test_project", "--lib"]) + assert result.exit_code == 0 + project_path = temp_project_dir / "test_project" + self._assert_project_created(project_path) + + +class TestNewCommandUvFailures: + def test_failed_to_initialize_with_uv(self, monkeypatch: Any) -> None: + def mock_run(*args: Any, **kwargs: Any) -> None: + # Let the first check for 'uv' succeed, but fail on 'uv init' + if args[0][0] == "uv" and args[0][1] == "init": + raise subprocess.CalledProcessError( + 1, args[0], stderr=b"uv init failed for some reason" + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + result = runner.invoke(app, ["new", "failing_project"]) + assert result.exit_code == 1 + assert "Failed to initialize project with uv" in result.output + + def test_failed_to_add_dependencies( + self, temp_project_dir: Path, monkeypatch: Any + ) -> None: + def mock_run(*args: Any, **kwargs: Any) -> None: + # Let 'uv init' succeed, but fail on 'uv add' + if args[0][0] == "uv" and args[0][1] == "add": + raise subprocess.CalledProcessError( + 1, args[0], stderr=b"Failed to resolve dependencies" + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + result = runner.invoke(app, ["new", "failing_deps"]) + assert result.exit_code == 1 + assert "Failed to install dependencies" in result.output + + def test_file_write_failure(self, temp_project_dir: Path, monkeypatch: Any) -> None: + original_write_text = Path.write_text + + def mock_write_text(self: Path, *args: Any, **kwargs: Any) -> None: + # Fail when trying to write README.md (let main.py succeed first) + if self.name == "README.md": + raise PermissionError("Permission denied") + original_write_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "write_text", mock_write_text) + + result = runner.invoke(app, ["new", "test_write_fail"]) + assert result.exit_code == 1 + assert "Failed to write template files" in result.output + + def test_uv_not_installed(self, temp_project_dir: Path, monkeypatch: Any) -> None: + monkeypatch.setattr(shutil, "which", lambda _: None) + + result = runner.invoke(app, ["new", "test_uv_missing_project"]) + assert result.exit_code == 1 + assert "uv is required to create new projects" in result.output + assert "https://uv.run/docs/installation/" in result.output