diff --git a/.gitignore b/.gitignore index 78e7d8d..7aa31ad 100644 --- a/.gitignore +++ b/.gitignore @@ -165,4 +165,4 @@ mapping/*# pixi environments .pixi .vscode/ -.DS_store +.DS_Store diff --git a/pixi.lock b/pixi.lock index 557dc5b..9bdd9b1 100644 --- a/pixi.lock +++ b/pixi.lock @@ -17078,7 +17078,7 @@ packages: name: rattler-build-conda-compat version: 0.2.2 path: . - sha256: b3a727754ef5bcca637b07bfbc84448be07be5a27f9dfa7f4ff17b7297f23def + sha256: 07000fd2752c27e8f7edeb6533558c7d379af3c1cb1b82df8457ccaa03d93a68 requires_dist: - typing-extensions>=4.12,<5 requires_python: '>=3.8' diff --git a/src/rattler_build_conda_compat/lint.py b/src/rattler_build_conda_compat/lint.py index 39105ac..81eed2c 100644 --- a/src/rattler_build_conda_compat/lint.py +++ b/src/rattler_build_conda_compat/lint.py @@ -57,7 +57,6 @@ def lint_recipe_yaml_by_schema(recipe_file): meta = yaml.load(fh) validator = Draft202012Validator(schema) - lints = [] for error in validator.iter_errors(meta): diff --git a/src/rattler_build_conda_compat/loader.py b/src/rattler_build_conda_compat/loader.py index 5354a4e..35ce741 100644 --- a/src/rattler_build_conda_compat/loader.py +++ b/src/rattler_build_conda_compat/loader.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools from contextlib import contextmanager from typing import TYPE_CHECKING, Any @@ -11,23 +12,55 @@ from collections.abc import Iterator from os import PathLike +SELECTOR_OPERATORS = ("and", "or", "not") + + +def _remove_empty_keys(some_dict: dict[str, Any]) -> dict[str, Any]: + filtered_dict = {} + for key, value in some_dict.items(): + if isinstance(value, list) and len(value) == 0: + continue + filtered_dict[key] = value + + return filtered_dict + + +def _flatten_lists(some_dict: dict[str, Any]) -> dict[str, Any]: + result_dict: dict[str, Any] = {} + for key, value in some_dict.items(): + if isinstance(value, dict): + result_dict[key] = _flatten_lists(value) + elif isinstance(value, list) and value and isinstance(value[0], list): + result_dict[key] = list(itertools.chain(*value)) + else: + result_dict[key] = value + + return result_dict + class RecipeLoader(yaml.BaseLoader): _namespace: dict[str, Any] | None = None + _allow_missing_selector: bool = False @classmethod @contextmanager - def with_namespace(cls: type[RecipeLoader], namespace: dict[str, Any] | None) -> Iterator[None]: + def with_namespace( + cls: type[RecipeLoader], + namespace: dict[str, Any] | None, + *, + allow_missing_selector: bool = False, + ) -> Iterator[None]: try: cls._namespace = namespace + cls._allow_missing_selector = allow_missing_selector yield finally: del cls._namespace - def construct_sequence( # noqa: C901 + def construct_sequence( # noqa: C901, PLR0912 self, node: yaml.ScalarNode | yaml.SequenceNode | yaml.MappingNode, - deep: bool = False, # noqa: FBT002, FBT001 + deep: bool = False, # noqa: FBT002, FBT001, ) -> list[yaml.ScalarNode]: """deep is True when creating an object/mapping recursively, in that case want the underlying elements available during construction @@ -56,6 +89,17 @@ def construct_sequence( # noqa: C901 to_be_eval = f"{value_node.value}" + if self._allow_missing_selector: + split_selectors = [ + selector + for selector in to_be_eval.split() + if selector not in SELECTOR_OPERATORS + ] + for selector in split_selectors: + if self._namespace and selector not in self._namespace: + cleaned_selector = selector.strip("(").rstrip(")") + self._namespace[cleaned_selector] = True + evaled = eval(to_be_eval, self._namespace) # noqa: S307 if evaled: the_evaluated_one = then_node_value @@ -84,22 +128,14 @@ def load_yaml(content: str | bytes) -> Any: # noqa: ANN401 return yaml.load(content, Loader=yaml.BaseLoader) # noqa: S506 -def remove_empty_keys(variant_dict: dict[str, Any]) -> dict[str, Any]: - filtered_dict = {} - for key, value in variant_dict.items(): - if isinstance(value, list) and len(value) == 0: - continue - filtered_dict[key] = value - - return filtered_dict - - def parse_recipe_config_file( - path: PathLike[str], namespace: dict[str, Any] | None + path: PathLike[str], namespace: dict[str, Any] | None, *, allow_missing_selector: bool = False ) -> dict[str, Any]: - with open(path) as f, RecipeLoader.with_namespace(namespace): + with open(path) as f, RecipeLoader.with_namespace( + namespace, allow_missing_selector=allow_missing_selector + ): content = yaml.load(f, Loader=RecipeLoader) # noqa: S506 - return remove_empty_keys(content) + return _flatten_lists(_remove_empty_keys(content)) def load_all_requirements(content: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/__snapshots__/test_rattler_loader.ambr b/tests/__snapshots__/test_rattler_loader.ambr index 61a2a88..ae33579 100644 --- a/tests/__snapshots__/test_rattler_loader.ambr +++ b/tests/__snapshots__/test_rattler_loader.ambr @@ -1,4 +1,19 @@ # serializer version: 1 +# name: test_load_recipe_with_missing_selectors + dict({ + 'package': dict({ + 'name': 'some-osx-recipe', + 'version': '1.0.0', + }), + 'requirements': dict({ + 'build': list([ + 'python', + 'ruby', + 'rust', + ]), + }), + }) +# --- # name: test_load_variants dict({ 'numpy': list([ diff --git a/tests/data/osx_recipe.yaml b/tests/data/osx_recipe.yaml new file mode 100644 index 0000000..b462e2f --- /dev/null +++ b/tests/data/osx_recipe.yaml @@ -0,0 +1,16 @@ +package: + name: some-osx-recipe + version: 1.0.0 +requirements: + build: + - if: osx and arm64 + then: + - python + + - if: osx and aarch64 + then: + - ruby + + - if: unix + then: + - rust diff --git a/tests/test_rattler_loader.py b/tests/test_rattler_loader.py index b613313..60bd1af 100644 --- a/tests/test_rattler_loader.py +++ b/tests/test_rattler_loader.py @@ -25,3 +25,15 @@ def test_load_all_requirements() -> None: content = load_all_requirements(recipe_content) print(content) + + +def test_load_recipe_with_missing_selectors(snapshot) -> None: + osx_recipe = Path("tests/data/osx_recipe.yaml") + + namespace = {"osx": True, "unix": True} + + loaded_variants = parse_recipe_config_file( + str(osx_recipe), namespace, allow_missing_selector=True + ) + + assert loaded_variants == snapshot