Skip to content

Commit 1b60d09

Browse files
authored
Merge pull request #2 from python-project-templates/tkp/t
Add defaults from copier templates
2 parents efb4d42 + 186b6b9 commit 1b60d09

File tree

5 files changed

+292
-13
lines changed

5 files changed

+292
-13
lines changed

check_dist/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
check_present,
88
check_sdist_vs_vcs,
99
check_wrong_platform_extensions,
10+
copier_defaults,
1011
find_dist_files,
1112
get_vcs_files,
1213
list_sdist_files,
1314
list_wheel_files,
1415
load_config,
16+
load_copier_config,
1517
load_hatch_config,
1618
matches_pattern,
1719
translate_extension,

check_dist/_core.py

Lines changed: 157 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44

55
import fnmatch
66
import os
7+
import re
78
import subprocess
89
import sys
910
import tarfile
1011
import tempfile
1112
import zipfile
1213
from pathlib import Path
1314

15+
import yaml
16+
1417
if sys.version_info >= (3, 11):
1518
import tomllib
1619
else:
@@ -65,20 +68,42 @@ def _wrong_platform_extensions() -> list[str]:
6568
return list(mapping.keys())
6669

6770

68-
def load_config(pyproject_path: str | Path = "pyproject.toml") -> dict:
69-
"""Load ``[tool.check-dist]`` configuration from *pyproject.toml*."""
71+
def load_config(pyproject_path: str | Path = "pyproject.toml", *, source_dir: str | Path | None = None) -> dict:
72+
"""Load ``[tool.check-dist]`` configuration from *pyproject.toml*.
73+
74+
If no ``[tool.check-dist]`` section exists and *source_dir* contains a
75+
``.copier-answers.yaml`` with an ``add_extension`` key, sensible
76+
defaults are derived from the copier template answers.
77+
"""
7078
path = Path(pyproject_path)
79+
empty = {
80+
"sdist": {"present": [], "absent": []},
81+
"wheel": {"present": [], "absent": []},
82+
}
7183
if not path.exists():
72-
return {
73-
"all": {"present": [], "absent": []},
74-
"sdist": {"present": [], "absent": []},
75-
"wheel": {"present": [], "absent": []},
76-
}
84+
# No pyproject.toml at all — try copier defaults
85+
if source_dir is not None:
86+
copier_cfg = load_copier_config(source_dir)
87+
defaults = copier_defaults(copier_cfg)
88+
if defaults is not None:
89+
return defaults
90+
return empty
7791

7892
with open(path, "rb") as f:
7993
config = tomllib.load(f)
8094

8195
cd = config.get("tool", {}).get("check-dist", {})
96+
97+
# If there's no [tool.check-dist] section at all, try copier defaults
98+
if not cd:
99+
if source_dir is None:
100+
source_dir = path.parent
101+
copier_cfg = load_copier_config(source_dir)
102+
defaults = copier_defaults(copier_cfg)
103+
if defaults is not None:
104+
return defaults
105+
return empty
106+
82107
base_present = cd.get("present", [])
83108
base_absent = cd.get("absent", [])
84109
sdist_cfg = cd.get("sdist", {})
@@ -108,6 +133,118 @@ def load_hatch_config(pyproject_path: str | Path = "pyproject.toml") -> dict:
108133
return config.get("tool", {}).get("hatch", {}).get("build", {})
109134

110135

136+
# ── Copier template defaults ─────────────────────────────────────────
137+
138+
# Per-extension type defaults for sdist/wheel present/absent patterns.
139+
# Keys follow the ``add_extension`` value in ``.copier-answers.yaml``.
140+
_EXTENSION_DEFAULTS: dict[str, dict] = {
141+
"cpp": {
142+
"sdist_present_extra": ["cpp"],
143+
"sdist_absent_extra": [".clang-format"],
144+
"wheel_absent_extra": ["cpp"],
145+
},
146+
"rust": {
147+
"sdist_present_extra": ["rust", "src", "Cargo.toml", "Cargo.lock"],
148+
"sdist_absent_extra": [".gitattributes", "target"],
149+
"wheel_absent_extra": ["rust", "src", "Cargo.toml"],
150+
},
151+
"js": {
152+
"sdist_present_extra": ["js"],
153+
"sdist_absent_extra": [".gitattributes", ".vscode"],
154+
"wheel_absent_extra": ["js"],
155+
},
156+
"jupyter": {
157+
"sdist_present_extra": ["js"],
158+
"sdist_absent_extra": [".gitattributes", ".vscode"],
159+
"wheel_absent_extra": ["js"],
160+
},
161+
"rustjswasm": {
162+
"sdist_present_extra": ["js", "rust", "src", "Cargo.toml", "Cargo.lock"],
163+
"sdist_absent_extra": [".gitattributes", ".vscode", "target"],
164+
"wheel_absent_extra": ["js", "rust", "src", "Cargo.toml"],
165+
},
166+
"cppjswasm": {
167+
"sdist_present_extra": ["cpp", "js"],
168+
"sdist_absent_extra": [".clang-format", ".vscode"],
169+
"wheel_absent_extra": ["js", "cpp"],
170+
},
171+
"python": {
172+
"sdist_present_extra": [],
173+
"sdist_absent_extra": [],
174+
"wheel_absent_extra": [],
175+
},
176+
}
177+
178+
# Common patterns shared across all extension types.
179+
_COMMON_SDIST_PRESENT = ["LICENSE", "pyproject.toml", "README.md"]
180+
_COMMON_SDIST_ABSENT = [
181+
".copier-answers.yaml",
182+
"Makefile",
183+
".github",
184+
"dist",
185+
"docs",
186+
"examples",
187+
"tests",
188+
]
189+
_COMMON_WHEEL_ABSENT = [
190+
".gitignore",
191+
".copier-answers.yaml",
192+
"Makefile",
193+
"pyproject.toml",
194+
".github",
195+
"dist",
196+
"docs",
197+
"examples",
198+
"tests",
199+
]
200+
201+
202+
def load_copier_config(source_dir: str | Path) -> dict:
203+
"""Load ``.copier-answers.yaml`` from *source_dir*, if it exists."""
204+
path = Path(source_dir) / ".copier-answers.yaml"
205+
if not path.exists():
206+
return {}
207+
with open(path) as f:
208+
return yaml.safe_load(f) or {}
209+
210+
211+
def _module_name_from_project(project_name: str) -> str:
212+
"""Convert a human project name to a Python module name.
213+
214+
Replaces spaces and hyphens with underscores.
215+
"""
216+
return re.sub(r"[\s-]+", "_", project_name).strip("_")
217+
218+
219+
def copier_defaults(copier_config: dict) -> dict | None:
220+
"""Derive default check-dist config from copier answers.
221+
222+
Returns a config dict with the same shape as ``load_config`` output,
223+
or ``None`` if deriving defaults is not possible (no ``add_extension``
224+
key, or unknown extension type).
225+
"""
226+
extension = copier_config.get("add_extension")
227+
project_name = copier_config.get("project_name")
228+
if not extension or not project_name:
229+
return None
230+
231+
ext_defaults = _EXTENSION_DEFAULTS.get(extension)
232+
if ext_defaults is None:
233+
return None
234+
235+
module = _module_name_from_project(project_name)
236+
237+
sdist_present = [module, *ext_defaults.get("sdist_present_extra", []), *_COMMON_SDIST_PRESENT]
238+
sdist_absent = [*_COMMON_SDIST_ABSENT, *ext_defaults.get("sdist_absent_extra", [])]
239+
wheel_present = [module]
240+
wheel_absent = [*_COMMON_WHEEL_ABSENT, *ext_defaults.get("wheel_absent_extra", [])]
241+
242+
return {
243+
"sdist": {"present": sdist_present, "absent": sdist_absent},
244+
"wheel": {"present": wheel_present, "absent": wheel_absent},
245+
}
246+
247+
111248
# ── Building ──────────────────────────────────────────────────────────
112249

113250

@@ -381,6 +518,7 @@ def check_sdist_vs_vcs(
381518
sdist_files: list[str],
382519
vcs_files: list[str],
383520
hatch_config: dict,
521+
sdist_absent: list[str] | None = None,
384522
) -> list[str]:
385523
"""Compare sdist contents against VCS-tracked files."""
386524
errors: list[str] = []
@@ -404,11 +542,19 @@ def check_sdist_vs_vcs(
404542
missing = sorted(expected - sdist_set)
405543
# Filter common non-issues (dotfiles like .gitattributes)
406544
missing = [f for f in missing if not f.startswith(".")]
545+
# Filter files that match the user's sdist absent patterns —
546+
# if a file is explicitly expected to be absent, it's not "missing".
547+
# Always include the common absent patterns (docs, tests, etc.) since
548+
# most build systems exclude these from sdists.
549+
all_absent = list(_COMMON_SDIST_ABSENT)
550+
if sdist_absent:
551+
all_absent.extend(sdist_absent)
552+
missing = [f for f in missing if not any(matches_pattern(f, pat) for pat in all_absent)]
407553

408554
if extra:
409-
errors.append(f"sdist contains files not tracked by VCS: {', '.join(extra)}")
555+
errors.append("\nsdist contains files not tracked by VCS:\n\t" + "\n\t".join(extra))
410556
if missing:
411-
errors.append(f"VCS-tracked files missing from sdist: {', '.join(missing)}")
557+
errors.append("\nVCS-tracked files missing from sdist: \n\t" + "\n\t".join(missing))
412558
return errors
413559

414560

@@ -444,7 +590,7 @@ def check_dist(
444590
source_dir = os.path.abspath(source_dir)
445591

446592
pyproject_path = os.path.join(source_dir, "pyproject.toml")
447-
config = load_config(pyproject_path)
593+
config = load_config(pyproject_path, source_dir=source_dir)
448594
hatch_config = load_hatch_config(pyproject_path)
449595

450596
if pre_built is not None:
@@ -483,7 +629,7 @@ def check_dist(
483629

484630
try:
485631
vcs_files = get_vcs_files(source_dir)
486-
errors.extend(check_sdist_vs_vcs(sdist_files, vcs_files, hatch_config))
632+
errors.extend(check_sdist_vs_vcs(sdist_files, vcs_files, hatch_config, sdist_absent=config["sdist"]["absent"]))
487633
except CheckDistError as exc:
488634
messages.append(f" Warning: could not compare against VCS: {exc}")
489635

check_dist/tests/test_all.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,20 @@
1616
from check_dist._core import (
1717
CheckDistError,
1818
_matches_hatch_pattern,
19+
_module_name_from_project,
1920
_sdist_expected_files,
2021
check_absent,
2122
check_dist,
2223
check_present,
2324
check_sdist_vs_vcs,
2425
check_wrong_platform_extensions,
26+
copier_defaults,
2527
find_dist_files,
2628
get_vcs_files,
2729
list_sdist_files,
2830
list_wheel_files,
2931
load_config,
32+
load_copier_config,
3033
load_hatch_config,
3134
matches_pattern,
3235
translate_extension,
@@ -424,6 +427,107 @@ def test_valid_config(self, tmp_path):
424427
assert cfg["targets"]["sdist"]["packages"] == ["mylib"]
425428

426429

430+
# ── Copier defaults ──────────────────────────────────────────────────
431+
432+
433+
class TestModuleNameFromProject:
434+
def test_spaces(self):
435+
assert _module_name_from_project("python template js") == "python_template_js"
436+
437+
def test_hyphens(self):
438+
assert _module_name_from_project("my-project") == "my_project"
439+
440+
def test_mixed(self):
441+
assert _module_name_from_project("python template-rust") == "python_template_rust"
442+
443+
def test_no_change(self):
444+
assert _module_name_from_project("mypkg") == "mypkg"
445+
446+
447+
class TestLoadCopierConfig:
448+
def test_missing_file(self, tmp_path):
449+
assert load_copier_config(tmp_path) == {}
450+
451+
def test_valid_file(self, tmp_path):
452+
(tmp_path / ".copier-answers.yaml").write_text("add_extension: rust\nproject_name: my project\n")
453+
cfg = load_copier_config(tmp_path)
454+
assert cfg["add_extension"] == "rust"
455+
assert cfg["project_name"] == "my project"
456+
457+
458+
class TestCopierDefaults:
459+
def test_cpp(self):
460+
cfg = copier_defaults({"add_extension": "cpp", "project_name": "python template cpp"})
461+
assert cfg is not None
462+
assert "python_template_cpp" in cfg["sdist"]["present"]
463+
assert "cpp" in cfg["sdist"]["present"]
464+
assert ".clang-format" in cfg["sdist"]["absent"]
465+
assert "cpp" in cfg["wheel"]["absent"]
466+
467+
def test_rust(self):
468+
cfg = copier_defaults({"add_extension": "rust", "project_name": "python template rust"})
469+
assert cfg is not None
470+
assert "Cargo.toml" in cfg["sdist"]["present"]
471+
assert "rust" in cfg["sdist"]["present"]
472+
assert "target" in cfg["sdist"]["absent"]
473+
474+
def test_js(self):
475+
cfg = copier_defaults({"add_extension": "js", "project_name": "python template js"})
476+
assert cfg is not None
477+
assert "js" in cfg["sdist"]["present"]
478+
assert "js" in cfg["wheel"]["absent"]
479+
480+
def test_unknown_extension(self):
481+
assert copier_defaults({"add_extension": "unknown", "project_name": "foo"}) is None
482+
483+
def test_no_extension(self):
484+
assert copier_defaults({"project_name": "foo"}) is None
485+
486+
def test_no_project_name(self):
487+
assert copier_defaults({"add_extension": "cpp"}) is None
488+
489+
def test_common_patterns_present(self):
490+
cfg = copier_defaults({"add_extension": "cpp", "project_name": "foo"})
491+
assert "LICENSE" in cfg["sdist"]["present"]
492+
assert "pyproject.toml" in cfg["sdist"]["present"]
493+
assert ".copier-answers.yaml" in cfg["sdist"]["absent"]
494+
assert ".github" in cfg["sdist"]["absent"]
495+
assert ".gitignore" in cfg["wheel"]["absent"]
496+
assert "pyproject.toml" in cfg["wheel"]["absent"]
497+
498+
499+
class TestLoadConfigWithCopierFallback:
500+
def test_no_check_dist_section_uses_copier(self, tmp_path):
501+
"""When pyproject.toml has no [tool.check-dist], copier defaults apply."""
502+
toml = tmp_path / "pyproject.toml"
503+
toml.write_text("[project]\nname = 'foo'\n")
504+
(tmp_path / ".copier-answers.yaml").write_text("add_extension: rust\nproject_name: my project\n")
505+
cfg = load_config(toml, source_dir=tmp_path)
506+
assert "my_project" in cfg["sdist"]["present"]
507+
assert "rust" in cfg["sdist"]["present"]
508+
509+
def test_explicit_config_takes_precedence(self, tmp_path):
510+
"""When [tool.check-dist] exists, copier defaults are ignored."""
511+
toml = tmp_path / "pyproject.toml"
512+
toml.write_text(
513+
textwrap.dedent("""\
514+
[tool.check-dist.sdist]
515+
present = ["custom"]
516+
""")
517+
)
518+
(tmp_path / ".copier-answers.yaml").write_text("add_extension: rust\nproject_name: my project\n")
519+
cfg = load_config(toml, source_dir=tmp_path)
520+
assert cfg["sdist"]["present"] == ["custom"]
521+
assert "rust" not in cfg["sdist"]["present"]
522+
523+
def test_no_copier_file_returns_empty(self, tmp_path):
524+
toml = tmp_path / "pyproject.toml"
525+
toml.write_text("[project]\nname = 'foo'\n")
526+
cfg = load_config(toml, source_dir=tmp_path)
527+
assert cfg["sdist"]["present"] == []
528+
assert cfg["wheel"]["present"] == []
529+
530+
427531
# ── list_sdist_files ──────────────────────────────────────────────────
428532

429533

0 commit comments

Comments
 (0)