blob: afcdb51e0c14495eae198c39c79a814400062623 [file]
#!/usr/bin/env python3
# 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.
"""Behavioral checks for CI routing and the complete PR/push diff."""
import contextlib
import io
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import plan
class SelectionTest(unittest.TestCase):
def suites(self, paths):
return {lane['suite'] for lane in plan.select(paths)['matrix']['include']}
def test_spark_keeps_common_docs_and_interoperability_tests(self):
suites = self.suites(['paimon-spark/paimon-spark-common/src/main/scala/Change.scala'])
self.assertEqual(suites, {'core', 'spark3', 'spark4', 'e2e-flink1'})
def test_flink_keeps_hive_iceberg_and_both_e2e_versions(self):
suites = self.suites(['paimon-flink/paimon-flink-common/src/main/java/Change.java'])
self.assertEqual(suites, {'core', 'flink1-common', 'flink1-connectors',
'flink2', 'e2e-flink1', 'e2e-flink2', 'iceberg'})
def test_shared_and_unknown_changes_run_all_java(self):
for path in ['paimon-core/Change.java', 'paimon-api/Change.java',
'paimon-format/pom.xml', 'paimon-new-module/pom.xml',
'.mvn/jvm.config', '.scalafmt.conf', 'copyright.txt']:
with self.subTest(path=path):
self.assertEqual(self.suites([path]), plan.ALL_JAVA)
def test_parent_pom_also_validates_python_build(self):
self.assertTrue(plan.select(['pom.xml'])['python'])
self.assertEqual(self.suites(['pom.xml']), plan.ALL_JAVA)
def test_docs_and_markdown_do_not_start_maven(self):
result = plan.select(['docs/start.md', 'README.md'])
self.assertTrue(result['docs'])
self.assertFalse(result['java'])
self.assertFalse(result['python'])
self.assertFalse(result['licensing'])
self.assertFalse(plan.select(['paimon-python/README.md'])['python'])
def test_mixed_changes_take_union(self):
self.assertEqual(self.suites(['docs/intro.md', 'paimon-spark/pom.xml',
'paimon-flink/pom.xml']),
{'core', 'spark3', 'spark4', 'flink1-common',
'flink1-connectors', 'flink2', 'e2e-flink1',
'e2e-flink2', 'iceberg'})
result = plan.select(['paimon-python/setup.py', 'paimon-core/Change.java'])
self.assertTrue(result['python'])
self.assertTrue(result['java'])
def test_java_python_fixtures_run_python(self):
for module, name in [('paimon-core', 'JavaPyE2ETest'),
('paimon-lance', 'JavaPyLanceE2ETest'),
('paimon-lumina', 'JavaPyLuminaE2ETest')]:
result = plan.select([module + '/src/test/java/org/apache/paimon/' + name + '.java'])
self.assertTrue(result['python'])
self.assertFalse(result['java'])
def test_rest_contract_runs_java_and_python(self):
for path in sorted(plan.REST_SPECS):
with self.subTest(path=path):
result = plan.select([path])
self.assertTrue(result['docs'] and result['python'])
self.assertEqual(self.suites([path]), {'core'})
result = plan.select([plan.OPENAPI_V4_ACTIONS])
self.assertTrue(result['python'] and result['licensing'])
self.assertEqual(self.suites([plan.OPENAPI_V4_ACTIONS]), plan.ALL_JAVA)
self.assertFalse(plan.select(['paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java'])['python'])
def test_leaf_modules_and_workflows(self):
self.assertEqual(self.suites(['paimon-eslib/pom.xml']), {'eslib'})
self.assertEqual(self.suites(['paimon-e2e-tests/pom.xml']),
{'e2e-flink1', 'e2e-flink2'})
self.assertTrue(plan.select(['.github/workflows/ci-python.yml'])['python'])
self.assertFalse(plan.select(['.github/workflows/ci-python.yml'])['java'])
self.assertTrue(plan.select(['.github/workflows/ci-docs.yml'])['docs'])
self.assertFalse(plan.select(['.github/workflows/release-java.yml'])['java'])
def test_ci_logic_and_manual_run_validate_every_consumer(self):
for result in [plan.select(['tools/ci/plan.py']),
plan.select(['.github/workflows/ci-java.yml']),
plan.select([], full=True)]:
self.assertTrue(all(result[key] for key in ('java', 'python', 'docs', 'licensing')))
self.assertEqual(len(result['matrix']['include']), 13)
def test_empty_diff_does_not_run_expensive_jobs(self):
result = plan.select([])
self.assertFalse(any(result[key] for key in ('java', 'python', 'docs', 'licensing')))
self.assertEqual(result['matrix'], {'include': []})
def test_size_guard_handles_spaces_deletions_and_symlinks(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
large = root / 'large file.txt'
large.write_bytes(b'x' * (1048576 + 1))
(root / 'link').symlink_to(large)
plan.check_file_sizes(['deleted.txt', 'link'], root)
with self.assertRaisesRegex(ValueError, 'large file'):
plan.check_file_sizes(['large file.txt'], root)
class GitDiffTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.previous = Path.cwd()
os.chdir(self.temp.name)
self.addCleanup(os.chdir, self.previous)
self.git('init', '-q')
self.git('config', 'user.email', 'ci-test@example.invalid')
self.git('config', 'user.name', 'CI test')
self.commit('base.txt', 'base')
self.base = self.git('rev-parse', 'HEAD')
def git(self, *args):
return subprocess.check_output(['git', *args], stderr=subprocess.DEVNULL).decode().strip()
def commit(self, name, content):
path = Path(name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
self.git('add', '--', name)
self.git('commit', '-qm', 'Test commit')
def test_pr_uses_merge_base_instead_of_including_base_branch_changes(self):
self.commit('base-only.txt', 'base change')
base_tip = self.git('rev-parse', 'HEAD')
self.git('checkout', '-q', '--detach', self.base)
self.commit('paimon-spark/name with spaces\nand newline.scala', 'spark')
head = self.git('rev-parse', 'HEAD')
paths = plan.changed_paths('pull_request', {
'pull_request': {'base': {'sha': base_tip}, 'head': {'sha': head}}})
self.assertEqual(paths, ['paimon-spark/name with spaces\nand newline.scala'])
def test_push_includes_all_commits_and_both_sides_of_renames(self):
self.commit('paimon-spark/A.scala', 'spark')
before = self.git('rev-parse', 'HEAD')
Path('paimon-flink').mkdir()
self.git('mv', 'paimon-spark/A.scala', 'paimon-flink/A.java')
self.git('commit', '-qm', 'Move across modules')
self.commit('docs/intro.md', 'docs')
paths = plan.changed_paths('push', {'before': before,
'after': self.git('rev-parse', 'HEAD')})
self.assertEqual(set(paths), {'paimon-spark/A.scala', 'paimon-flink/A.java',
'docs/intro.md'})
def test_large_diffs_do_not_truncate_late_core_changes(self):
for index in range(350):
Path('doc{:03d}.md'.format(index)).write_text('docs')
self.commit('z-new-core/Change.java', 'core')
self.git('add', '.')
self.git('commit', '-qm', 'Many docs')
paths = plan.changed_paths('push', {'before': self.base,
'after': self.git('rev-parse', 'HEAD')})
self.assertEqual(len(paths), 351)
self.assertTrue(plan.select(paths)['java'])
def test_new_branch_missing_commits_and_manual_run_fall_back(self):
self.assertIsNone(plan.changed_paths('push', {'before': '0' * 40, 'after': self.base}))
self.assertIsNone(plan.changed_paths('workflow_dispatch', {}))
with patch('subprocess.check_output', side_effect=subprocess.CalledProcessError(128, 'git')):
self.assertIsNone(plan.changed_paths('push', {'before': 'a' * 40, 'after': self.base}))
def run_pr_plan(self, base, head):
Path('event.json').write_text(json.dumps({
'pull_request': {'base': {'sha': base}, 'head': {'sha': head}}}))
env = {'GITHUB_EVENT_NAME': 'pull_request', 'GITHUB_EVENT_PATH': 'event.json',
'GITHUB_OUTPUT': 'output.txt', 'GITHUB_STEP_SUMMARY': 'summary.md'}
with patch.dict(os.environ, env), contextlib.redirect_stdout(io.StringIO()) as log:
plan.main()
outputs = dict(line.split('=', 1) for line in Path('output.txt').read_text().splitlines())
return {key: json.loads(value) for key, value in outputs.items()}, log.getvalue()
def test_pr_unavailable_diff_runs_full_ci_with_unchanged_oversized_file(self):
self.commit('docs/static/old image.png', 'x' * (1048576 + 1))
base = self.git('rev-parse', 'HEAD')
self.commit('paimon-spark/Change.scala', 'spark')
head = self.git('rev-parse', 'HEAD')
git_output = subprocess.check_output
def unavailable_diff(command, *args, **kwargs):
if command[:2] == ['git', 'diff']:
raise subprocess.CalledProcessError(128, command)
return git_output(command, *args, **kwargs)
with patch('subprocess.check_output', side_effect=unavailable_diff):
outputs, log = self.run_pr_plan(base, head)
self.assertTrue(all(outputs[key] for key in ('java', 'python', 'docs', 'licensing')))
self.assertEqual(len(outputs['matrix']['include']), 13)
summary = Path('summary.md').read_text()
self.assertIn('Full run (manual or unavailable diff).', summary)
self.assertIn('Skipping changed-file size check', log)
self.assertIn('Skipping changed-file size check', summary)
def test_pr_size_guard_ignores_unchanged_but_rejects_changed_oversized_files(self):
self.commit('docs/static/old image.png', 'x' * (1048576 + 1))
base = self.git('rev-parse', 'HEAD')
self.commit('paimon-spark/Change.scala', 'spark')
outputs, _ = self.run_pr_plan(base, self.git('rev-parse', 'HEAD'))
self.assertEqual(len(outputs['matrix']['include']), 6)
for path in ('docs/static/old image.png', 'docs/static/new image.png'):
with self.subTest(path=path):
base = self.git('rev-parse', 'HEAD')
self.commit(path, 'y' * (1048576 + 1))
with self.assertRaisesRegex(ValueError, 'Changed file exceeds 1 MiB') as error:
self.run_pr_plan(base, self.git('rev-parse', 'HEAD'))
self.assertIn(path, str(error.exception))
def test_github_outputs_are_valid_json_and_summary_explains_selection(self):
self.commit('paimon-spark/Change.scala', 'spark')
Path('event.json').write_text(json.dumps({'before': self.base,
'after': self.git('rev-parse', 'HEAD')}))
env = {'GITHUB_EVENT_NAME': 'push', 'GITHUB_EVENT_PATH': 'event.json',
'GITHUB_OUTPUT': 'output.txt', 'GITHUB_STEP_SUMMARY': 'summary.md'}
with patch.dict(os.environ, env), contextlib.redirect_stdout(None):
plan.main()
outputs = dict(line.split('=', 1) for line in Path('output.txt').read_text().splitlines())
self.assertTrue(json.loads(outputs['java']))
self.assertFalse(json.loads(outputs['python']))
self.assertEqual(len(json.loads(outputs['matrix'])['include']), 6)
self.assertIn('Spark interoperability', Path('summary.md').read_text())
if __name__ == '__main__':
unittest.main()