1
0
mirror of synced 2026-01-26 22:02:03 -05:00
Files
airbyte/airbyte-integrations/bases/source-acceptance-test/source_acceptance_test/conftest.py

359 lines
16 KiB
Python

#
# Copyright (c) 2022 Airbyte, Inc., all rights reserved.
#
import copy
import json
import logging
import os
from glob import glob
from logging import Logger
from pathlib import Path
from subprocess import STDOUT, check_output, run
from typing import Any, List, MutableMapping, Optional, Set
import pytest
from airbyte_cdk.models import AirbyteRecordMessage, AirbyteStream, ConfiguredAirbyteCatalog, ConnectorSpecification, Type
from docker import errors
from source_acceptance_test.base import BaseTest
from source_acceptance_test.config import Config, EmptyStreamConfiguration, ExpectedRecordsConfig
from source_acceptance_test.tests import TestBasicRead
from source_acceptance_test.utils import (
ConnectorRunner,
SecretDict,
build_configured_catalog_from_custom_catalog,
build_configured_catalog_from_discovered_catalog_and_empty_streams,
filter_output,
load_config,
load_yaml_or_json_path,
)
@pytest.fixture(name="acceptance_test_config", scope="session")
def acceptance_test_config_fixture(pytestconfig) -> Config:
"""Fixture with test's config"""
return load_config(pytestconfig.getoption("--acceptance-test-config", skip=True))
@pytest.fixture(name="base_path")
def base_path_fixture(pytestconfig, acceptance_test_config) -> Path:
"""Fixture to define base path for every path-like fixture"""
if acceptance_test_config.base_path:
return Path(acceptance_test_config.base_path).absolute()
return Path(pytestconfig.getoption("--acceptance-test-config")).absolute()
@pytest.fixture(name="test_strictness_level", scope="session")
def test_strictness_level_fixture(acceptance_test_config: Config) -> Config.TestStrictnessLevel:
return acceptance_test_config.test_strictness_level
@pytest.fixture(name="cache_discovered_catalog", scope="session")
def cache_discovered_catalog_fixture(acceptance_test_config: Config) -> bool:
return acceptance_test_config.cache_discovered_catalog
@pytest.fixture(name="connector_config_path")
def connector_config_path_fixture(inputs, base_path) -> Path:
"""Fixture with connector's config path. The path to the latest updated configurations will be returned if any."""
original_configuration_path = Path(base_path) / getattr(inputs, "config_path")
updated_configurations_glob = f"{original_configuration_path.parent}/updated_configurations/{original_configuration_path.stem}|**{original_configuration_path.suffix}"
existing_configurations_path_creation_time = [
(config_file_path, os.path.getctime(config_file_path)) for config_file_path in glob(updated_configurations_glob)
]
if existing_configurations_path_creation_time:
existing_configurations_path_creation_time.sort(key=lambda x: x[1])
most_recent_configuration_path = existing_configurations_path_creation_time[-1][0]
else:
most_recent_configuration_path = original_configuration_path
logging.info(f"Using {most_recent_configuration_path} as configuration. It is the most recent version.")
return Path(most_recent_configuration_path)
@pytest.fixture(name="invalid_connector_config_path")
def invalid_connector_config_path_fixture(inputs, base_path) -> Path:
"""Fixture with connector's config path"""
return Path(base_path) / getattr(inputs, "invalid_config_path")
@pytest.fixture(name="connector_spec_path")
def connector_spec_path_fixture(inputs, base_path) -> Path:
"""Fixture with connector's specification path"""
return Path(base_path) / getattr(inputs, "spec_path")
@pytest.fixture(name="configured_catalog_path")
def configured_catalog_path_fixture(inputs, base_path) -> Optional[str]:
"""Fixture with connector's configured_catalog path"""
if getattr(inputs, "configured_catalog_path"):
return Path(base_path) / getattr(inputs, "configured_catalog_path")
return None
@pytest.fixture(name="configured_catalog")
def configured_catalog_fixture(
configured_catalog_path: Optional[str],
discovered_catalog: MutableMapping[str, AirbyteStream],
) -> ConfiguredAirbyteCatalog:
"""Build a configured catalog.
If a configured catalog path is given we build a configured catalog from it, we build it from the discovered catalog otherwise.
"""
if configured_catalog_path:
return build_configured_catalog_from_custom_catalog(configured_catalog_path, discovered_catalog)
else:
return build_configured_catalog_from_discovered_catalog_and_empty_streams(discovered_catalog, set())
@pytest.fixture(name="image_tag")
def image_tag_fixture(acceptance_test_config) -> str:
return acceptance_test_config.connector_image
@pytest.fixture(name="connector_config")
def connector_config_fixture(base_path, connector_config_path) -> SecretDict:
with open(str(connector_config_path), "r") as file:
contents = file.read()
return SecretDict(json.loads(contents))
@pytest.fixture(name="invalid_connector_config")
def invalid_connector_config_fixture(base_path, invalid_connector_config_path) -> MutableMapping[str, Any]:
"""TODO: implement default value - generate from valid config"""
with open(str(invalid_connector_config_path), "r") as file:
contents = file.read()
return json.loads(contents)
@pytest.fixture(name="malformed_connector_config")
def malformed_connector_config_fixture(connector_config) -> MutableMapping[str, Any]:
"""TODO: drop required field, add extra"""
malformed_config = copy.deepcopy(connector_config)
return malformed_config
@pytest.fixture(name="connector_spec")
def connector_spec_fixture(connector_spec_path) -> ConnectorSpecification:
spec_obj = load_yaml_or_json_path(connector_spec_path)
return ConnectorSpecification.parse_obj(spec_obj)
@pytest.fixture(name="docker_runner")
def docker_runner_fixture(image_tag, tmp_path, connector_config_path) -> ConnectorRunner:
return ConnectorRunner(image_tag, volume=tmp_path, connector_configuration_path=connector_config_path)
@pytest.fixture(name="previous_connector_image_name")
def previous_connector_image_name_fixture(image_tag, inputs) -> str:
"""Fixture with previous connector image name to use for backward compatibility tests"""
return f"{image_tag.split(':')[0]}:{inputs.backward_compatibility_tests_config.previous_connector_version}"
@pytest.fixture(name="previous_connector_docker_runner")
def previous_connector_docker_runner_fixture(previous_connector_image_name, tmp_path) -> ConnectorRunner:
"""Fixture to create a connector runner with the previous connector docker image.
Returns None if the latest image was not found, to skip downstream tests if the current connector is not yet published to the docker registry.
Raise not found error if the previous connector image is not latest and expected to be published.
"""
try:
return ConnectorRunner(previous_connector_image_name, volume=tmp_path / "previous_connector")
except (errors.NotFound, errors.ImageNotFound) as e:
if previous_connector_image_name.endswith("latest"):
logging.warning(
f"\n We did not find the {previous_connector_image_name} image for this connector. This probably means this version has not yet been published to an accessible docker registry like DockerHub."
)
return None
else:
raise e
@pytest.fixture(scope="session", autouse=True)
def pull_docker_image(acceptance_test_config) -> None:
"""Startup fixture to pull docker image"""
image_name = acceptance_test_config.connector_image
config_filename = "acceptance-test-config.yml"
try:
ConnectorRunner(image_name=image_name, volume=Path("."))
except errors.ImageNotFound:
pytest.exit(f"Docker image `{image_name}` not found, please check your {config_filename} file", returncode=1)
@pytest.fixture(name="empty_streams")
def empty_streams_fixture(inputs, test_strictness_level) -> Set[EmptyStreamConfiguration]:
empty_streams = getattr(inputs, "empty_streams", set())
if test_strictness_level is Config.TestStrictnessLevel.high and empty_streams:
all_empty_streams_have_bypass_reasons = all([bool(empty_stream.bypass_reason) for empty_stream in inputs.empty_streams])
if not all_empty_streams_have_bypass_reasons:
pytest.fail("A bypass_reason must be filled in for all empty streams when test_strictness_level is set to high.")
return empty_streams
@pytest.fixture(name="expect_records_config")
def expect_records_config_fixture(inputs):
return inputs.expect_records
@pytest.fixture(name="expected_records_by_stream")
def expected_records_by_stream_fixture(
test_strictness_level: Config.TestStrictnessLevel,
configured_catalog: ConfiguredAirbyteCatalog,
empty_streams: Set[EmptyStreamConfiguration],
expect_records_config: ExpectedRecordsConfig,
base_path,
) -> MutableMapping[str, List[MutableMapping]]:
def enforce_high_strictness_level_rules(expect_records_config, configured_catalog, empty_streams, records_by_stream) -> Optional[str]:
error_prefix = "High strictness level error: "
if expect_records_config is None:
pytest.fail(error_prefix + "expect_records must be configured for the basic_read test.")
elif expect_records_config.path:
not_seeded_streams = find_not_seeded_streams(configured_catalog, empty_streams, records_by_stream)
if not_seeded_streams:
pytest.fail(
error_prefix
+ f"{', '.join(not_seeded_streams)} streams are declared in the catalog but do not have expected records. Please add expected records to {expect_records_config.path} or declare these streams in empty_streams."
)
else:
if not getattr(expect_records_config, "bypass_reason", None):
pytest.fail(error_prefix / "A bypass reason must be filled if no path to expected records is provided.")
expected_records_by_stream = {}
if expect_records_config:
if expect_records_config.path:
expected_records_file_path = str(base_path / expect_records_config.path)
with open(expected_records_file_path, "r") as f:
all_records = [AirbyteRecordMessage.parse_raw(line) for line in f]
expected_records_by_stream = TestBasicRead.group_by_stream(all_records)
if test_strictness_level is Config.TestStrictnessLevel.high:
enforce_high_strictness_level_rules(expect_records_config, configured_catalog, empty_streams, expected_records_by_stream)
return expected_records_by_stream
def find_not_seeded_streams(
configured_catalog: ConfiguredAirbyteCatalog,
empty_streams: Set[EmptyStreamConfiguration],
records_by_stream: MutableMapping[str, List[MutableMapping]],
) -> Set[str]:
stream_names_in_catalog = set([configured_stream.stream.name for configured_stream in configured_catalog.streams])
empty_streams_names = set([stream.name for stream in empty_streams])
expected_record_stream_names = set(records_by_stream.keys())
expected_seeded_stream_names = stream_names_in_catalog - empty_streams_names
return expected_seeded_stream_names - expected_record_stream_names
@pytest.fixture(name="cached_schemas", scope="session")
def cached_schemas_fixture() -> MutableMapping[str, AirbyteStream]:
"""Simple cache for discovered catalog: stream_name -> json_schema"""
return {}
@pytest.fixture(name="previous_cached_schemas", scope="session")
def previous_cached_schemas_fixture() -> MutableMapping[str, AirbyteStream]:
"""Simple cache for discovered catalog of previous connector: stream_name -> json_schema"""
return {}
@pytest.fixture(name="discovered_catalog")
def discovered_catalog_fixture(
connector_config, docker_runner: ConnectorRunner, cached_schemas, cache_discovered_catalog: bool
) -> MutableMapping[str, AirbyteStream]:
"""JSON schemas for each stream"""
if not cached_schemas or not cache_discovered_catalog:
output = docker_runner.call_discover(config=connector_config)
catalogs = [message.catalog for message in output if message.type == Type.CATALOG]
for stream in catalogs[-1].streams:
cached_schemas[stream.name] = stream
return cached_schemas
@pytest.fixture(name="previous_discovered_catalog")
def previous_discovered_catalog_fixture(
connector_config, previous_connector_docker_runner: ConnectorRunner, previous_cached_schemas
) -> MutableMapping[str, AirbyteStream]:
"""JSON schemas for each stream"""
if previous_connector_docker_runner is None:
logging.warning(
"\n We could not retrieve the previous discovered catalog as a connector runner for the previous connector version could not be instantiated."
)
return None
if not previous_cached_schemas:
output = previous_connector_docker_runner.call_discover(config=connector_config)
catalogs = [message.catalog for message in output if message.type == Type.CATALOG]
for stream in catalogs[-1].streams:
previous_cached_schemas[stream.name] = stream
return previous_cached_schemas
@pytest.fixture
def detailed_logger() -> Logger:
"""
Create logger object for recording detailed test information into a file
"""
LOG_DIR = "acceptance_tests_logs"
if os.environ.get("ACCEPTANCE_TEST_DOCKER_CONTAINER"):
LOG_DIR = os.path.join("/test_input", LOG_DIR)
run(["mkdir", "-p", LOG_DIR])
filename = os.environ["PYTEST_CURRENT_TEST"].split("/")[-1].replace(" (setup)", "").replace(":", "_") + ".txt"
filename = os.path.join(LOG_DIR, filename)
formatter = logging.Formatter("%(message)s")
logger = logging.getLogger(f"detailed_logger {filename}")
logger.setLevel(logging.DEBUG)
fh = logging.FileHandler(filename, mode="w")
fh.setFormatter(formatter)
logger.log_json_list = lambda l: logger.info(json.dumps(list(l), indent=1))
logger.handlers = [fh]
return logger
@pytest.fixture(name="actual_connector_spec")
def actual_connector_spec_fixture(request: BaseTest, docker_runner: ConnectorRunner) -> ConnectorSpecification:
if not request.instance.spec_cache:
output = docker_runner.call_spec()
spec_messages = filter_output(output, Type.SPEC)
assert len(spec_messages) == 1, "Spec message should be emitted exactly once"
spec = spec_messages[0].spec
request.instance.spec_cache = spec
return request.instance.spec_cache
@pytest.fixture(name="previous_connector_spec")
def previous_connector_spec_fixture(
request: BaseTest, previous_connector_docker_runner: ConnectorRunner
) -> Optional[ConnectorSpecification]:
if previous_connector_docker_runner is None:
logging.warning(
"\n We could not retrieve the previous connector spec as a connector runner for the previous connector version could not be instantiated."
)
return None
if not request.instance.previous_spec_cache:
output = previous_connector_docker_runner.call_spec()
spec_messages = filter_output(output, Type.SPEC)
assert len(spec_messages) == 1, "Spec message should be emitted exactly once"
spec = spec_messages[0].spec
request.instance.previous_spec_cache = spec
return request.instance.previous_spec_cache
def pytest_sessionfinish(session, exitstatus):
"""Called after whole test run finished, right before returning the exit status to the system.
https://docs.pytest.org/en/6.2.x/reference.html#pytest.hookspec.pytest_sessionfinish
"""
logger = logging.getLogger()
# this is specifically for contributors to run tests locally and show success for a git hash
# therefore if this fails for any reason we just treat as a no-op
try:
result = "PASSED" if session.testscollected > 0 and session.testsfailed == 0 else "FAILED"
print() # create a line break
logger.info(
# session.startdir gives local path to the connector folder, so we can verify which cnctr was tested
f"{session.startdir} - SAT run - "
# using subprocess.check_output to run cmd to get git hash
f"{check_output('git rev-parse HEAD', stderr=STDOUT, shell=True).decode('ascii').strip()}"
f" - {result}"
)
except Exception as e:
logger.info(e) # debug
pass