Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
21 changes: 7 additions & 14 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,11 @@ jobs:
name: linux-wheels
path: python/target/wheels/pylance-*.whl

forward-compat:
compat:
needs: linux
timeout-minutes: 60
runs-on: ubuntu-24.04
name: Forward Compatibility Tests (${{ matrix.lance-version }})
strategy:
matrix:
lance-version: ["0.16.0", "0.30.0", "0.36.0"]
name: Compatibility Tests
defaults:
run:
shell: bash
Expand All @@ -154,16 +152,11 @@ jobs:
- name: Install dependencies
run: |
pip install $(ls wheels/pylance-*.whl)[tests,ray]
- name: Generate forward compatibility files
env:
PYTHONPATH: python/tests
run: python -m forward_compat.datagen
- name: Run forward compatibility tests (pylance ${{ matrix.lance-version }})
- name: Run compatibility tests
run: |
python -m venv venv
source venv/bin/activate
pip install pytest pylance==${{ matrix.lance-version }}
pytest python/tests/forward_compat --run-forward
make compattest
env:
COMPAT_TEMP_VENV: 1

linux-arm:
timeout-minutes: 45
Expand Down
8 changes: 4 additions & 4 deletions python/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ doctest:
pytest --doctest-modules $(PYTEST_ARGS) python/lance
.PHONY: doctest

compattest:
pytest --run-compat $(PYTEST_ARGS) python/tests/compat
.PHONY: compattest

format: format-python
cargo fmt
.PHONY: format
Expand All @@ -24,10 +28,6 @@ build:
maturin develop
.PHONY: build

clean:
rm -rf ./target
.PHONY: clean

format-python:
ruff format python
ruff check --fix python
Expand Down
308 changes: 308 additions & 0 deletions python/python/tests/compat/compat_decorator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The Lance Authors

"""
Compatibility test infrastructure for Lance.

This module provides the @compat_test() decorator and supporting infrastructure
for testing forward and backward compatibility across Lance versions.
"""

import inspect
import json
import subprocess
import sys
from functools import lru_cache

import pytest


@lru_cache(maxsize=1)
def last_stable_release():
"""Returns the latest stable version available on PyPI.

Queries the PyPI JSON API to get the latest stable release of pylance.
Results are cached to avoid repeated network calls.
"""
try:
import urllib.request

with urllib.request.urlopen(
"https://pypi.org/pypi/pylance/json", timeout=5
) as response:
data = json.loads(response.read())
version = data["info"]["version"]
return version
except Exception as e:
# If we can't fetch, return None which will be filtered out
print(
f"Warning: Could not fetch latest stable release from PyPI: {e}",
file=sys.stderr,
)
return None


@lru_cache(maxsize=1)
def last_beta_release():
"""Returns the latest beta version available on fury.io.

Uses pip to query the fury.io index for pre-release versions of pylance.
Results are cached to avoid repeated network calls.
"""
try:
# Use pip index to get versions from fury.io
result = subprocess.run(
[
sys.executable,
"-m",
"pip",
"index",
"versions",
"pylance",
"--pre",
"--extra-index-url",
"https://pypi.fury.io/lancedb/",
],
capture_output=True,
text=True,
timeout=10,
)

if result.returncode == 0:
# Parse output to find available versions
# Output format: "pylance (x.y.z)"
# Available versions: x.y.z.betaN, x.y.z, ...
for line in result.stdout.splitlines():
if "Available versions:" in line:
versions_str = line.split("Available versions:")[1].strip()
versions = [v.strip() for v in versions_str.split(",")]
# Return the first beta/pre-release version
for v in versions:
if "beta" in v or "rc" in v or "a" in v or "b" in v:
return v
# If no pre-release found, return the first version
if versions:
return versions[0]

print(
"Warning: Could not fetch latest beta release from fury.io",
file=sys.stderr,
)
return None

except Exception as e:
print(
f"Warning: Could not fetch latest beta release from fury.io: {e}",
file=sys.stderr,
)
return None


# Fetch versions (cached)
LAST_STABLE_RELEASE = last_stable_release()
LAST_BETA_RELEASE = last_beta_release()


class UpgradeDowngradeTest:
"""Base class for compatibility tests.

Subclasses should implement:
- create(): Create test data/indices with current Lance version
- check_read(): Verify data can be read correctly
- check_write(): Verify data can be written/modified
"""

def create(self):
pass

def check_read(self):
pass

def check_write(self):
pass


# Default versions to test, filtering out any that couldn't be fetched
VERSIONS = [
v
for v in ["0.16.0", "0.30.0", "0.36.0", LAST_STABLE_RELEASE, LAST_BETA_RELEASE]
if v is not None
]


def compat_test(versions=None):
"""Decorator to generate upgrade/downgrade compatibility tests.

This decorator transforms a test class into two parameterized pytest test functions:

1. Downgrade test: Writes with current version, then reads with old version.
2. Upgrade-Downgrade test: Writes with old version, reads with current version,
writes with current version, reads with old version.

The test class should inherit from UpgradeDowngradeTest and implement:
- create(): Write data with the current Lance version
- check_read(): Verify data can be read
- check_write(): Verify data can be written

The class can be parametrized with @pytest.mark.parametrize, and those
parameters will be applied to the generated test functions.

Parameters
----------
versions : list of str, optional
List of Lance versions to test against. Defaults to VERSIONS.

Example
-------
@compat_test()
@pytest.mark.parametrize("file_version", ["1.0", "2.0"])
class BasicTypes(UpgradeDowngradeTest):
def __init__(self, path: Path, file_version: str):
self.path = path
self.file_version = file_version

def create(self):
# Write data
pass

def check_read(self):
# Read and verify data
pass

def check_write(self):
# Write data
pass
"""
if versions is None:
versions = VERSIONS

# Filter out None values (in case some versions couldn't be fetched)
versions = [v for v in versions if v is not None]

# Skip if no valid versions
if not versions:

def decorator(cls):
return cls

return decorator

def decorator(cls):
# Extract existing parametrize marks from the class
existing_params = (
[
m
for m in (
cls.pytestmark
if isinstance(cls.pytestmark, list)
else [cls.pytestmark]
)
if getattr(m, "name", None) == "parametrize"
]
if hasattr(cls, "pytestmark")
else []
)

# Get parameter names from __init__ (excluding 'self' and 'path')
sig = inspect.signature(cls.__init__)
param_names = [p for p in sig.parameters.keys() if p not in ("self", "path")]

# Create test functions dynamically with proper signatures
downgrade_func = _make_test_function(cls, param_names, "downgrade")
upgrade_downgrade_func = _make_test_function(
cls, param_names, "upgrade_downgrade"
)

# Apply version parametrization
downgrade_func = pytest.mark.parametrize("version", versions)(downgrade_func)
upgrade_downgrade_func = pytest.mark.parametrize("version", versions)(
upgrade_downgrade_func
)

# Apply existing parametrize marks
for mark in existing_params:
downgrade_func = pytest.mark.parametrize(*mark.args, **mark.kwargs)(
downgrade_func
)
upgrade_downgrade_func = pytest.mark.parametrize(*mark.args, **mark.kwargs)(
upgrade_downgrade_func
)

# Apply compat marker
downgrade_func = pytest.mark.compat(downgrade_func)
upgrade_downgrade_func = pytest.mark.compat(upgrade_downgrade_func)

# Set function names
downgrade_func.__name__ = f"test_{cls.__name__}_downgrade"
upgrade_downgrade_func.__name__ = f"test_{cls.__name__}_upgrade_downgrade"

# Register test functions in the module where the class is defined
module = sys.modules[cls.__module__]
setattr(module, downgrade_func.__name__, downgrade_func)
setattr(module, upgrade_downgrade_func.__name__, upgrade_downgrade_func)

return cls

return decorator


def _make_test_function(cls, param_names, test_type):
"""Create a test function with the correct signature for pytest.

Parameters
----------
cls : class
The test class to create a function for
param_names : list of str
Names of parameters from the class __init__ (excluding self and path)
test_type : str
Either "downgrade" or "upgrade_downgrade"

Returns
-------
function
Test function with correct signature for pytest
"""
# Build function signature
sig_params = "venv_factory, tmp_path, version"
for param in param_names:
sig_params += f", {param}"

# Build parameter passing to __init__
init_params = ", ".join(param_names) if param_names else ""

# Build function body based on test type
if test_type == "downgrade":
func_body = f'''
def test_func({sig_params}):
"""Test that old Lance version can read data written by current version."""
from pathlib import Path
obj = cls(tmp_path / "data.lance", {init_params})
# Current version: create data
Comment thread
wjones127 marked this conversation as resolved.
obj.create()
# Old version: verify can read
venv = venv_factory.get_venv(version)
venv.execute_method(obj, "check_read")
venv.execute_method(obj, "check_write")
'''
else: # upgrade_downgrade
func_body = f'''
def test_func({sig_params}):
"""Test round-trip compatibility: old -> current -> old."""
from pathlib import Path
obj = cls(tmp_path / "data.lance", {init_params})
venv = venv_factory.get_venv(version)
# Old version: create data
venv.execute_method(obj, "create")
# Current version: read and write
obj.check_read()
obj.check_write()
# Old version: verify can still read
venv.execute_method(obj, "check_read")
venv.execute_method(obj, "check_write")
'''

# Execute to create the function
namespace = {"cls": cls}
exec(func_body, namespace)
return namespace["test_func"]
Loading