blob: 428fbac0eef615d4c8b33d97ccd85d9dccea451e [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Exercise actual distributions without importing runtime/native dependencies.
Run with ``python dev/test_packaging.py`` after installing the build tools.
The same suite runs on Python 3.6 and modern Python in CI.
"""
import email
import os
from pathlib import Path
import runpy
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
import zipfile
from packaging.markers import default_environment
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from packaging.version import Version
try:
import tomllib
except ImportError:
import tomli as tomllib
def command(args, cwd, env=None):
result = subprocess.run(args, cwd=str(cwd), env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if result.returncode:
raise AssertionError(result.stdout.decode("utf-8", errors="replace")[-8000:])
return result.stdout.decode("utf-8")
def wheel_contents(path):
with zipfile.ZipFile(str(path)) as archive:
names = archive.namelist()
metadata = email.message_from_bytes(archive.read(next(
name for name in names if name.endswith(".dist-info/METADATA"))))
full_version = archive.read("pypaimon/_full_version").decode().strip()
return names, metadata, full_version
class PackagingTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temp = tempfile.TemporaryDirectory(prefix="pypaimon-packaging-")
cls.root = Path(cls.temp.name)
cls.source = cls.root / "source"
project = Path(__file__).resolve().parent.parent
shutil.copytree(str(project), str(cls.source), ignore=shutil.ignore_patterns(
"build", "dist", "*.egg-info", "__pycache__", ".pytest_cache", ".venv"))
cls.env = os.environ.copy()
for key in ("GIT_DIR", "GIT_WORK_TREE", "PYPAIMON_BUILD_VERSION"):
cls.env.pop(key, None)
cls.env.update(GIT_AUTHOR_DATE="2020-01-02T03:04:05+00:00",
GIT_COMMITTER_DATE="2020-01-02T03:04:05+00:00")
cls.commit = cls.init_git(cls.source)
base = runpy.run_path(str(cls.source / "pypaimon/_version.py"))["VERSION"]
cls.version = str(Version(base + "20200102" if base.endswith(".dev") else base))
with (cls.source / "pyproject.toml").open("rb") as config:
cls.config = tomllib.load(config)
cls.dist = cls.root / "custom-output"
cls.build(cls.source, cls.dist)
cls.wheel = next(cls.dist.glob("*.whl"))
cls.sdist = next(cls.dist.glob("*.tar.gz"))
@classmethod
def tearDownClass(cls):
cls.temp.cleanup()
@classmethod
def init_git(cls, root):
for args in (["init", "-q"], ["config", "user.name", "Packaging Test"],
["config", "user.email", "test@example.com"], ["add", "."],
["commit", "-qm", "test source"]):
command(["git"] + args, root, cls.env)
return command(["git", "rev-parse", "HEAD"], root, cls.env).strip()
@classmethod
def build(cls, source, output, extra=(), env=None):
command([sys.executable, "-m", "build", "--no-isolation", "--outdir",
str(output)] + list(extra), source, env or cls.env)
def test_sdist_and_wheel_share_version_and_commit(self):
self.assertEqual(2, len(list(self.dist.iterdir())))
self.assertEqual("pypaimon-{}.tar.gz".format(self.version), self.sdist.name)
self.assertEqual("pypaimon-{}-py3-none-any.whl".format(self.version), self.wheel.name)
_, metadata, full_version = wheel_contents(self.wheel)
self.assertEqual(self.version, metadata["Version"])
self.assertEqual("python-{}-{}".format(self.version, self.commit), full_version)
with tarfile.open(str(self.sdist)) as archive:
prefix = "pypaimon-{}/".format(self.version)
self.assertEqual(full_version, archive.extractfile(
prefix + "pypaimon/_full_version").read().decode().strip())
def test_distribution_contents(self):
names, metadata, _ = wheel_contents(self.wheel)
self.assertEqual(">=3.6", metadata["Requires-Python"])
self.assertFalse(any(name.startswith("pypaimon/tests/")
for name in names))
self.assertFalse(any(name.endswith((".so", ".pyd", ".jar")) for name in names))
self.assertIn("pypaimon/_version.py", names)
execution_files = (
"pypaimon/table/source/search_execution.py",
"pypaimon/ray/search_execution.py",
"pypaimon/execution/native/read/search_execution.py",
"pypaimon/execution/python/read/search/distributed_search.py",
"pypaimon/execution/python/read/search/distributed_vector_search.py",
"pypaimon/execution/python/read/search/distributed_full_text_search.py",
"pypaimon/execution/python/map_blob_key_serializer.py",
"pypaimon/execution/python/write/external_path_provider.py",
"pypaimon/execution/python/read/predicate_json_parser.py",
"pypaimon/execution/python/read/blob_view_lookup.py",
"pypaimon/execution/python/read/interval_partition.py",
"pypaimon/execution/python/read/map_shared_shredding.py",
"pypaimon/execution/python/read/variant_shredding.py",
"pypaimon/execution/python/write/dynamic_bucket.py",
"pypaimon/execution/python/write/merge_condition.py",
"pypaimon/execution/python/write/merge_transform.py",
"pypaimon/schema/data_type_casts.py",
"pypaimon/utils/numeric_utils.py",
"pypaimon/utils/temporal_utils.py",
"pypaimon/utils/temporal_string.py",
"pypaimon/utils/row_to_string.py",
"pypaimon/execution/python/casting/__init__.py",
"pypaimon/execution/python/casting/data_type_casts.py",
"pypaimon/execution/python/casting/schema_evolution.py",
"pypaimon/execution/python/casting/decimal_cast.py",
"pypaimon/execution/python/casting/temporal_cast.py",
"pypaimon/execution/python/casting/temporal_string.py",
"pypaimon/execution/python/write/partition_spec.py",
"pypaimon/execution/python/deletionvectors/__init__.py",
"pypaimon/execution/python/deletionvectors/deletion_vector.py",
"pypaimon/execution/python/deletionvectors/bitmap_deletion_vector.py",
"pypaimon/execution/python/deletionvectors/apply_deletion_vector_reader.py",
"pypaimon/write/backend_dispatch.py",
"pypaimon/read/read_dispatch.py",
"pypaimon/read/read_support.py",
"pypaimon/read/read_explain.py",
"pypaimon/globalindex/index_dispatch.py",
"pypaimon/table/source/search_dispatch.py",
"pypaimon/write/update_dispatch.py",
"pypaimon/write/write_dispatch.py",
"pypaimon/globalindex/vindex_input.py",
"pypaimon/write/input_adapter.py",
"pypaimon/write/merge_condition.py",
"pypaimon/write/merge_transform.py",
"pypaimon/read/datasource/torch_dataset.py",
"pypaimon/read/datasource/ray_datasource.py",
"pypaimon/read/datasource/split_provider.py",
"pypaimon/write/ray_datasink.py",
"pypaimon/execution/python/read/table_read.py",
"pypaimon/execution/python/read/table_scan.py",
"pypaimon/execution/python/read/auth_support.py",
"pypaimon/execution/python/read/scan_distribution.py",
"pypaimon/execution/python/read/format_table_read.py",
"pypaimon/execution/python/read/format_table_scan.py",
"pypaimon/execution/python/read/object_table_read.py",
"pypaimon/execution/python/read/object_table_scan.py",
"pypaimon/table/system/system_table_read.py",
"pypaimon/table/system/system_table_scan.py",
"pypaimon/execution/python/merge_engine_dispatch.py",
"pypaimon/execution/python/read/search/vector_search_read.py",
"pypaimon/execution/python/read/search/factories.py",
"pypaimon/execution/python/read/search/hybrid_search.py",
"pypaimon/ray/vector_index_build.py",
"pypaimon/execution/python/globalindex/btree/btree_index_reader.py",
"pypaimon/execution/python/globalindex/btree/block_handle.py",
"pypaimon/execution/python/globalindex/block_compression.py",
"pypaimon/execution/python/globalindex/key_serializer.py",
"pypaimon/execution/python/index/pk/primary_key_index_source_policy.py",
"pypaimon/execution/python/index/pksorted/pk_sorted_bucket_index_state.py",
"pypaimon/execution/python/index/pksorted/pk_sorted_index_group.py",
"pypaimon/execution/python/globalindex/vindex/vindex_vector_global_index_reader.py",
"pypaimon/execution/python/write/table_write.py",
"pypaimon/execution/python/write/table_commit.py",
"pypaimon/execution/python/write/format_table_write.py",
"pypaimon/execution/python/write/format_table_commit.py",
"pypaimon/execution/python/write/bucket_metadata.py",
"pypaimon/execution/native/read/table_read.py",
"pypaimon/execution/native/read/plan.py",
"pypaimon/execution/native/read/system_table.py",
"pypaimon/execution/native/read/vindex_reader.py",
"pypaimon/execution/native/write/native_write.py",
"pypaimon/execution/native/write/native_commit.py",
"pypaimon/execution/native/write/bucket_metadata.py",
"pypaimon/execution/native/write/postpone_bucket.py",
"pypaimon/read/blob_read_support.py",
"pypaimon/read/scan_request.py",
"pypaimon/table/source/search_types.py",
"pypaimon/globalindex/vindex_reader.py",
"pypaimon/write/bucket_planning.py",
"pypaimon/write/commit_options.py",
"pypaimon/write/input_routing.py",
)
for name in execution_files:
self.assertIn(name, names)
for name in names:
if name.startswith("pypaimon/execution/") and name.endswith(".py"):
self.assertIn(name.split("/")[2], ("python", "native", "__init__.py"))
internal_aliases = (
"pypaimon/common/external_path_provider.py",
"pypaimon/common/map_blob_key_serializer.py",
"pypaimon/utils/blob_view_lookup.py",
"pypaimon/read/interval_partition.py",
"pypaimon/casting/__init__.py",
"pypaimon/casting/data_type_casts.py",
"pypaimon/casting/schema_evolution.py",
"pypaimon/casting/decimal_cast.py",
"pypaimon/casting/temporal_cast.py",
"pypaimon/casting/temporal_string.py",
"pypaimon/casting/numeric_utils.py",
"pypaimon/casting/temporal_utils.py",
"pypaimon/casting/row_to_string.py",
"pypaimon/write/partition_spec.py",
"pypaimon/deletionvectors/__init__.py",
"pypaimon/deletionvectors/deletion_vector.py",
"pypaimon/deletionvectors/bitmap_deletion_vector.py",
"pypaimon/deletionvectors/apply_deletion_vector_reader.py",
"pypaimon/execution/python/globalindex/ray_build.py",
"pypaimon/execution/python/read/system_table_read.py",
"pypaimon/execution/python/read/system_table_scan.py",
"pypaimon/common/merge_engine_dispatch.py",
"pypaimon/read/native_plan.py",
"pypaimon/read/native_row_iterator.py",
"pypaimon/table/source/full_text_read.py",
"pypaimon/table/source/vector_search_scan.py",
"pypaimon/table/source/native_search_plan.py",
"pypaimon/table/system/native_system_table.py",
"pypaimon/globalindex/bitmap/__init__.py",
"pypaimon/globalindex/btree/__init__.py",
"pypaimon/globalindex/full_text/__init__.py",
"pypaimon/globalindex/lumina/__init__.py",
"pypaimon/globalindex/vindex/__init__.py",
"pypaimon/globalindex/btree/block_reader.py",
"pypaimon/globalindex/btree_format/block_handle.py",
"pypaimon/globalindex/bitmap/bitmap_index_writer.py",
"pypaimon/globalindex/block_compression.py",
"pypaimon/index/pk/primary_key_index_source_policy.py",
"pypaimon/index/pksorted/pk_sorted_bucket_index_state.py",
"pypaimon/index/pksorted/pk_sorted_index_group.py",
)
for name in internal_aliases:
self.assertNotIn(name, names)
for suffix in ("/LICENSE", "/NOTICE"):
self.assertTrue(any(".dist-info/" in name and name.endswith(suffix)
for name in names))
with tarfile.open(str(self.sdist)) as archive:
names = archive.getnames()
prefix = "pypaimon-{}/".format(self.version)
for name in ("pyproject.toml", "setup.py", "LICENSE", "NOTICE", "README.md"):
self.assertIn(prefix + name, names)
for name in execution_files:
self.assertIn(prefix + name, names)
for name in internal_aliases:
self.assertNotIn(prefix + name, names)
self.assertFalse(any("requirements.txt" in name or "requirements-dev.txt" in name
or name.startswith(prefix + "pypaimon/tests/")
for name in names))
@staticmethod
def active_requirements(values, environment):
result = set()
for value in values:
requirement = Requirement(value)
if requirement.marker is None or requirement.marker.evaluate(environment):
result.add((canonicalize_name(requirement.name), str(requirement.specifier)))
return result
def test_metadata_preserves_core_and_extra_conditions(self):
_, metadata, _ = wheel_contents(self.wheel)
project = self.config["project"]
self.assertEqual(set(project["optional-dependencies"]),
set(metadata.get_all("Provides-Extra")))
for python_version in ("3.6", "3.7", "3.8", "3.11", "3.13"):
for system in ("Linux", "Windows"):
for extra in [""] + list(project["optional-dependencies"]):
with self.subTest(python=python_version, system=system, extra=extra):
environment = default_environment()
environment.update(python_version=python_version,
python_full_version=python_version + ".0",
platform_system=system, extra=extra)
expected = project["dependencies"] + project[
"optional-dependencies"].get(extra, [])
self.assertEqual(self.active_requirements(expected, environment),
self.active_requirements(
metadata.get_all("Requires-Dist"), environment))
def test_readerwriterlock_python_compatibility(self):
_, metadata, _ = wheel_contents(self.wheel)
requirements = [Requirement(value) for value in metadata.get_all("Requires-Dist")]
locks = [requirement for requirement in requirements
if canonicalize_name(requirement.name) == "readerwriterlock"]
for python_version, supports_1_0_10 in (("3.6", False), ("3.7", False), ("3.8", True), ("3.13", True)):
with self.subTest(python=python_version):
environment = default_environment()
environment.update(python_version=python_version,
python_full_version=python_version + ".0", extra="")
active = [requirement for requirement in locks
if requirement.marker is None or requirement.marker.evaluate(environment)]
self.assertEqual(1, len(active))
self.assertIn("1.0.9", active[0].specifier)
self.assertEqual(supports_1_0_10, "1.0.10" in active[0].specifier)
self.assertNotIn("2.0", active[0].specifier)
def test_python_ci_dependencies_do_not_pull_native_sql(self):
resolve = runpy.run_path(str(self.source / "dev/install.py"))["resolve_group"]
extras = self.config["project"]["optional-dependencies"]
def dependencies(group):
pending = list(resolve(self.config["dependency-groups"], group))
names, expanded = set(), set()
while pending:
requirement = Requirement(pending.pop())
names.add(canonicalize_name(requirement.name))
if canonicalize_name(requirement.name) == "pypaimon":
for extra in requirement.extras - expanded:
pending.extend(extras[extra])
expanded.add(extra)
return names
# Follow local extras too: the Rust extension previously entered the
# ordinary Python and Torch environments indirectly through [sql].
for group in ("torch", "python-tests", "legacy"):
with self.subTest(group=group):
self.assertNotIn("pypaimon-rust", dependencies(group))
self.assertIn("pypaimon-rust", dependencies("dev"))
def test_dev_group_reuses_product_extras(self):
resolve = runpy.run_path(str(self.source / "dev/install.py"))["resolve_group"]
requirements = [Requirement(value) for value in resolve(
self.config["dependency-groups"], "dev")]
extras = set().union(*(requirement.extras for requirement in requirements
if requirement.name == "pypaimon"))
self.assertTrue({"ray", "sql", "vindex", "full-text"}.issubset(extras))
# Product constraints belong to extras, not a second developer copy.
names = {requirement.name for requirement in requirements}
self.assertFalse(names.intersection({"datafusion", "paimon-vindex", "requests", "h5py"}))
def test_sdist_rebuild_preserves_provenance_in_another_git_repo(self):
outer = self.root / "downstream"
outer.mkdir()
(outer / "README").write_text("Unrelated downstream repository")
downstream_commit = self.init_git(outer)
self.assertNotEqual(self.commit, downstream_commit)
with tarfile.open(str(self.sdist)) as archive:
# This archive was just built from our test source.
archive.extractall(str(outer))
output = self.root / "rebuilt"
self.build(outer / "pypaimon-{}".format(self.version), output, extra=("--wheel",))
_, metadata, full_version = wheel_contents(next(output.glob("*.whl")))
self.assertEqual(self.version, metadata["Version"])
self.assertEqual("python-{}-{}".format(self.version, self.commit), full_version)
def test_release_override_applies_to_both_formats(self):
env = dict(self.env, PYPAIMON_BUILD_VERSION="2.2rc1")
output = self.root / "release-candidate"
self.build(self.source, output, env=env)
self.assertEqual({"pypaimon-2.2rc1.tar.gz", "pypaimon-2.2rc1-py3-none-any.whl"},
{path.name for path in output.iterdir()})
_, metadata, full_version = wheel_contents(next(output.glob("*.whl")))
self.assertEqual("2.2rc1", metadata["Version"])
self.assertEqual("python-2.2rc1-{}".format(self.commit), full_version)
self.assertEqual("", command(["git", "diff", "--", "pypaimon/_version.py"],
self.source, self.env))
def test_invalid_release_override_fails(self):
env = dict(self.env, PYPAIMON_BUILD_VERSION="not a version")
result = subprocess.run([sys.executable, "-m", "build", "--no-isolation", "--sdist"],
cwd=str(self.source), env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
self.assertNotEqual(0, result.returncode)
if __name__ == "__main__":
unittest.main(verbosity=2)