-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
143 Parse and Scan Dependencies from
pyproject.toml
(#238)
* Deprecates DependencyType, as DependencySection already exists and covers the same concept * Starts work on new pyproject.toml dep scanner * Introduces MatchSpec to ProjectDependency type to parse and (eventually) leverage constraint information * Adds exception handling for bad pyproject.toml files * Adds MVP validation for the pyproject.toml scanner * Adds support for using non-standard project file names * Minor fix to dict.get() calls * Adds initial pyproject.toml scanner tests * Adds test coverage for pyproject.toml scanner * Adds version constraint test for pyproject.toml scanner
- Loading branch information
1 parent
d19b1c7
commit 52558c9
Showing
12 changed files
with
484 additions
and
30 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
76 changes: 76 additions & 0 deletions
76
conda_recipe_manager/scanner/dependency/pyproject_dep_scanner.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
""" | ||
:Description: Reads dependencies from a `pyproject.toml` file. | ||
""" | ||
|
||
from __future__ import annotations | ||
|
||
import tomllib | ||
from pathlib import Path | ||
from typing import Final, cast | ||
|
||
from conda_recipe_manager.parser.dependency import DependencySection | ||
from conda_recipe_manager.scanner.dependency.base_dep_scanner import ( | ||
BaseDependencyScanner, | ||
ProjectDependency, | ||
new_project_dependency, | ||
) | ||
from conda_recipe_manager.types import MessageCategory | ||
|
||
|
||
class PyProjectDependencyScanner(BaseDependencyScanner): | ||
""" | ||
Dependency Scanner class capable of scanning `pyproject.toml` files. | ||
""" | ||
|
||
def __init__(self, src_dir: Path | str, project_file_name: str = "pyproject.toml"): | ||
""" | ||
Constructs a `PyProjectDependencyScanner`. | ||
:param src_dir: Path to the Python source code to scan. | ||
:param project_file_name: (Optional) Allows for custom pyproject file names. Primarily used for testing, | ||
defaults to standard `pyproject.toml` name. | ||
""" | ||
super().__init__() | ||
self._src_dir: Final[Path] = Path(src_dir) | ||
self._project_fn: Final[str] = project_file_name | ||
|
||
def scan(self) -> set[ProjectDependency]: | ||
""" | ||
Actively scans a project for dependencies. Implementation is dependent on the type of scanner used. | ||
:returns: A set of unique dependencies found by the scanner, if any are found. | ||
""" | ||
try: | ||
with open(self._src_dir / self._project_fn, "rb") as f: | ||
data = cast(dict[str, dict[str, list[str] | dict[str, list[str]]]], tomllib.load(f)) | ||
except (FileNotFoundError, tomllib.TOMLDecodeError) as e: | ||
if isinstance(e, FileNotFoundError): | ||
self._msg_tbl.add_message(MessageCategory.EXCEPTION, f"`{self._project_fn}` file not found.") | ||
if isinstance(e, tomllib.TOMLDecodeError): | ||
self._msg_tbl.add_message(MessageCategory.EXCEPTION, f"Could not parse `{self._project_fn}` file.") | ||
return set() | ||
|
||
# NOTE: There is a `validate-pyproject` library hosted on `conda-forge`, but it is marked as "experimental" by | ||
# its maintainers. Given that and that we only read a small portion of the file, we only validate what we use. | ||
if "project" not in data: | ||
self._msg_tbl.add_message( | ||
MessageCategory.ERROR, f"`{self._project_fn}` file is missing a `project` section." | ||
) | ||
return set() | ||
|
||
# NOTE: The dependency constraint system used in `pyproject.toml` appears to be compatible with `conda`'s | ||
# `MatchSpec` object. For now, dependencies that can't be parsed with `MatchSpec` will store the raw string in | ||
# a `.name` field. | ||
# TODO Future, consider handling Environment Markers: | ||
# https://packaging.python.org/en/latest/specifications/dependency-specifiers/#environment-markers | ||
deps: set[ProjectDependency] = set() | ||
for dep_name in cast(list[str], data["project"].get("dependencies", [])): | ||
deps.add(new_project_dependency(dep_name, DependencySection.RUN)) | ||
|
||
# Optional dependencies are stored in a dictionary, where the key is the "package extra" name and the value is | ||
# a dependency list. For example: {'dev': ['pytest'], 'conda_build': ['conda-build']} | ||
for dep_lst in cast(dict[str, list[str]], data["project"].get("optional-dependencies", {})).values(): | ||
for dep_name in dep_lst: | ||
deps.add(new_project_dependency(dep_name, DependencySection.RUN_CONSTRAINTS)) | ||
|
||
return deps |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
""" | ||
:Description: Provides unit tests for the `PyProjectDependencyScanner` class. | ||
""" | ||
|
||
import pytest | ||
from conda.models.match_spec import MatchSpec | ||
|
||
from conda_recipe_manager.parser.dependency import DependencySection | ||
from conda_recipe_manager.scanner.dependency.base_dep_scanner import ProjectDependency | ||
from conda_recipe_manager.scanner.dependency.pyproject_dep_scanner import PyProjectDependencyScanner | ||
from conda_recipe_manager.types import MessageCategory | ||
from tests.file_loading import get_test_path | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"project_fn,expected", | ||
[ | ||
( | ||
"crm_mock_pyproject.toml", | ||
{ | ||
ProjectDependency(MatchSpec("click"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("jinja2"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("pyyaml"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("jsonschema"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("requests"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("gitpython"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("networkx"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("matplotlib"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("pygraphviz"), DependencySection.RUN), | ||
# Optional dependencies | ||
ProjectDependency(MatchSpec("pytest"), DependencySection.RUN_CONSTRAINTS), | ||
ProjectDependency(MatchSpec("conda-build"), DependencySection.RUN_CONSTRAINTS), | ||
}, | ||
), | ||
( | ||
"crm_mock_pyproject_version_constraints.toml", | ||
{ | ||
ProjectDependency(MatchSpec("click >= 1.2"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("jinja2"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("pyyaml"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("jsonschema"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("requests >= 2.8.1, == 2.8.*"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("gitpython"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("networkx"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("matplotlib"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("pygraphviz"), DependencySection.RUN), | ||
# Optional dependencies | ||
ProjectDependency(MatchSpec("pytest ~= 8.1"), DependencySection.RUN_CONSTRAINTS), | ||
ProjectDependency(MatchSpec("conda-build"), DependencySection.RUN_CONSTRAINTS), | ||
}, | ||
), | ||
( | ||
"crm_mock_pyproject_only_deps.toml", | ||
{ | ||
ProjectDependency(MatchSpec("click"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("jinja2"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("pyyaml"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("jsonschema"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("requests"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("gitpython"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("networkx"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("matplotlib"), DependencySection.RUN), | ||
ProjectDependency(MatchSpec("pygraphviz"), DependencySection.RUN), | ||
}, | ||
), | ||
( | ||
"crm_mock_pyproject_only_optional.toml", | ||
{ | ||
ProjectDependency(MatchSpec("pytest"), DependencySection.RUN_CONSTRAINTS), | ||
ProjectDependency(MatchSpec("conda-build"), DependencySection.RUN_CONSTRAINTS), | ||
}, | ||
), | ||
], | ||
) | ||
def test_scan(project_fn: str, expected: set[ProjectDependency]) -> None: | ||
""" | ||
Tests scanning for Python dependencies with a mocked-out Python project. | ||
:param project_fn: Name of the dummy `pyproject.toml` file to use. | ||
:param expected: Expected value | ||
""" | ||
scanner = PyProjectDependencyScanner(get_test_path() / "pyproject_toml", project_fn) | ||
assert scanner.scan() == expected | ||
|
||
|
||
def test_scan_missing_pyproject() -> None: | ||
""" | ||
Tests that the scanner fails gracefully if a `pyproject.toml` file could not be found | ||
""" | ||
scanner = PyProjectDependencyScanner(get_test_path() / "pyproject_toml", "the_limit_dne.toml") | ||
assert scanner.scan() == set() | ||
assert scanner.get_message_table().get_messages(MessageCategory.EXCEPTION) == [ | ||
"`the_limit_dne.toml` file not found." | ||
] | ||
|
||
|
||
def test_scan_corrupt_pyproject() -> None: | ||
""" | ||
Tests that the scanner fails gracefully if the `pyproject.toml` file is corrupt. | ||
""" | ||
scanner = PyProjectDependencyScanner(get_test_path() / "pyproject_toml", "corrupt_pyproject.toml") | ||
assert scanner.scan() == set() | ||
assert scanner.get_message_table().get_messages(MessageCategory.EXCEPTION) == [ | ||
"Could not parse `corrupt_pyproject.toml` file." | ||
] | ||
|
||
|
||
def test_scan_missing_project_pyproject() -> None: | ||
""" | ||
Tests that the scanner fails gracefully if the `pyproject.toml` file is missing a `project` section. | ||
""" | ||
scanner = PyProjectDependencyScanner(get_test_path() / "pyproject_toml", "no_project_pyproject.toml") | ||
assert scanner.scan() == set() | ||
assert scanner.get_message_table().get_messages(MessageCategory.ERROR) == [ | ||
"`no_project_pyproject.toml` file is missing a `project` section." | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
[project | ||
dependencies = [ | ||
"click", | ||
"jsonschema" | ||
] |
Oops, something went wrong.