diff --git a/docs/PYPROJECT_MERGE.md b/docs/PYPROJECT_MERGE.md new file mode 100644 index 0000000..a4313c6 --- /dev/null +++ b/docs/PYPROJECT_MERGE.md @@ -0,0 +1,35 @@ +# pyproject.toml merge rules + +When scaffolding layers include a `pyproject.toml`, CPA **merges** into an existing destination file instead of overwriting it. + +## Rules + +| Key | Behavior | +|-----|----------| +| `[project].dependencies` | Union by package name; **later layer wins** on version conflict | +| `[project].optional-dependencies.*` | Same union-per-group | +| `[dependency-groups].*` | Same union-per-group (uv) | +| Nested tables (`[tool.*]`, etc.) | Deep merge; scalars: later wins | +| Other arrays | Later layer replaces | + +## Example + +Base template: + +```toml +[project] +name = "my-api" +dependencies = ["fastapi>=0.115"] +``` + +Extension overlay: + +```toml +[project] +dependencies = ["psycopg[binary]>=3.2"] + +[dependency-groups] +dev = ["ruff>=0.8"] +``` + +Result keeps `name`, unions dependencies, and adds the dev group. diff --git a/packages/create-python-app-core/pyproject.toml b/packages/create-python-app-core/pyproject.toml index 0ccb6b5..541e175 100644 --- a/packages/create-python-app-core/pyproject.toml +++ b/packages/create-python-app-core/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "httpx>=0.28.1", "jinja2>=3.1.0", "packaging>=24.0", + "tomlkit>=0.13.0", ] [build-system] 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 f1f8d8e..bc86322 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 @@ -38,6 +38,7 @@ get_template_dir_path, resolve_source, ) +from create_python_app_core.pyproject_merge import merge_pyproject_text __all__ = [ "__version__", @@ -56,6 +57,7 @@ "write_cache_meta", "merge_layers", "load_layer", + "merge_pyproject_text", "get_template_dir_path", "CpaConfig", "CpaCustomOption", diff --git a/packages/create-python-app-core/src/create_python_app_core/loaders.py b/packages/create-python-app-core/src/create_python_app_core/loaders.py index 65a934f..f4fbaf2 100644 --- a/packages/create-python-app-core/src/create_python_app_core/loaders.py +++ b/packages/create-python-app-core/src/create_python_app_core/loaders.py @@ -10,6 +10,7 @@ from create_python_app_core.errors import ManifestLoadError, ScaffoldAbortedError from create_python_app_core.paths import ResolvedSource, get_template_dir_path +from create_python_app_core.pyproject_merge import merge_pyproject_into _JINJA = Environment( undefined=StrictUndefined, @@ -76,28 +77,37 @@ def process_file( out_rel = _output_rel(rel) target = dest_root / out_rel - if mode == "copy": - if target.exists() and not overwrite: - return None - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, target) - return target - if mode == "append": content = src.read_text(encoding="utf-8") _write_text(target, content, append=True) return target - # template modes - rendered = render_template( - src.read_text(encoding="utf-8"), - context, - path=str(rel), - ) - append = mode == "appendTemplate" - if target.exists() and not overwrite and not append: + if mode in {"copyTemplate", "appendTemplate"}: + rendered = render_template( + src.read_text(encoding="utf-8"), + context, + path=str(rel), + ) + append = mode == "appendTemplate" + if out_rel.name == "pyproject.toml" and not append: + text = merge_pyproject_into(target, rendered) + _write_text(target, text, append=False) + return target + if target.exists() and not overwrite and not append: + return None + _write_text(target, rendered, append=append) + return target + + # plain copy + if out_rel.name == "pyproject.toml": + text = merge_pyproject_into(target, src.read_text(encoding="utf-8")) + _write_text(target, text, append=False) + return target + + if target.exists() and not overwrite: return None - _write_text(target, rendered, append=append) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, target) return target diff --git a/packages/create-python-app-core/src/create_python_app_core/pyproject_merge.py b/packages/create-python-app-core/src/create_python_app_core/pyproject_merge.py new file mode 100644 index 0000000..31d8458 --- /dev/null +++ b/packages/create-python-app-core/src/create_python_app_core/pyproject_merge.py @@ -0,0 +1,108 @@ +"""Merge pyproject.toml layers (CNA package.json merge parity).""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import tomlkit +from tomlkit.items import Array, Table + +_DEP_NAME_RE = re.compile( + r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", +) + + +def dependency_name(spec: str) -> str: + """Extract the distribution name from a PEP 508 requirement string.""" + match = _DEP_NAME_RE.match(spec) + if not match: + return spec.strip().lower() + return match.group(1).lower().replace("_", "-") + + +def merge_dependency_lists(base: list[Any], overlay: list[Any]) -> list[str]: + """Union dependency specs; later layer wins on the same package name.""" + by_name: dict[str, str] = {} + order: list[str] = [] + for raw in [*base, *overlay]: + spec = str(raw) + name = dependency_name(spec) + if name not in by_name: + order.append(name) + by_name[name] = spec + return [by_name[name] for name in order] + + +_DEP_LIST_KEYS = frozenset({"dependencies", "optional-dependencies"}) + + +def _is_mapping(value: Any) -> bool: + return isinstance(value, (dict, Table)) + + +def _is_sequence(value: Any) -> bool: + return isinstance(value, (list, Array)) and not isinstance(value, (str, bytes)) + + +def merge_tables(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]: + """Deep-merge TOML tables with dependency-list union rules.""" + result: dict[str, Any] = dict(base) + for key, overlay_value in overlay.items(): + if key not in result: + result[key] = overlay_value + continue + base_value = result[key] + if key == "dependencies" and _is_sequence(base_value) and _is_sequence(overlay_value): + result[key] = merge_dependency_lists(list(base_value), list(overlay_value)) + elif ( + key == "optional-dependencies" + and _is_mapping(base_value) + and _is_mapping(overlay_value) + ): + merged_opt: dict[str, Any] = dict(base_value) + for opt_key, opt_val in overlay_value.items(): + if opt_key in merged_opt and _is_sequence(merged_opt[opt_key]) and _is_sequence(opt_val): + merged_opt[opt_key] = merge_dependency_lists( + list(merged_opt[opt_key]), list(opt_val) + ) + else: + merged_opt[opt_key] = opt_val + result[key] = merged_opt + elif key == "dependency-groups" and _is_mapping(base_value) and _is_mapping(overlay_value): + merged_groups: dict[str, Any] = dict(base_value) + for gkey, gval in overlay_value.items(): + if gkey in merged_groups and _is_sequence(merged_groups[gkey]) and _is_sequence(gval): + merged_groups[gkey] = merge_dependency_lists( + list(merged_groups[gkey]), list(gval) + ) + elif gkey in merged_groups and _is_mapping(merged_groups[gkey]) and _is_mapping(gval): + merged_groups[gkey] = merge_tables(dict(merged_groups[gkey]), dict(gval)) + else: + merged_groups[gkey] = gval + result[key] = merged_groups + elif _is_mapping(base_value) and _is_mapping(overlay_value): + result[key] = merge_tables(dict(base_value), dict(overlay_value)) + else: + # Later layer wins for scalars and non-dep arrays. + result[key] = overlay_value + return result + + +def merge_pyproject_text(base_text: str, overlay_text: str) -> str: + """Merge two pyproject.toml documents; overlay wins conflicts.""" + base_doc = tomlkit.parse(base_text) + overlay_doc = tomlkit.parse(overlay_text) + merged = merge_tables(dict(base_doc), dict(overlay_doc)) + out = tomlkit.document() + for key, value in merged.items(): + out[key] = value + return tomlkit.dumps(out) + + +def merge_pyproject_into(existing: Path, overlay_text: str) -> str: + """Merge overlay into an existing pyproject.toml file and return new text.""" + if existing.is_file(): + return merge_pyproject_text(existing.read_text(encoding="utf-8"), overlay_text) + return overlay_text diff --git a/packages/create-python-app-core/tests/test_pyproject_merge.py b/packages/create-python-app-core/tests/test_pyproject_merge.py new file mode 100644 index 0000000..2e41db1 --- /dev/null +++ b/packages/create-python-app-core/tests/test_pyproject_merge.py @@ -0,0 +1,90 @@ +from pathlib import Path + +from create_python_app_core.loaders import merge_layers +from create_python_app_core.paths import ResolvedSource +from create_python_app_core.pyproject_merge import ( + dependency_name, + merge_dependency_lists, + merge_pyproject_text, +) + + +def _layer(tmp: Path, name: str, files: dict[str, str]) -> tuple[ResolvedSource, Path]: + root = tmp / name + tpl = root / "template" + for rel, content in files.items(): + path = tpl / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + src = ResolvedSource(kind="file", url=f"file://{root}", local_path=root) + return src, root + + +def test_dependency_name() -> None: + assert dependency_name("fastapi>=0.115") == "fastapi" + assert dependency_name("psycopg[binary]>=3.0") == "psycopg" + assert dependency_name("Foo_Bar==1.0") == "foo-bar" + + +def test_merge_dependency_lists_later_wins() -> None: + merged = merge_dependency_lists( + ["fastapi>=0.100", "uvicorn>=0.30"], + ["fastapi>=0.115", "httpx>=0.28"], + ) + assert merged == ["fastapi>=0.115", "uvicorn>=0.30", "httpx>=0.28"] + + +def test_merge_pyproject_text_unions_deps() -> None: + base = """ +[project] +name = "base" +dependencies = ["fastapi>=0.100"] + +[dependency-groups] +dev = ["pytest>=8"] +""" + overlay = """ +[project] +dependencies = ["fastapi>=0.115", "uvicorn>=0.32"] + +[dependency-groups] +dev = ["ruff>=0.8"] + +[tool.ruff] +line-length = 100 +""" + merged = merge_pyproject_text(base, overlay) + assert "fastapi>=0.115" in merged + assert "uvicorn>=0.32" in merged + assert "pytest>=8" in merged + assert "ruff>=0.8" in merged + assert "line-length = 100" in merged + + +def test_merge_layers_merges_pyproject(tmp_path: Path) -> None: + a = _layer( + tmp_path, + "a", + { + "pyproject.toml": ( + '[project]\nname = "app"\ndependencies = ["fastapi>=0.100"]\n' + ) + }, + ) + b = _layer( + tmp_path, + "b", + { + "pyproject.toml": ( + '[project]\ndependencies = ["uvicorn>=0.32"]\n' + '[dependency-groups]\ndev = ["ruff>=0.8"]\n' + ) + }, + ) + dest = tmp_path / "out" + merge_layers([a, b], dest) + text = (dest / "pyproject.toml").read_text() + assert 'name = "app"' in text + assert "fastapi>=0.100" in text + assert "uvicorn>=0.32" in text + assert "ruff>=0.8" in text diff --git a/uv.lock b/uv.lock index fb2cbb2..988611a 100644 --- a/uv.lock +++ b/uv.lock @@ -163,6 +163,7 @@ dependencies = [ { name = "httpx" }, { name = "jinja2" }, { name = "packaging" }, + { name = "tomlkit" }, ] [package.metadata] @@ -170,6 +171,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "packaging", specifier = ">=24.0" }, + { name = "tomlkit", specifier = ">=0.13.0" }, ] [[package]] @@ -597,6 +599,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + [[package]] name = "typer" version = "0.27.0"