diff --git a/ci/test_validate_stable_lance.py b/ci/test_validate_stable_lance.py new file mode 100644 index 000000000..ea53c01b4 --- /dev/null +++ b/ci/test_validate_stable_lance.py @@ -0,0 +1,72 @@ +import tempfile +import unittest +from pathlib import Path + +from validate_stable_lance import validate + + +class ValidateStableLanceTest(unittest.TestCase): + def write_fixture( + self, + root: Path, + *, + rust: str = "=10.0.0", + python: str = "10.0.0", + java: str = "10.0.0", + ) -> None: + (root / "python").mkdir() + (root / "java").mkdir() + (root / "Cargo.toml").write_text( + f'[workspace.dependencies]\nlance = "{rust}"\nlance-core = "{rust}"\n' + ) + (root / "python" / "pyproject.toml").write_text( + '[project.optional-dependencies]\ntests = ["pylance==' + python + '"]\n' + ) + (root / "java" / "pom.xml").write_text( + "" + + java + + "" + ) + + def test_accepts_matching_stable_versions(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.write_fixture(root) + self.assertEqual(validate(root), "10.0.0") + + def test_rejects_prerelease(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.write_fixture(root, python="10.0.0rc1") + with self.assertRaisesRegex(ValueError, "not stable"): + validate(root) + + def test_rejects_sdk_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.write_fixture(root, java="9.0.0") + with self.assertRaisesRegex(ValueError, "do not match across SDKs"): + validate(root) + + def test_rejects_non_exact_rust_dependency(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.write_fixture(root, rust="10.0.0") + with self.assertRaisesRegex(ValueError, "not exact"): + validate(root) + + def test_rejects_unpublished_rust_source(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.write_fixture(root) + (root / "Cargo.toml").write_text( + "[workspace.dependencies]\n" + 'lance = { version = "=10.0.0", git = "https://example.com/lance", ' + 'tag = "v10.0.0" }\n' + ) + with self.assertRaisesRegex(ValueError, "unpublished source fields"): + validate(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/ci/validate_stable_lance.py b/ci/validate_stable_lance.py old mode 100644 new mode 100755 index 4edd4c522..2cd722517 --- a/ci/validate_stable_lance.py +++ b/ci/validate_stable_lance.py @@ -1,34 +1,112 @@ +#!/usr/bin/env python3 +"""Validate that every SDK uses the same published stable Lance release.""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from pathlib import Path + import tomllib -found_preview_lance = False +STABLE_VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") -with open("Cargo.toml", "rb") as f: - cargo_data = tomllib.load(f) - for name, dep in cargo_data["workspace"]["dependencies"].items(): - if name == "lance" or name.startswith("lance-"): - if isinstance(dep, str): - version = dep - elif isinstance(dep, dict): - # Version doesn't have the beta tag in it, so we instead look - # at the git tag. - version = dep.get('tag', dep.get('version')) - else: - raise ValueError("Unexpected type for dependency: " + str(dep)) +def _stable_version(raw: str, *, dependency: str, exact: bool = False) -> str: + value = raw.strip() + if exact and not value.startswith("="): + raise ValueError(f"Dependency '{dependency}' is not exact: {raw}") + value = value.removeprefix("=").removeprefix("v") + if STABLE_VERSION.fullmatch(value) is None: + raise ValueError(f"Dependency '{dependency}' is not stable: {raw}") + return value - if "beta" in version: - found_preview_lance = True - print(f"Dependency '{name}' is a preview version: {version}") -with open("python/pyproject.toml", "rb") as f: - py_proj_data = tomllib.load(f) +def rust_lance_version(repo_root: Path) -> str: + with (repo_root / "Cargo.toml").open("rb") as cargo_file: + dependencies = tomllib.load(cargo_file)["workspace"]["dependencies"] - for dep in py_proj_data["project"]["dependencies"]: - if dep.startswith("pylance"): - if "b" in dep: - found_preview_lance = True - print(f"Dependency '{dep}' is a preview version") - break # Only one pylance dependency + versions: dict[str, str] = {} + for name, dependency in dependencies.items(): + if name != "lance" and not name.startswith("lance-"): + continue -if found_preview_lance: - raise ValueError("Found preview version of Lance in dependencies") + if isinstance(dependency, str): + raw_version = dependency + elif isinstance(dependency, dict): + forbidden_sources = [ + source + for source in ("git", "path", "branch", "rev", "tag") + if source in dependency + ] + if forbidden_sources: + joined = ", ".join(forbidden_sources) + raise ValueError( + f"Dependency '{name}' uses unpublished source fields: {joined}" + ) + raw_version = dependency.get("version") + if raw_version is None: + raise ValueError(f"Dependency '{name}' has no version") + else: + raise TypeError(f"Dependency '{name}' has an unexpected definition") + + versions[name] = _stable_version(raw_version, dependency=name, exact=True) + + if not versions: + raise ValueError("No Rust Lance dependencies found") + unique_versions = set(versions.values()) + if len(unique_versions) != 1: + details = ", ".join(f"{name}={version}" for name, version in versions.items()) + raise ValueError(f"Rust Lance dependency versions do not match: {details}") + return unique_versions.pop() + + +def python_lance_version(repo_root: Path) -> str: + with (repo_root / "python" / "pyproject.toml").open("rb") as pyproject_file: + pyproject = tomllib.load(pyproject_file) + + requirements = pyproject["project"]["optional-dependencies"]["tests"] + pylance_requirements = [ + requirement for requirement in requirements if requirement.startswith("pylance") + ] + if len(pylance_requirements) != 1: + raise ValueError( + "Expected exactly one pylance requirement in the Python test dependencies" + ) + + requirement = pylance_requirements[0] + prefix = "pylance==" + if not requirement.startswith(prefix): + raise ValueError(f"Python test dependency is not exact: {requirement}") + return _stable_version(requirement[len(prefix) :], dependency="pylance") + + +def java_lance_version(repo_root: Path) -> str: + pom_root = ET.parse(repo_root / "java" / "pom.xml").getroot() + versions = [ + element.text + for element in pom_root.iter() + if element.tag.rsplit("}", 1)[-1] == "lance-core.version" + ] + if len(versions) != 1 or versions[0] is None: + raise ValueError("Expected exactly one Java lance-core.version property") + return _stable_version(versions[0], dependency="Java lance-core") + + +def validate(repo_root: Path) -> str: + versions = { + "Rust": rust_lance_version(repo_root), + "Python": python_lance_version(repo_root), + "Java": java_lance_version(repo_root), + } + if len(set(versions.values())) != 1: + details = ", ".join(f"{sdk}={version}" for sdk, version in versions.items()) + raise ValueError( + f"Lance dependency versions do not match across SDKs: {details}" + ) + return versions["Rust"] + + +if __name__ == "__main__": + version = validate(Path(__file__).resolve().parents[1]) + print(f"Validated published stable Lance v{version} across Rust, Python, and Java")