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 ff6787c..0d5e45c 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 @@ -19,6 +19,7 @@ PackageManagerFallbackError, ScaffoldAbortedError, ) +from create_python_app_core.paths import default_cache_dir, resolve_source __all__ = [ "__version__", @@ -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", diff --git a/packages/create-python-app-core/src/create_python_app_core/paths.py b/packages/create-python-app-core/src/create_python_app_core/paths.py new file mode 100644 index 0000000..dbba7bd --- /dev/null +++ b/packages/create-python-app-core/src/create_python_app_core/paths.py @@ -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[^/]+)/(?P[^/#?]+)(?P/.*)?$", + 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 diff --git a/packages/create-python-app-core/tests/test_paths.py b/packages/create-python-app-core/tests/test_paths.py new file mode 100644 index 0000000..bd78228 --- /dev/null +++ b/packages/create-python-app-core/tests/test_paths.py @@ -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"