Skip to content

Commit 4528af3

Browse files
committed
Hatch environments support PEP735 dependency-groups
Closes #754 Closes #1852
1 parent 64031c1 commit 4528af3

File tree

6 files changed

+392
-6
lines changed

6 files changed

+392
-6
lines changed

Diff for: backend/src/hatchling/metadata/core.py

+35
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import sys
5+
from collections import defaultdict
56
from contextlib import suppress
67
from copy import deepcopy
78
from typing import TYPE_CHECKING, Any, Generic, cast
@@ -52,6 +53,8 @@ def __init__(
5253
self._core: CoreMetadata | None = None
5354
self._hatch: HatchMetadata | None = None
5455

56+
self._dependency_groups: dict[str, list[str]] | None = None
57+
5558
self._core_raw_metadata: dict[str, Any] | None = None
5659
self._dynamic: list[str] | None = None
5760
self._name: str | None = None
@@ -77,6 +80,38 @@ def context(self) -> Context:
7780

7881
return self._context
7982

83+
@property
84+
def dependency_groups(self) -> dict[str, Any]:
85+
"""
86+
https://peps.python.org/pep-0735/
87+
"""
88+
if self._dependency_groups is None:
89+
dependency_groups = self.config.get('dependency-groups', {})
90+
91+
if not isinstance(dependency_groups, dict):
92+
message = 'Field `dependency-groups` must be a table'
93+
raise ValueError(message)
94+
95+
original_names = defaultdict(list)
96+
normalized_groups = {}
97+
98+
for group_name, value in dependency_groups.items():
99+
normed_group_name = normalize_project_name(group_name)
100+
original_names[normed_group_name].append(group_name)
101+
normalized_groups[normed_group_name] = value
102+
103+
errors = []
104+
for normed_name, names in original_names.items():
105+
if len(names) > 1:
106+
errors.append(f"{normed_name} ({', '.join(names)})")
107+
if errors:
108+
msg = f"Field `dependency-groups` contains duplicate names: {', '.join(errors)}"
109+
raise ValueError(msg)
110+
111+
self._dependency_groups = dependency_groups
112+
113+
return self._dependency_groups
114+
80115
@property
81116
def core_raw_metadata(self) -> dict[str, Any]:
82117
if self._core_raw_metadata is None:

Diff for: docs/config/environment/advanced.md

+65
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,71 @@ you could then run your tests consecutively in all 4 environments with:
127127
hatch run test:cov
128128
```
129129

130+
## Dependency Groups
131+
132+
Environments can use [PEP 735](https://peps.python.org/pep-0735/) dependency groups with the `dependency-groups` option:
133+
134+
```toml config-example
135+
[dependency-groups]
136+
test = [
137+
"pytest>=7.0.0",
138+
"pytest-cov>=4.1.0",
139+
]
140+
lint = [
141+
"black",
142+
"ruff",
143+
"mypy",
144+
]
145+
# Groups can include other groups
146+
dev = [
147+
{"include-group": "test"},
148+
{"include-group": "lint"},
149+
"pre-commit",
150+
]
151+
152+
[tool.hatch.envs.test]
153+
dependency-groups = ["test"]
154+
155+
[tool.hatch.envs.lint]
156+
dependency-groups = ["lint"]
157+
158+
[tool.hatch.envs.dev]
159+
dependency-groups = ["dev"]
160+
```
161+
162+
The `dependency-groups` option specifies which PEP 735 dependency groups to include in the environment's dependencies. This is particularly useful for organizing related dependencies and including them in appropriate environments.
163+
164+
### Combining with Other Dependencies
165+
166+
Dependency groups can be combined with other dependency mechanisms:
167+
168+
```toml config-example
169+
[project]
170+
name = "my-app"
171+
version = "0.1.0"
172+
dependencies = [
173+
"requests>=2.28.0",
174+
]
175+
176+
[dependency-groups]
177+
test = ["pytest>=7.0.0"]
178+
docs = ["sphinx>=7.0.0"]
179+
180+
[tool.hatch.envs.test]
181+
# Include the test dependency group
182+
dependency-groups = ["test"]
183+
# Add environment-specific dependencies
184+
dependencies = [
185+
"coverage[toml]>=7.0.0",
186+
]
187+
# Project dependencies will be included if not skip-install and in dev-mode
188+
```
189+
190+
In this example, the test environment would include:
191+
1. Project dependencies (`requests>=2.28.0`)
192+
2. The test dependency group (`pytest>=7.0.0`)
193+
3. Environment-specific dependencies (`coverage[toml]>=7.0.0`)
194+
130195
## Option overrides
131196

132197
You can modify options based on the conditions of different sources like [matrix variables](#matrix-variable-overrides) with the `overrides` table, using [dotted key](https://toml.io/en/v1.0.0#table) syntax for each declaration:

Diff for: docs/config/environment/overview.md

+22
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,28 @@ features = [
103103
!!! note
104104
Features/optional dependencies are also known as `extras` in other tools.
105105

106+
### Dependency Groups
107+
108+
You can include [PEP 735](https://peps.python.org/pep-0735/) dependency groups in your environments using the `dependency-groups` option:
109+
110+
```toml config-example
111+
[dependency-groups]
112+
test = [
113+
"pytest>=7.0.0",
114+
"pytest-cov>=4.1.0",
115+
]
116+
117+
[tool.hatch.envs.test]
118+
dependency-groups = [
119+
"test",
120+
]
121+
```
122+
123+
Dependency groups provide a standardized way to organize related dependencies and can be shared across different build systems. See [advanced usage](advanced.md#dependency-groups) for more details on dependency group features like including other groups.
124+
125+
!!! note
126+
Unlike features which affect how the project itself is installed, dependency groups are separate dependencies that are installed alongside the project.
127+
106128
### Dev mode
107129

108130
By default, environments will always reflect the current state of your project on disk, for example, by installing it in editable mode in a Python environment. Set `dev-mode` to `false` to disable this behavior and have your project installed only upon creation of a new environment. From then on, you need to manage your project installation manually.

Diff for: src/hatch/env/plugin/interface.py

+43-2
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,8 @@ def dependencies_complex(self):
288288

289289
# Ensure these are checked last to speed up initial environment creation since
290290
# they will already be installed along with the project
291-
if (not self.skip_install and self.dev_mode) or self.features:
292-
from hatch.utils.dep import get_complex_dependencies, get_complex_features
291+
if (not self.skip_install and self.dev_mode) or self.features or self.dependency_groups:
292+
from hatch.utils.dep import get_complex_dependencies, get_complex_dependency_group, get_complex_features
293293

294294
dependencies, optional_dependencies = self.app.project.get_dependencies()
295295
dependencies_complex = get_complex_dependencies(dependencies)
@@ -308,6 +308,11 @@ def dependencies_complex(self):
308308

309309
all_dependencies_complex.extend(optional_dependencies_complex[feature].values())
310310

311+
for dependency_group in self.dependency_groups:
312+
all_dependencies_complex.extend(
313+
get_complex_dependency_group(self.metadata.dependency_groups, dependency_group)
314+
)
315+
311316
return all_dependencies_complex
312317

313318
@cached_property
@@ -424,6 +429,42 @@ def features(self):
424429

425430
return sorted(all_features)
426431

432+
@cached_property
433+
def dependency_groups(self):
434+
from hatchling.metadata.utils import normalize_project_name
435+
436+
dependency_groups = self.config.get('dependency-groups', [])
437+
if not isinstance(dependency_groups, list):
438+
message = f'Field `tool.hatch.envs.{self.name}.dependency-groups` must be an array of strings'
439+
raise TypeError(message)
440+
441+
all_dependency_groups = set()
442+
for i, dependency_group in enumerate(dependency_groups, 1):
443+
if not isinstance(dependency_group, str):
444+
message = (
445+
f'Dependency Group #{i} of field `tool.hatch.envs.{self.name}.dependency-groups` must be a string'
446+
)
447+
raise TypeError(message)
448+
449+
if not dependency_group:
450+
message = f'Dependency Group #{i} of field `tool.hatch.envs.{self.name}.dependency-groups` cannot be an empty string'
451+
raise ValueError(message)
452+
453+
normalized_dependency_group = normalize_project_name(dependency_group)
454+
if (
455+
not self.metadata.hatch.metadata.hook_config
456+
and normalized_dependency_group not in self.metadata.dependency_groups
457+
):
458+
message = (
459+
f'Dependency Group `{normalized_dependency_group}` of field `tool.hatch.envs.{self.name}.dependency-groups` is not '
460+
f'defined in field `dependency-groups`'
461+
)
462+
raise ValueError(message)
463+
464+
all_dependency_groups.add(normalized_dependency_group)
465+
466+
return sorted(all_dependency_groups)
467+
427468
@cached_property
428469
def description(self) -> str:
429470
"""

Diff for: src/hatch/utils/dep.py

+37-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
from typing import Any
44

5-
from hatchling.metadata.utils import get_normalized_dependency
5+
from packaging.requirements import Requirement
66

7-
if TYPE_CHECKING:
8-
from packaging.requirements import Requirement
7+
from hatchling.metadata.utils import get_normalized_dependency, normalize_project_name
98

109

1110
def normalize_marker_quoting(text: str) -> str:
@@ -52,3 +51,37 @@ def get_complex_features(features: dict[str, list[str]]) -> dict[str, dict[str,
5251
}
5352

5453
return optional_dependencies_complex
54+
55+
56+
def get_complex_dependency_group(
57+
dependency_groups: dict[str, Any], group: str, past_groups: tuple[str, ...] = ()
58+
) -> list[Requirement]:
59+
if group in past_groups:
60+
msg = f'Cyclic dependency group include: {group} -> {past_groups}'
61+
raise ValueError(msg)
62+
63+
if group not in dependency_groups:
64+
msg = f"Dependency group '{group}' not found"
65+
raise LookupError(msg)
66+
67+
raw_group = dependency_groups[group]
68+
if not isinstance(raw_group, list):
69+
msg = f"Dependency group '{group}' is not a list"
70+
raise TypeError(msg)
71+
72+
realized_group = []
73+
for item in raw_group:
74+
if isinstance(item, str):
75+
realized_group.append(Requirement(item))
76+
elif isinstance(item, dict):
77+
if tuple(item.keys()) != ('include-group',):
78+
msg = f'Invalid dependency group item: {item}'
79+
raise ValueError(msg)
80+
81+
include_group = normalize_project_name(next(iter(item.values())))
82+
realized_group.extend(get_complex_dependency_group(dependency_groups, include_group, (*past_groups, group)))
83+
else:
84+
msg = f'Invalid dependency group item: {item}'
85+
raise TypeError(msg)
86+
87+
return realized_group

0 commit comments

Comments
 (0)