diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index c776de0..1e8f9b9 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -47,6 +47,9 @@ language: python files: (^|/)dependencies[.]yaml$ args: [--fix] + additional_dependencies: + - --extra-index-url=https://pypi.anaconda.org/rapidsai-wheels-nightly/simple + - .[dependencies] - id: verify-hardcoded-version name: verify-hardcoded-version description: make sure RAPIDS version is not hard-coded in files diff --git a/pyproject.toml b/pyproject.toml index 685e126..b5635b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,10 +36,14 @@ test = [ "pre-commit", "pytest", "rapids-pre-commit-hooks[alpha-spec]", + "rapids-pre-commit-hooks[dependencies]", ] alpha-spec = [ "rapids-metadata>=0.4.0,<0.5.0.dev0", ] +dependencies = [ + "rapids-metadata>=0.4.0,<0.5.0.dev0", +] [project.scripts] verify-alpha-spec = "rapids_pre_commit_hooks.alpha_spec:main" diff --git a/src/rapids_pre_commit_hooks/dependencies/__init__.py b/src/rapids_pre_commit_hooks/dependencies/__init__.py index 4843d0e..f7ab930 100644 --- a/src/rapids_pre_commit_hooks/dependencies/__init__.py +++ b/src/rapids_pre_commit_hooks/dependencies/__init__.py @@ -3,6 +3,7 @@ import argparse +from .cuda_suffixed import CUDASuffixedHandler from .use_cuda_wheels import UseCUDAWheelsHandler from ..lint import Linter, LintMain from ..utils.dependencies_yaml import ( @@ -13,6 +14,7 @@ def check_dependencies(linter: "Linter", args: "argparse.Namespace") -> None: handler = ChainedHandler() + handler.add_handler(CUDASuffixedHandler(linter, args)) handler.add_handler(UseCUDAWheelsHandler(linter, args)) traverse_dependencies_yaml(handler, linter.content) @@ -22,6 +24,17 @@ def main() -> None: m.argparser.description = ( "Verify that dependencies.yaml follows the correct conventions." ) + m.argparser.add_argument( + "--rapids-version", + help="Specify a RAPIDS version to use instead of reading from the " + "VERSION file", + ) + m.argparser.add_argument( + "--rapids-version-file", + help="Specify a file to read the RAPIDS version from instead of " + "VERSION", + default="VERSION", + ) with m.execute() as ctx: ctx.add_check(check_dependencies) diff --git a/src/rapids_pre_commit_hooks/dependencies/cuda_suffixed.py b/src/rapids_pre_commit_hooks/dependencies/cuda_suffixed.py new file mode 100644 index 0000000..924f233 --- /dev/null +++ b/src/rapids_pre_commit_hooks/dependencies/cuda_suffixed.py @@ -0,0 +1,354 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import contextlib +import os +import re +from dataclasses import dataclass, field +from functools import cache +from typing import Optional, TYPE_CHECKING + +from packaging.requirements import InvalidRequirement, Requirement + +from rapids_pre_commit_hooks.utils.dependencies_yaml import ( + Handler, +) +from rapids_metadata.remote import fetch_latest + +if TYPE_CHECKING: + import argparse + from collections.abc import Generator + + import yaml + + from rapids_pre_commit_hooks.lint import Linter + from rapids_metadata.metadata import RAPIDSMetadata, RAPIDSVersion + + +# Extra packages that need to have/not have the -cu* suffix that are not in +# RAPIDS +EXTRA_CUDA_SUFFIXED_PACKAGES: set[str] = { + "xgboost", +} + + +@cache +def all_metadata() -> "RAPIDSMetadata": + return fetch_latest() + + +def get_rapids_version(args: "argparse.Namespace") -> "RAPIDSVersion": + md = all_metadata() + return ( + md.versions[args.rapids_version] + if args.rapids_version + else md.get_current_version(os.getcwd(), args.rapids_version_file) + ) + + +class CUDASuffixedHandler(Handler): + @dataclass + class CommonContext: + common_key: "yaml.Node" + + @dataclass + class CommonItemContext: + has_python_output_type: bool = False + suspicious_suffixed_packages: "list[tuple[str, str, Optional[str], yaml.Node]]" = field( # noqa: E501 + default_factory=list + ) + suspicious_unsuffixed_packages: "list[tuple[str, Optional[str], yaml.Node]]" = field( # noqa: E501 + default_factory=list + ) + + @dataclass + class SpecificItemContext: + has_python_output_type: bool = False + matrices_item_contexts: "list[CUDASuffixedHandler.MatricesItemContext]" = field( # noqa: E501 + default_factory=list + ) + + @dataclass + class MatricesItemContext: + matrix_node: "Optional[yaml.Node]" = None + cuda_suffixed_node: "Optional[yaml.Node]" = None + cuda_suffixed: "Optional[bool]" = None + cuda_node: "Optional[yaml.Node]" = None + cuda_major: "Optional[int]" = None + suspicious_suffixed_packages: "list[tuple[str, str, Optional[str], yaml.Node]]" = field( # noqa: E501 + default_factory=list + ) + suspicious_unsuffixed_packages: "list[tuple[str, Optional[str], yaml.Node]]" = field( # noqa: E501 + default_factory=list + ) + + def __init__(self, linter: "Linter", args: "argparse.Namespace") -> None: + self.linter = linter + self.args = args + + def handle_output_type( + self, + output_types_context: ( + "CUDASuffixedHandler.CommonItemContext | " + "CUDASuffixedHandler.SpecificItemContext" + ), + item: "yaml.Node", + ) -> None: + if item.value in {"requirements", "pyproject"}: + output_types_context.has_python_output_type = True + + @contextlib.contextmanager + def handle_common( + self, + dependency_set_context: None, # noqa: ARG002 + key: "yaml.Node", + value: "yaml.Node", # noqa: ARG002 + ) -> "Generator[CUDASuffixedHandler.CommonContext]": + context = CUDASuffixedHandler.CommonContext(key) + yield context + + @contextlib.contextmanager + def handle_common_item( + self, + common_context: "CUDASuffixedHandler.CommonContext", + item: "yaml.Node", # noqa: ARG002 + ) -> "Generator[CUDASuffixedHandler.CommonItemContext]": + context = CUDASuffixedHandler.CommonItemContext() + yield context + + if context.has_python_output_type: + for ( + name, + suffix, + anchor, + node, + ) in context.suspicious_suffixed_packages: + w = self.linter.add_warning( + (node.start_mark.index, node.end_mark.index), + f'package "{name}" in common dependency set', + ) + w.add_note( + ( + common_context.common_key.start_mark.index, + common_context.common_key.end_mark.index, + ), + "place in a specific dependency set with " + 'cuda_suffixed: "true" instead', + ) + for name, anchor, node in context.suspicious_unsuffixed_packages: + w = self.linter.add_warning( + (node.start_mark.index, node.end_mark.index), + f'package "{name}" in common dependency set', + ) + w.add_note( + ( + common_context.common_key.start_mark.index, + common_context.common_key.end_mark.index, + ), + "place in a specific dependency set with " + 'cuda_suffixed: "false" instead', + ) + + @contextlib.contextmanager + def handle_specific_item( + self, + specific_context: None, # noqa: ARG002 + item: "yaml.Node", # noqa: ARG002 + ) -> "Generator[CUDASuffixedHandler.SpecificItemContext]": + context = CUDASuffixedHandler.SpecificItemContext() + yield context + + if context.has_python_output_type: + for matrices_item_context in context.matrices_item_contexts: + if matrices_item_context.cuda_suffixed is None: + for ( + name, + suffix, + anchor, + node, + ) in matrices_item_context.suspicious_suffixed_packages: + w = self.linter.add_warning( + (node.start_mark.index, node.end_mark.index), + f'package "{name}" in specific dependency set ' + "with no cuda_suffixed field", + ) + if matrices_item_context.matrix_node: + w.add_note( + ( + matrices_item_context.matrix_node.start_mark.index, + matrices_item_context.matrix_node.end_mark.index, + ), + "place in a specific dependency set with " + 'cuda_suffixed: "true" instead', + ) + for ( + name, + anchor, + node, + ) in matrices_item_context.suspicious_unsuffixed_packages: + w = self.linter.add_warning( + (node.start_mark.index, node.end_mark.index), + f'package "{name}" in common dependency set', + ) + if matrices_item_context.matrix_node: + w.add_note( + ( + matrices_item_context.matrix_node.start_mark.index, + matrices_item_context.matrix_node.end_mark.index, + ), + "place in a specific dependency set with " + 'cuda_suffixed: "false" instead', + ) + elif matrices_item_context.cuda_suffixed: + if matrices_item_context.cuda_major: + for ( + name, + suffix, + anchor, + node, + ) in ( + matrices_item_context.suspicious_suffixed_packages + ): + if ( + suffix + != f"-cu{matrices_item_context.cuda_major}" + ): + w = self.linter.add_warning( + ( + node.start_mark.index, + node.end_mark.index, + ), + f'package "{name}" has wrong -cu* suffix', + ) + anchor_text = f"&{anchor} " if anchor else "" + req = Requirement(node.value) + req.name = ( + f"{name}" + f"-cu{matrices_item_context.cuda_major}" + ) + w.add_replacement( + ( + node.start_mark.index, + node.end_mark.index, + ), + f"{anchor_text}{req}", + ) + for ( + name, + anchor, + node, + ) in matrices_item_context.suspicious_unsuffixed_packages: + w = self.linter.add_warning( + (node.start_mark.index, node.end_mark.index), + f'package "{name}" in specific dependency set ' + 'with cuda_suffixed: "true"', + ) + if matrices_item_context.cuda_major: + anchor_text = f"&{anchor} " if anchor else "" + req = Requirement(node.value) + req.name = ( + f"{name}-cu{matrices_item_context.cuda_major}" + ) + w.add_replacement( + (node.start_mark.index, node.end_mark.index), + f"{anchor_text}{req}", + ) + elif matrices_item_context.matrix_node: + w.add_note( + ( + matrices_item_context.matrix_node.start_mark.index, + matrices_item_context.matrix_node.end_mark.index, + ), + "add a cuda matrix field and add matching " + "-cu* suffix to package name", + ) + else: + for ( + name, + suffix, + anchor, + node, + ) in matrices_item_context.suspicious_suffixed_packages: + w = self.linter.add_warning( + (node.start_mark.index, node.end_mark.index), + f'package "{name}" in specific dependency set ' + 'with cuda_suffixed: "false"', + ) + anchor_text = f"&{anchor} " if anchor else "" + req = Requirement(node.value) + req.name = name + w.add_replacement( + (node.start_mark.index, node.end_mark.index), + f"{anchor_text}{req}", + ) + + @contextlib.contextmanager + def handle_matrices_item( + self, + matrices_context: "CUDASuffixedHandler.SpecificItemContext", + item: "yaml.Node", # noqa: ARG002 + ) -> "Generator[CUDASuffixedHandler.MatricesItemContext]": + context = CUDASuffixedHandler.MatricesItemContext() + yield context + + matrices_context.matrices_item_contexts.append(context) + + @contextlib.contextmanager + def handle_matrix( + self, + matrices_item_context: "CUDASuffixedHandler.MatricesItemContext", + key: "yaml.Node", + value: "yaml.Node", # noqa: ARG002 + ) -> "Generator[CUDASuffixedHandler.MatricesItemContext]": + matrices_item_context.matrix_node = key + yield matrices_item_context + + def handle_matrix_item( + self, + matrix_context: "CUDASuffixedHandler.MatricesItemContext", + key: "yaml.Node", + value: "yaml.Node", + ) -> None: + if key.value == "cuda_suffixed": + matrix_context.cuda_suffixed_node = value + if value.value == "true": + matrix_context.cuda_suffixed = True + elif value.value == "false": + matrix_context.cuda_suffixed = False + elif key.value == "cuda" and ( + match := re.search(r"^(?P[0-9]+)", value.value) + ): + matrix_context.cuda_node = value + matrix_context.cuda_major = int(match.group("major")) + + def handle_package( + self, + packages_context: ( + "CUDASuffixedHandler.CommonItemContext | " + "CUDASuffixedHandler.MatricesItemContext" + ), + anchor: "Optional[str]", + item: "yaml.Node", + ) -> None: + try: + req = Requirement(item.value) + except InvalidRequirement: + return + + cuda_suffixed_packages = ( + get_rapids_version(self.args).cuda_suffixed_packages + | EXTRA_CUDA_SUFFIXED_PACKAGES + ) + + if req.name in cuda_suffixed_packages: + packages_context.suspicious_unsuffixed_packages.append( + (req.name, anchor, item) + ) + elif ( + match := re.search( + r"^(?P.*)(?P-cu[0-9]+)$", req.name + ) + ) and match.group("package") in cuda_suffixed_packages: + packages_context.suspicious_suffixed_packages.append( + (match.group("package"), match.group("suffix"), anchor, item) + ) diff --git a/src/rapids_pre_commit_hooks/dependencies/use_cuda_wheels.py b/src/rapids_pre_commit_hooks/dependencies/use_cuda_wheels.py index 805255f..ff88059 100644 --- a/src/rapids_pre_commit_hooks/dependencies/use_cuda_wheels.py +++ b/src/rapids_pre_commit_hooks/dependencies/use_cuda_wheels.py @@ -1,18 +1,19 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -import argparse import contextlib import re from dataclasses import dataclass, field from typing import Any, Optional, TYPE_CHECKING from packaging.requirements import InvalidRequirement, Requirement + from rapids_pre_commit_hooks.utils.dependencies_yaml import ( Handler, ) if TYPE_CHECKING: + import argparse from collections.abc import Generator import yaml @@ -78,7 +79,7 @@ class Context: default_factory=list ) - def __init__(self, linter: "Linter", args: argparse.Namespace): + def __init__(self, linter: "Linter", args: "argparse.Namespace"): self.linter = linter self.args = args diff --git a/src/rapids_pre_commit_hooks/utils/dependencies_yaml.py b/src/rapids_pre_commit_hooks/utils/dependencies_yaml.py index b45682f..fd3b514 100644 --- a/src/rapids_pre_commit_hooks/utils/dependencies_yaml.py +++ b/src/rapids_pre_commit_hooks/utils/dependencies_yaml.py @@ -50,6 +50,21 @@ def handle_common_item( ) -> "contextlib.AbstractContextManager[Any]": return contextlib.nullcontext(common_context) + def handle_output_types( + self, + common_item_or_specific_item_context: "Any", + key: "yaml.Node", # noqa: ARG002 + value: "yaml.Node", # noqa: ARG002 + ) -> "contextlib.AbstractContextManager[Any]": + return contextlib.nullcontext(common_item_or_specific_item_context) + + def handle_output_type( + self, + output_types_context: "Any", + item: "yaml.Node", # noqa: ARG002 + ) -> None: + pass + def handle_specific( self, dependency_set_context: "Any", @@ -196,6 +211,26 @@ def handle_common_item( "handle_common_item", common_context, *args, **kwargs ) + def handle_output_types( + self, + common_item_or_specific_item_context: "tuple[Any, ...]", + *args, + **kwargs, + ) -> "contextlib.AbstractContextManager[tuple[Any, ...]]": + return self._handle_context( + "handle_output_types", + common_item_or_specific_item_context, + *args, + **kwargs, + ) + + def handle_output_type( + self, output_types_context: "tuple[Any, ...]", *args, **kwargs + ) -> None: + return self._handle_no_context( + "handle_output_type", output_types_context, *args, **kwargs + ) + def handle_specific( self, dependency_set_context: "tuple[Any, ...]", *args, **kwargs ) -> "contextlib.AbstractContextManager[tuple[Any, ...]]": @@ -293,6 +328,34 @@ def traverse_packages( ) +def traverse_output_type( + handler: Handler, + output_types_context: "Any", + node: "yaml.Node", +) -> None: + if node_has_type(node, "str"): + handler.handle_output_type(output_types_context, node) + + +def traverse_output_types( + handler: Handler, + common_item_or_specific_item_context: "Any", + key_node: "yaml.Node", + node: "yaml.Node", +) -> None: + if node_has_type(node, "seq"): + with handler.handle_output_types( + common_item_or_specific_item_context, key_node, node + ) as output_types_context: + for item in node.value: + traverse_output_type(handler, output_types_context, item) + elif node_has_type(node, "str"): + with handler.handle_output_types( + common_item_or_specific_item_context, key_node, node + ) as output_types_context: + traverse_output_type(handler, output_types_context, node) + + def traverse_common_item( handler: Handler, common_context: "Any", @@ -309,6 +372,16 @@ def traverse_common_item( common_item_value, ) in node.value: if ( + node_has_type(common_item_key, "str") + and common_item_key.value == "output_types" + ): + traverse_output_types( + handler, + common_item_context, + common_item_key, + common_item_value, + ) + elif ( node_has_type(common_item_key, "str") and common_item_key.value == "packages" ): @@ -444,6 +517,16 @@ def traverse_specific_item( specific_item_value, ) in node.value: if ( + node_has_type(specific_item_key, "str") + and specific_item_key.value == "output_types" + ): + traverse_output_types( + handler, + specific_item_context, + specific_item_key, + specific_item_value, + ) + elif ( node_has_type(specific_item_key, "str") and specific_item_key.value == "matrices" ): diff --git a/tests/examples/verify-dependencies/fail/metadata.yaml b/tests/examples/verify-dependencies/fail/metadata.yaml new file mode 100644 index 0000000..4171270 --- /dev/null +++ b/tests/examples/verify-dependencies/fail/metadata.yaml @@ -0,0 +1 @@ +write_version_file: true diff --git a/tests/examples/verify-dependencies/pass/metadata.yaml b/tests/examples/verify-dependencies/pass/metadata.yaml new file mode 100644 index 0000000..4171270 --- /dev/null +++ b/tests/examples/verify-dependencies/pass/metadata.yaml @@ -0,0 +1 @@ +write_version_file: true diff --git a/tests/rapids_pre_commit_hooks/dependencies/test_cuda_suffixed.py b/tests/rapids_pre_commit_hooks/dependencies/test_cuda_suffixed.py new file mode 100644 index 0000000..6b702c2 --- /dev/null +++ b/tests/rapids_pre_commit_hooks/dependencies/test_cuda_suffixed.py @@ -0,0 +1,1033 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from rapids_pre_commit_hooks import lint +from rapids_pre_commit_hooks.dependencies.cuda_suffixed import ( + CUDASuffixedHandler, +) +from rapids_pre_commit_hooks.utils import dependencies_yaml +from rapids_pre_commit_hooks_test_utils import ( + find_yaml_node_for_span, + parse_named_spans, + zip_expected_warnings, +) + + +def _compose(content): + loader = dependencies_yaml.AnchorPreservingLoader(content) + try: + return loader.get_single_node() + finally: + loader.dispose() + + +class TestCUDASuffixedHandler: + @pytest.mark.parametrize( + ["output_type", "expected"], + [ + pytest.param("requirements", True, id="requirements"), + pytest.param("pyproject", True, id="pyproject"), + pytest.param("conda", False, id="conda"), + ], + ) + def test_handle_output_type(self, output_type, expected): + handler = CUDASuffixedHandler(Mock(), Mock()) + context = CUDASuffixedHandler.CommonItemContext() + + handler.handle_output_type(context, Mock(value=output_type)) + assert context.has_python_output_type is expected + + context.has_python_output_type = True + handler.handle_output_type(context, Mock(value=output_type)) + assert context.has_python_output_type is True + + def test_handle_common(self): + composed = _compose( + """\ + common: + packages: [] + """ + ) + common_key, common = composed.value[0] + + handler = CUDASuffixedHandler(Mock(), Mock()) + with handler.handle_common(Mock(), common_key, common) as context: + assert context.common_key == common_key + + @pytest.mark.parametrize( + [ + "content", + "has_python_output_type", + "suffixed_names", + "unsuffixed_names", + "warnings", + ], + [ + pytest.param( + """\ + + common: + : ~~~~~~warnings.0.notes.0 + : ~~~~~~warnings.1.notes.0 + + packages: + + - package-cu12 + : ~~~~~~~~~~~~suffixed.0 + : ~~~~~~~~~~~~warnings.0.warning + + - package + : ~~~~~~~unsuffixed.0 + : ~~~~~~~warnings.1.warning + """, + True, + ["package"], + ["package"], + [ + { + "warning": 'package "package" in common ' + "dependency set", + "notes": [ + "place in a specific dependency set with " + 'cuda_suffixed: "true" instead', + ], + }, + { + "warning": 'package "package" in common ' + "dependency set", + "notes": [ + "place in a specific dependency set with " + 'cuda_suffixed: "false" instead', + ], + }, + ], + id="both-package-forms", + ), + pytest.param( + """\ + + common: + + packages: + + - package-cu12 + : ~~~~~~~~~~~~suffixed.0 + + - package + : ~~~~~~~unsuffixed.0 + """, + False, + ["package"], + ["package"], + [], + id="non-python-output", + ), + pytest.param( + """\ + + common: + + packages: [] + """, + True, + [], + [], + [], + id="no-suspicious-packages", + ), + ], + ) + def test_handle_common_item( + self, + content, + has_python_output_type, + suffixed_names, + unsuffixed_names, + warnings, + ): + content, spans = parse_named_spans(content, dict) + composed = _compose(content) + common_key, common = composed.value[0] + linter = lint.Linter( + "dependencies.yaml", content, "verify-dependencies" + ) + handler = CUDASuffixedHandler(linter, Mock()) + + common_context = Mock(common_key=common_key) + with handler.handle_common_item( + common_context, common + ) as item_context: + item_context.has_python_output_type = has_python_output_type + item_context.suspicious_suffixed_packages.extend( + ( + name, + None, + None, + find_yaml_node_for_span(composed, span), + ) + for name, span in zip( + suffixed_names, + spans.get("suffixed", []), + strict=True, + ) + ) + item_context.suspicious_unsuffixed_packages.extend( + ( + name, + None, + find_yaml_node_for_span(composed, span), + ) + for name, span in zip( + unsuffixed_names, + spans.get("unsuffixed", []), + strict=True, + ) + ) + + assert linter.warnings == zip_expected_warnings( + spans.get("warnings", []), warnings + ) + + @pytest.mark.parametrize( + [ + "content", + "has_python_output_type", + "cuda_suffixed", + "cuda_major", + "suffixed_names", + "unsuffixed_names", + "warnings", + ], + [ + pytest.param( + """\ + + matrix: + : ~~~~~~matrix + : ~~~~~~warnings.0.notes.0 + + cuda: "12.0" + + packages: + + - package-cu12 + : ~~~~~~~~~~~~suffixed.0 + : ~~~~~~~~~~~~warnings.0.warning + """, + True, + None, + None, + [("package", "-cu12", None)], + [], + [ + { + "warning": 'package "package" in specific dependency ' + "set with no cuda_suffixed field", + "notes": [ + "place in a specific dependency set with " + 'cuda_suffixed: "true" instead', + ], + }, + ], + id="no-field-suffixed-package", + ), + pytest.param( + """\ + + matrix: + : ~~~~~~matrix + : ~~~~~~warnings.0.notes.0 + + cuda: "12.0" + + packages: + + - package + : ~~~~~~~unsuffixed.0 + : ~~~~~~~warnings.0.warning + """, + True, + None, + None, + [], + [("package", None)], + [ + { + "warning": 'package "package" in common ' + "dependency set", + "notes": [ + "place in a specific dependency set with " + 'cuda_suffixed: "false" instead', + ], + }, + ], + id="no-field-unsuffixed-package", + ), + pytest.param( + """\ + + matrix: + : ~~~~~~matrix + : ~~~~~~warnings.0.notes.0 + + cuda_suffixed: "true" + + packages: + + - package + : ~~~~~~~unsuffixed.0 + : ~~~~~~~warnings.0.warning + """, + True, + True, + None, + [], + [("package", None)], + [ + { + "warning": 'package "package" in specific dependency ' + 'set with cuda_suffixed: "true"', + "notes": [ + "add a cuda matrix field and add matching -cu* " + "suffix to package name", + ], + }, + ], + id="true-unsuffixed-package", + ), + pytest.param( + """\ + + matrix: + : ~~~~~~matrix + + cuda_suffixed: "true" + + cuda: "12.8" + + packages: + + - package + : ~~~~~~~unsuffixed.0 + : ~~~~~~~warnings.0.warning + : ~~~~~~~warnings.0.replacements.0 + """, + True, + True, + 12, + [], + [("package", None)], + [ + { + "warning": 'package "package" in specific dependency ' + 'set with cuda_suffixed: "true"', + "replacements": [ + "package-cu12", + ], + }, + ], + id="true-unsuffixed-package-cuda-major", + ), + pytest.param( + """\ + + matrix: + : ~~~~~~matrix + + cuda_suffixed: "true" + + cuda: "12.8" + + packages: + + - package==26.08.*,>=0.0.0a0 + : ~~~~~~~~~~~~~~~~~~~~~~~~~~unsuffixed.0 + : ~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.warning + : ~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.replacements.0 + """, + True, + True, + 12, + [], + [("package", None)], + [ + { + "warning": 'package "package" in specific dependency ' + 'set with cuda_suffixed: "true"', + "replacements": [ + "package-cu12==26.08.*,>=0.0.0a0", + ], + }, + ], + id="true-unsuffixed-package-cuda-major-version-req", + ), + pytest.param( + """\ + + matrix: + : ~~~~~~matrix + + cuda_suffixed: "true" + + cuda: "12.8" + + packages: + + - &package_anchor package + : ~~~~~~~~~~~~~~~~~~~~~~~unsuffixed.0 + : ~~~~~~~~~~~~~~~~~~~~~~~warnings.0.warning + : ~~~~~~~~~~~~~~~~~~~~~~~warnings.0.replacements.0 + """, + True, + True, + 12, + [], + [("package", "package_anchor")], + [ + { + "warning": 'package "package" in specific dependency ' + 'set with cuda_suffixed: "true"', + "replacements": [ + "&package_anchor package-cu12", + ], + }, + ], + id="true-unsuffixed-package-cuda-major-anchor", + ), + pytest.param( + """\ + + matrix: + + cuda_suffixed: "true" + + packages: + + - package-cu12 + : ~~~~~~~~~~~~suffixed.0 + """, + True, + True, + None, + [("package", "-cu12", None)], + [], + [], + id="true-suffixed-package", + ), + pytest.param( + """\ + + matrix: + + cuda_suffixed: "true" + + cuda: "12.*" + + packages: + + - package-cu12 + : ~~~~~~~~~~~~suffixed.0 + """, + True, + True, + 12, + [("package", "-cu12", None)], + [], + [], + id="true-suffixed-package-cuda-major", + ), + pytest.param( + """\ + + matrix: + + cuda_suffixed: "true" + + cuda: "13.*" + + packages: + + - package-cu12 + : ~~~~~~~~~~~~suffixed.0 + : ~~~~~~~~~~~~warnings.0.warning + : ~~~~~~~~~~~~warnings.0.replacements.0 + """, + True, + True, + 13, + [("package", "-cu12", None)], + [], + [ + { + "warning": 'package "package" has wrong -cu* suffix', + "replacements": [ + "package-cu13", + ], + }, + ], + id="true-suffixed-package-wrong-cuda-major", + ), + pytest.param( + """\ + + matrix: + + cuda_suffixed: "true" + + cuda: "13.*" + + packages: + + - package-cu12==26.08.*,>=0.0.0a0 + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~suffixed.0 + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.warning + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.replacements.0 + """, + True, + True, + 13, + [("package", "-cu12", None)], + [], + [ + { + "warning": 'package "package" has wrong -cu* suffix', + "replacements": [ + "package-cu13==26.08.*,>=0.0.0a0", + ], + }, + ], + id="true-suffixed-package-wrong-cuda-major-version-req", + ), + pytest.param( + """\ + + matrix: + + cuda_suffixed: "true" + + cuda: "13.*" + + packages: + + - &package_anchor package-cu12 + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~suffixed.0 + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.warning + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.replacements.0 + """, + True, + True, + 13, + [("package", "-cu12", "package_anchor")], + [], + [ + { + "warning": 'package "package" has wrong -cu* suffix', + "replacements": [ + "&package_anchor package-cu13", + ], + }, + ], + id="true-suffixed-package-wrong-cuda-major-anchor", + ), + pytest.param( + """\ + + matrix: + : ~~~~~~matrix + + cuda_suffixed: "false" + + packages: + + - package-cu12 + : ~~~~~~~~~~~~suffixed.0 + : ~~~~~~~~~~~~warnings.0.warning + : ~~~~~~~~~~~~warnings.0.replacements.0 + """, + True, + False, + None, + [("package", "-cu12", None)], + [], + [ + { + "warning": 'package "package" in specific dependency ' + 'set with cuda_suffixed: "false"', + "replacements": [ + "package", + ], + }, + ], + id="false-suffixed-package", + ), + pytest.param( + """\ + + matrix: + : ~~~~~~matrix + + cuda_suffixed: "false" + + packages: + + - package-cu12==26.08.*,>=0.0.0a0 + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~suffixed.0 + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.warning + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.replacements.0 + """, + True, + False, + None, + [("package", "-cu12", None)], + [], + [ + { + "warning": 'package "package" in specific dependency ' + 'set with cuda_suffixed: "false"', + "replacements": [ + "package==26.08.*,>=0.0.0a0", + ], + }, + ], + id="false-suffixed-package-version-req", + ), + pytest.param( + """\ + + matrix: + : ~~~~~~matrix + + cuda_suffixed: "false" + + packages: + + - &package_anchor package-cu12 + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~suffixed.0 + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.warning + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.replacements.0 + """, + True, + False, + None, + [("package", "-cu12", "package_anchor")], + [], + [ + { + "warning": 'package "package" in specific dependency ' + 'set with cuda_suffixed: "false"', + "replacements": [ + "&package_anchor package", + ], + }, + ], + id="false-suffixed-package-anchor", + ), + pytest.param( + """\ + + matrix: + + cuda_suffixed: "false" + + packages: + + - package + : ~~~~~~~unsuffixed.0 + """, + True, + False, + None, + [], + [("package", None)], + [], + id="false-unsuffixed-package", + ), + pytest.param( + """\ + + packages: + + - package-cu12 + : ~~~~~~~~~~~~suffixed.0 + """, + False, + None, + None, + [("package", "-cu12", None)], + [], + [], + id="non-python-output", + ), + ], + ) + def test_handle_specific_item( + self, + content, + has_python_output_type, + cuda_suffixed, + cuda_major, + suffixed_names, + unsuffixed_names, + warnings, + ): + content, spans = parse_named_spans(content, dict) + composed = _compose(content) + linter = lint.Linter( + "dependencies.yaml", content, "verify-dependencies" + ) + handler = CUDASuffixedHandler(linter, Mock()) + matrix_node = ( + find_yaml_node_for_span(composed, span) + if (span := spans.get("matrix")) + else None + ) + + with handler.handle_specific_item( + Mock(), composed + ) as specific_context: + specific_context.has_python_output_type = has_python_output_type + matrix_context = CUDASuffixedHandler.MatricesItemContext( + matrix_node=matrix_node, + cuda_suffixed=cuda_suffixed, + cuda_major=cuda_major, + suspicious_suffixed_packages=[ + ( + name, + suffix, + anchor, + find_yaml_node_for_span(composed, span), + ) + for (name, suffix, anchor), span in zip( + suffixed_names, + spans.get("suffixed", []), + strict=True, + ) + ], + suspicious_unsuffixed_packages=[ + (name, anchor, find_yaml_node_for_span(composed, span)) + for (name, anchor), span in zip( + unsuffixed_names, + spans.get("unsuffixed", []), + strict=True, + ) + ], + ) + specific_context.matrices_item_contexts.append(matrix_context) + + assert linter.warnings == zip_expected_warnings( + spans.get("warnings", []), warnings + ) + + def test_handle_matrices_item(self): + handler = CUDASuffixedHandler(Mock(), Mock()) + specific_context = CUDASuffixedHandler.SpecificItemContext() + + with handler.handle_matrices_item( + specific_context, Mock() + ) as matrix_context: + assert specific_context.matrices_item_contexts == [] + + assert specific_context.matrices_item_contexts == [matrix_context] + + def test_handle_matrix(self): + content, spans = parse_named_spans( + """\ + + matrix: + : ~~~~~~matrix_key + + cuda_suffixed: "true" + """ + ) + composed = _compose(content) + matrix_key, matrix = composed.value[0] + context = CUDASuffixedHandler.MatricesItemContext() + handler = CUDASuffixedHandler(Mock(), Mock()) + + with handler.handle_matrix( + context, matrix_key, matrix + ) as matrix_context: + assert matrix_context is context + assert context.matrix_node == find_yaml_node_for_span( + composed, spans["matrix_key"] + ) + + @pytest.mark.parametrize( + [ + "content", + "expected_cuda_suffixed", + "has_cuda_suffixed_node", + "expected_cuda_major", + "has_cuda_node", + ], + [ + pytest.param( + 'cuda_suffixed: "true"', + True, + True, + None, + False, + id="cuda-suffixed-true", + ), + pytest.param( + 'cuda_suffixed: "false"', + False, + True, + None, + False, + id="cuda-suffixed-false", + ), + pytest.param( + 'cuda_suffixed: "other"', + None, + True, + None, + False, + id="cuda-suffixed-other", + ), + pytest.param( + 'cuda: "12.8"', + None, + False, + 12, + True, + id="cuda-version", + ), + pytest.param( + 'cuda: "12.*"', + None, + False, + 12, + True, + id="cuda-version-wildcard", + ), + pytest.param( + 'cuda: "invalid"', + None, + False, + None, + False, + id="cuda-version-invalid", + ), + pytest.param( + 'other: "value"', + None, + False, + None, + False, + id="other", + ), + ], + ) + def test_handle_matrix_item( + self, + content, + expected_cuda_suffixed, + has_cuda_suffixed_node, + expected_cuda_major, + has_cuda_node, + ): + composed = _compose(content) + key, value = composed.value[0] + context = CUDASuffixedHandler.MatricesItemContext() + handler = CUDASuffixedHandler(Mock(), Mock()) + + handler.handle_matrix_item(context, key, value) + + assert context.cuda_suffixed is expected_cuda_suffixed + assert context.cuda_suffixed_node == ( + value if has_cuda_suffixed_node else None + ) + assert context.cuda_major == expected_cuda_major + assert context.cuda_node == (value if has_cuda_node else None) + + @pytest.mark.parametrize( + [ + "requirement", + "suffixed_names", + "unsuffixed_names", + ], + [ + pytest.param( + "package", + [], + ["package"], + id="unsuffixed", + ), + pytest.param( + "package[extra]>=1.0", + [], + ["package"], + id="unsuffixed-with-extras-and-version", + ), + pytest.param( + "package-cu12", + [("package", "-cu12")], + [], + id="suffixed", + ), + pytest.param( + "package-cu123==1.0", + [("package", "-cu123")], + [], + id="multi-digit-suffix", + ), + pytest.param( + "package-cu12x", + [], + [], + id="invalid-cuda-suffix", + ), + pytest.param( + "other-cu12", + [], + [], + id="unknown-package", + ), + pytest.param( + "not a requirement", + [], + [], + id="invalid-requirement", + ), + ], + ) + def test_handle_package( + self, requirement, suffixed_names, unsuffixed_names + ): + package_node = _compose(requirement) + rapids_version = SimpleNamespace(cuda_suffixed_packages={"package"}) + context = CUDASuffixedHandler.MatricesItemContext() + handler = CUDASuffixedHandler(Mock(), Mock()) + + with patch( + "rapids_pre_commit_hooks.dependencies.cuda_suffixed." + "get_rapids_version", + return_value=rapids_version, + ): + handler.handle_package(context, None, package_node) + + assert context.suspicious_suffixed_packages == [ + (name, suffix, None, package_node) + for (name, suffix) in suffixed_names + ] + assert context.suspicious_unsuffixed_packages == [ + (name, None, package_node) for name in unsuffixed_names + ] + + +@pytest.mark.parametrize( + ["content", "warnings"], + [ + pytest.param( + """\ + + dependencies: + + file_set: + + common: + : ~~~~~~warnings.0.notes.0 + : ~~~~~~warnings.1.notes.0 + + - output_types: pyproject + + packages: + + - package-cu12 + : ~~~~~~~~~~~~warnings.0.warning + + - package + : ~~~~~~~warnings.1.warning + """, + [ + { + "warning": 'package "package" in common dependency set', + "notes": [ + "place in a specific dependency set with " + 'cuda_suffixed: "true" instead', + ], + }, + { + "warning": 'package "package" in common dependency set', + "notes": [ + "place in a specific dependency set with " + 'cuda_suffixed: "false" instead', + ], + }, + ], + id="common-python-packages", + ), + pytest.param( + """\ + + dependencies: + + file_set: + + common: + + - output_types: conda + + packages: + + - package-cu12 + + - package + """, + [], + id="common-non-python-output", + ), + pytest.param( + """\ + + dependencies: + + file_set: + + specific: + + - output_types: requirements + + matrices: + + - matrix: + : ~~~~~~warnings.0.notes.0 + + cuda: "12.8" + + packages: + + - package-cu12 + : ~~~~~~~~~~~~warnings.0.warning + + - matrix: + + cuda_suffixed: "true" + + cuda: "12.8" + + packages: + + - package + : ~~~~~~~warnings.1.warning + : ~~~~~~~warnings.1.replacements.0 + + - matrix: + : ~~~~~~warnings.2.notes.0 + + cuda_suffixed: "true" + + packages: + + - package + : ~~~~~~~warnings.2.warning + + - matrix: + + cuda_suffixed: "false" + + packages: + + - package-cu12 + : ~~~~~~~~~~~~warnings.3.warning + : ~~~~~~~~~~~~warnings.3.replacements.0 + """, + [ + { + "warning": 'package "package" in specific dependency set ' + "with no cuda_suffixed field", + "notes": [ + "place in a specific dependency set with " + 'cuda_suffixed: "true" instead', + ], + }, + { + "warning": 'package "package" in specific dependency set ' + 'with cuda_suffixed: "true"', + "replacements": [ + "package-cu12", + ], + }, + { + "warning": 'package "package" in specific dependency set ' + 'with cuda_suffixed: "true"', + "notes": [ + "add a cuda matrix field and add matching -cu* " + "suffix to package name", + ], + }, + { + "warning": 'package "package" in specific dependency set ' + 'with cuda_suffixed: "false"', + "replacements": [ + "package", + ], + }, + ], + id="specific-invalid-package-forms", + ), + pytest.param( + """\ + + dependencies: + + file_set: + + specific: + + - output_types: pyproject + + matrices: + + - matrix: + + cuda_suffixed: "true" + + packages: + + - package-cu12 + + - matrix: + + cuda_suffixed: "false" + + packages: + + - package + """, + [], + id="specific-valid-package-forms", + ), + pytest.param( + """\ + + dependencies: + + file_set: + + common: + + - output_types: pyproject + + packages: + + - non-rapids-package + + - non-rapids-package-cu12 + + specific: + + - output_types: pyproject + + matrices: + + - matrix: + + packages: + + - non-rapids-package + + - non-rapids-package-cu12 + + - matrix: + + cuda_suffixed: "false" + + packages: + + - non-rapids-package + + - non-rapids-package-cu12 + + - matrix: + + cuda_suffixed: "true" + + packages: + + - non-rapids-package + + - non-rapids-package-cu12 + """, + [], + id="non-rapids-packages", + ), + ], +) +def test_check_cuda_suffixed_integration(content, warnings): + content, spans = parse_named_spans(content, dict) + + loader = dependencies_yaml.AnchorPreservingLoader(content) + try: + composed = loader.get_single_node() + finally: + loader.dispose() + + args = Mock() + linter = lint.Linter("dependencies.yaml", content, "verify-dependencies") + rapids_version = SimpleNamespace(cuda_suffixed_packages={"package"}) + + handler = CUDASuffixedHandler(linter, args) + + with patch( + "rapids_pre_commit_hooks.dependencies.cuda_suffixed." + "get_rapids_version", + return_value=rapids_version, + ): + dependencies_yaml.traverse_root(handler, {}, set(), composed) + + assert linter.warnings == zip_expected_warnings( + spans.get("warnings", []), warnings + ) diff --git a/tests/rapids_pre_commit_hooks/dependencies/test_use_cuda_wheels.py b/tests/rapids_pre_commit_hooks/dependencies/test_use_cuda_wheels.py index 3b1a639..eae4f61 100644 --- a/tests/rapids_pre_commit_hooks/dependencies/test_use_cuda_wheels.py +++ b/tests/rapids_pre_commit_hooks/dependencies/test_use_cuda_wheels.py @@ -6,7 +6,7 @@ import pytest from packaging.requirements import Requirement -from rapids_pre_commit_hooks import lint, dependencies +from rapids_pre_commit_hooks import lint from rapids_pre_commit_hooks.dependencies.use_cuda_wheels import ( UseCUDAWheelsHandler, is_cupy_ctk_package, @@ -115,7 +115,7 @@ def test_handle_common( args = Mock() linter = lint.Linter( - "dependencies.yaml", content, "verify-use-cuda-wheels" + "dependencies.yaml", content, "verify-dependencies" ) loader = dependencies_yaml.AnchorPreservingLoader(content) try: @@ -286,7 +286,7 @@ def test_handle_matrices_item( args = Mock() linter = lint.Linter( - "dependencies.yaml", content, "verify-use-cuda-wheels" + "dependencies.yaml", content, "verify-dependencies" ) loader = dependencies_yaml.AnchorPreservingLoader(content) try: @@ -350,7 +350,7 @@ def test_handle_matrix(self): args = Mock() linter = lint.Linter( - "dependencies.yaml", content, "verify-use-cuda-wheels" + "dependencies.yaml", content, "verify-dependencies" ) loader = dependencies_yaml.AnchorPreservingLoader(content) try: @@ -403,7 +403,7 @@ def test_handle_matrix_item(self, content, expected_has_use_cuda_wheels): args = Mock() linter = lint.Linter( - "dependencies.yaml", content, "verify-use-cuda-wheels" + "dependencies.yaml", content, "verify-dependencies" ) loader = dependencies_yaml.AnchorPreservingLoader(content) try: @@ -461,7 +461,7 @@ def test_handle_packages(self, content): args = Mock() linter = lint.Linter( - "dependencies.yaml", content, "verify-use-cuda-wheels" + "dependencies.yaml", content, "verify-dependencies" ) loader = dependencies_yaml.AnchorPreservingLoader(content) try: @@ -534,7 +534,7 @@ def test_handle_packages(self, content): def test_handle_package(self, content, expected_node, expected_name): args = Mock() linter = lint.Linter( - "dependencies.yaml", content, "verify-use-cuda-wheels" + "dependencies.yaml", content, "verify-dependencies" ) loader = dependencies_yaml.AnchorPreservingLoader(content) try: @@ -781,10 +781,15 @@ def test_handle_package(self, content, expected_node, expected_name): def test_check_use_cuda_wheels_integration(content, warnings): content, spans = parse_named_spans(content, dict) + loader = dependencies_yaml.AnchorPreservingLoader(content) + try: + composed = loader.get_single_node() + finally: + loader.dispose() + args = Mock() - linter = lint.Linter( - "dependencies.yaml", content, "verify-use-cuda-wheels" - ) + linter = lint.Linter("dependencies.yaml", content, "verify-dependencies") + handler = UseCUDAWheelsHandler(linter, args) expected_warnings = [ lint.LintWarning( @@ -804,5 +809,5 @@ def test_check_use_cuda_wheels_integration(content, warnings): ) ] - dependencies.check_dependencies(linter, args) + dependencies_yaml.traverse_root(handler, {}, set(), composed) assert linter.warnings == expected_warnings diff --git a/tests/rapids_pre_commit_hooks/utils/test_dependencies_yaml.py b/tests/rapids_pre_commit_hooks/utils/test_dependencies_yaml.py index a746c15..342b0ed 100644 --- a/tests/rapids_pre_commit_hooks/utils/test_dependencies_yaml.py +++ b/tests/rapids_pre_commit_hooks/utils/test_dependencies_yaml.py @@ -5,7 +5,12 @@ import pytest import yaml + from rapids_pre_commit_hooks.utils import dependencies_yaml +from rapids_pre_commit_hooks_test_utils import ( + find_yaml_node_for_span, + parse_named_spans, +) class TestChainedHandler: @@ -42,6 +47,12 @@ class TestChainedHandler: (Mock(),), id="handle_common_item", ), + pytest.param( + "handle_output_types", + True, + (Mock(), Mock()), + id="handle_output_types", + ), pytest.param( "handle_specific", True, @@ -139,6 +150,11 @@ def test_context(self, hook_name, use_context, hook_args): ("anchor", Mock()), id="handle_package", ), + pytest.param( + "handle_output_type", + (Mock(),), + id="handle_output_type", + ), ], ) def test_no_context(self, hook_name, hook_args): @@ -347,6 +363,96 @@ def test_traverse_packages_used_anchor(): assert manager.mock_calls == expected_calls +def test_traverse_output_type(): + output_types = yaml.SafeLoader("""\ + [requirements] + """).get_single_node() + output_type = output_types.value[0] + output_types_context = Mock() + manager = MagicMock() + + expected_calls = [ + call.handler.handle_output_type(output_types_context, output_type), + ] + manager.reset_mock() + + dependencies_yaml.traverse_output_type( + manager.handler, output_types_context, output_type + ) + + assert manager.mock_calls == expected_calls + + +@pytest.mark.parametrize( + ["content"], + [ + pytest.param( + """\ + + output_types: pyproject + : ~~~~~~~~~~~~key_node + : ~~~~~~~~~node + : ~~~~~~~~~items.0 + """, + id="string-item", + ), + pytest.param( + """\ + + output_types: [requirements, pyproject] + : ~~~~~~~~~~~~key_node + : ~~~~~~~~~~~~~~~~~~~~~~~~~node + : ~~~~~~~~~~~~items.0 + : ~~~~~~~~~items.1 + """, + id="list", + ), + pytest.param( + """\ + + output_types: [] + : ~~~~~~~~~~~~key_node + : ~~node + """, + id="empty-list", + ), + ], +) +def test_traverse_output_types(content): + content, spans = parse_named_spans(content) + item = yaml.SafeLoader(content).get_single_node() + output_types_key = find_yaml_node_for_span(item, spans["key_node"]) + output_types = find_yaml_node_for_span(item, spans["node"]) + item_context = Mock() + manager = MagicMock() + + expected_calls = [ + call.handler.handle_output_types( + item_context, output_types_key, output_types + ), + call.handler.handle_output_types().__enter__(), + *( + call.traverse_output_type( + manager.handler, + manager.handler.handle_output_types().__enter__(), + find_yaml_node_for_span(item, output_type_span), + ) + for output_type_span in spans.get("items", []) + ), + call.handler.handle_output_types().__exit__(None, None, None), + ] + manager.reset_mock() + + with ( + patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml.traverse_output_type", + manager.traverse_output_type, + ), + ): + dependencies_yaml.traverse_output_types( + manager.handler, item_context, output_types_key, output_types + ) + + assert manager.mock_calls == expected_calls + + def test_traverse_common_item(): common = yaml.SafeLoader("""\ - output_types: pyproject @@ -359,6 +465,12 @@ def test_traverse_common_item(): expected_calls = [ call.handler.handle_common_item(common_context, common_item), call.handler.handle_common_item().__enter__(), + call.traverse_output_types( + manager.handler, + manager.handler.handle_common_item().__enter__(), + common_item.value[0][0], + common_item.value[0][1], + ), call.traverse_packages( manager.handler, manager.handler.handle_common_item().__enter__(), @@ -372,6 +484,10 @@ def test_traverse_common_item(): manager.reset_mock() with ( + patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml.traverse_output_types", + manager.traverse_output_types, + ), patch( "rapids_pre_commit_hooks.utils.dependencies_yaml.traverse_packages", manager.traverse_packages, @@ -621,6 +737,12 @@ def test_traverse_specific_item(): expected_calls = [ call.handler.handle_specific_item(specific_context, specific_item), call.handler.handle_specific_item().__enter__(), + call.traverse_output_types( + manager.handler, + manager.handler.handle_specific_item().__enter__(), + specific_item.value[0][0], + specific_item.value[0][1], + ), call.traverse_matrices( manager.handler, manager.handler.handle_specific_item().__enter__(), @@ -634,6 +756,10 @@ def test_traverse_specific_item(): manager.reset_mock() with ( + patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml.traverse_output_types", + manager.traverse_output_types, + ), patch( "rapids_pre_commit_hooks.utils.dependencies_yaml.traverse_matrices", manager.traverse_matrices, diff --git a/tests/test_testing_utils.py b/tests/test_testing_utils.py index c0581c6..1f1e8d2 100644 --- a/tests/test_testing_utils.py +++ b/tests/test_testing_utils.py @@ -5,11 +5,13 @@ import pytest +from rapids_pre_commit_hooks.lint import LintWarning, Note, Replacement from rapids_pre_commit_hooks.utils.yaml import AnchorPreservingLoader from rapids_pre_commit_hooks_test_utils import ( ParseError, find_yaml_node_for_span, parse_named_spans, + zip_expected_warnings, ) @@ -681,6 +683,63 @@ def test_parse_named_spans( assert spans == expected_spans +@pytest.mark.parametrize( + ["content", "warnings", "expected_warnings"], + [ + pytest.param( + """\ + + This is a warning + : ~~~~0.warning + : ~~0.notes.0 + : ~0.notes.1 + : ^0.replacements.0 + : ~~~~0.replacements.1 + : ^1.warning + """, + [ + { + "warning": "First warning", + "notes": [ + "First note", + "Second note", + ], + "replacements": [ + "!", + "THIS", + ], + }, + { + "warning": "Second warning", + }, + ], + [ + LintWarning( + (0, 4), + "First warning", + notes=[ + Note((5, 7), "First note"), + Note((8, 9), "Second note"), + ], + replacements=[ + Replacement((17, 17), "!"), + Replacement((0, 4), "THIS"), + ], + ), + LintWarning( + (4, 4), + "Second warning", + notes=[], + replacements=[], + ), + ], + ), + ], +) +def test_zip_expected_warnings(content, warnings, expected_warnings): + content, spans = parse_named_spans(content, list) + assert zip_expected_warnings(spans, warnings) == expected_warnings + + @pytest.mark.parametrize( ["content", "node_lambda"], [ diff --git a/tests/utils/rapids_pre_commit_hooks_test_utils.py b/tests/utils/rapids_pre_commit_hooks_test_utils.py index 5ee3841..86a9ee9 100644 --- a/tests/utils/rapids_pre_commit_hooks_test_utils.py +++ b/tests/utils/rapids_pre_commit_hooks_test_utils.py @@ -7,10 +7,10 @@ from typing import TYPE_CHECKING from rapids_pre_commit_hooks.utils.yaml import node_has_type -from rapids_pre_commit_hooks.lint import Lines +from rapids_pre_commit_hooks.lint import Lines, LintWarning, Note, Replacement if TYPE_CHECKING: - from typing import Optional, TypeGuard + from typing import Optional, TypeGuard, TypedDict import yaml @@ -261,6 +261,54 @@ def postprocess(named_spans: "_NamedSpans") -> "NamedSpans": return content, postprocessed +if TYPE_CHECKING: + + class ExpectedWarningSpan(TypedDict): + warning: "Span" + notes: "list[Span]" + replacements: "list[Span]" + + class ExpectedWarning(TypedDict): + warning: str + notes: list[str] + replacements: list[str] + + +def zip_expected_warnings( + warning_spans: "list[ExpectedWarningSpan]", + warnings: "list[ExpectedWarning]", +) -> "list[LintWarning]": + return [ + LintWarning( + warning_span["warning"], + warning["warning"], + notes=[ + Note( + note_span, + note, + ) + for note_span, note in zip( + warning_span.get("notes", []), + warning.get("notes", []), + strict=True, + ) + ], + replacements=[ + Replacement( + replacement_span, + replacement, + ) + for replacement_span, replacement in zip( + warning_span.get("replacements", []), + warning.get("replacements", []), + strict=True, + ) + ], + ) + for warning_span, warning in zip(warning_spans, warnings, strict=True) + ] + + def find_yaml_node_for_span( node: "yaml.Node", span: "Span" ) -> "Optional[yaml.Node]":