diff --git a/.github/scripts/check_min_required_version.py b/.github/scripts/check_min_required_version.py new file mode 100644 index 000000000..fc9eed663 --- /dev/null +++ b/.github/scripts/check_min_required_version.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Ensure min required versions are updated when co-modifying dependent packages. + +When a PR modifies two packages where one depends on the other (e.g. +uipath-platform depends on uipath-core), the minimum required version of +the dependency must match the dependency's current version. + +Example: if uipath-core is at version 0.5.7 and both uipath-core and +uipath-platform are modified, then uipath-platform's dependency on +uipath-core must specify >=0.5.7 (not an older minimum). +""" + +import os +import re +import subprocess +import sys +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + +PACKAGES_DIR = Path("packages") + + +def get_changed_packages() -> set[str]: + """Return set of package directory names that have source changes.""" + base_sha = os.getenv("BASE_SHA", "") + head_sha = os.getenv("HEAD_SHA", "") + diff_spec = ( + f"{base_sha}...{head_sha}" if base_sha and head_sha else "origin/main...HEAD" + ) + + try: + result = subprocess.run( + ["git", "diff", "--name-only", diff_spec], + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as e: + print(f"Error running git diff: {e}", file=sys.stderr) + return set() + + changed: set[str] = set() + for file_path in result.stdout.strip().split("\n"): + if file_path.startswith("packages/"): + parts = file_path.split("/") + if len(parts) >= 2: + pkg_dir = parts[1] + if (PACKAGES_DIR / pkg_dir / "pyproject.toml").exists(): + changed.add(pkg_dir) + return changed + + +def read_pyproject(pkg_dir: str) -> dict | None: + """Read and parse a package's pyproject.toml.""" + pyproject = PACKAGES_DIR / pkg_dir / "pyproject.toml" + if not pyproject.exists(): + return None + with open(pyproject, "rb") as f: + return tomllib.load(f) + + +def get_version(pyproject: dict) -> str | None: + """Extract the version from parsed pyproject data.""" + return pyproject.get("project", {}).get("version") + + +def get_name(pyproject: dict) -> str | None: + """Extract the package name from parsed pyproject data.""" + return pyproject.get("project", {}).get("name") + + +def get_dependencies(pyproject: dict) -> list[str]: + """Extract the dependencies list from parsed pyproject data.""" + return pyproject.get("project", {}).get("dependencies", []) + + +def parse_min_version(dep_spec: str, dep_name: str) -> str | None: + """Extract the minimum version from a dependency specifier. + + Looks for >=X.Y.Z pattern in a dependency string like + "uipath-core>=0.5.4, <0.6.0". + """ + pattern = rf"^{re.escape(dep_name)}>=([^\s,]+)" + match = re.match(pattern, dep_spec.strip()) + if match: + return match.group(1) + return None + + +def check_min_versions(changed_packages: set[str]) -> list[str]: + """Check that min required versions are up to date for changed packages. + + Returns a list of error messages for any violations found. + """ + errors: list[str] = [] + + # Build a map of package name -> (dir_name, current_version) + pkg_info: dict[str, tuple[str, str]] = {} + for pkg_dir in sorted(PACKAGES_DIR.iterdir()): + if not pkg_dir.is_dir(): + continue + pyproject = read_pyproject(pkg_dir.name) + if pyproject is None: + continue + name = get_name(pyproject) + version = get_version(pyproject) + if name and version: + pkg_info[name] = (pkg_dir.name, version) + + # For each changed package, check its dependencies against other changed packages + for pkg_dir in sorted(changed_packages): + pyproject = read_pyproject(pkg_dir) + if pyproject is None: + continue + + pkg_name = get_name(pyproject) + if not pkg_name: + continue + + for dep_spec in get_dependencies(pyproject): + for dep_name, (dep_dir, dep_version) in pkg_info.items(): + if dep_dir not in changed_packages: + continue + + min_ver = parse_min_version(dep_spec, dep_name) + if min_ver is None: + continue + + if min_ver != dep_version: + errors.append( + f"{pkg_name} requires {dep_name}>={min_ver}, " + f"but {dep_name} is at version {dep_version}. " + f"Update the minimum version in " + f"packages/{pkg_dir}/pyproject.toml to " + f"{dep_name}>={dep_version}" + ) + + return errors + + +def main() -> int: + """Run the min required version check.""" + changed = get_changed_packages() + if not changed: + print("No changed packages detected.") + return 0 + + print(f"Changed packages: {', '.join(sorted(changed))}") + errors = check_min_versions(changed) + + if errors: + print("\nMin required version check FAILED:", file=sys.stderr) + for err in errors: + print(f" FAIL: {err}", file=sys.stderr) + return 1 + + print("Min required version check passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_check_min_required_version.py b/.github/scripts/test_check_min_required_version.py new file mode 100644 index 000000000..a9bbfb703 --- /dev/null +++ b/.github/scripts/test_check_min_required_version.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Tests for check_min_required_version.py.""" + +from unittest import mock + +from check_min_required_version import ( + check_min_versions, + main, + parse_min_version, +) + + +class TestParseMinVersion: + def test_extracts_min_version(self): + assert parse_min_version("uipath-core>=0.5.4, <0.6.0", "uipath-core") == "0.5.4" + + def test_extracts_min_version_no_upper_bound(self): + assert parse_min_version("uipath-core>=0.5.4", "uipath-core") == "0.5.4" + + def test_returns_none_for_different_package(self): + assert parse_min_version("uipath-core>=0.5.4", "uipath-platform") is None + + def test_returns_none_for_no_min_version(self): + assert parse_min_version("uipath-core==0.5.4", "uipath-core") is None + + def test_handles_whitespace(self): + assert ( + parse_min_version(" uipath-core>=1.2.3, <2.0.0 ", "uipath-core") + == "1.2.3" + ) + + +class TestCheckMinVersions: + def _make_pyproject(self, name, version, dependencies=None): + data = {"project": {"name": name, "version": version}} + if dependencies is not None: + data["project"]["dependencies"] = dependencies + return data + + def test_passes_when_min_version_matches(self, tmp_path): + core = self._make_pyproject("uipath-core", "0.5.7") + platform = self._make_pyproject( + "uipath-platform", "0.1.4", ["uipath-core>=0.5.7, <0.6.0"] + ) + + with ( + mock.patch( + "check_min_required_version.read_pyproject", + side_effect=lambda d: { + "uipath-core": core, + "uipath-platform": platform, + }.get(d), + ), + mock.patch( + "check_min_required_version.PACKAGES_DIR", + tmp_path, + ), + ): + # Create fake package dirs + (tmp_path / "uipath-core").mkdir() + (tmp_path / "uipath-core" / "pyproject.toml").touch() + (tmp_path / "uipath-platform").mkdir() + (tmp_path / "uipath-platform" / "pyproject.toml").touch() + + errors = check_min_versions({"uipath-core", "uipath-platform"}) + assert errors == [] + + def test_fails_when_min_version_outdated(self, tmp_path): + core = self._make_pyproject("uipath-core", "0.5.7") + platform = self._make_pyproject( + "uipath-platform", "0.1.4", ["uipath-core>=0.5.4, <0.6.0"] + ) + + with ( + mock.patch( + "check_min_required_version.read_pyproject", + side_effect=lambda d: { + "uipath-core": core, + "uipath-platform": platform, + }.get(d), + ), + mock.patch( + "check_min_required_version.PACKAGES_DIR", + tmp_path, + ), + ): + (tmp_path / "uipath-core").mkdir() + (tmp_path / "uipath-core" / "pyproject.toml").touch() + (tmp_path / "uipath-platform").mkdir() + (tmp_path / "uipath-platform" / "pyproject.toml").touch() + + errors = check_min_versions({"uipath-core", "uipath-platform"}) + assert len(errors) == 1 + assert "uipath-core>=0.5.4" in errors[0] + assert "uipath-core>=0.5.7" in errors[0] + + def test_skips_when_dependency_not_changed(self, tmp_path): + core = self._make_pyproject("uipath-core", "0.5.7") + platform = self._make_pyproject( + "uipath-platform", "0.1.4", ["uipath-core>=0.5.4, <0.6.0"] + ) + + with ( + mock.patch( + "check_min_required_version.read_pyproject", + side_effect=lambda d: { + "uipath-core": core, + "uipath-platform": platform, + }.get(d), + ), + mock.patch( + "check_min_required_version.PACKAGES_DIR", + tmp_path, + ), + ): + (tmp_path / "uipath-core").mkdir() + (tmp_path / "uipath-core" / "pyproject.toml").touch() + (tmp_path / "uipath-platform").mkdir() + (tmp_path / "uipath-platform" / "pyproject.toml").touch() + + # Only platform changed, core not changed — should pass + errors = check_min_versions({"uipath-platform"}) + assert errors == [] + + def test_multiple_violations(self, tmp_path): + core = self._make_pyproject("uipath-core", "0.5.7") + platform = self._make_pyproject( + "uipath-platform", "0.1.4", ["uipath-core>=0.5.2, <0.6.0"] + ) + uipath = self._make_pyproject( + "uipath", + "2.10.26", + ["uipath-core>=0.5.2, <0.6.0", "uipath-platform>=0.1.0, <0.2.0"], + ) + + pyprojects = { + "uipath-core": core, + "uipath-platform": platform, + "uipath": uipath, + } + + with ( + mock.patch( + "check_min_required_version.read_pyproject", + side_effect=lambda d: pyprojects.get(d), + ), + mock.patch( + "check_min_required_version.PACKAGES_DIR", + tmp_path, + ), + ): + for name in pyprojects: + (tmp_path / name).mkdir() + (tmp_path / name / "pyproject.toml").touch() + + errors = check_min_versions({"uipath-core", "uipath-platform", "uipath"}) + # platform has outdated core dep, uipath has outdated core and platform deps + assert len(errors) == 3 + + +class TestMain: + def test_no_changed_packages(self): + with mock.patch( + "check_min_required_version.get_changed_packages", return_value=set() + ): + assert main() == 0 + + def test_passes_when_versions_correct(self): + with ( + mock.patch( + "check_min_required_version.get_changed_packages", + return_value={"uipath-core", "uipath-platform"}, + ), + mock.patch( + "check_min_required_version.check_min_versions", + return_value=[], + ), + ): + assert main() == 0 + + def test_fails_when_versions_outdated(self): + with ( + mock.patch( + "check_min_required_version.get_changed_packages", + return_value={"uipath-core", "uipath-platform"}, + ), + mock.patch( + "check_min_required_version.check_min_versions", + return_value=["some error"], + ), + ): + assert main() == 1