From 9bc0d18a1b4d0b79ef5298bab0627cb2b787ca8a Mon Sep 17 00:00:00 2001 From: Kyle Edwards Date: Fri, 18 Sep 2026 11:10:04 -0400 Subject: [PATCH 1/2] Enforce naming conventions in `dependencies.yaml` Enforce the following naming conventions for file keys: * `py_build_` * `py_rapids_build_` * `py_run_` * `py_test_` Issue: https://github.com/rapidsai/pre-commit-hooks/issues/132 --- .../dependencies/__init__.py | 2 + .../dependencies/naming_conventions.py | 158 ++++++++ .../utils/dependencies_yaml.py | 288 +++++++++++++- .../dependencies/test_naming_conventions.py | 355 ++++++++++++++++++ .../utils/test_dependencies_yaml.py | 343 ++++++++++++++++- 5 files changed, 1131 insertions(+), 15 deletions(-) create mode 100644 src/rapids_pre_commit_hooks/dependencies/naming_conventions.py create mode 100644 tests/rapids_pre_commit_hooks/dependencies/test_naming_conventions.py diff --git a/src/rapids_pre_commit_hooks/dependencies/__init__.py b/src/rapids_pre_commit_hooks/dependencies/__init__.py index f7ab930..52b4eb8 100644 --- a/src/rapids_pre_commit_hooks/dependencies/__init__.py +++ b/src/rapids_pre_commit_hooks/dependencies/__init__.py @@ -4,6 +4,7 @@ import argparse from .cuda_suffixed import CUDASuffixedHandler +from .naming_conventions import NamingConventionsHandler from .use_cuda_wheels import UseCUDAWheelsHandler from ..lint import Linter, LintMain from ..utils.dependencies_yaml import ( @@ -15,6 +16,7 @@ def check_dependencies(linter: "Linter", args: "argparse.Namespace") -> None: handler = ChainedHandler() handler.add_handler(CUDASuffixedHandler(linter, args)) + handler.add_handler(NamingConventionsHandler(linter, args)) handler.add_handler(UseCUDAWheelsHandler(linter, args)) traverse_dependencies_yaml(handler, linter.content) diff --git a/src/rapids_pre_commit_hooks/dependencies/naming_conventions.py b/src/rapids_pre_commit_hooks/dependencies/naming_conventions.py new file mode 100644 index 0000000..c8720f7 --- /dev/null +++ b/src/rapids_pre_commit_hooks/dependencies/naming_conventions.py @@ -0,0 +1,158 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import contextlib +import dataclasses +import re +from typing import TYPE_CHECKING + +from ..utils.dependencies_yaml import Handler + +if TYPE_CHECKING: + import argparse + from collections.abc import Generator + from typing import Optional + + import yaml + + from ..lint import Linter + + +class NamingConventionsHandler(Handler): + @dataclasses.dataclass(frozen=True) + class NamingConventionKey: + table: str = dataclasses.field(kw_only=True) + key: "Optional[str]" = dataclasses.field(kw_only=True, default=None) + + PROJECT_NAMING_CONVENTIONS: "dict[NamingConventionKey, str]" = { + NamingConventionKey(table="build-system"): "py_build_{project_name}", + NamingConventionKey( + table="tool.rapids-build-backend", key="requires" + ): "py_rapids_build_{project_name}", + NamingConventionKey(table="project"): "py_run_{project_name}", + NamingConventionKey( + table="project.optional-dependencies", key="test" + ): "py_test_{project_name}", + } + + @dataclasses.dataclass + class FileContext: + pyproject_output_node: "Optional[yaml.Node]" = None + project_name: "Optional[str]" = None + pyproject_dir_node: "Optional[yaml.Node]" = None + table_node: "Optional[yaml.Node]" = None + key_node: "Optional[yaml.Node]" = None + + def __init__(self, linter: "Linter", args: "argparse.Namespace") -> None: + self.linter = linter + self.args = args + + @contextlib.contextmanager + def handle_files_item( + self, + files_context: "None", # noqa: ARG002 + key: "yaml.Node", + value: "yaml.Node", # noqa: ARG002 + ) -> "Generator[NamingConventionsHandler.FileContext]": + context = NamingConventionsHandler.FileContext() + yield context + + if ( + context.pyproject_output_node + and context.pyproject_dir_node + and context.table_node + ): + naming_conventions_key = ( + NamingConventionsHandler.NamingConventionKey( + table=context.table_node.value, + key=None + if context.key_node is None + else context.key_node.value, + ) + ) + if ( + naming_convention + := NamingConventionsHandler.PROJECT_NAMING_CONVENTIONS.get( + naming_conventions_key + ) + ): + expected_file_key_name = naming_convention.format( + project_name=context.project_name + ) + if key.value != expected_file_key_name: + w = self.linter.add_warning( + (key.start_mark.index, key.end_mark.index), + "expected file key name is " + f'"{expected_file_key_name}"', + ) + w.add_replacement( + (key.start_mark.index, key.end_mark.index), + expected_file_key_name, + ) + w.add_note( + ( + context.pyproject_output_node.start_mark.index, + context.pyproject_output_node.end_mark.index, + ), + "file key has pyproject output type", + ) + w.add_note( + ( + context.pyproject_dir_node.start_mark.index, + context.pyproject_dir_node.end_mark.index, + ), + f'and project name "{context.project_name}"', + ) + w.add_note( + ( + context.table_node.start_mark.index, + context.table_node.end_mark.index, + ), + f'and table extra "{context.table_node.value}"', + ) + if context.key_node: + w.add_note( + ( + context.key_node.start_mark.index, + context.key_node.end_mark.index, + ), + f'and key extra "{context.key_node.value}"', + ) + + def handle_file_output_item( + self, + file_output_context: "NamingConventionsHandler.FileContext", + item: "yaml.Node", + ) -> None: + if item.value == "pyproject": + file_output_context.pyproject_output_node = item + + def handle_pyproject_dir( + self, + files_item_context: "NamingConventionsHandler.FileContext", + key: "yaml.Node", # noqa: ARG002 + value: "yaml.Node", + ) -> None: + if match := re.search( + r"^python/(?P[^/]*)$", value.value + ): + files_item_context.project_name = match.group( + "project_dirname" + ).replace("-", "_") + files_item_context.pyproject_dir_node = value + + def handle_extras_table( + self, + extras_context: "NamingConventionsHandler.FileContext", + key: "yaml.Node", # noqa: ARG002 + value: "yaml.Node", + ) -> None: + extras_context.table_node = value + + def handle_extras_key( + self, + extras_context: "NamingConventionsHandler.FileContext", + key: "yaml.Node", # noqa: ARG002 + value: "yaml.Node", + ) -> None: + extras_context.key_node = value diff --git a/src/rapids_pre_commit_hooks/utils/dependencies_yaml.py b/src/rapids_pre_commit_hooks/utils/dependencies_yaml.py index 6fe9646..4f729ef 100644 --- a/src/rapids_pre_commit_hooks/utils/dependencies_yaml.py +++ b/src/rapids_pre_commit_hooks/utils/dependencies_yaml.py @@ -24,6 +24,69 @@ def handle_root( ) -> "contextlib.AbstractContextManager[Any]": return contextlib.nullcontext() + def handle_files( + self, + root_context: "Any", + key: "yaml.Node", # noqa: ARG002 + value: "yaml.Node", # noqa: ARG002 + ) -> "contextlib.AbstractContextManager[Any]": + return contextlib.nullcontext(root_context) + + def handle_files_item( + self, + files_context: "Any", + key: "yaml.Node", # noqa: ARG002 + value: "yaml.Node", # noqa: ARG002 + ) -> "contextlib.AbstractContextManager[Any]": + return contextlib.nullcontext(files_context) + + def handle_file_output( + self, + files_item_context: "Any", + key: "yaml.Node", # noqa: ARG002 + value: "yaml.Node", # noqa: ARG002 + ) -> "contextlib.AbstractContextManager[Any]": + return contextlib.nullcontext(files_item_context) + + def handle_file_output_item( + self, + file_output_context: "Any", # noqa: ARG002 + item: "yaml.Node", # noqa: ARG002 + ) -> None: + pass + + def handle_extras( + self, + files_item_context: "Any", + key: "yaml.Node", # noqa: ARG002 + value: "yaml.Node", # noqa: ARG002 + ) -> "contextlib.AbstractContextManager[Any]": + return contextlib.nullcontext(files_item_context) + + def handle_extras_table( + self, + extras_context: "Any", # noqa: ARG002 + key: "yaml.Node", # noqa: ARG002 + value: "yaml.Node", # noqa: ARG002 + ) -> None: + pass + + def handle_extras_key( + self, + extras_context: "Any", # noqa: ARG002 + key: "yaml.Node", # noqa: ARG002 + value: "yaml.Node", # noqa: ARG002 + ) -> None: + pass + + def handle_pyproject_dir( + self, + files_item_context: "Any", # noqa: ARG002 + key: "yaml.Node", # noqa: ARG002 + value: "yaml.Node", # noqa: ARG002 + ) -> None: + pass + def handle_dependencies( self, root_context: "Any", @@ -189,6 +252,62 @@ def handle_root( ) -> "contextlib.AbstractContextManager[tuple[Any, ...]]": return self._handle_context("handle_root", None, *args, **kwargs) + def handle_files( + self, root_context: "tuple[Any, ...]", *args, **kwargs + ) -> "contextlib.AbstractContextManager[tuple[Any, ...]]": + return self._handle_context( + "handle_files", root_context, *args, **kwargs + ) + + def handle_files_item( + self, files_context: "tuple[Any, ...]", *args, **kwargs + ) -> "contextlib.AbstractContextManager[tuple[Any, ...]]": + return self._handle_context( + "handle_files_item", files_context, *args, **kwargs + ) + + def handle_file_output( + self, files_item_context: "tuple[Any, ...]", *args, **kwargs + ) -> "contextlib.AbstractContextManager[tuple[Any, ...]]": + return self._handle_context( + "handle_file_output", files_item_context, *args, **kwargs + ) + + def handle_file_output_item( + self, file_output_context: "tuple[Any, ...]", *args, **kwargs + ) -> None: + return self._handle_no_context( + "handle_file_output_item", file_output_context, *args, **kwargs + ) + + def handle_extras( + self, files_item_context: "tuple[Any, ...]", *args, **kwargs + ) -> "contextlib.AbstractContextManager[tuple[Any, ...]]": + return self._handle_context( + "handle_extras", files_item_context, *args, **kwargs + ) + + def handle_extras_table( + self, extras_context: "tuple[Any, ...]", *args, **kwargs + ) -> None: + return self._handle_no_context( + "handle_extras_table", extras_context, *args, **kwargs + ) + + def handle_extras_key( + self, extras_context: "tuple[Any, ...]", *args, **kwargs + ) -> None: + return self._handle_no_context( + "handle_extras_key", extras_context, *args, **kwargs + ) + + def handle_pyproject_dir( + self, files_item_context: "tuple[Any, ...]", *args, **kwargs + ) -> None: + return self._handle_no_context( + "handle_pyproject_dir", files_item_context, *args, **kwargs + ) + def handle_dependencies( self, root_context: "tuple[Any, ...]", *args, **kwargs ) -> "contextlib.AbstractContextManager[tuple[Any, ...]]": @@ -297,6 +416,146 @@ def handle_package( ) +def traverse_file_output_item( + handler: Handler, + file_output_context: "Any", + node: "yaml.Node", +) -> None: + if node_has_type(node, "str"): + handler.handle_file_output_item(file_output_context, node) + + +def traverse_file_output( + handler: Handler, + files_item_context: "Any", + key_node: "yaml.Node", + node: "yaml.Node", +) -> None: + if node_has_type(node, "seq"): + with handler.handle_file_output( + files_item_context, key_node, node + ) as file_output_context: + for item in node.value: + traverse_file_output_item(handler, file_output_context, item) + elif node_has_type(node, "str"): + with handler.handle_file_output( + files_item_context, key_node, node + ) as file_output_context: + traverse_file_output_item(handler, file_output_context, node) + + +def traverse_extras_table( + handler: Handler, + extras_context: "Any", + key_node: "yaml.Node", + node: "yaml.Node", +) -> None: + if node_has_type(node, "str"): + handler.handle_extras_table(extras_context, key_node, node) + + +def traverse_extras_key( + handler: Handler, + extras_context: "Any", + key_node: "yaml.Node", + node: "yaml.Node", +) -> None: + if node_has_type(node, "str"): + handler.handle_extras_key(extras_context, key_node, node) + + +def traverse_extras( + handler: Handler, + files_item_context: "Any", + key_node: "yaml.Node", + node: "yaml.Node", +) -> None: + if node_has_type(node, "map"): + with handler.handle_extras( + files_item_context, key_node, node + ) as extras_context: + for extras_key, extras_value in node.value: + if node_has_type(extras_key, "str"): + if extras_key.value == "table": + traverse_extras_table( + handler, + extras_context, + extras_key, + extras_value, + ) + elif extras_key.value == "key": + traverse_extras_key( + handler, + extras_context, + extras_key, + extras_value, + ) + + +def traverse_pyproject_dir( + handler: Handler, + files_item_context: "Any", + key_node: "yaml.Node", + node: "yaml.Node", +) -> None: + if node_has_type(node, "str"): + handler.handle_pyproject_dir(files_item_context, key_node, node) + + +def traverse_files_item( + handler: Handler, + files_context: "Any", + key_node: "yaml.Node", + node: "yaml.Node", +) -> None: + if node_has_type(node, "map"): + with handler.handle_files_item( + files_context, key_node, node + ) as files_item_context: + for files_item_key, files_item_value in node.value: + if node_has_type(files_item_key, "str"): + if files_item_key.value == "output": + traverse_file_output( + handler, + files_item_context, + files_item_key, + files_item_value, + ) + elif files_item_key.value == "extras": + traverse_extras( + handler, + files_item_context, + files_item_key, + files_item_value, + ) + elif files_item_key.value == "pyproject_dir": + traverse_pyproject_dir( + handler, + files_item_context, + files_item_key, + files_item_value, + ) + + +def traverse_files( + handler: Handler, + root_context: "Any", + key_node: "yaml.Node", + node: "yaml.Node", +) -> None: + if node_has_type(node, "map"): + with handler.handle_files( + root_context, key_node, node + ) as files_context: + for files_item_key, files_item_value in node.value: + traverse_files_item( + handler, + files_context, + files_item_key, + files_item_value, + ) + + def traverse_package( handler: Handler, packages_context: "Any", @@ -635,18 +894,23 @@ def traverse_root( if node_has_type(node, "map"): with handler.handle_root(node) as root_context: for root_key, root_value in node.value: - if ( - node_has_type(root_key, "str") - and root_key.value == "dependencies" - ): - traverse_dependencies( - handler, - root_context, - anchors, - used_anchors, - root_key, - root_value, - ) + if node_has_type(root_key, "str"): + if root_key.value == "files": + traverse_files( + handler, + root_context, + root_key, + root_value, + ) + elif root_key.value == "dependencies": + traverse_dependencies( + handler, + root_context, + anchors, + used_anchors, + root_key, + root_value, + ) def traverse_dependencies_yaml(handler: Handler, content: str) -> None: diff --git a/tests/rapids_pre_commit_hooks/dependencies/test_naming_conventions.py b/tests/rapids_pre_commit_hooks/dependencies/test_naming_conventions.py new file mode 100644 index 0000000..99543fb --- /dev/null +++ b/tests/rapids_pre_commit_hooks/dependencies/test_naming_conventions.py @@ -0,0 +1,355 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from unittest.mock import Mock + +import pytest + +from rapids_pre_commit_hooks import lint +from rapids_pre_commit_hooks.dependencies.naming_conventions import ( + NamingConventionsHandler, +) +from rapids_pre_commit_hooks.utils import dependencies_yaml +from rapids_pre_commit_hooks_test_utils import ( + parse_named_spans, + zip_expected_warnings, +) + + +def _compose(content): + loader = dependencies_yaml.AnchorPreservingLoader(content) + try: + return loader.get_single_node() + finally: + loader.dispose() + + +class TestNamingConventionsHandler: + @pytest.mark.parametrize( + ["content", "expected_warnings"], + [ + pytest.param( + """\ + + files: + + wrong_name: + : ~~~~~~~~~~warnings.0.warning + : ~~~~~~~~~~warnings.0.replacements.0 + + output: pyproject + : ~~~~~~~~~warnings.0.notes.0 + + pyproject_dir: python/dask-cuda + : ~~~~~~~~~~~~~~~~warnings.0.notes.1 + + extras: + + table: project.optional-dependencies + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.notes.2 + + key: test + : ~~~~warnings.0.notes.3 + """, + [ + { + "warning": "expected file key name is " + '"py_test_dask_cuda"', + "replacements": ["py_test_dask_cuda"], + "notes": [ + "file key has pyproject output type", + 'and project name "dask_cuda"', + 'and table extra "project.optional-dependencies"', + 'and key extra "test"', + ], + }, + ], + id="extras-key", + ), + pytest.param( + """\ + + files: + + wrong_name: + : ~~~~~~~~~~warnings.0.warning + : ~~~~~~~~~~warnings.0.replacements.0 + + output: pyproject + : ~~~~~~~~~warnings.0.notes.0 + + pyproject_dir: python/dask-cuda + : ~~~~~~~~~~~~~~~~warnings.0.notes.1 + + extras: + + table: project + : ~~~~~~~warnings.0.notes.2 + """, + [ + { + "warning": "expected file key name is " + '"py_run_dask_cuda"', + "replacements": ["py_run_dask_cuda"], + "notes": [ + "file key has pyproject output type", + 'and project name "dask_cuda"', + 'and table extra "project"', + ], + }, + ], + id="no-extras-key", + ), + pytest.param( + """\ + + files: + + wrong_name: + + output: pyproject + + pyproject_dir: python/dask-cuda + + extras: + + table: unknown + """, + [], + id="mismatched-table", + ), + pytest.param( + """\ + + files: + + wrong_name: + + output: pyproject + + pyproject_dir: python/dask-cuda + + extras: + + table: project.optional-dependencies + + key: docs + """, + [], + id="mismatched-key", + ), + pytest.param( + """\ + + files: + + wrong_name: + + output: requirements + + pyproject_dir: python/dask-cuda + + extras: + + table: project + """, + [], + id="non-pyproject-output", + ), + pytest.param( + """\ + + files: + + wrong_name: + + output: pyproject + + extras: + + table: project + """, + [], + id="no-pyproject-dir", + ), + pytest.param( + """\ + + files: + + wrong_name: + + output: pyproject + + pyproject_dir: python/dask-cuda + """, + [], + id="no-extras-table", + ), + pytest.param( + """\ + + files: + + py_run_dask_cuda: + + output: pyproject + + pyproject_dir: python/dask-cuda + + extras: + + table: project + """, + [], + id="correct-naming-convention-without-key", + ), + pytest.param( + """\ + + files: + + py_test_dask_cuda: + + output: pyproject + + pyproject_dir: python/dask-cuda + + extras: + + table: project.optional-dependencies + + key: test + """, + [], + id="correct-naming-convention-with-key", + ), + ], + ) + def test_handle_files_item(self, content, expected_warnings): + content, spans = parse_named_spans(content, dict) + root = _compose(content) + files = root.value[0][1] + file_key, file_value = files.value[0] + file_fields = {key.value: value for key, value in file_value.value} + extras_fields = ( + {key.value: value for key, value in file_fields["extras"].value} + if "extras" in file_fields + else {} + ) + linter = lint.Linter( + "dependencies.yaml", content, "verify-dependencies" + ) + handler = NamingConventionsHandler(linter, Mock()) + + with handler.handle_files_item(None, file_key, file_value) as context: + handler.handle_file_output_item(context, file_fields["output"]) + if "pyproject_dir" in file_fields: + handler.handle_pyproject_dir( + context, Mock(), file_fields["pyproject_dir"] + ) + if "table" in extras_fields: + handler.handle_extras_table( + context, Mock(), extras_fields["table"] + ) + if "key" in extras_fields: + handler.handle_extras_key( + context, Mock(), extras_fields["key"] + ) + + assert linter.warnings == zip_expected_warnings( + spans.get("warnings", []), + expected_warnings, + ) + + @pytest.mark.parametrize( + ["output_type", "expected"], + [ + pytest.param("pyproject", True, id="pyproject"), + pytest.param("requirements", False, id="requirements"), + pytest.param("conda", False, id="conda"), + pytest.param("none", False, id="none"), + ], + ) + def test_handle_file_output_item(self, output_type, expected): + handler = NamingConventionsHandler(Mock(), Mock()) + context = NamingConventionsHandler.FileContext() + item = Mock(value=output_type) + + handler.handle_file_output_item(context, item) + + assert context.pyproject_output_node is (item if expected else None) + + @pytest.mark.parametrize( + ["pyproject_dir", "project_name"], + [ + pytest.param("python/cudf", "cudf", id="project"), + pytest.param( + "python/dask-cuda", "dask_cuda", id="hyphenated-project" + ), + pytest.param("python/cudf/subdir", None, id="nested-dir"), + pytest.param("cpp/cudf", None, id="non-python-dir"), + ], + ) + def test_handle_pyproject_dir(self, pyproject_dir, project_name): + handler = NamingConventionsHandler(Mock(), Mock()) + context = NamingConventionsHandler.FileContext() + value = Mock(value=pyproject_dir) + + handler.handle_pyproject_dir(context, Mock(), value) + + assert context.project_name == project_name + assert context.pyproject_dir_node is ( + value if project_name is not None else None + ) + + def test_handle_extras_table(self): + handler = NamingConventionsHandler(Mock(), Mock()) + context = NamingConventionsHandler.FileContext() + value = Mock(value="project") + + handler.handle_extras_table(context, Mock(), value) + + assert context.table_node is value + + def test_handle_extras_key(self): + handler = NamingConventionsHandler(Mock(), Mock()) + context = NamingConventionsHandler.FileContext() + value = Mock(value="test") + + handler.handle_extras_key(context, Mock(), value) + + assert context.key_node is value + + +@pytest.mark.parametrize( + ["content", "warnings"], + [ + pytest.param( + """\ + + files: + + wrong_test_name: + : ~~~~~~~~~~~~~~~warnings.0.warning + : ~~~~~~~~~~~~~~~warnings.0.replacements.0 + + output: [conda, pyproject] + : ~~~~~~~~~warnings.0.notes.0 + + pyproject_dir: python/dask-cuda + : ~~~~~~~~~~~~~~~~warnings.0.notes.1 + + extras: + + table: project.optional-dependencies + : ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~warnings.0.notes.2 + + key: test + : ~~~~warnings.0.notes.3 + + wrong_build_name: + : ~~~~~~~~~~~~~~~~warnings.1.warning + : ~~~~~~~~~~~~~~~~warnings.1.replacements.0 + + output: pyproject + : ~~~~~~~~~warnings.1.notes.0 + + pyproject_dir: python/cudf + : ~~~~~~~~~~~warnings.1.notes.1 + + extras: + + table: build-system + : ~~~~~~~~~~~~warnings.1.notes.2 + + py_run_dask_cuda: + + output: pyproject + + pyproject_dir: python/dask-cuda + + extras: + + table: project + + requirements: + + output: requirements + + pyproject_dir: python/cudf + + extras: + + table: project + + dependencies: {} + """, + [ + { + "warning": 'expected file key name is "py_test_dask_cuda"', + "replacements": ["py_test_dask_cuda"], + "notes": [ + "file key has pyproject output type", + 'and project name "dask_cuda"', + 'and table extra "project.optional-dependencies"', + 'and key extra "test"', + ], + }, + { + "warning": 'expected file key name is "py_build_cudf"', + "replacements": ["py_build_cudf"], + "notes": [ + "file key has pyproject output type", + 'and project name "cudf"', + 'and table extra "build-system"', + ], + }, + ], + id="files", + ), + ], +) +def test_check_naming_conventions_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") + handler = NamingConventionsHandler(linter, args) + + dependencies_yaml.traverse_root( + handler, loader.document_anchors[0], set(), composed + ) + + assert linter.warnings == zip_expected_warnings( + spans.get("warnings", []), 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 7c2f784..455e289 100644 --- a/tests/rapids_pre_commit_hooks/utils/test_dependencies_yaml.py +++ b/tests/rapids_pre_commit_hooks/utils/test_dependencies_yaml.py @@ -30,6 +30,30 @@ class TestChainedHandler: (Mock(), Mock()), id="handle_dependencies", ), + pytest.param( + "handle_files", + True, + (Mock(), Mock()), + id="handle_files", + ), + pytest.param( + "handle_files_item", + True, + (Mock(), Mock()), + id="handle_files_item", + ), + pytest.param( + "handle_file_output", + True, + (Mock(), Mock()), + id="handle_file_output", + ), + pytest.param( + "handle_extras", + True, + (Mock(), Mock()), + id="handle_extras", + ), pytest.param( "handle_dependency_set", True, @@ -156,6 +180,26 @@ def test_context(self, hook_name, use_context, hook_args): (Mock(),), id="handle_output_type", ), + pytest.param( + "handle_file_output_item", + (Mock(),), + id="handle_file_output_item", + ), + pytest.param( + "handle_extras_table", + (Mock(), Mock()), + id="handle_extras_table", + ), + pytest.param( + "handle_extras_key", + (Mock(), Mock()), + id="handle_extras_key", + ), + pytest.param( + "handle_pyproject_dir", + (Mock(), Mock()), + id="handle_pyproject_dir", + ), ], ) def test_no_context(self, hook_name, hook_args): @@ -178,6 +222,286 @@ def test_no_context(self, hook_name, hook_args): assert manager.mock_calls == expected_calls +def test_traverse_file_output_item(): + file_output = yaml.SafeLoader("[pyproject]").get_single_node() + file_output_item = file_output.value[0] + file_output_context = Mock() + manager = MagicMock() + + expected_calls = [ + call.handler.handle_file_output_item( + file_output_context, file_output_item + ), + ] + manager.reset_mock() + + dependencies_yaml.traverse_file_output_item( + manager.handler, file_output_context, file_output_item + ) + + assert manager.mock_calls == expected_calls + + +@pytest.mark.parametrize( + ["content"], + [ + pytest.param( + """\ + + output: pyproject + : ~~~~~~key_node + : ~~~~~~~~~node + : ~~~~~~~~~items.0 + """, + id="string-item", + ), + pytest.param( + """\ + + output: [requirements, pyproject] + : ~~~~~~key_node + : ~~~~~~~~~~~~~~~~~~~~~~~~~node + : ~~~~~~~~~~~~items.0 + : ~~~~~~~~~items.1 + """, + id="list", + ), + pytest.param( + """\ + + output: [] + : ~~~~~~key_node + : ~~node + """, + id="empty-list", + ), + ], +) +def test_traverse_file_output(content): + content, spans = parse_named_spans(content) + files_item = yaml.SafeLoader(content).get_single_node() + output_key = find_yaml_node_for_span(files_item, spans["key_node"]) + output = find_yaml_node_for_span(files_item, spans["node"]) + files_item_context = Mock() + manager = MagicMock() + + expected_calls = [ + call.handler.handle_file_output( + files_item_context, output_key, output + ), + call.handler.handle_file_output().__enter__(), + *( + call.traverse_file_output_item( + manager.handler, + manager.handler.handle_file_output().__enter__(), + find_yaml_node_for_span(files_item, item_span), + ) + for item_span in spans.get("items", []) + ), + call.handler.handle_file_output().__exit__(None, None, None), + ] + manager.reset_mock() + + with patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml." + "traverse_file_output_item", + manager.traverse_file_output_item, + ): + dependencies_yaml.traverse_file_output( + manager.handler, files_item_context, output_key, output + ) + + assert manager.mock_calls == expected_calls + + +@pytest.mark.parametrize( + ["function_name", "handler_name", "content"], + [ + pytest.param( + "traverse_extras_table", + "handle_extras_table", + "table: project.optional-dependencies", + id="extras-table", + ), + pytest.param( + "traverse_extras_key", + "handle_extras_key", + "key: test", + id="extras-key", + ), + pytest.param( + "traverse_pyproject_dir", + "handle_pyproject_dir", + "pyproject_dir: python", + id="pyproject-dir", + ), + ], +) +def test_traverse_string_value(function_name, handler_name, content): + parent = yaml.SafeLoader(content).get_single_node() + key, value = parent.value[0] + parent_context = Mock() + manager = MagicMock() + + expected_calls = [ + getattr(call.handler, handler_name)(parent_context, key, value), + ] + manager.reset_mock() + + getattr(dependencies_yaml, function_name)( + manager.handler, parent_context, key, value + ) + + assert manager.mock_calls == expected_calls + + +def test_traverse_extras(): + files_item = yaml.SafeLoader("""\ + extras: + table: project.optional-dependencies + key: test + """).get_single_node() + extras_key, extras = files_item.value[0] + files_item_context = Mock() + manager = MagicMock() + + expected_calls = [ + call.handler.handle_extras(files_item_context, extras_key, extras), + call.handler.handle_extras().__enter__(), + call.traverse_extras_table( + manager.handler, + manager.handler.handle_extras().__enter__(), + extras.value[0][0], + extras.value[0][1], + ), + call.traverse_extras_key( + manager.handler, + manager.handler.handle_extras().__enter__(), + extras.value[1][0], + extras.value[1][1], + ), + call.handler.handle_extras().__exit__(None, None, None), + ] + manager.reset_mock() + + with ( + patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml." + "traverse_extras_table", + manager.traverse_extras_table, + ), + patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml." + "traverse_extras_key", + manager.traverse_extras_key, + ), + ): + dependencies_yaml.traverse_extras( + manager.handler, files_item_context, extras_key, extras + ) + + assert manager.mock_calls == expected_calls + + +def test_traverse_files_item(): + files = yaml.SafeLoader("""\ + test: + output: pyproject + extras: {} + pyproject_dir: python + includes: [] + """).get_single_node() + files_item_key, files_item = files.value[0] + files_context = Mock() + manager = MagicMock() + + expected_calls = [ + call.handler.handle_files_item( + files_context, files_item_key, files_item + ), + call.handler.handle_files_item().__enter__(), + call.traverse_file_output( + manager.handler, + manager.handler.handle_files_item().__enter__(), + files_item.value[0][0], + files_item.value[0][1], + ), + call.traverse_extras( + manager.handler, + manager.handler.handle_files_item().__enter__(), + files_item.value[1][0], + files_item.value[1][1], + ), + call.traverse_pyproject_dir( + manager.handler, + manager.handler.handle_files_item().__enter__(), + files_item.value[2][0], + files_item.value[2][1], + ), + call.handler.handle_files_item().__exit__(None, None, None), + ] + manager.reset_mock() + + with ( + patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml." + "traverse_file_output", + manager.traverse_file_output, + ), + patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml.traverse_extras", + manager.traverse_extras, + ), + patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml." + "traverse_pyproject_dir", + manager.traverse_pyproject_dir, + ), + ): + dependencies_yaml.traverse_files_item( + manager.handler, files_context, files_item_key, files_item + ) + + assert manager.mock_calls == expected_calls + + +def test_traverse_files(): + root = yaml.SafeLoader("""\ + files: + test: {} + all: {} + """).get_single_node() + files_key, files = root.value[0] + root_context = Mock() + manager = MagicMock() + + expected_calls = [ + call.handler.handle_files(root_context, files_key, files), + call.handler.handle_files().__enter__(), + call.traverse_files_item( + manager.handler, + manager.handler.handle_files().__enter__(), + files.value[0][0], + files.value[0][1], + ), + call.traverse_files_item( + manager.handler, + manager.handler.handle_files().__enter__(), + files.value[1][0], + files.value[1][1], + ), + call.handler.handle_files().__exit__(None, None, None), + ] + manager.reset_mock() + + with patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml.traverse_files_item", + manager.traverse_files_item, + ): + dependencies_yaml.traverse_files( + manager.handler, root_context, files_key, files + ) + + assert manager.mock_calls == expected_calls + + @pytest.mark.parametrize( ["content", "used_anchors", "anchor"], [ @@ -930,6 +1254,12 @@ def test_traverse_root(): expected_calls = [ call.handler.handle_root(root), call.handler.handle_root().__enter__(), + call.traverse_files( + manager.handler, + manager.handler.handle_root().__enter__(), + root.value[0][0], + root.value[0][1], + ), call.traverse_dependencies( manager.handler, manager.handler.handle_root().__enter__(), @@ -942,9 +1272,16 @@ def test_traverse_root(): ] manager.reset_mock() - with patch( - "rapids_pre_commit_hooks.utils.dependencies_yaml.traverse_dependencies", - manager.traverse_dependencies, + with ( + patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml.traverse_files", + manager.traverse_files, + ), + patch( + "rapids_pre_commit_hooks.utils.dependencies_yaml." + "traverse_dependencies", + manager.traverse_dependencies, + ), ): dependencies_yaml.traverse_root(manager.handler, {}, set(), root) From 7510109e8ee7e222c7140772254d5ecba5497e51 Mon Sep 17 00:00:00 2001 From: Kyle Edwards Date: Fri, 18 Sep 2026 11:21:56 -0400 Subject: [PATCH 2/2] Check for empty Python directory --- .../dependencies/naming_conventions.py | 2 +- .../dependencies/test_naming_conventions.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/rapids_pre_commit_hooks/dependencies/naming_conventions.py b/src/rapids_pre_commit_hooks/dependencies/naming_conventions.py index c8720f7..3502346 100644 --- a/src/rapids_pre_commit_hooks/dependencies/naming_conventions.py +++ b/src/rapids_pre_commit_hooks/dependencies/naming_conventions.py @@ -134,7 +134,7 @@ def handle_pyproject_dir( value: "yaml.Node", ) -> None: if match := re.search( - r"^python/(?P[^/]*)$", value.value + r"^python/(?P[^/]+)$", value.value ): files_item_context.project_name = match.group( "project_dirname" diff --git a/tests/rapids_pre_commit_hooks/dependencies/test_naming_conventions.py b/tests/rapids_pre_commit_hooks/dependencies/test_naming_conventions.py index 99543fb..35a8690 100644 --- a/tests/rapids_pre_commit_hooks/dependencies/test_naming_conventions.py +++ b/tests/rapids_pre_commit_hooks/dependencies/test_naming_conventions.py @@ -124,6 +124,18 @@ class TestNamingConventionsHandler: [], id="non-pyproject-output", ), + pytest.param( + """\ + + files: + + wrong_name: + + output: pyproject + + pyproject_dir: python/ + + extras: + + table: project + """, + [], + id="empty-python-dir", + ), pytest.param( """\ + files: