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

Preliminary support for Pixi projects using pyproject.toml #455

Merged
merged 9 commits into from
Oct 7, 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ The `--deps` option tells FawltyDeps where to look for your project's declared
dependencies. A number of file formats are supported:

- `*requirements*.txt` and `*requirements*.in`
- `pyproject.toml` (following PEP 621 or Poetry conventions)
- `pyproject.toml` (following PEP 621, Poetry, or Pixi conventions)
- `setup.py` (only limited support for simple files with a single `setup()`
call and no computation involved for setting the `install_requires` and
`extras_require` arguments)
Expand Down
87 changes: 85 additions & 2 deletions fawltydeps/extract_declared_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import ast
import configparser
import contextlib
import logging
import re
import sys
Expand Down Expand Up @@ -57,10 +58,15 @@ def parse_requirements_txt(path: Path) -> Iterator[DeclaredDependency]:
https://pip.pypa.io/en/stable/reference/requirements-file-format/.
"""
source = Location(path)
for dep in RequirementsFile.from_file(path).requirements:
parsed = RequirementsFile.from_file(path)
for dep in parsed.requirements:
if dep.name:
yield DeclaredDependency(dep.name, source)

if parsed.invalid_lines and logger.isEnabledFor(logging.DEBUG):
error_messages = "\n".join(line.dumps() for line in parsed.invalid_lines)
logger.debug(f"Invalid lines found in {source}:\n{error_messages}")


def parse_setup_py(path: Path) -> Iterator[DeclaredDependency]: # noqa: C901
"""Extract dependencies (package names) from setup.py.
Expand Down Expand Up @@ -239,6 +245,60 @@ def parse_extra(contents: TomlData, src: Location) -> NamedLocations:
yield from parse_pyproject_elements(poetry_config, source, "Poetry", fields_parsers)


def parse_pixi_pyproject_dependencies(
pixi_config: TomlData, source: Location
) -> Iterator[DeclaredDependency]:
"""Extract dependencies from `tool.pixi` fields in a pyproject.toml.

- [tool.pixi.dependencies] contains mandatory Conda deps
- [tool.pixi.pypi-dependencies] contains mandatory PyPI deps
- [tool.pixi.feature.<NAME>.dependencies] contains optional Conda deps
- [tool.pixi.feature.<NAME>.pypi-dependencies] contains optional PyPI deps

NOTE: We do not currently differentiate between Conda dependencies and PyPI
dependencies, meaning that we assume that a Conda dependency named FOO will
map one-to-one to a Python package named FOO. This is certainly not true for
Conda dependencies that are not Python packages, and it probably isn't even
true for all Conda dependencies that do indeed include Python packages.
"""

def parse_main(contents: TomlData, src: Location) -> NamedLocations:
return (
(req, src)
for req in contents["dependencies"].keys() # noqa: SIM118
if req != "python"
)

def parse_pypi(contents: TomlData, src: Location) -> NamedLocations:
return (
(req, src)
for req in contents["pypi-dependencies"].keys() # noqa: SIM118
)

def parse_feature(contents: TomlData, src: Location) -> NamedLocations:
return (
(req, src)
for feature in contents["feature"].values()
for req in feature.get("dependencies", {}).keys() # noqa: SIM118
if req != "python"
)

def parse_feature_pypi(contents: TomlData, src: Location) -> NamedLocations:
return (
(req, src)
for feature in contents["feature"].values()
for req in feature.get("pypi-dependencies", {}).keys() # noqa: SIM118
)

fields_parsers = [
("main", parse_main),
("pypi", parse_pypi),
("feature", parse_feature),
("feature pypi", parse_feature_pypi),
]
yield from parse_pyproject_elements(pixi_config, source, "Pixi", fields_parsers)


def parse_pep621_pyproject_contents( # noqa: C901
parsed_contents: TomlData, source: Location
) -> Iterator[DeclaredDependency]:
Expand Down Expand Up @@ -351,12 +411,35 @@ def parse_pyproject_toml(path: Path) -> Iterator[DeclaredDependency]:
We currently handle:
- PEP 621 core and dynamic metadata fields.
- Poetry-specific metadata in `tool.poetry` sections.
- Pixi-specific metadata in `tool.pixi` sections.
"""
source = Location(path)
with path.open("rb") as tomlfile:
parsed_contents = tomllib.load(tomlfile)

yield from parse_pep621_pyproject_contents(parsed_contents, source)
skip = set()

# Skip dependencies onto self (such as Pixi's "editable mode" hack)
with contextlib.suppress(KeyError):
skip.add(parsed_contents["project"]["name"])

# In Pixi, dependencies from [tool.pixi.dependencies] _override_
# dependencies from PEP621 dependencies with the same name.
# Therefore, parse the Pixi sections first, and skip dependencies with the
mknorps marked this conversation as resolved.
Show resolved Hide resolved
# same name in the PEP621 section below.
if "pixi" in parsed_contents.get("tool", {}):
for dep in parse_pixi_pyproject_dependencies(
parsed_contents["tool"]["pixi"], source
):
if dep.name not in skip:
skip.add(dep.name)
yield dep
else:
logger.debug("%s does not contain [tool.pixi].", source)

for dep in parse_pep621_pyproject_contents(parsed_contents, source):
if dep.name not in skip:
yield dep

if "poetry" in parsed_contents.get("tool", {}):
yield from parse_poetry_pyproject_dependencies(
Expand Down
8 changes: 4 additions & 4 deletions fawltydeps/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
notebooks (*.ipynb).
Supports finding dependency declarations in *requirements*.txt (and .in) files,
pyproject.toml (following PEP 621 or Poetry conventions), setup.cfg, as well as
limited support for setup.py files with a single, simple setup() call and
minimal computation involved in setting the install_requires and extras_require
arguments.
pyproject.toml (following PEP 621, Poetry, or Pixi conventions), setup.cfg, as
well as limited support for setup.py files with a single, simple setup() call
and minimal computation involved in setting the install_requires and
extras_require arguments.
"""

from __future__ import annotations
Expand Down
2 changes: 2 additions & 0 deletions tests/sample_projects/pixi_pyproject_example/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# GitHub syntax highlighting
pixi.lock linguist-language=YAML linguist-generated=true
3 changes: 3 additions & 0 deletions tests/sample_projects/pixi_pyproject_example/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# pixi environments
.pixi
*.egg-info
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// not relevant for pixi but for `conda run -p`
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"manifest_path": "/home/jherland/code/fawltydeps/tests/sample_projects/pixi_pyproject_example/pyproject.toml",
"environment_name": "default",
"pixi_version": "0.27.1"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/home/jherland/code/fawltydeps/tests/sample_projects/pixi_pyproject_example/.pixi/envs/default/conda-meta
Loading
Loading