Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand DownExpand Up@@ -54,6 +61,7 @@
"CpaCustomOption",
"load_cpa_config",
"assert_directory_is_empty",
"scaffold_project",
"CPA_USER_AGENT",
"check_for_latest_version",
"check_python_version",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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__
Expand DownExpand Up@@ -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

Expand All@@ -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,
)
Original file line numberDiff line numberDiff line change
@@ -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
22 changes: 22 additions & 0 deletions packages/create-python-app-core/tests/test_installer.py
Original file line numberDiff line numberDiff line change
@@ -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"