Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions .github/scripts/check_min_required_version.py
Original file line number Diff line number Diff line change
@@ -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())
192 changes: 192 additions & 0 deletions .github/scripts/test_check_min_required_version.py
Original file line number Diff line number Diff line change
@@ -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
Loading