Skip to content

Commit

Permalink
Support recursive optional dependencies
Browse files Browse the repository at this point in the history
  • Loading branch information
ofek committed Apr 14, 2024
1 parent f4359e8 commit d259331
Show file tree
Hide file tree
Showing 4 changed files with 88 additions and 16 deletions.
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

0 comments on commit d259331

Please sign in to comment.