Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
5 changes: 5 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
45 changes: 45 additions & 0 deletions data/dependencies.yml
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had the discussion on Slack regarding a lack of being able to require a minimum hatch version, despite that, we need hatch as a global dependency to even create the hatch environments, so this dependency does not make sense to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was hoping that it would be able to detect a mismatch of version between the installed hatch and the requested hatch when it created the build env it used to install the package, but that might have been an empty hope. Im not sure where this dep should get declared but we need to declare it somewhere, because now the template wouldnt work with hatch versions older than this. So i guess this is a problem we would need to fix for whatever implementation of dependency-groups we do here

Copy link
Member

@lwasser lwasser Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we think about the hatch dependency in the same way we think about python. This template supports hatch >= version x.x.x in our documentation? I know it's not a perfect solution.

The better solution would be something akin to python-requires

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i think in any case it's sort of at a different level than the template itself, it seems like a feature that hatch needs to me. the thing i worry about with putting dependencies in the README is that it's much easier to not see them than programmatic specifications that force them to be true, but I added a note in the readme here: b1dd00d

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

definitely not a concern for your pr here! agree 💯 !!

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:
20 changes: 20 additions & 0 deletions includes/dependencies.toml.jinja
Original file line number Diff line number Diff line change
@@ -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 %}
95 changes: 30 additions & 65 deletions template/pyproject.toml.jinja
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{%- from pathjoin("includes", "dependencies.toml.jinja") import render_deps with context -%}
################################################################################
# Build Configuration
################################################################################
Expand Down Expand Up @@ -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 %}
Comment on lines +91 to +93
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I always have to look up the whitespace behavior of jinja2, but isn't it possible to have each group start on a newline without inserting a literal \n?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could also consider render_deps having only the inner=True behavior, that would likely solve the whitespace issues here. As you'd have to specify something like:

{%- if use_test %}
tests = [
{{ render_deps("tests") }}
]
{%- endif %}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it this way mostly because i wanted the top level pyproject.toml to be a logical template (what to display) and the macro to be the structural template (how to display it), and to link the keys in the dict with the output to avoid key discrepancies (tests/test). Its a little awkward but this way of writing them makes them self contained lines that dont have different start/end whitespace behavior that would need to be handled by, e.g., remembering to suppress whitespace at the end of the last line and start of first, and future groups could copy/paste the one line and just modify the key


################################################################################
# Tool Configuration
Expand Down Expand Up @@ -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") }}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this, btw, is why it's nice to have the dependencies separated out as data - if we need to do weird things with them like render them twice, we can do it without needing to remember to keep multiple places updated, all the deps stay specified in the yaml

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]
Expand All @@ -293,7 +256,7 @@ check = [
{%- if use_test %}
[tool.hatch.envs.test]
description = """Run the test suite."""
features = [
dependency-groups = [
"tests",
]

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"]
Expand All @@ -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 }}"
Expand Down
25 changes: 22 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 -
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, OK. So we are modifying this function rather than using what the copier uses, to allow us to import the dependency files effectively in a way that we can trust the "API" part won't change on us? Is that the gist?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's basically so that we do effectively what they do in the tests where we are loading the copier template directly (rather than calling copier, which we do in most of the tests) without reaching inside their private methods. also they use pyyaml and we are using ruamel.yaml, so had to adapt it for that too

# 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)

Expand Down
23 changes: 21 additions & 2 deletions tests/test_template_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -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"]