Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support recursive optional dependencies #1387

Merged
merged 1 commit into from
Apr 14, 2024
Merged
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
69 changes: 56 additions & 13 deletions backend/src/hatchling/metadata/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Generic, cast

from hatchling.metadata.utils import get_normalized_dependency, is_valid_project_name, normalize_project_name
from hatchling.metadata.utils import (
format_dependency,
is_valid_project_name,
normalize_project_name,
normalize_requirement,
)
from hatchling.plugin.manager import PluginManagerBound
from hatchling.utils.constants import DEFAULT_CONFIG_FILE
from hatchling.utils.fs import locate_file
Expand Down Expand Up @@ -1209,7 +1214,8 @@ def dependencies_complex(self) -> dict[str, Requirement]:
)
raise ValueError(message)

dependencies_complex[get_normalized_dependency(requirement)] = requirement
normalize_requirement(requirement)
dependencies_complex[format_dependency(requirement)] = requirement

self._dependencies_complex = dict(sorted(dependencies_complex.items()))

Expand Down Expand Up @@ -1250,6 +1256,7 @@ def optional_dependencies_complex(self) -> dict[str, dict[str, Requirement]]:

normalized_options: dict[str, str] = {}
optional_dependency_entries = {}
inherited_options: dict[str, set[str]] = {}

for option, dependencies in optional_dependencies.items():
if not is_valid_project_name(option):
Expand All @@ -1260,6 +1267,16 @@ def optional_dependencies_complex(self) -> dict[str, dict[str, Requirement]]:
)
raise ValueError(message)

normalized_option = (
option if self.hatch_metadata.allow_ambiguous_features else normalize_project_name(option)
)
if normalized_option in normalized_options:
message = (
f'Optional dependency groups `{normalized_options[normalized_option]}` and `{option}` of '
f'field `project.optional-dependencies` both evaluate to `{normalized_option}`.'
)
raise ValueError(message)

if not isinstance(dependencies, list):
message = (
f'Dependencies for option `{option}` of field `project.optional-dependencies` must be an array'
Expand Down Expand Up @@ -1293,21 +1310,25 @@ def optional_dependencies_complex(self) -> dict[str, dict[str, Requirement]]:
)
raise ValueError(message)

entries[get_normalized_dependency(requirement)] = requirement

normalized_option = (
option if self.hatch_metadata.allow_ambiguous_features else normalize_project_name(option)
)
if normalized_option in normalized_options:
message = (
f'Optional dependency groups `{normalized_options[normalized_option]}` and `{option}` of '
f'field `project.optional-dependencies` both evaluate to `{normalized_option}`.'
)
raise ValueError(message)
normalize_requirement(requirement)
if requirement.name == self.name:
if normalized_option in inherited_options:
inherited_options[normalized_option].update(requirement.extras)
else:
inherited_options[normalized_option] = set(requirement.extras)
else:
entries[format_dependency(requirement)] = requirement

normalized_options[normalized_option] = option
optional_dependency_entries[normalized_option] = dict(sorted(entries.items()))

visited: set[str] = set()
resolved: set[str] = set()
for dependent_option in inherited_options:
_resolve_optional_dependencies(
optional_dependency_entries, dependent_option, inherited_options, visited, resolved
)

self._optional_dependencies_complex = dict(sorted(optional_dependency_entries.items()))

return self._optional_dependencies_complex
Expand Down Expand Up @@ -1578,3 +1599,25 @@ def hooks(self) -> dict[str, MetadataHookInterface]:
self._hooks = configured_hooks

return self._hooks


def _resolve_optional_dependencies(
optional_dependencies_complex, dependent_option, inherited_options, visited, resolved
):
if dependent_option in resolved:
return

if dependent_option in visited:
message = f'Field `project.optional-dependencies` defines a circular dependency group: {dependent_option}'
raise ValueError(message)

visited.add(dependent_option)
if dependent_option in inherited_options:
for selected_option in inherited_options[dependent_option]:
_resolve_optional_dependencies(
optional_dependencies_complex, selected_option, inherited_options, visited, resolved
)
optional_dependencies_complex[dependent_option].update(optional_dependencies_complex[selected_option])

resolved.add(dependent_option)
visited.remove(dependent_option)
9 changes: 8 additions & 1 deletion backend/src/hatchling/metadata/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def normalize_project_name(project_name: str) -> str:
return re.sub(r'[-_.]+', '-', project_name).lower()


def get_normalized_dependency(requirement: Requirement) -> str:
def normalize_requirement(requirement: Requirement) -> None:
# Changes to this function affect reproducibility between versions
from packaging.specifiers import SpecifierSet

Expand All @@ -33,10 +33,17 @@ def get_normalized_dependency(requirement: Requirement) -> str:
if requirement.extras:
requirement.extras = {normalize_project_name(extra) for extra in requirement.extras}


def format_dependency(requirement: Requirement) -> str:
# All TOML writers use double quotes, so allow direct writing or copy/pasting to avoid escaping
return str(requirement).replace('"', "'")


def get_normalized_dependency(requirement: Requirement) -> str:
normalize_requirement(requirement)
return format_dependency(requirement)


def resolve_metadata_fields(metadata: ProjectMetadata) -> dict[str, Any]:
# https://packaging.python.org/en/latest/specifications/declaring-project-metadata/
return {
Expand Down
1 change: 1 addition & 0 deletions docs/history/hatchling.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

***Fixed:***

- Support recursive optional dependencies
- Set the `packaging` dependency version as `>=23.2` to avoid its URL validation which can conflict with context formatting

## [1.22.5](https://github.com/pypa/hatch/releases/tag/hatchling-v1.22.5) - 2024-04-04 ## {: #hatchling-v1.22.5 }
Expand Down
25 changes: 23 additions & 2 deletions tests/backend/metadata/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,19 @@ def test_conflict(self, isolation):
):
_ = metadata.core.optional_dependencies

def test_circular_dependency(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{'project': {'name': 'my-app', 'optional-dependencies': {'foo': ['my-app[bar]'], 'bar': ['my-app[foo]']}}},
)

with pytest.raises(
ValueError,
match='Field `project.optional-dependencies` defines a circular dependency group: foo',
):
_ = metadata.core.optional_dependencies

def test_allow_ambiguity(self, isolation):
metadata = ProjectMetadata(
str(isolation),
Expand Down Expand Up @@ -1339,7 +1352,7 @@ def test_context_formatting(self, isolation, uri_slash_prefix):
str(isolation),
None,
{
'project': {'optional-dependencies': {'foo': ['proj @ {root:uri}']}},
'project': {'name': 'my-app', 'optional-dependencies': {'foo': ['proj @ {root:uri}']}},
'tool': {'hatch': {'metadata': {'allow-direct-references': True}}},
},
)
Expand All @@ -1352,7 +1365,10 @@ def test_direct_reference_allowed(self, isolation):
str(isolation),
None,
{
'project': {'optional-dependencies': {'foo': ['proj @ git+https://github.com/org/proj.git@v1']}},
'project': {
'name': 'my-app',
'optional-dependencies': {'foo': ['proj @ git+https://github.com/org/proj.git@v1']},
},
'tool': {'hatch': {'metadata': {'allow-direct-references': True}}},
},
)
Expand All @@ -1365,6 +1381,7 @@ def test_correct(self, isolation):
None,
{
'project': {
'name': 'my-app',
'optional-dependencies': {
'foo': [
'python___dateutil;platform_python_implementation=="CPython"',
Expand All @@ -1373,6 +1390,8 @@ def test_correct(self, isolation):
'fOO; python_version< "3.8"',
],
'bar': ['foo', 'bar', 'Baz'],
'baz': ['my___app[XYZ]'],
'xyz': ['my...app[Bar]'],
},
},
},
Expand All @@ -1383,11 +1402,13 @@ def test_correct(self, isolation):
== metadata.core.optional_dependencies
== {
'bar': ['bar', 'baz', 'foo'],
'baz': ['bar', 'baz', 'foo'],
'foo': [
'bar-baz[eddsa,tls,zu-bat]<9000b1,>=1.2rc5',
"foo; python_version < '3.8'",
"python-dateutil; platform_python_implementation == 'CPython'",
],
'xyz': ['bar', 'baz', 'foo'],
}
)

Expand Down