blob: e6da2f74be0b378008f6c8e7c35c51462294f192 [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.
from __future__ import annotations
from pathlib import Path
import pytest
from ci.prek.common_prek_utils import (
ConsoleDiff,
check_list_sorted,
get_imports_from_file,
get_provider_base_dir_from_path,
get_provider_id_from_path,
initialize_breeze_prek,
insert_documentation,
pre_process_mypy_files,
read_airflow_version,
read_allowed_kubernetes_versions,
temporary_tsc_project,
)
PROVIDERS_AMAZON_S3_PATH = "providers/amazon/hooks/s3.py"
AIRFLOW_MODELS_DAG_PATH = "airflow/models/dag.py"
class TestPreProcessMypyFiles:
def test_excludes_conftest(self):
files = ["tests/conftest.py", "tests/test_foo.py"]
result = pre_process_mypy_files(files)
assert "tests/conftest.py" not in result
assert "tests/test_foo.py" in result
def test_excludes_init(self):
files = ["airflow/__init__.py", AIRFLOW_MODELS_DAG_PATH]
result = pre_process_mypy_files(files)
assert "airflow/__init__.py" not in result
assert AIRFLOW_MODELS_DAG_PATH in result
def test_excludes_both(self):
files = ["conftest.py", "__init__.py", "test_foo.py"]
result = pre_process_mypy_files(files)
assert result == ["test_foo.py"]
def test_empty_list(self):
assert pre_process_mypy_files([]) == []
def test_on_non_main_branch_excludes_providers(self, monkeypatch):
monkeypatch.setenv("DEFAULT_BRANCH", "v2-10-stable")
files = [PROVIDERS_AMAZON_S3_PATH, AIRFLOW_MODELS_DAG_PATH]
result = pre_process_mypy_files(files)
assert PROVIDERS_AMAZON_S3_PATH not in result
assert AIRFLOW_MODELS_DAG_PATH in result
def test_on_main_branch_keeps_providers(self, monkeypatch):
monkeypatch.setenv("DEFAULT_BRANCH", "main")
files = [PROVIDERS_AMAZON_S3_PATH, AIRFLOW_MODELS_DAG_PATH]
result = pre_process_mypy_files(files)
assert PROVIDERS_AMAZON_S3_PATH in result
assert AIRFLOW_MODELS_DAG_PATH in result
def test_no_default_branch_keeps_providers(self, monkeypatch):
monkeypatch.delenv("DEFAULT_BRANCH", raising=False)
files = [PROVIDERS_AMAZON_S3_PATH]
result = pre_process_mypy_files(files)
assert PROVIDERS_AMAZON_S3_PATH in result
class TestGetImportsFromFile:
def test_simple_import(self, write_python_file):
path = write_python_file("import os\nimport sys\n")
result = get_imports_from_file(path, only_top_level=True)
assert "os" in result
assert "sys" in result
def test_from_import(self, write_python_file):
path = write_python_file("from collections import defaultdict\n")
result = get_imports_from_file(path, only_top_level=True)
assert "collections.defaultdict" in result
def test_skips_future_imports(self, write_python_file):
path = write_python_file("from __future__ import annotations\nimport os\n")
result = get_imports_from_file(path, only_top_level=True)
assert not any("__future__" in imp for imp in result)
assert "os" in result
def test_top_level_only_excludes_nested(self, write_python_file):
code = """\
import os
def inner():
import json
"""
path = write_python_file(code)
top_level = get_imports_from_file(path, only_top_level=True)
assert "os" in top_level
assert "json" not in top_level
def test_all_levels_includes_nested(self, write_python_file):
code = """\
import os
def inner():
import json
"""
path = write_python_file(code)
all_level = get_imports_from_file(path, only_top_level=False)
assert "os" in all_level
assert "json" in all_level
def test_multiple_from_imports(self, write_python_file):
path = write_python_file("from pathlib import Path, PurePath\n")
result = get_imports_from_file(path, only_top_level=True)
assert "pathlib.Path" in result
assert "pathlib.PurePath" in result
def test_empty_file(self, write_python_file):
path = write_python_file("")
result = get_imports_from_file(path, only_top_level=True)
assert result == []
class TestInsertDocumentation:
def test_replaces_content_between_header_and_footer(self, write_text_file):
path = write_text_file("before\n<!-- START -->\nold content\n<!-- END -->\nafter\n")
result = insert_documentation(
path,
content=["new line 1\n", "new line 2\n"],
header="<!-- START -->",
footer="<!-- END -->",
)
assert result is True
text = path.read_text()
assert "new line 1" in text
assert "new line 2" in text
assert "old content" not in text
assert "before" in text
assert "after" in text
def test_returns_false_when_content_unchanged(self, write_text_file):
path = write_text_file("before\n<!-- START -->\nkept\n<!-- END -->\nafter\n")
result = insert_documentation(
path,
content=["kept\n"],
header="<!-- START -->",
footer="<!-- END -->",
)
assert result is False
def test_exits_when_header_not_found(self, write_text_file):
path = write_text_file("no markers here\n")
with pytest.raises(SystemExit):
insert_documentation(
path,
content=["anything\n"],
header="<!-- MISSING -->",
footer="<!-- END -->",
)
def test_add_comment_prefixes_lines(self, write_text_file):
path = write_text_file("before\n# START\nold\n# END\nafter\n")
result = insert_documentation(
path,
content=["line one\n", "line two\n"],
header="# START",
footer="# END",
add_comment=True,
)
assert result is True
text = path.read_text()
assert "# line one\n" in text
assert "# line two\n" in text
def test_add_comment_handles_blank_lines(self, write_text_file):
path = write_text_file("# START\nold\n# END\n")
result = insert_documentation(
path,
content=["\n"],
header="# START",
footer="# END",
add_comment=True,
)
assert result is True
text = path.read_text()
assert "#\n" in text
def test_preserves_header_and_footer_lines(self, write_text_file):
path = write_text_file("<!-- START -->\nold\n<!-- END -->\n")
insert_documentation(
path,
content=["new\n"],
header="<!-- START -->",
footer="<!-- END -->",
)
text = path.read_text()
assert "<!-- START -->" in text
assert "<!-- END -->" in text
def test_header_with_leading_whitespace(self, write_text_file):
path = write_text_file(" <!-- START -->\nold\n <!-- END -->\n")
result = insert_documentation(
path,
content=["new\n"],
header="<!-- START -->",
footer="<!-- END -->",
)
assert result is True
assert "new" in path.read_text()
def test_multiple_content_lines(self, write_text_file):
path = write_text_file("header line\n## BEGIN\nreplaced\n## FINISH\nfooter line\n")
insert_documentation(
path,
content=["a\n", "b\n", "c\n"],
header="## BEGIN",
footer="## FINISH",
)
text = path.read_text()
assert "a\nb\nc\n" in text
assert "replaced" not in text
assert "header line" in text
assert "footer line" in text
class TestReadAirflowVersion:
def test_returns_version_string(self):
version = read_airflow_version()
assert isinstance(version, str)
# Airflow version should look like X.Y.Z or X.Y.Z.devN
parts = version.split(".")
assert len(parts) >= 3
assert parts[0].isdigit()
assert parts[1].isdigit()
class TestReadAllowedKubernetesVersions:
def test_returns_list_of_versions(self):
versions = read_allowed_kubernetes_versions()
assert isinstance(versions, list)
assert len(versions) > 0
def test_versions_have_no_v_prefix(self):
versions = read_allowed_kubernetes_versions()
for v in versions:
assert not v.startswith("v"), f"Version {v!r} should not have 'v' prefix"
def test_versions_look_like_semver(self):
versions = read_allowed_kubernetes_versions()
for v in versions:
parts = v.split(".")
assert len(parts) >= 2, f"Version {v!r} should have at least major.minor"
assert parts[0].isdigit()
assert parts[1].isdigit()
class TestConsoleDiff:
def test_dump_added_lines(self):
diff = ConsoleDiff()
lines = list(diff._dump("+", ["line1", "line2"], 0, 2))
assert lines == ["[green]+ line1[/]", "[green]+ line2[/]"]
def test_dump_removed_lines(self):
diff = ConsoleDiff()
lines = list(diff._dump("-", ["line1"], 0, 1))
assert lines == ["[red]- line1[/]"]
def test_dump_unchanged_lines(self):
diff = ConsoleDiff()
lines = list(diff._dump(" ", ["line1", "line2"], 0, 2))
assert lines == [" line1", " line2"]
def test_dump_range(self):
diff = ConsoleDiff()
lines = list(diff._dump("+", ["a", "b", "c", "d"], 1, 3))
assert lines == ["[green]+ b[/]", "[green]+ c[/]"]
class TestCheckListSorted:
def test_sorted_list_returns_true(self):
errors: list[str] = []
result = check_list_sorted(["a", "b", "c"], "test list", errors)
assert result is True
assert errors == []
def test_unsorted_list_returns_false(self):
errors: list[str] = []
result = check_list_sorted(["c", "a", "b"], "test list", errors)
assert result is False
assert len(errors) == 1
assert "not sorted" in errors[0]
def test_duplicates_returns_false(self):
errors: list[str] = []
result = check_list_sorted(["a", "a", "b"], "test list", errors)
assert result is False
assert len(errors) == 1
def test_empty_list_returns_true(self):
errors: list[str] = []
result = check_list_sorted([], "empty", errors)
assert result is True
assert errors == []
def test_single_element_returns_true(self):
errors: list[str] = []
result = check_list_sorted(["only"], "single", errors)
assert result is True
assert errors == []
class TestGetProviderIdFromPath:
def test_simple_provider(self, create_provider_tree):
file_path = create_provider_tree("providers/amazon")
result = get_provider_id_from_path(file_path)
assert result == "amazon"
def test_nested_provider(self, create_provider_tree):
file_path = create_provider_tree("providers/apache/hive")
result = get_provider_id_from_path(file_path)
assert result == "apache.hive"
def test_no_provider_yaml(self, tmp_path):
some_dir = tmp_path / "no_provider"
some_dir.mkdir()
test_file = some_dir / "file.py"
test_file.touch()
result = get_provider_id_from_path(test_file)
assert result is None
def test_no_providers_parent(self, tmp_path):
# provider.yaml exists but no "providers" parent directory
some_dir = tmp_path / "something" / "else"
some_dir.mkdir(parents=True)
(some_dir / "provider.yaml").touch()
test_file = some_dir / "file.py"
test_file.touch()
result = get_provider_id_from_path(test_file)
assert result is None
class TestGetProviderBaseDirFromPath:
def test_finds_provider_dir(self, tmp_path):
provider_dir = tmp_path / "providers" / "amazon"
provider_dir.mkdir(parents=True)
(provider_dir / "provider.yaml").touch()
sub_file = provider_dir / "hooks" / "s3.py"
sub_file.parent.mkdir()
sub_file.touch()
result = get_provider_base_dir_from_path(sub_file)
assert result == provider_dir
def test_returns_none_without_provider_yaml(self, tmp_path):
some_dir = tmp_path / "no_provider"
some_dir.mkdir()
test_file = some_dir / "file.py"
test_file.touch()
result = get_provider_base_dir_from_path(test_file)
assert result is None
def test_finds_nearest_provider_yaml(self, tmp_path):
outer = tmp_path / "providers" / "google"
inner = outer / "cloud"
inner.mkdir(parents=True)
(outer / "provider.yaml").touch()
test_file = inner / "hooks.py"
test_file.touch()
result = get_provider_base_dir_from_path(test_file)
assert result == outer
class TestInitializeBreezePrek:
def test_raises_when_not_main(self):
with pytest.raises(SystemExit, match="intended to be executed"):
initialize_breeze_prek("some_module", "script.py")
def test_exits_when_skip_env_set(self, monkeypatch):
monkeypatch.setenv("SKIP_BREEZE_PREK_HOOKS", "1")
with pytest.raises(SystemExit) as exc_info:
initialize_breeze_prek("__main__", "script.py")
assert exc_info.value.code == 0
def test_exits_when_breeze_not_found(self, monkeypatch):
monkeypatch.delenv("SKIP_BREEZE_PREK_HOOKS", raising=False)
monkeypatch.setattr("shutil.which", lambda _: None)
with pytest.raises(SystemExit) as exc_info:
initialize_breeze_prek("__main__", "script.py")
assert exc_info.value.code == 1
class TestTemporaryTscProject:
def test_creates_temp_tsconfig(self, tmp_path):
tsconfig = tmp_path / "tsconfig.json"
tsconfig.write_text("{}")
with temporary_tsc_project(tsconfig, ["src/app.ts", "src/main.ts"]) as temp:
content = Path(temp.name).read_text()
assert '"src/app.ts"' in content
assert '"src/main.ts"' in content
assert f"./{tsconfig.name}" in content
def test_raises_when_tsconfig_missing(self, tmp_path):
missing = tmp_path / "nonexistent.json"
with pytest.raises(RuntimeError, match="Cannot find"):
with temporary_tsc_project(missing, []):
pass
def test_extends_original_tsconfig(self, tmp_path):
tsconfig = tmp_path / "tsconfig.base.json"
tsconfig.write_text("{}")
with temporary_tsc_project(tsconfig, ["file.ts"]) as temp:
content = Path(temp.name).read_text()
assert f'"extends": "./{tsconfig.name}"' in content
assert '"include": ["file.ts"]' in content