airbyte_cdk.test.standard_tests.connector_base

Base class for connector test suites.

  1# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
  2"""Base class for connector test suites."""
  3
  4from __future__ import annotations
  5
  6import importlib
  7import os
  8from pathlib import Path
  9from typing import TYPE_CHECKING, cast
 10
 11from boltons.typeutils import classproperty
 12
 13from airbyte_cdk.test import entrypoint_wrapper
 14from airbyte_cdk.test.models import (
 15    ConnectorTestScenario,
 16)
 17from airbyte_cdk.test.standard_tests._job_runner import IConnector, run_test_job
 18from airbyte_cdk.test.standard_tests.docker_base import DockerConnectorTestSuite
 19
 20if TYPE_CHECKING:
 21    from collections.abc import Callable
 22
 23    from airbyte_cdk.test import entrypoint_wrapper
 24
 25
 26class ConnectorTestSuiteBase(DockerConnectorTestSuite):
 27    """Base class for Python connector test suites."""
 28
 29    connector: type[IConnector] | Callable[[], IConnector] | None  # type: ignore [reportRedeclaration]
 30    """The connector class or a factory function that returns an scenario of IConnector."""
 31
 32    @classproperty  # type: ignore [no-redef]
 33    def connector(cls) -> type[IConnector] | Callable[[], IConnector] | None:
 34        """Get the connector class for the test suite.
 35
 36        This assumes a python connector and should be overridden by subclasses to provide the
 37        specific connector class to be tested.
 38        """
 39        connector_root = cls.get_connector_root_dir()
 40        connector_name = cls.connector_name
 41
 42        expected_module_name = connector_name.replace("-", "_").lower()
 43        expected_class_name = connector_name.replace("-", "_").title().replace("_", "")
 44
 45        # dynamically import and get the connector class: <expected_module_name>.<expected_class_name>
 46
 47        cwd_snapshot = Path().absolute()
 48        os.chdir(connector_root)
 49
 50        # Dynamically import the module
 51        try:
 52            module = importlib.import_module(expected_module_name)
 53        except ModuleNotFoundError as e:
 54            raise ImportError(
 55                f"Could not import module '{expected_module_name}'. "
 56                "Please ensure you are running from within the connector's virtual environment, "
 57                "for instance by running `poetry run airbyte-cdk connector test` from the "
 58                "connector directory. If the issue persists, check that the connector "
 59                f"module matches the expected module name '{expected_module_name}' and that the "
 60                f"connector class matches the expected class name '{expected_class_name}'. "
 61                "Alternatively, you can run `airbyte-cdk image test` to run a subset of tests "
 62                "against the connector's image."
 63            ) from e
 64        finally:
 65            # Change back to the original working directory
 66            os.chdir(cwd_snapshot)
 67
 68        # Dynamically get the class from the module
 69        try:
 70            return cast(type[IConnector], getattr(module, expected_class_name))
 71        except AttributeError as e:
 72            # We did not find it based on our expectations, so let's check if we can find it
 73            # with a case-insensitive match.
 74            matching_class_name = next(
 75                (name for name in dir(module) if name.lower() == expected_class_name.lower()),
 76                None,
 77            )
 78            if not matching_class_name:
 79                raise ImportError(
 80                    f"Module '{expected_module_name}' does not have a class named '{expected_class_name}'."
 81                ) from e
 82            return cast(type[IConnector], getattr(module, matching_class_name))
 83
 84    @classmethod
 85    def create_connector(
 86        cls,
 87        scenario: ConnectorTestScenario | None,
 88    ) -> IConnector:
 89        """Instantiate the connector class."""
 90        connector = cls.connector  # type: ignore
 91        if connector:
 92            if callable(connector) or isinstance(connector, type):
 93                # If the connector is a class or factory function, instantiate it:
 94                return cast(IConnector, connector())  # type: ignore [redundant-cast]
 95
 96        # Otherwise, we can't instantiate the connector. Fail with a clear error message.
 97        raise NotImplementedError(
 98            "No connector class or connector factory function provided. "
 99            "Please provide a class or factory function in `cls.connector`, or "
100            "override `cls.create_connector()` to define a custom initialization process."
101        )
102
103    # Test Definitions
104
105    def test_check(
106        self,
107        scenario: ConnectorTestScenario,
108    ) -> None:
109        """Run `connection` acceptance tests."""
110        result: entrypoint_wrapper.EntrypointOutput = run_test_job(
111            self.create_connector(scenario),
112            "check",
113            test_scenario=scenario,
114            connector_root=self.get_connector_root_dir(),
115        )
116        assert len(result.connection_status_messages) == 1, (
117            f"Expected exactly one CONNECTION_STATUS message. "
118            "Got: {result.connection_status_messages!s}"
119        )
 27class ConnectorTestSuiteBase(DockerConnectorTestSuite):
 28    """Base class for Python connector test suites."""
 29
 30    connector: type[IConnector] | Callable[[], IConnector] | None  # type: ignore [reportRedeclaration]
 31    """The connector class or a factory function that returns an scenario of IConnector."""
 32
 33    @classproperty  # type: ignore [no-redef]
 34    def connector(cls) -> type[IConnector] | Callable[[], IConnector] | None:
 35        """Get the connector class for the test suite.
 36
 37        This assumes a python connector and should be overridden by subclasses to provide the
 38        specific connector class to be tested.
 39        """
 40        connector_root = cls.get_connector_root_dir()
 41        connector_name = cls.connector_name
 42
 43        expected_module_name = connector_name.replace("-", "_").lower()
 44        expected_class_name = connector_name.replace("-", "_").title().replace("_", "")
 45
 46        # dynamically import and get the connector class: <expected_module_name>.<expected_class_name>
 47
 48        cwd_snapshot = Path().absolute()
 49        os.chdir(connector_root)
 50
 51        # Dynamically import the module
 52        try:
 53            module = importlib.import_module(expected_module_name)
 54        except ModuleNotFoundError as e:
 55            raise ImportError(
 56                f"Could not import module '{expected_module_name}'. "
 57                "Please ensure you are running from within the connector's virtual environment, "
 58                "for instance by running `poetry run airbyte-cdk connector test` from the "
 59                "connector directory. If the issue persists, check that the connector "
 60                f"module matches the expected module name '{expected_module_name}' and that the "
 61                f"connector class matches the expected class name '{expected_class_name}'. "
 62                "Alternatively, you can run `airbyte-cdk image test` to run a subset of tests "
 63                "against the connector's image."
 64            ) from e
 65        finally:
 66            # Change back to the original working directory
 67            os.chdir(cwd_snapshot)
 68
 69        # Dynamically get the class from the module
 70        try:
 71            return cast(type[IConnector], getattr(module, expected_class_name))
 72        except AttributeError as e:
 73            # We did not find it based on our expectations, so let's check if we can find it
 74            # with a case-insensitive match.
 75            matching_class_name = next(
 76                (name for name in dir(module) if name.lower() == expected_class_name.lower()),
 77                None,
 78            )
 79            if not matching_class_name:
 80                raise ImportError(
 81                    f"Module '{expected_module_name}' does not have a class named '{expected_class_name}'."
 82                ) from e
 83            return cast(type[IConnector], getattr(module, matching_class_name))
 84
 85    @classmethod
 86    def create_connector(
 87        cls,
 88        scenario: ConnectorTestScenario | None,
 89    ) -> IConnector:
 90        """Instantiate the connector class."""
 91        connector = cls.connector  # type: ignore
 92        if connector:
 93            if callable(connector) or isinstance(connector, type):
 94                # If the connector is a class or factory function, instantiate it:
 95                return cast(IConnector, connector())  # type: ignore [redundant-cast]
 96
 97        # Otherwise, we can't instantiate the connector. Fail with a clear error message.
 98        raise NotImplementedError(
 99            "No connector class or connector factory function provided. "
100            "Please provide a class or factory function in `cls.connector`, or "
101            "override `cls.create_connector()` to define a custom initialization process."
102        )
103
104    # Test Definitions
105
106    def test_check(
107        self,
108        scenario: ConnectorTestScenario,
109    ) -> None:
110        """Run `connection` acceptance tests."""
111        result: entrypoint_wrapper.EntrypointOutput = run_test_job(
112            self.create_connector(scenario),
113            "check",
114            test_scenario=scenario,
115            connector_root=self.get_connector_root_dir(),
116        )
117        assert len(result.connection_status_messages) == 1, (
118            f"Expected exactly one CONNECTION_STATUS message. "
119            "Got: {result.connection_status_messages!s}"
120        )

Base class for Python connector test suites.

def connector(unknown):

The connector class or a factory function that returns an scenario of IConnector.

@classmethod
def create_connector( cls, scenario: airbyte_cdk.test.models.ConnectorTestScenario | None) -> airbyte_cdk.test.standard_tests._job_runner.IConnector:
 85    @classmethod
 86    def create_connector(
 87        cls,
 88        scenario: ConnectorTestScenario | None,
 89    ) -> IConnector:
 90        """Instantiate the connector class."""
 91        connector = cls.connector  # type: ignore
 92        if connector:
 93            if callable(connector) or isinstance(connector, type):
 94                # If the connector is a class or factory function, instantiate it:
 95                return cast(IConnector, connector())  # type: ignore [redundant-cast]
 96
 97        # Otherwise, we can't instantiate the connector. Fail with a clear error message.
 98        raise NotImplementedError(
 99            "No connector class or connector factory function provided. "
100            "Please provide a class or factory function in `cls.connector`, or "
101            "override `cls.create_connector()` to define a custom initialization process."
102        )

Instantiate the connector class.

def test_check( self, scenario: airbyte_cdk.test.models.ConnectorTestScenario) -> None:
106    def test_check(
107        self,
108        scenario: ConnectorTestScenario,
109    ) -> None:
110        """Run `connection` acceptance tests."""
111        result: entrypoint_wrapper.EntrypointOutput = run_test_job(
112            self.create_connector(scenario),
113            "check",
114            test_scenario=scenario,
115            connector_root=self.get_connector_root_dir(),
116        )
117        assert len(result.connection_status_messages) == 1, (
118            f"Expected exactly one CONNECTION_STATUS message. "
119            "Got: {result.connection_status_messages!s}"
120        )

Run connection acceptance tests.