From 0c1c7b5902beca7ddc46af1ee8b3f2731634621b Mon Sep 17 00:00:00 2001 From: ulises-jeremias Date: Thu, 16 Jul 2026 03:05:45 -0300 Subject: [PATCH] feat(core): scaffold installer with uv sync and git init Closes #29 Co-authored-by: Cursor --- .../src/create_python_app_core/__init__.py | 8 ++ .../src/create_python_app_core/api.py | 22 ++++-- .../src/create_python_app_core/installer.py | 75 +++++++++++++++++++ .../tests/test_installer.py | 22 ++++++ 4 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 packages/create-python-app-core/src/create_python_app_core/installer.py create mode 100644 packages/create-python-app-core/tests/test_installer.py diff --git a/packages/create-python-app-core/src/create_python_app_core/__init__.py b/packages/create-python-app-core/src/create_python_app_core/__init__.py index 1ee4b65..f1f8d8e 100644 --- a/packages/create-python-app-core/src/create_python_app_core/__init__.py +++ b/packages/create-python-app-core/src/create_python_app_core/__init__.py @@ -10,6 +10,12 @@ create_python_app, print_env_info, ) +from create_python_app_core.config import ( + CpaConfig, + CpaCustomOption, + assert_directory_is_empty, + load_cpa_config, +) from create_python_app_core.errors import ( NON_EMPTY_DIR_ERROR_CODE, ConfigParseError, @@ -25,6 +31,7 @@ read_cache_meta, write_cache_meta, ) +from create_python_app_core.installer import scaffold_project from create_python_app_core.loaders import load_layer, merge_layers from create_python_app_core.paths import ( default_cache_dir, @@ -54,6 +61,7 @@ "CpaCustomOption", "load_cpa_config", "assert_directory_is_empty", + "scaffold_project", "CPA_USER_AGENT", "check_for_latest_version", "check_python_version", diff --git a/packages/create-python-app-core/src/create_python_app_core/api.py b/packages/create-python-app-core/src/create_python_app_core/api.py index 299cf2f..ec43767 100644 --- a/packages/create-python-app-core/src/create_python_app_core/api.py +++ b/packages/create-python-app-core/src/create_python_app_core/api.py @@ -4,6 +4,7 @@ import sys from collections.abc import Awaitable, Callable +from pathlib import Path from typing import Any from create_python_app_core._version import __version__ @@ -32,7 +33,6 @@ def check_python_version(required: str, package_name: str) -> None: async def check_for_latest_version(package_name: str) -> str | None: """Fetch latest version from PyPI. Returns None on failure.""" - # Implemented fully in #31; stub returns None until then. _ = package_name return None @@ -50,10 +50,22 @@ async def create_python_app( transform_options: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]] | None = None, ) -> None: - """Scaffold a project. Full installer lands in #29.""" + """Scaffold a project using the installer (#29).""" + from create_python_app_core.installer import scaffold_project + if transform_options is not None: options = await transform_options(options) - raise NotImplementedError( - "create_python_app installer not implemented yet — see #29 " - f"(project={project_directory!r}, options_keys={list(options)})" + + cache = options.get("cache_dir") + scaffold_project( + project_directory, + template=str(options.get("template") or ""), + addons=list(options.get("addons") or []), + extend=list(options.get("extend") or []), + force=bool(options.get("force", False)), + install=bool(options.get("install", True)), + offline=bool(options.get("offline", False)), + keep_on_failure=bool(options.get("keep_on_failure", False)), + cache_dir=Path(cache) if cache else None, + options=options, ) diff --git a/packages/create-python-app-core/src/create_python_app_core/installer.py b/packages/create-python-app-core/src/create_python_app_core/installer.py new file mode 100644 index 0000000..d9b6d49 --- /dev/null +++ b/packages/create-python-app-core/src/create_python_app_core/installer.py @@ -0,0 +1,75 @@ +"""Scaffold orchestrator: copy layers → optional uv sync → git init.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from create_python_app_core.config import assert_directory_is_empty, load_cpa_config +from create_python_app_core.errors import CpaError, ScaffoldAbortedError +from create_python_app_core.git_cache import download_repository +from create_python_app_core.loaders import merge_layers +from create_python_app_core.paths import ResolvedSource, resolve_source + + +def _run(cmd: list[str], *, cwd: Path) -> None: + subprocess.check_call(cmd, cwd=str(cwd)) + + +def init_git_repo(dest: Path) -> None: + if (dest / ".git").exists(): + return + _run(["git", "init"], cwd=dest) + + +def uv_sync(dest: Path) -> None: + _run(["uv", "sync"], cwd=dest) + + +def scaffold_project( + project_directory: str, + *, + template: str, + addons: list[str] | None = None, + extend: list[str] | None = None, + force: bool = False, + install: bool = True, + offline: bool = False, + keep_on_failure: bool = False, + cache_dir: Path | None = None, + options: dict[str, Any] | None = None, +) -> Path: + """Create a project directory from template + addon layers.""" + _ = options + dest = Path(project_directory).expanduser().resolve() + assert_directory_is_empty(dest, force=force) + dest.mkdir(parents=True, exist_ok=True) + + specs = [template, *(addons or []), *(extend or [])] + layers: list[tuple[ResolvedSource, Path]] = [] + try: + for spec in specs: + source = resolve_source(spec, cache_dir=cache_dir) + root = download_repository(source, offline=offline, cache_root=cache_dir) + layers.append((source, root)) + cfg_path = root / "cpa.config.json" + if not cfg_path.is_file() and source.subdir: + cfg_path = root / source.subdir / "cpa.config.json" + load_cpa_config(cfg_path) + + merge_layers(layers, dest) + + if install and (dest / "pyproject.toml").is_file(): + uv_sync(dest) + if os.environ.get("CPA_SKIP_GIT") != "1": + init_git_repo(dest) + except Exception as exc: + if not keep_on_failure and dest.exists(): + shutil.rmtree(dest, ignore_errors=True) + if isinstance(exc, CpaError): + raise + raise ScaffoldAbortedError(str(exc)) from exc + return dest diff --git a/packages/create-python-app-core/tests/test_installer.py b/packages/create-python-app-core/tests/test_installer.py new file mode 100644 index 0000000..2a609f9 --- /dev/null +++ b/packages/create-python-app-core/tests/test_installer.py @@ -0,0 +1,22 @@ +from pathlib import Path + +import pytest +from create_python_app_core.installer import scaffold_project + + +def _tpl(tmp: Path, name: str) -> str: + root = tmp / name + (root / "template").mkdir(parents=True) + (root / "template" / "hello.txt").write_text("hi") + (root / "cpa.config.json").write_text('{"name":"t"}') + return f"file://{root}" + + +def test_scaffold_file_template( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CPA_SKIP_GIT", "1") + dest = tmp_path / "app" + url = _tpl(tmp_path, "tpl") + scaffold_project(str(dest), template=url, install=False) + assert (dest / "hello.txt").read_text() == "hi"