Skip to content

Commit 7c6604a

Browse files
ulises-jeremiasulises-jeremias
andauthored
feat(core): merge pyproject.toml across template layers (#150)
Union dependencies by package name (later wins on conflicts) and deep-merge tool tables. Documents rules in docs/PYPROJECT_MERGE.md. Closes #147 Co-authored-by: ulises-jeremias <ulises.jeremias@users.noreply.github.com>
1 parent fbce8ce commit 7c6604a

7 files changed

Lines changed: 273 additions & 16 deletions

File tree

docs/PYPROJECT_MERGE.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# pyproject.toml merge rules
2+
3+
When scaffolding layers include a `pyproject.toml`, CPA **merges** into an existing destination file instead of overwriting it.
4+
5+
## Rules
6+
7+
| Key | Behavior |
8+
|-----|----------|
9+
| `[project].dependencies` | Union by package name; **later layer wins** on version conflict |
10+
| `[project].optional-dependencies.*` | Same union-per-group |
11+
| `[dependency-groups].*` | Same union-per-group (uv) |
12+
| Nested tables (`[tool.*]`, etc.) | Deep merge; scalars: later wins |
13+
| Other arrays | Later layer replaces |
14+
15+
## Example
16+
17+
Base template:
18+
19+
```toml
20+
[project]
21+
name = "my-api"
22+
dependencies = ["fastapi>=0.115"]
23+
```
24+
25+
Extension overlay:
26+
27+
```toml
28+
[project]
29+
dependencies = ["psycopg[binary]>=3.2"]
30+
31+
[dependency-groups]
32+
dev = ["ruff>=0.8"]
33+
```
34+
35+
Result keeps `name`, unions dependencies, and adds the dev group.

packages/create-python-app-core/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies = [
88
"httpx>=0.28.1",
99
"jinja2>=3.1.0",
1010
"packaging>=24.0",
11+
"tomlkit>=0.13.0",
1112
]
1213

1314
[build-system]

packages/create-python-app-core/src/create_python_app_core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
get_template_dir_path,
3939
resolve_source,
4040
)
41+
from create_python_app_core.pyproject_merge import merge_pyproject_text
4142

4243
__all__ = [
4344
"__version__",
@@ -56,6 +57,7 @@
5657
"write_cache_meta",
5758
"merge_layers",
5859
"load_layer",
60+
"merge_pyproject_text",
5961
"get_template_dir_path",
6062
"CpaConfig",
6163
"CpaCustomOption",

packages/create-python-app-core/src/create_python_app_core/loaders.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from create_python_app_core.errors import ManifestLoadError, ScaffoldAbortedError
1212
from create_python_app_core.paths import ResolvedSource, get_template_dir_path
13+
from create_python_app_core.pyproject_merge import merge_pyproject_into
1314

1415
_JINJA = Environment(
1516
undefined=StrictUndefined,
@@ -76,28 +77,37 @@ def process_file(
7677
out_rel = _output_rel(rel)
7778
target = dest_root / out_rel
7879

79-
if mode == "copy":
80-
if target.exists() and not overwrite:
81-
return None
82-
target.parent.mkdir(parents=True, exist_ok=True)
83-
shutil.copy2(src, target)
84-
return target
85-
8680
if mode == "append":
8781
content = src.read_text(encoding="utf-8")
8882
_write_text(target, content, append=True)
8983
return target
9084

91-
# template modes
92-
rendered = render_template(
93-
src.read_text(encoding="utf-8"),
94-
context,
95-
path=str(rel),
96-
)
97-
append = mode == "appendTemplate"
98-
if target.exists() and not overwrite and not append:
85+
if mode in {"copyTemplate", "appendTemplate"}:
86+
rendered = render_template(
87+
src.read_text(encoding="utf-8"),
88+
context,
89+
path=str(rel),
90+
)
91+
append = mode == "appendTemplate"
92+
if out_rel.name == "pyproject.toml" and not append:
93+
text = merge_pyproject_into(target, rendered)
94+
_write_text(target, text, append=False)
95+
return target
96+
if target.exists() and not overwrite and not append:
97+
return None
98+
_write_text(target, rendered, append=append)
99+
return target
100+
101+
# plain copy
102+
if out_rel.name == "pyproject.toml":
103+
text = merge_pyproject_into(target, src.read_text(encoding="utf-8"))
104+
_write_text(target, text, append=False)
105+
return target
106+
107+
if target.exists() and not overwrite:
99108
return None
100-
_write_text(target, rendered, append=append)
109+
target.parent.mkdir(parents=True, exist_ok=True)
110+
shutil.copy2(src, target)
101111
return target
102112

103113

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Merge pyproject.toml layers (CNA package.json merge parity)."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from pathlib import Path
7+
from typing import Any
8+
9+
import tomlkit
10+
from tomlkit.items import Array, Table
11+
12+
_DEP_NAME_RE = re.compile(
13+
r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)",
14+
)
15+
16+
17+
def dependency_name(spec: str) -> str:
18+
"""Extract the distribution name from a PEP 508 requirement string."""
19+
match = _DEP_NAME_RE.match(spec)
20+
if not match:
21+
return spec.strip().lower()
22+
return match.group(1).lower().replace("_", "-")
23+
24+
25+
def merge_dependency_lists(base: list[Any], overlay: list[Any]) -> list[str]:
26+
"""Union dependency specs; later layer wins on the same package name."""
27+
by_name: dict[str, str] = {}
28+
order: list[str] = []
29+
for raw in [*base, *overlay]:
30+
spec = str(raw)
31+
name = dependency_name(spec)
32+
if name not in by_name:
33+
order.append(name)
34+
by_name[name] = spec
35+
return [by_name[name] for name in order]
36+
37+
38+
_DEP_LIST_KEYS = frozenset({"dependencies", "optional-dependencies"})
39+
40+
41+
def _is_mapping(value: Any) -> bool:
42+
return isinstance(value, (dict, Table))
43+
44+
45+
def _is_sequence(value: Any) -> bool:
46+
return isinstance(value, (list, Array)) and not isinstance(value, (str, bytes))
47+
48+
49+
def merge_tables(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
50+
"""Deep-merge TOML tables with dependency-list union rules."""
51+
result: dict[str, Any] = dict(base)
52+
for key, overlay_value in overlay.items():
53+
if key not in result:
54+
result[key] = overlay_value
55+
continue
56+
base_value = result[key]
57+
if key == "dependencies" and _is_sequence(base_value) and _is_sequence(overlay_value):
58+
result[key] = merge_dependency_lists(list(base_value), list(overlay_value))
59+
elif (
60+
key == "optional-dependencies"
61+
and _is_mapping(base_value)
62+
and _is_mapping(overlay_value)
63+
):
64+
merged_opt: dict[str, Any] = dict(base_value)
65+
for opt_key, opt_val in overlay_value.items():
66+
if opt_key in merged_opt and _is_sequence(merged_opt[opt_key]) and _is_sequence(opt_val):
67+
merged_opt[opt_key] = merge_dependency_lists(
68+
list(merged_opt[opt_key]), list(opt_val)
69+
)
70+
else:
71+
merged_opt[opt_key] = opt_val
72+
result[key] = merged_opt
73+
elif key == "dependency-groups" and _is_mapping(base_value) and _is_mapping(overlay_value):
74+
merged_groups: dict[str, Any] = dict(base_value)
75+
for gkey, gval in overlay_value.items():
76+
if gkey in merged_groups and _is_sequence(merged_groups[gkey]) and _is_sequence(gval):
77+
merged_groups[gkey] = merge_dependency_lists(
78+
list(merged_groups[gkey]), list(gval)
79+
)
80+
elif gkey in merged_groups and _is_mapping(merged_groups[gkey]) and _is_mapping(gval):
81+
merged_groups[gkey] = merge_tables(dict(merged_groups[gkey]), dict(gval))
82+
else:
83+
merged_groups[gkey] = gval
84+
result[key] = merged_groups
85+
elif _is_mapping(base_value) and _is_mapping(overlay_value):
86+
result[key] = merge_tables(dict(base_value), dict(overlay_value))
87+
else:
88+
# Later layer wins for scalars and non-dep arrays.
89+
result[key] = overlay_value
90+
return result
91+
92+
93+
def merge_pyproject_text(base_text: str, overlay_text: str) -> str:
94+
"""Merge two pyproject.toml documents; overlay wins conflicts."""
95+
base_doc = tomlkit.parse(base_text)
96+
overlay_doc = tomlkit.parse(overlay_text)
97+
merged = merge_tables(dict(base_doc), dict(overlay_doc))
98+
out = tomlkit.document()
99+
for key, value in merged.items():
100+
out[key] = value
101+
return tomlkit.dumps(out)
102+
103+
104+
def merge_pyproject_into(existing: Path, overlay_text: str) -> str:
105+
"""Merge overlay into an existing pyproject.toml file and return new text."""
106+
if existing.is_file():
107+
return merge_pyproject_text(existing.read_text(encoding="utf-8"), overlay_text)
108+
return overlay_text
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from pathlib import Path
2+
3+
from create_python_app_core.loaders import merge_layers
4+
from create_python_app_core.paths import ResolvedSource
5+
from create_python_app_core.pyproject_merge import (
6+
dependency_name,
7+
merge_dependency_lists,
8+
merge_pyproject_text,
9+
)
10+
11+
12+
def _layer(tmp: Path, name: str, files: dict[str, str]) -> tuple[ResolvedSource, Path]:
13+
root = tmp / name
14+
tpl = root / "template"
15+
for rel, content in files.items():
16+
path = tpl / rel
17+
path.parent.mkdir(parents=True, exist_ok=True)
18+
path.write_text(content)
19+
src = ResolvedSource(kind="file", url=f"file://{root}", local_path=root)
20+
return src, root
21+
22+
23+
def test_dependency_name() -> None:
24+
assert dependency_name("fastapi>=0.115") == "fastapi"
25+
assert dependency_name("psycopg[binary]>=3.0") == "psycopg"
26+
assert dependency_name("Foo_Bar==1.0") == "foo-bar"
27+
28+
29+
def test_merge_dependency_lists_later_wins() -> None:
30+
merged = merge_dependency_lists(
31+
["fastapi>=0.100", "uvicorn>=0.30"],
32+
["fastapi>=0.115", "httpx>=0.28"],
33+
)
34+
assert merged == ["fastapi>=0.115", "uvicorn>=0.30", "httpx>=0.28"]
35+
36+
37+
def test_merge_pyproject_text_unions_deps() -> None:
38+
base = """
39+
[project]
40+
name = "base"
41+
dependencies = ["fastapi>=0.100"]
42+
43+
[dependency-groups]
44+
dev = ["pytest>=8"]
45+
"""
46+
overlay = """
47+
[project]
48+
dependencies = ["fastapi>=0.115", "uvicorn>=0.32"]
49+
50+
[dependency-groups]
51+
dev = ["ruff>=0.8"]
52+
53+
[tool.ruff]
54+
line-length = 100
55+
"""
56+
merged = merge_pyproject_text(base, overlay)
57+
assert "fastapi>=0.115" in merged
58+
assert "uvicorn>=0.32" in merged
59+
assert "pytest>=8" in merged
60+
assert "ruff>=0.8" in merged
61+
assert "line-length = 100" in merged
62+
63+
64+
def test_merge_layers_merges_pyproject(tmp_path: Path) -> None:
65+
a = _layer(
66+
tmp_path,
67+
"a",
68+
{
69+
"pyproject.toml": (
70+
'[project]\nname = "app"\ndependencies = ["fastapi>=0.100"]\n'
71+
)
72+
},
73+
)
74+
b = _layer(
75+
tmp_path,
76+
"b",
77+
{
78+
"pyproject.toml": (
79+
'[project]\ndependencies = ["uvicorn>=0.32"]\n'
80+
'[dependency-groups]\ndev = ["ruff>=0.8"]\n'
81+
)
82+
},
83+
)
84+
dest = tmp_path / "out"
85+
merge_layers([a, b], dest)
86+
text = (dest / "pyproject.toml").read_text()
87+
assert 'name = "app"' in text
88+
assert "fastapi>=0.100" in text
89+
assert "uvicorn>=0.32" in text
90+
assert "ruff>=0.8" in text

uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)