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@@ -19,6 +19,7 @@
PackageManagerFallbackError,
ScaffoldAbortedError,
)
from create_python_app_core.paths import default_cache_dir, resolve_source

__all__ = [
"__version__",
Expand All@@ -29,6 +30,8 @@
"ScaffoldAbortedError",
"NonEmptyTargetDirectoryError",
"NON_EMPTY_DIR_ERROR_CODE",
"default_cache_dir",
"resolve_source",
"CPA_USER_AGENT",
"check_for_latest_version",
"check_python_version",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
"""Resolve template/extension locations (GitHub URL, file://, slugs, ?ref=)."""

from __future__ import annotations

import os
import re
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse

from create_python_app_core.errors import CpaError

_GITHUB_RE = re.compile(
r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/#?]+)(?P<path>/.*)?$",
re.IGNORECASE,
)


@dataclass(frozen=True)
class ResolvedSource:
kind: str # github | file | slug
url: str
ref: str | None = None
subdir: str | None = None
local_path: Path | None = None


def default_cache_dir() -> Path:
override = os.environ.get("CPA_CACHE_DIR")
if override:
return Path(override).expanduser().resolve()
return Path.home() / ".cache" / "cpa"


def _parse_ref(query: dict[str, list[str]]) -> str | None:
refs = query.get("ref") or query.get("branch") or query.get("tag")
if not refs:
return None
ref = refs[0]
if os.environ.get("CPA_STRICT_REPRO") == "1" and not re.fullmatch(
r"[0-9a-f]{40}", ref
):
raise CpaError(
f"Invalid ref parameter '{ref}' with CPA_STRICT_REPRO=1: "
"expected a full 40-character commit SHA.",
code="CPA_STRICT_REPRO",
)
return ref


def resolve_source(spec: str, *, cache_dir: Path | None = None) -> ResolvedSource:
"""Resolve a template/extension specifier into a concrete source."""
_ = cache_dir or default_cache_dir()
if spec.startswith("file://"):
parsed = urlparse(spec)
query = parse_qs(parsed.query)
path = Path(unquote(parsed.path))
if parsed.netloc and parsed.netloc != "localhost":
# file://host/path — uncommon; treat netloc+path
path = Path(f"/{parsed.netloc}{unquote(parsed.path)}")
subdir = (query.get("subdir") or [None])[0]
return ResolvedSource(
kind="file",
url=spec,
ref=_parse_ref(query),
subdir=subdir,
local_path=path,
)

if "://" in spec or spec.startswith("git@"):
parsed = urlparse(spec if "://" in spec else f"ssh://{spec}")
query = parse_qs(parsed.query)
m = _GITHUB_RE.match(spec.split("?")[0])
kind = "github" if m else "git"
subdir = (query.get("subdir") or [None])[0]
return ResolvedSource(
kind=kind,
url=spec.split("?")[0],
ref=_parse_ref(query),
subdir=subdir,
)

# legacy slug
return ResolvedSource(kind="slug", url=spec, ref=None, subdir=None)


def get_template_dir_path(source: ResolvedSource, root: Path) -> Path:
"""Prefer template/ subdirectory when present."""
base = root
if source.subdir:
base = root / source.subdir
candidate = base / "template"
return candidate if candidate.is_dir() else base
38 changes: 38 additions & 0 deletions packages/create-python-app-core/tests/test_paths.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
from pathlib import Path

import pytest
from create_python_app_core.errors import CpaError
from create_python_app_core.paths import default_cache_dir, resolve_source


def test_default_cache_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.delenv("CPA_CACHE_DIR", raising=False)
assert default_cache_dir() == Path.home() / ".cache" / "cpa"
monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "c"))
assert default_cache_dir() == (tmp_path / "c").resolve()


def test_file_url(tmp_path: Path) -> None:
src = resolve_source(f"file://{tmp_path}?subdir=templates/foo")
assert src.kind == "file"
assert src.subdir == "templates/foo"


def test_github_url_with_ref() -> None:
src = resolve_source("https://github.com/org/repo?ref=main&subdir=templates/x")
assert src.kind == "github"
assert src.ref == "main"
assert src.subdir == "templates/x"


def test_strict_repro_requires_sha(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CPA_STRICT_REPRO", "1")
with pytest.raises(CpaError):
resolve_source("https://github.com/org/repo?ref=main")
sha = "a" * 40
src = resolve_source(f"https://github.com/org/repo?ref={sha}")
assert src.ref == sha


def test_slug() -> None:
assert resolve_source("fastapi-starter").kind == "slug"