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 pixi.toml #456

Merged
merged 6 commits into from
Oct 8, 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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ dependencies. A number of file formats are supported:
call and no computation involved for setting the `install_requires` and
`extras_require` arguments)
- `setup.cfg`
- `pixi.toml`

The `--deps` option accepts a space-separated list of files or directories.
Each file will be parsed for declared dependencies; each directory will
Expand Down Expand Up @@ -436,8 +437,8 @@ Here is a complete list of configuration directives we support:
in the repository.
- `deps_parser_choice`: Manually select which format to use for parsing
declared dependencies. Must be one of `"requirements.txt"`, `"setup.py"`,
`"setup.cfg"`, `"pyproject.toml"`, or leave it unset (i.e. the default) for
auto-detection (based on filename).
`"setup.cfg"`, `"pyproject.toml"`, `"pixi.toml"`, or leave it unset
(i.e. the default) for auto-detection (based on filename).
- `install-deps`: Automatically install Python dependencies gathered with
FawltyDeps into a temporary virtual environment. This will use `uv` or `pip`
to download and install packages from PyPI by default.
Expand Down
35 changes: 34 additions & 1 deletion fawltydeps/extract_declared_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,11 @@ def parse_pyproject_toml(path: Path) -> Iterator[DeclaredDependency]:
"""
source = Location(path)
with path.open("rb") as tomlfile:
parsed_contents = tomllib.load(tomlfile)
try:
parsed_contents = tomllib.load(tomlfile)
except tomllib.TOMLDecodeError as e:
logger.error(f"Failed to parse {source}: {e}")
return

skip = set()

Expand Down Expand Up @@ -449,6 +453,32 @@ def parse_pyproject_toml(path: Path) -> Iterator[DeclaredDependency]:
logger.debug("%s does not contain [tool.poetry].", source)


def parse_pixi_toml(path: Path) -> Iterator[DeclaredDependency]:
"""Extract dependencies (package names) from pixi.toml.

See https://pixi.sh/latest/reference/project_configuration/ for more
information about the pixi.toml format.
"""
source = Location(path)
with path.open("rb") as tomlfile:
try:
parsed_contents = tomllib.load(tomlfile)
except tomllib.TOMLDecodeError as e:
logger.error(f"Failed to parse {source}: {e}")
return

skip = set()

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

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


class ParsingStrategy(NamedTuple):
"""Named pairing of an applicability criterion and a dependency parser."""

Expand Down Expand Up @@ -483,6 +513,9 @@ def first_applicable_parser(path: Path) -> Optional[ParserChoice]:
ParserChoice.SETUP_PY: ParsingStrategy(
lambda path: path.name == "setup.py", parse_setup_py
),
ParserChoice.PIXI_TOML: ParsingStrategy(
lambda path: path.name == "pixi.toml", parse_pixi_toml
),
}


Expand Down
6 changes: 1 addition & 5 deletions fawltydeps/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@
Supports finding 3rd-party imports in Python scripts (*.py) and Jupyter
notebooks (*.ipynb).

Supports finding dependency declarations in *requirements*.txt (and .in) files,
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.
Supports finding dependency declarations in a wide variety of file formats.
"""

from __future__ import annotations
Expand Down
16 changes: 9 additions & 7 deletions fawltydeps/packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,13 +339,15 @@ def find_package_dirs(cls, path: Path) -> Iterator[Path]: # noqa: C901, PLR0912
if found:
return

elif (path / "bin/python").is_file(): # Assume POSIX
for _site_packages in path.glob("lib/python?.*/site-packages"):
if _site_packages.is_dir():
yield _site_packages
found = True
if found:
return
else: # Assume POSIX
python_exe = path / "bin/python"
if python_exe.is_file() or python_exe.is_symlink():
for _site_packages in path.glob("lib/python?.*/site-packages"):
if _site_packages.is_dir():
yield _site_packages
found = True
if found:
return

# Workaround for projects using PEP582:
if path.name == "__pypackages__":
Expand Down
20 changes: 11 additions & 9 deletions fawltydeps/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class ParserChoice(Enum):
SETUP_PY = "setup.py"
SETUP_CFG = "setup.cfg"
PYPROJECT_TOML = "pyproject.toml"
PIXI_TOML = "pixi.toml"

def __str__(self) -> str:
return self.value
Expand Down Expand Up @@ -152,21 +153,22 @@ def __post_init__(self) -> None:
super().__post_init__()
assert self.path.is_dir() # noqa: S101, sanity check

# Support Windows projects
# Support virtualenvs and system-wide installs on Windows
if sys.platform.startswith("win"):
if (
self.path.match(str(Path("Lib", "site-packages")))
and (self.path.parent.parent / "Scripts" / "python.exe").is_file()
):
return # also ok
# Support vitualenvs, poetry2nix envs, system-wide installs, etc.
elif (
self.path.match("lib/python?.*/site-packages")
and (self.path.parent.parent.parent / "bin/python").is_file()
):
return # all ok

# Also support projects using __pypackages__ from PEP582:
# Support vitualenvs, poetry2nix envs, system-wide installs, etc. on POSIX
else:
python_exe = self.path.parent.parent.parent / "bin/python"
if self.path.match("lib/python?.*/site-packages") and (
python_exe.is_file() or python_exe.is_symlink()
):
return # all ok

# Support projects using __pypackages__ from PEP582:
if self.path.match("__pypackages__/?.*/lib"):
return # also ok

Expand Down
17 changes: 16 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ def fake_project(write_tmp_files, fake_venv): # noqa: C901
lists of strings (extras/optional deps).
The dependencies will be written into associated files, formatted
according to the filenames (must be one of requirements.txt, setup.py,
setup.cfg, or pyproject.toml).
setup.cfg, pyproject.toml, or pixi.toml).
- extra_file_contents: a dict with extra files and their associated contents
to be forwarded directly to write_tmp_files().

Expand Down Expand Up @@ -217,6 +217,20 @@ def format_pyproject_toml(deps: Deps, extras: ExtraDeps) -> str:
"""
) + "\n".join(f"{k} = {v!r}" for k, v in extras.items())

def format_pixi_toml(deps: Deps, extras: ExtraDeps) -> str:
ret = dedent(
"""\
[project]
name = "MyLib"

[dependencies]
"""
) + "\n".join(f'{dep} = "*"' for dep in deps)
for feature, deps in extras.items():
ret += f"\n[feature.{feature}.dependencies]\n"
ret += "\n".join(f'{dep} = "*"' for dep in deps)
return ret

def format_deps(
filename: str, all_deps: Union[Deps, Tuple[Deps, ExtraDeps]]
) -> str:
Expand All @@ -229,6 +243,7 @@ def format_deps(
"setup.py": format_setup_py,
"setup.cfg": format_setup_cfg,
"pyproject.toml": format_pyproject_toml,
"pixi.toml": format_pixi_toml,
}
formatter = formatters.get(Path(filename).name, format_requirements_txt)
return formatter(deps, extras)
Expand Down
2 changes: 2 additions & 0 deletions tests/sample_projects/pixi_default_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_default_example/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# pixi environments
.pixi
*.egg-info
Empty file.
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,41 @@
{
"build": "h297d8ca_0",
"build_number": 0,
"depends": [
"libgcc-ng >=12",
"libstdcxx-ng >=12"
],
"license": "Apache-2.0",
"license_family": "Apache",
"md5": "3aa1c7e292afeff25a0091ddd7c69b72",
"name": "ninja",
"purls": [],
"sha256": "40f7b76b07067935f8a5886aab0164067b7aa71eb5ad20b7278618c0c2c98e06",
"size": 2198858,
"subdir": "linux-64",
"timestamp": 1715440571685,
"version": "1.12.1",
"fn": "ninja-1.12.1-h297d8ca_0.conda",
"url": "https://conda.anaconda.org/conda-forge/linux-64/ninja-1.12.1-h297d8ca_0.conda",
"channel": "https://conda.anaconda.org/conda-forge/",
"extracted_package_dir": "/home/jherland/.cache/rattler/cache/pkgs/ninja-1.12.1-h297d8ca_0",
"files": [
"bin/ninja"
],
"paths_data": {
"paths_version": 1,
"paths": [
{
"_path": "bin/ninja",
"path_type": "hardlink",
"sha256": "b3aee46f212fcc09c222d02e600a060ba7cd5332d10b6fbaaac5f8ad56bdc9ee",
"sha256_in_prefix": "b3aee46f212fcc09c222d02e600a060ba7cd5332d10b6fbaaac5f8ad56bdc9ee",
"size_in_bytes": 7626496
}
]
},
"link": {
"source": "/home/jherland/.cache/rattler/cache/pkgs/ninja-1.12.1-h297d8ca_0",
"type": 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"manifest_path": "/home/jherland/code/fawltydeps/tests/sample_projects/pixi_default_example/pixi.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_default_example/.pixi/envs/default/conda-meta
Loading
Loading