Skip to content

Commit

Permalink
Implement parsing of [project.dependencies] (#213)
Browse files Browse the repository at this point in the history
Implements feature suggested in #209 by @nickpowersys.
  • Loading branch information
basnijholt authored Jan 2, 2025
1 parent 23f5c9f commit 3d69198
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 41 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,6 @@ ENV/

# Rope project settings
.ropeproject

# other
.pixi
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Try it now and streamline your development process!
- [Supported Selectors](#supported-selectors)
- [Usage](#usage)
- [Implementation](#implementation)
- [`[project.dependencies]` in `pyproject.toml` handling](#projectdependencies-in-pyprojecttoml-handling)
- [:jigsaw: Build System Integration](#jigsaw-build-system-integration)
- [Example packages](#example-packages)
- [Setuptools Integration](#setuptools-integration)
Expand Down Expand Up @@ -309,6 +310,44 @@ Note that the `package-name:unix` syntax can also be used in the `requirements.y
`unidep` parses these selectors and filters dependencies according to the platform where it's being installed.
It is also used for creating environment and lock files that are portable across different platforms, ensuring that each environment has the appropriate dependencies installed.

### `[project.dependencies]` in `pyproject.toml` handling

The `project_dependency_handling` option in `[tool.unidep]` (in `pyproject.toml`) controls how dependencies listed in the standard `[project.dependencies]` section of `pyproject.toml` are handled when processed by `unidep`.

**Modes:**

- **`ignore`** (default): Dependencies in `[project.dependencies]` are ignored by `unidep`.
- **`same-name`**: Dependencies in `[project.dependencies]` are treated as dependencies with the same name for both Conda and Pip. They will be added to the `dependencies` list in `[tool.unidep]` under the assumption that the package name is the same for both package managers.
- **`pip-only`**: Dependencies in `[project.dependencies]` are treated as pip-only dependencies. They will be added to the `dependencies` list in `[tool.unidep]` under the `pip` key.

**Example `pyproject.toml`:**

```toml
[build-system]
requires = ["hatchling", "unidep"]
build-backend = "hatchling.build"
[project]
name = "my-project"
version = "0.1.0"
dependencies = [ # These will be handled according to the `project_dependency_handling` option
"requests",
"pandas",
]

[tool.unidep]
project_dependency_handling = "same-name" # Or "pip-only", "ignore"
dependencies = [
{conda = "python-graphviz", pip = "graphivz"},
]
```

**Notes:**

- The `project_dependency_handling` option only affects how dependencies from `[project.dependencies]` are processed. Dependencies directly listed under `[tool.unidep.dependencies]` are handled as before.
- This feature is helpful for projects that are already using the standard `[project.dependencies]` field and want to integrate `unidep` without duplicating their dependency list.
- The `project_dependency_handling` feature is _*only available*_ when using `pyproject.toml` files. It is not supported in `requirements.yaml` files.

## :jigsaw: Build System Integration

> [!TIP]
Expand Down
28 changes: 28 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""unidep tests."""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

from unidep._dependencies_parsing import yaml_to_toml

if TYPE_CHECKING:
import sys

if sys.version_info >= (3, 8):
from typing import Literal
else: # pragma: no cover
from typing_extensions import Literal


REPO_ROOT = Path(__file__).parent.parent


def maybe_as_toml(toml_or_yaml: Literal["toml", "yaml"], p: Path) -> Path:
if toml_or_yaml == "toml":
toml = yaml_to_toml(p)
p.unlink()
p = p.with_name("pyproject.toml")
p.write_text(toml)
return p
10 changes: 1 addition & 9 deletions tests/test_local_wheels_and_zip.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,8 @@
import pytest

from unidep import parse_local_dependencies, parse_requirements
from unidep._dependencies_parsing import yaml_to_toml


def maybe_as_toml(toml_or_yaml: Literal["toml", "yaml"], p: Path) -> Path:
if toml_or_yaml == "toml":
toml = yaml_to_toml(p)
p.unlink()
p = p.with_name("pyproject.toml")
p.write_text(toml)
return p
from .helpers import maybe_as_toml


@pytest.mark.parametrize("toml_or_yaml", ["toml", "yaml"])
Expand Down
12 changes: 2 additions & 10 deletions tests/test_parse_yaml_local_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
parse_requirements,
resolve_conflicts,
)
from unidep._dependencies_parsing import yaml_to_toml

from .helpers import maybe_as_toml

if TYPE_CHECKING:
import sys
Expand All @@ -31,15 +32,6 @@
REPO_ROOT = Path(__file__).parent.parent


def maybe_as_toml(toml_or_yaml: Literal["toml", "yaml"], p: Path) -> Path:
if toml_or_yaml == "toml":
toml = yaml_to_toml(p)
p.unlink()
p = p.with_name("pyproject.toml")
p.write_text(toml)
return p


@pytest.mark.parametrize("toml_or_yaml", ["toml", "yaml"])
def test_circular_local_dependencies(
toml_or_yaml: Literal["toml", "yaml"],
Expand Down
12 changes: 2 additions & 10 deletions tests/test_parse_yaml_nested_local_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
parse_local_dependencies,
parse_requirements,
)
from unidep._dependencies_parsing import yaml_to_toml

from .helpers import maybe_as_toml

if TYPE_CHECKING:
import sys
Expand All @@ -26,15 +27,6 @@
REPO_ROOT = Path(__file__).parent.parent


def maybe_as_toml(toml_or_yaml: Literal["toml", "yaml"], p: Path) -> Path:
if toml_or_yaml == "toml":
toml = yaml_to_toml(p)
p.unlink()
p = p.with_name("pyproject.toml")
p.write_text(toml)
return p


@pytest.mark.parametrize("toml_or_yaml", ["toml", "yaml"])
def test_nested_local_dependencies_multiple_levels(
toml_or_yaml: Literal["toml", "yaml"],
Expand Down
135 changes: 135 additions & 0 deletions tests/test_project_dependency_handling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Tests for the `project_dependency_handling` feature."""

from __future__ import annotations

import textwrap
from typing import TYPE_CHECKING, Literal

import pytest

from unidep._dependencies_parsing import (
_add_project_dependencies,
parse_requirements,
)
from unidep.platform_definitions import Spec

if TYPE_CHECKING:
from pathlib import Path


@pytest.mark.parametrize(
("project_dependencies", "handling_mode", "expected"),
[
# Test same-name
(
["pandas", "requests"],
"same-name",
["pandas", "requests"],
),
# Test pip-only
(
["pandas", "requests"],
"pip-only",
[{"pip": "pandas"}, {"pip": "requests"}],
),
# Test ignore
(["pandas", "requests"], "ignore", []),
# Test invalid handling mode
(["pandas", "requests"], "invalid", []),
],
)
def test_project_dependency_handling(
project_dependencies: list[str],
handling_mode: Literal["same-name", "pip-only", "ignore", "invalid"],
expected: list[dict[str, str] | str],
) -> None:
valid_unidep_dependencies: list[dict[str, str] | str] = [
{"conda": "pandas", "pip": "pandas"},
"requests",
{"conda": "zstd", "pip": "zstandard"},
]
unidep_dependencies = valid_unidep_dependencies.copy()
if handling_mode == "invalid":
with pytest.raises(ValueError, match="Invalid `project_dependency_handling`"):
_add_project_dependencies(
project_dependencies,
unidep_dependencies,
handling_mode, # type: ignore[arg-type]
)
else:
_add_project_dependencies(
project_dependencies,
unidep_dependencies,
handling_mode, # type: ignore[arg-type]
)
assert unidep_dependencies == valid_unidep_dependencies + expected


@pytest.mark.parametrize(
"project_dependency_handling",
["same-name", "pip-only", "ignore"],
)
def test_project_dependency_handling_in_pyproject_toml(
tmp_path: Path,
project_dependency_handling: Literal["same-name", "pip-only", "ignore"],
) -> None:
p = tmp_path / "pyproject.toml"
p.write_text(
textwrap.dedent(
f"""\
[build-system]
requires = ["hatchling", "unidep"]
build-backend = "hatchling.build"
[project]
name = "my-project"
version = "0.1.0"
dependencies = [
"requests",
"pandas",
]
[tool.unidep]
project_dependency_handling = "{project_dependency_handling}"
dependencies = [
{{ conda = "python-graphviz", pip = "graphviz" }},
{{ conda = "graphviz" }},
]
""",
),
)

requirements = parse_requirements(p)

expected = {
"python-graphviz": [
Spec(name="python-graphviz", which="conda", identifier="17e5d607"),
],
"graphviz": [
Spec(name="graphviz", which="pip", identifier="17e5d607"),
Spec(name="graphviz", which="conda", identifier="5eb93b8c"),
],
}
if project_dependency_handling == "pip-only":
expected.update(
{
"requests": [Spec(name="requests", which="pip", identifier="08fd8713")],
"pandas": [Spec(name="pandas", which="pip", identifier="9e467fa1")],
},
)
elif project_dependency_handling == "same-name":
expected.update(
{
"requests": [
Spec(name="requests", which="conda", identifier="08fd8713"),
Spec(name="requests", which="pip", identifier="08fd8713"),
],
"pandas": [
Spec(name="pandas", which="conda", identifier="9e467fa1"),
Spec(name="pandas", which="pip", identifier="9e467fa1"),
],
},
)
else:
assert project_dependency_handling == "ignore"
assert requirements.requirements == expected
12 changes: 2 additions & 10 deletions tests/test_unidep.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@
)
from unidep._conda_env import CondaEnvironmentSpec
from unidep._conflicts import VersionConflictError
from unidep._dependencies_parsing import yaml_to_toml
from unidep.platform_definitions import Platform, Spec
from unidep.utils import is_pip_installable

from .helpers import maybe_as_toml

if TYPE_CHECKING:
import sys

Expand All @@ -37,15 +38,6 @@
REPO_ROOT = Path(__file__).parent.parent


def maybe_as_toml(toml_or_yaml: Literal["toml", "yaml"], p: Path) -> Path:
if toml_or_yaml == "toml":
toml = yaml_to_toml(p)
p.unlink()
p = p.with_name("pyproject.toml")
p.write_text(toml)
return p


@pytest.fixture(params=["toml", "yaml"])
def setup_test_files(
request: pytest.FixtureRequest,
Expand Down
39 changes: 37 additions & 2 deletions unidep/_dependencies_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import functools
import hashlib
import os
import sys
Expand Down Expand Up @@ -162,6 +163,7 @@ def _parse_overwrite_pins(overwrite_pins: list[str]) -> dict[str, str | None]:
return result


@functools.lru_cache
def _load(p: Path, yaml: YAML) -> dict[str, Any]:
if p.suffix == ".toml":
if not HAS_TOML: # pragma: no cover
Expand All @@ -174,11 +176,44 @@ def _load(p: Path, yaml: YAML) -> dict[str, Any]:
)
raise ImportError(msg)
with p.open("rb") as f:
return tomllib.load(f)["tool"]["unidep"]
pyproject = tomllib.load(f)
project_dependencies = pyproject.get("project", {}).get("dependencies", [])
unidep_cfg = pyproject["tool"]["unidep"]
if not project_dependencies:
return unidep_cfg
unidep_dependencies = unidep_cfg.setdefault("dependencies", [])
project_dependency_handling = unidep_cfg.get(
"project_dependency_handling",
"ignore",
)
_add_project_dependencies(
project_dependencies,
unidep_dependencies,
project_dependency_handling,
)
return unidep_cfg
with p.open() as f:
return yaml.load(f)


def _add_project_dependencies(
project_dependencies: list[str],
unidep_dependencies: list[dict[str, str] | str],
project_dependency_handling: Literal["same-name", "pip-only", "ignore"],
) -> None:
"""Add project dependencies to unidep dependencies based on the chosen handling."""
if project_dependency_handling == "same-name":
unidep_dependencies.extend(project_dependencies)
elif project_dependency_handling == "pip-only":
unidep_dependencies.extend([{"pip": dep} for dep in project_dependencies])
elif project_dependency_handling != "ignore":
msg = (
f"Invalid `project_dependency_handling` value: {project_dependency_handling}." # noqa: E501
" Must be one of 'same-name', 'pip-only', 'ignore'."
)
raise ValueError(msg)


def _get_local_dependencies(data: dict[str, Any]) -> list[str]:
"""Get `local_dependencies` from a `requirements.yaml` or `pyproject.toml` file."""
if "local_dependencies" in data:
Expand Down Expand Up @@ -417,7 +452,7 @@ def parse_requirements(
datas: list[dict[str, Any]] = []
all_extras: list[list[str]] = []
seen: set[PathWithExtras] = set()
yaml = YAML(typ="rt")
yaml = YAML(typ="rt") # Might be unused if all are TOML files
for path_with_extras in paths_with_extras:
_update_data_structures(
path_with_extras=path_with_extras,
Expand Down

0 comments on commit 3d69198

Please sign in to comment.