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
35 changes: 35 additions & 0 deletions docs/PYPROJECT_MERGE.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/create-python-app-core/pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ dependencies = [
"httpx>=0.28.1",
"jinja2>=3.1.0",
"packaging>=24.0",
"tomlkit>=0.13.0",
]

[build-system]
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@
get_template_dir_path,
resolve_source,
)
from create_python_app_core.pyproject_merge import merge_pyproject_text

__all__ = [
"__version__",
Expand All@@ -56,6 +57,7 @@
"write_cache_meta",
"merge_layers",
"load_layer",
"merge_pyproject_text",
"get_template_dir_path",
"CpaConfig",
"CpaCustomOption",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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


Expand Down
Original file line numberDiff line numberDiff line change
@@ -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
90 changes: 90 additions & 0 deletions packages/create-python-app-core/tests/test_pyproject_merge.py
Original file line numberDiff line numberDiff line change
@@ -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
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading