Source code for conda_recipe_manager.parser.recipe_reader_deps

"""
:Description: Provides a subclass of RecipeReader that adds advanced dependency management tools.
"""

from __future__ import annotations

from typing import Final, Optional, cast

from conda_recipe_manager.parser._types import ROOT_NODE_VALUE
from conda_recipe_manager.parser.dependency import (
    Dependency,
    DependencyMap,
    DependencySection,
    dependency_data_from_str,
    str_to_dependency_section,
)
from conda_recipe_manager.parser.recipe_reader import RecipeReader
from conda_recipe_manager.parser.selector_parser import SelectorParser
from conda_recipe_manager.parser.types import SchemaVersion


[docs] class RecipeReaderDeps(RecipeReader): """ Extension of the base RecipeReader class to enables advanced dependency management abilities. The base RecipeReader class is so large, that this has been broken-out for maintenance purposes. """ @staticmethod def _add_top_level_dependencies(root_package: str, dep_map: DependencyMap) -> None: """ Helper function that applies "root"/top-level dependencies to packages in multi-output recipes. """ if len(dep_map) <= 1 or root_package not in dep_map: return root_dependencies: Final[list[Dependency]] = dep_map[root_package] for package in dep_map: if package == root_package: continue # Change the "required_by" package name to the current package, not the root package name. dep_map[package].extend( [Dependency(package, d.path, d.type, d.data, d.selector) for d in root_dependencies] ) @staticmethod def _sanitize_dep(dep: Optional[str]) -> Optional[str]: """ Sanitizes dependency strings. Invalid dependencies can be ignored with a `None` check. This function prevents consumption of bad recipe file data. :param dep: Dependency string to validate. This is the string found in a list in a dependency section. :returns: The sanitized string, if valid. `None`, if invalid. """ if dep is None: return None # TODO V1 support missing here: V1 selectors return an `if/then` dictionary, not a string! dep = dep.strip() if not dep: return None return dep def _fetch_optional_selector(self, path: str) -> Optional[SelectorParser]: """ Given a recipe path, optionally return a SelectorParser object. :param path: Path to the target value :returns: A parsed selector, if one is available. Otherwise, None. """ try: return SelectorParser(self.get_selector_at_path(path), self._schema_version) except KeyError: return None
[docs] def get_package_names_to_path(self) -> dict[str, str]: """ Get a map containing all the packages (artifacts) named in a recipe to their paths in the recipe structure. :raises KeyError: If a package in the recipe does not have a name :raises ValueError: If a recipe contains a package with duplicate names :returns: Mapping of package name to path where that package is found """ # TODO Figure out: Skip top-level packages for multi-output recipe files? package_tbl: dict[str, str] = {} root_name_path: Final[str] = ( "/recipe/name" if self.is_multi_output() and self._schema_version == SchemaVersion.V1 else "/package/name" ) name_path: Final[str] = ( "/package/name" if self.is_multi_output() and self._schema_version == SchemaVersion.V1 else "/name" ) for path in self.get_package_paths(): try: if path == ROOT_NODE_VALUE: name = cast(str, self.get_value(root_name_path, sub_vars=True)) else: name = cast(str, self.get_value(RecipeReader.append_to_path(path, name_path), sub_vars=True)) except KeyError as e: raise KeyError(f"Could not find a package name associated with path: {path}") from e if name in package_tbl: raise ValueError(f"Duplicate package name found: {name}") package_tbl[name] = path return package_tbl
def _extract_requirements( self, requirements: dict[str, list[Optional[str]]], dep_map: DependencyMap, path: str, package: str ) -> None: """ Extracts dependencies from a requirements section. :param requirements: The requirements section to extract dependencies from. :param dep_map: The dependency map to add the dependencies to. :param path: The path to the requirements section. :param package: The package to add the dependencies to. """ for section_str, deps in requirements.items(): section = str_to_dependency_section(section_str) # Unrecognized sections will be skipped as "junk" data if section is None or deps is None: continue for i, dep in enumerate(deps): dep = RecipeReaderDeps._sanitize_dep(dep) if dep is None: continue # NOTE: `get_dependency_paths()` uses the same approach for calculating dependency paths. dep_path = RecipeReader.append_to_path(path, f"/requirements/{section_str}/{i}") dep_map[package].append( Dependency( required_by=package, path=dep_path, type=section, data=dependency_data_from_str(dep), selector=self._fetch_optional_selector(dep_path), ) ) def _extract_test_requirements( self, test_requirements: list[Optional[str]], dep_map: DependencyMap, path: str, package: str ) -> None: """ Extracts test dependencies from a test requirements section. :param test_requirements: The test requirements section to extract dependencies from. :param dep_map: The dependency map to add the dependencies to. :param path: The path to the test requirements section. :param package: The package to add the dependencies to. """ for i, dep in enumerate(test_requirements): dep = RecipeReaderDeps._sanitize_dep(dep) if dep is None: continue # TODO add V1 support, the test section is different. dep_path = RecipeReader.append_to_path(path, f"/test/requires/{i}") dep_map[package].append( Dependency( required_by=package, path=dep_path, type=DependencySection.TESTS, data=dependency_data_from_str(dep), selector=self._fetch_optional_selector(dep_path), ) )
[docs] def get_all_dependencies(self, include_test_dependencies: bool = False) -> DependencyMap: """ Get a parsed representation of all the dependencies found in the recipe. :param include_test_dependencies: (Optional) If True, include test dependencies. Defaults to False, which will exclude test dependencies, for backwards compatibility. :raises KeyError: If a package in the recipe does not have a name :raises ValueError: If a recipe contains a package with duplicate names :returns: A structured representation of the dependencies. """ # TODO Figure out: Skip top-level packages for multi-output recipe files? package_path_tbl: Final[dict[str, str]] = self.get_package_names_to_path() root_package = "" dep_map: DependencyMap = {} for package, path in package_path_tbl.items(): if path == ROOT_NODE_VALUE: root_package = package # Requirements requirements = cast( Optional[str | dict[str, list[Optional[str]]]], self.get_value(RecipeReader.append_to_path(path, "/requirements"), default={}, sub_vars=True), ) # Skip over empty/malformed requirements sections if requirements is not None and not isinstance(requirements, str): dep_map[package] = [] self._extract_requirements(requirements, dep_map, path, package) # Test requirements if not include_test_dependencies: continue test_requirements = cast( Optional[list[Optional[str]]], self.get_value(RecipeReader.append_to_path(path, "/test/requires"), default=[], sub_vars=True), ) # Skip over empty/malformed test requirements sections if test_requirements is not None and isinstance(test_requirements, list): if package not in dep_map: dep_map[package] = [] self._extract_test_requirements(test_requirements, dep_map, path, package) # Apply top-level dependencies to multi-output recipe packages RecipeReaderDeps._add_top_level_dependencies(root_package, dep_map) return dep_map