diff --git a/src/rapids_pre_commit_hooks/hardcoded_version.py b/src/rapids_pre_commit_hooks/hardcoded_version.py index 49fe3d4..199509c 100644 --- a/src/rapids_pre_commit_hooks/hardcoded_version.py +++ b/src/rapids_pre_commit_hooks/hardcoded_version.py @@ -3,6 +3,7 @@ import bisect import re +from enum import Enum from typing import TYPE_CHECKING import tomlkit @@ -15,6 +16,7 @@ import argparse import os from collections.abc import Generator + from typing import Optional from .lint import Lines, Span @@ -46,6 +48,47 @@ ) +class VersionScheme(Enum): + CALVER = "calver" + SEMVER = "semver" + + +def get_previous_minor_version( + version: tuple[int, int, int], version_scheme: VersionScheme +) -> "Optional[tuple[int, int, int]]": + if version_scheme == VersionScheme.CALVER: + year, month, _ = version + month_index = year * 12 + month - 1 + prev_month_index = month_index - 2 + prev_year = prev_month_index // 12 + prev_month = (prev_month_index % 12) + 1 + return (prev_year, prev_month, 0) + elif version_scheme == VersionScheme.SEMVER: + major, minor, _ = version + if minor == 0: + return None + return (major, minor - 1, 0) + + raise ValueError + + +def get_next_minor_version( + version: tuple[int, int, int], version_scheme: VersionScheme +) -> tuple[int, int, int]: + if version_scheme == VersionScheme.CALVER: + year, month, _ = version + month_index = year * 12 + month - 1 + next_month_index = month_index + 2 + next_year = next_month_index // 12 + next_month = (next_month_index % 12) + 1 + return (next_year, next_month, 0) + elif version_scheme == VersionScheme.SEMVER: + major, minor, _ = version + return (major, minor + 1, 0) + + raise ValueError + + def get_excluded_span_pyproject_toml( document: tomlkit.TOMLDocument, path: tuple[str, ...] ) -> "Generator[Span]": @@ -121,18 +164,32 @@ def skip_heuristics(lines: "Lines", match: "re.Match[str]") -> bool: def find_hardcoded_versions( - content: str, full_version: tuple[int, int, int] + content: str, + full_version: tuple[int, int, int], + version_scheme: VersionScheme, ) -> "Generator[re.Match[str]]": """Detect all instances of a specific 2- or 3-part version in text content.""" - major, minor, patch = full_version + to_check = [ + full_version, + get_next_minor_version(full_version, version_scheme), + ] + if ( + prev := get_previous_minor_version(full_version, version_scheme) + ) is not None: + to_check.append(prev) return ( match for match in HARDCODED_VERSION_RE.finditer(content) - if int(match.group("major")) == major - and int(match.group("minor")) == minor - and (not match.group("patch") or int(match.group("patch")) == patch) + if any( + int(match.group("major")) == major + and int(match.group("minor")) == minor + and ( + not match.group("patch") or int(match.group("patch")) == patch + ) + for major, minor, patch in to_check + ) ) @@ -172,7 +229,9 @@ def check_hardcoded_version( full_version = read_version_file(args.version_file) excluded_spans = sorted(get_excluded_spans(linter)) - for match in find_hardcoded_versions(linter.content, full_version): + for match in find_hardcoded_versions( + linter.content, full_version, VersionScheme(args.version_scheme) + ): span_index = bisect.bisect_right(excluded_spans, match.span("full")) if span_index > 0: span_start, span_end = excluded_spans[span_index - 1] @@ -209,6 +268,13 @@ def main() -> None: help="File to read the version from (default: VERSION)", default="VERSION", ) + m.argparser.add_argument( + "--version-scheme", + help="Version scheme to use (default: calver)", + type=VersionScheme, + default=VersionScheme.CALVER, + choices=[s.value for s in VersionScheme], + ) with m.execute() as ctx: ctx.add_check(check_hardcoded_version) diff --git a/tests/examples/verify-hardcoded-version/pass/main/test.sh b/tests/examples/verify-hardcoded-version/pass/main/test.sh index 7f8b449..6b0b199 100644 --- a/tests/examples/verify-hardcoded-version/pass/main/test.sh +++ b/tests/examples/verify-hardcoded-version/pass/main/test.sh @@ -1,3 +1,3 @@ #!/bin/sh -echo "RAPIDS 26.04" +echo "RAPIDS 26.06" diff --git a/tests/rapids_pre_commit_hooks/test_hardcoded_version.py b/tests/rapids_pre_commit_hooks/test_hardcoded_version.py index 80fc2bc..f81beba 100644 --- a/tests/rapids_pre_commit_hooks/test_hardcoded_version.py +++ b/tests/rapids_pre_commit_hooks/test_hardcoded_version.py @@ -11,6 +11,138 @@ from rapids_pre_commit_hooks_test_utils import parse_named_spans +@pytest.mark.parametrize( + ["version", "version_scheme", "expected_version"], + [ + pytest.param( + (26, 1, 0), + hardcoded_version.VersionScheme.CALVER, + (25, 11, 0), + id="calver-january", + ), + pytest.param( + (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, + (25, 12, 0), + id="calver-february", + ), + pytest.param( + (26, 3, 0), + hardcoded_version.VersionScheme.CALVER, + (26, 1, 0), + id="calver-march", + ), + pytest.param( + (26, 11, 0), + hardcoded_version.VersionScheme.CALVER, + (26, 9, 0), + id="calver-november", + ), + pytest.param( + (26, 12, 0), + hardcoded_version.VersionScheme.CALVER, + (26, 10, 0), + id="calver-december", + ), + pytest.param( + (26, 1, 2), + hardcoded_version.VersionScheme.CALVER, + (25, 11, 0), + id="calver-patch", + ), + pytest.param( + (0, 49, 0), + hardcoded_version.VersionScheme.SEMVER, + (0, 48, 0), + id="semver", + ), + pytest.param( + (0, 49, 1), + hardcoded_version.VersionScheme.SEMVER, + (0, 48, 0), + id="semver-patch", + ), + pytest.param( + (1, 0, 0), + hardcoded_version.VersionScheme.SEMVER, + None, + id="semver-major", + ), + ], +) +def test_get_previous_minor_version(version, version_scheme, expected_version): + assert ( + hardcoded_version.get_previous_minor_version(version, version_scheme) + == expected_version + ) + + +@pytest.mark.parametrize( + ["version", "version_scheme", "expected_version"], + [ + pytest.param( + (26, 1, 0), + hardcoded_version.VersionScheme.CALVER, + (26, 3, 0), + id="calver-january", + ), + pytest.param( + (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, + (26, 4, 0), + id="calver-february", + ), + pytest.param( + (26, 10, 0), + hardcoded_version.VersionScheme.CALVER, + (26, 12, 0), + id="calver-october", + ), + pytest.param( + (26, 11, 0), + hardcoded_version.VersionScheme.CALVER, + (27, 1, 0), + id="calver-november", + ), + pytest.param( + (26, 12, 0), + hardcoded_version.VersionScheme.CALVER, + (27, 2, 0), + id="calver-december", + ), + pytest.param( + (26, 1, 2), + hardcoded_version.VersionScheme.CALVER, + (26, 3, 0), + id="calver-patch", + ), + pytest.param( + (0, 49, 0), + hardcoded_version.VersionScheme.SEMVER, + (0, 50, 0), + id="semver", + ), + pytest.param( + (0, 49, 1), + hardcoded_version.VersionScheme.SEMVER, + (0, 50, 0), + id="semver-patch", + ), + pytest.param( + (1, 0, 0), + hardcoded_version.VersionScheme.SEMVER, + (1, 1, 0), + id="semver-major", + ), + ], +) +def test_get_next_minor_version(version, version_scheme, expected_version): + assert ( + hardcoded_version.get_next_minor_version(version, version_scheme) + == expected_version + ) + + @pytest.mark.parametrize( ["content"], [ @@ -533,7 +665,7 @@ def test_skip_heuristics( @pytest.mark.parametrize( - ["content", "version"], + ["content", "version", "version_scheme"], [ pytest.param( """\ @@ -543,6 +675,7 @@ def test_skip_heuristics( : ~~0.minor """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="no-patch-version", ), pytest.param( @@ -553,6 +686,7 @@ def test_skip_heuristics( : ~~0.minor """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="text-before", ), pytest.param( @@ -563,6 +697,7 @@ def test_skip_heuristics( : ~~0.minor """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="text-after", ), pytest.param( @@ -573,6 +708,7 @@ def test_skip_heuristics( : ~~0.minor """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="text-before-and-after", ), pytest.param( @@ -587,6 +723,7 @@ def test_skip_heuristics( : ~~1.minor """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="multiple-instances", ), pytest.param( @@ -598,6 +735,7 @@ def test_skip_heuristics( : ~~0.patch """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="patch-version", ), pytest.param( @@ -605,13 +743,15 @@ def test_skip_heuristics( > 26.02.01 """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="wrong-patch-version", ), pytest.param( """\ - > 26.04 + > 26.03 """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="wrong-major-minor-version", ), pytest.param( @@ -622,6 +762,7 @@ def test_skip_heuristics( : ~~0.minor """, (0, 48, 0), + hardcoded_version.VersionScheme.SEMVER, id="ucxx-version", ), pytest.param( @@ -633,6 +774,7 @@ def test_skip_heuristics( : ~~0.patch """, (0, 48, 0), + hardcoded_version.VersionScheme.SEMVER, id="ucxx-patch-version", ), pytest.param( @@ -640,6 +782,7 @@ def test_skip_heuristics( > 026.02 """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="number-before", ), pytest.param( @@ -647,6 +790,7 @@ def test_skip_heuristics( > 26.020 """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="number-after", ), pytest.param( @@ -658,6 +802,7 @@ def test_skip_heuristics( : ~0.patch """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="no-zero-prefix", ), pytest.param( @@ -668,6 +813,7 @@ def test_skip_heuristics( : ~0.minor """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="no-zero-prefix-asterisk", ), pytest.param( @@ -675,6 +821,7 @@ def test_skip_heuristics( > 2026.02.00 """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="4-digit-major", ), pytest.param( @@ -682,6 +829,7 @@ def test_skip_heuristics( > 26.0002.00 """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="4-digit-minor", ), pytest.param( @@ -692,16 +840,181 @@ def test_skip_heuristics( : ~~0.minor """, (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, id="4-digit-patch", ), + pytest.param( + """\ + > 25.12 + : ~~~~~0.full + : ~~0.major + : ~~0.minor + """, + (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, + id="calver-previous", + ), + pytest.param( + """\ + > 25.12.00 + : ~~~~~~~~0.full + : ~~0.major + : ~~0.minor + : ~~0.patch + """, + (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, + id="calver-previous-patch", + ), + pytest.param( + """\ + > 25.12.01 + """, + (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, + id="calver-previous-patch-wrong", + ), + pytest.param( + """\ + > 25.10 + """, + (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, + id="calver-previous-too-far", + ), + pytest.param( + """\ + > 26.04 + : ~~~~~0.full + : ~~0.major + : ~~0.minor + """, + (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, + id="calver-next", + ), + pytest.param( + """\ + > 26.04.00 + : ~~~~~~~~0.full + : ~~0.major + : ~~0.minor + : ~~0.patch + """, + (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, + id="calver-next-patch", + ), + pytest.param( + """\ + > 26.04.01 + """, + (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, + id="calver-next-patch-wrong", + ), + pytest.param( + """\ + > 26.06 + """, + (26, 2, 0), + hardcoded_version.VersionScheme.CALVER, + id="calver-next-too-far", + ), + pytest.param( + """\ + > 0.48 + : ~~~~0.full + : ~0.major + : ~~0.minor + """, + (0, 49, 0), + hardcoded_version.VersionScheme.SEMVER, + id="semver-previous", + ), + pytest.param( + """\ + > 0.48.00 + """, + (1, 0, 0), + hardcoded_version.VersionScheme.SEMVER, + id="semver-previous-major", + ), + pytest.param( + """\ + > 0.48.00 + : ~~~~~~~0.full + : ~0.major + : ~~0.minor + : ~~0.patch + """, + (0, 49, 0), + hardcoded_version.VersionScheme.SEMVER, + id="semver-previous-patch", + ), + pytest.param( + """\ + > 0.48.01 + """, + (0, 49, 0), + hardcoded_version.VersionScheme.SEMVER, + id="semver-previous-patch-wrong", + ), + pytest.param( + """\ + > 0.47 + """, + (0, 49, 0), + hardcoded_version.VersionScheme.SEMVER, + id="semver-previous-too-far", + ), + pytest.param( + """\ + > 0.50 + : ~~~~0.full + : ~0.major + : ~~0.minor + """, + (0, 49, 0), + hardcoded_version.VersionScheme.SEMVER, + id="semver-next", + ), + pytest.param( + """\ + > 0.50.00 + : ~~~~~~~0.full + : ~0.major + : ~~0.minor + : ~~0.patch + """, + (0, 49, 0), + hardcoded_version.VersionScheme.SEMVER, + id="semver-next-patch", + ), + pytest.param( + """\ + > 0.50.01 + """, + (0, 49, 0), + hardcoded_version.VersionScheme.SEMVER, + id="semver-next-patch-wrong", + ), + pytest.param( + """\ + > 0.51 + """, + (0, 49, 0), + hardcoded_version.VersionScheme.SEMVER, + id="semver-next-too-far", + ), ], ) -def test_find_hardcoded_versions(content, version): +def test_find_hardcoded_versions(content, version, version_scheme): content, spans = parse_named_spans(content, list) assert [ {group: match.span(group) for group in match.groupdict().keys()} for match in hardcoded_version.find_hardcoded_versions( - content, version + content, version, version_scheme ) ] == [{"patch": (-1, -1), **s} for s in spans] @@ -832,7 +1145,7 @@ def test_read_version_file(tmp_path, content, version, context): pytest.param( "file.txt", """\ - + RAPIDS 26.04 + + RAPIDS 26.06 """, "VERSION", (26, 2, 0), @@ -904,7 +1217,11 @@ def test_check_hardcoded_version( Mock(return_value=version), ) as mock_read_version_file: hardcoded_version.check_hardcoded_version( - linter, Mock(version_file=version_file) + linter, + Mock( + version_file=version_file, + version_scheme=hardcoded_version.VersionScheme.CALVER, + ), ) if version_file_read: mock_read_version_file.assert_called_once_with(version_file)