diff --git a/README.md b/README.md index dad3ed0..39c3088 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,12 @@ dist/yourpackage-0.1.0.tar.gz dist/yourpackage-0.1.0-py2.py3-none-any.whl ``` +> [!IMPORTANT] +> This template makes use of [PEP-735 `dependency-groups`](https://peps.python.org/pep-0735/) +> which are only supported in versions of hatch [greater than v1.16.0](https://hatch.pypa.io/dev/blog/2025/11/24/hatch-v1160/#dependency-groups). +> To see which version of hatch you have installed use `hatch --version`, +> and to update hatch use [`hatch self update`](https://hatch.pypa.io/dev/cli/reference/#hatch-self-update). + To use the hatch build environment run: `hatch run build:check` diff --git a/copier.yml b/copier.yml index 2bf7d4e..05491cb 100644 --- a/copier.yml +++ b/copier.yml @@ -167,5 +167,10 @@ use_test: default: "yes" help: "Do you want to test your code? We strongly recommend that you add tests to your package." +deps: + when: false + type: yaml + multiselect: false + default: !include data/dependencies.yml _subdirectory: "template" diff --git a/data/dependencies.yml b/data/dependencies.yml new file mode 100644 index 0000000..9f1e131 --- /dev/null +++ b/data/dependencies.yml @@ -0,0 +1,45 @@ +# Deps are specified as dicts of dicts +# +# The top-level key is the key that will be used within the pyproject.toml file, +# unless "key" is specified in the render_deps macro +# +# Within each group, +# the key is the package name, and the value, if present, is the version constraint. +# Use empty values to indicate no version constraint. + + +build: + pip-audit: + twine: + +dev: + hatch: ">=1.16.0" + pre-commit: + +mkdocs: + mkdocs-material: '~=9.5' + mkdocstrings[python]: '~=0.24' + mkdocs-awesome-pages-plugin: '~=2.9' + +sphinx: + sphinx: '~=8.0' + myst-parser: '>=4.0' + pydata-sphinx-theme: '~=0.16' + sphinx-autobuild: '>=2024.10.3' + sphinx-autoapi: '>=3.6.0' + sphinx_design: '>=0.6.1' + sphinx-copybutton: '>=0.5.2' + +style: + pydoclint: + ruff: + +tests: + pytest: + pytest-cov: + pytest-raises: + pytest-randomly: + pytest-xdist: + +types: + mypy: \ No newline at end of file diff --git a/includes/dependencies.toml.jinja b/includes/dependencies.toml.jinja new file mode 100644 index 0000000..001b7b9 --- /dev/null +++ b/includes/dependencies.toml.jinja @@ -0,0 +1,20 @@ +{# Import this macro like `from .. import render_deps with context` to give it access to the deps data #} +{% macro render_deps(group, inner=False, key=None) %} +{#- for some reason copier insists on loading things as nested lists inconsistently across versions #} +{%- if not deps[0] is mapping %} + {%- set d=deps[0][0] %} +{%- else -%} + {%- set d=deps[0] %} +{%- endif -%} +{%- if group and group in d -%} +{%- if not inner -%} +{{ group if not key else key }} = [ +{%- endif %} +{%- for package in d[group]|sort %} + "{{ package }}{% if d[group][package] %}{{ d[group][package] }}{% endif %}", +{%- endfor %} +{%- if not inner %} +] +{%- endif -%} +{%- endif -%} +{% endmacro %} \ No newline at end of file diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 74a340a..d2f6ade 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -1,3 +1,4 @@ +{%- from pathjoin("includes", "dependencies.toml.jinja") import render_deps with context -%} ################################################################################ # Build Configuration ################################################################################ @@ -69,67 +70,27 @@ Documentation = "{{ dev_platform_url }}/{{ username }}/{{ project_slug }}/blob/m {%- endif %} Download = "https://pypi.org/project/{{ project_slug }}/#files" -[project.optional-dependencies] -# The groups below should be in the [development-groups] table -# They are here now because hatch hasn't released support for them but plans to -# in Mid November 2025. +[dependency-groups] dev = [ - "hatch", - "pre-commit", - {%- if not use_hatch_envs %} - "{{ package_name }}[ - {%- if documentation!="" %}docs,{% endif -%} - {%- if use_test %}tests,{% endif -%} - {%- if use_lint %}style,{% endif -%} - {%- if use_types %}types,{% endif -%} - audit]", +{{- render_deps("dev", inner=True) }} + {%- if documentation in ("sphinx", "mkdocs") %} + {include-group = "docs"}, + {%- endif %} + {%- if use_test %} + {include-group = "tests"}, + {%- endif -%} + {%- if use_lint %} + {include-group = "style"}, + {%- endif -%} + {%- if use_types %} + {include-group = "types"}, {%- endif %} ] - -docs = [ -{%- if documentation == "sphinx" %} - "sphinx~=8.0", - "myst-parser>=4.0", - "pydata-sphinx-theme~=0.16", - "sphinx-autobuild>=2024.10.3", - "sphinx-autoapi>=3.6.0", - "sphinx_design>=0.6.1", - "sphinx-copybutton>=0.5.2", -{%- elif documentation == "mkdocs" %} - "mkdocs-material ~=9.5", - "mkdocstrings[python] ~=0.24", - "mkdocs-awesome-pages-plugin ~=2.9", -{% endif %} -] - -build = [ - "pip-audit", - "twine", -] - -{%- if use_test %} -tests = [ - "pytest", - "pytest-cov", - "pytest-raises", - "pytest-randomly", - "pytest-xdist", -] -{%- endif %} - -{%- if use_lint %} -style = [ - "pydoclint", - "ruff", -] -{%- endif %} - -{%- if use_types %} -types = [ - "mypy", -] -{%- endif %} - +{{ render_deps(documentation, key="docs") }} +{{ render_deps("build") }} +{%- if use_test %}{{ "\n" }}{{ render_deps("tests") }}{% endif %} +{%- if use_lint %}{{ "\n" }}{{ render_deps("style") }}{% endif %} +{%- if use_types %}{{ "\n" }}{{ render_deps("types") }}{% endif %} ################################################################################ # Tool Configuration @@ -276,10 +237,12 @@ installer = "uv" # This table installs the tools you need to test and build your package [tool.hatch.envs.build] description = """Test the installation the package.""" -features = [ +dependency-groups = [ "build", ] +{{ render_deps("build", key="dependencies") }} detached = true +builder = true # This table installs created the command hatch run install:check which will build and check your package. [tool.hatch.envs.build.scripts] @@ -293,7 +256,7 @@ check = [ {%- if use_test %} [tool.hatch.envs.test] description = """Run the test suite.""" -features = [ +dependency-groups = [ "tests", ] @@ -310,10 +273,10 @@ run = "pytest {args:--cov={{ package_name }} --cov-report=term-missing --cov-rep [tool.hatch.envs.docs] description = """Build or serve the documentation.""" # Install optional dependency test for docs -features = [ +dependency-groups = [ "docs", ] - +builder = true # This table contains the scripts that you can use to build and serve your docs # hatch run docs:build will build your documentation # hatch run docs:serve will serve them 'live' on your computer locally @@ -333,7 +296,7 @@ serve = ["sphinx-autobuild docs --watch src/{{ package_name }} {args:-b html doc [tool.hatch.envs.style] description = """Check the code and documentation style.""" -features = [ +dependency-groups = [ "style", ] detached = true @@ -349,9 +312,10 @@ check = ["docstrings", "code"] [tool.hatch.envs.audit] description = """Check dependencies for security vulnerabilities.""" -features = [ +dependency-groups = [ "build", ] +builder = true [tool.hatch.envs.audit.scripts] check = ["pip-audit"] @@ -361,7 +325,8 @@ check = ["pip-audit"] #--------------- Typing ---------------# [tool.hatch.envs.types] description = """Check the static types of the codebase.""" -features = ["types"] +dependency-groups = ["types"] +builder = true [tool.hatch.envs.types.scripts] check = "mypy src/{{ package_name }}" diff --git a/tests/conftest.py b/tests/conftest.py index 659a3e8..5bac47f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,16 @@ """Provide fixtures to the entire test suite.""" import shutil -from pathlib import Path -from typing import TYPE_CHECKING, Generator +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Any, Generator import pytest +from funcy import lflatten from _pytest.monkeypatch import MonkeyPatch from jinja2 import Environment, FileSystemLoader -from ruamel.yaml import YAML +from ruamel.yaml import YAML, Loader +from ruamel.yaml.constructor import Constructor +from ruamel.yaml.nodes import ScalarNode if TYPE_CHECKING: from _pytest.config.argparsing import Parser @@ -16,9 +19,25 @@ COPIER_CONFIG_PATH = Path(__file__).parents[1] / "copier.yml" INCLUDES_PATH = Path(__file__).parents[1] / "includes" +# handle copier's !include tags - +# they went and did us the favor of making their entire package private, +# so to respect their wishes to touch nothing we copy it here +# with mild modifications for our use case and for ruamel.yaml +# https://github.com/copier-org/copier/blob/24e842d838cf41b90a024ae4f80834add0ea95c2/copier/_template.py#L86 +def _include(loader: Constructor, node: ScalarNode) -> Any: + if not isinstance(node, ScalarNode): + raise ValueError(f"Unsupported YAML node: {node!r}") + include_file = str(loader.construct_scalar(node)) + if PurePosixPath(include_file).is_absolute(): + raise ValueError("YAML include file path must be a relative path") + path = next(COPIER_CONFIG_PATH.parent.glob(include_file)) + return [YAML(typ="safe").load(path.read_bytes())] + def _load_copier_config() -> dict: yaml = YAML(typ="safe") + yaml.constructor.add_constructor("!include", _include) + with COPIER_CONFIG_PATH.open("r") as yfile: return yaml.load(yfile) diff --git a/tests/test_template_init.py b/tests/test_template_init.py index 9e6a13f..a42c756 100644 --- a/tests/test_template_init.py +++ b/tests/test_template_init.py @@ -29,6 +29,7 @@ import pytest from copier import run_copy from git import Repo +from ruamel.yaml import YAML from validate_pyproject import api as validator_api try: @@ -169,7 +170,7 @@ def test_template_suite( project_dir = generated() # Run the local test suite. - run_command("hatch build --clean", project_dir) + run_command("hatch run build:check", project_dir) run_command(f"hatch run +py={sys.version_info.major}.{sys.version_info.minor} test:run", project_dir) run_command("hatch run style:check", project_dir) @@ -242,7 +243,7 @@ def test_non_hatch_deps( # validate pyproject.toml file if present validator_api.Validator()(pyproject) - optional_deps = pyproject["project"]["optional-dependencies"] + optional_deps = pyproject["dependency-groups"] groups = ("dev", "tests", "style", "types", "build") assert all(group in optional_deps for group in groups) @@ -254,3 +255,21 @@ def test_non_hatch_deps( if documentation != "no": assert "docs" in optional_deps assert any(dep.startswith(documentation) for dep in optional_deps["docs"]) + +def test_deps_sorted(generated: Callable[..., Path]): + """Dependencies in dep groups are sorted when rendering.""" + unsorted = {"z": None, "x": None, "y": None} + with (TEMPLATE / "data" / "dependencies.yml").open() as f: + deps = YAML(typ="safe").load(f) + + deps["tests"] = unsorted + project = generated( + use_test=True, + deps=[deps], + ) + pyproject_file = project / "pyproject.toml" + with pyproject_file.open("rb") as pfile: + pyproject = tomllib.load(pfile) + + assert "tests" in pyproject["dependency-groups"] + assert pyproject["dependency-groups"]["tests"] == ["x", "y", "z"]