Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SAT: spec - backward compatibility - syntactic validation #15194

Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3135a98
fix conflicts
alafanechere Aug 1, 2022
dcfc90e
move common fixtures to conftest
alafanechere Aug 2, 2022
c05157e
add tests for enum
alafanechere Aug 2, 2022
6bce211
move test_additional_properties_is_true to TestSpecBackwardCompatibility
alafanechere Aug 2, 2022
023645e
move back tests to TestSpec
alafanechere Aug 2, 2022
85696f5
declare marker in plugin
alafanechere Aug 2, 2022
a72bd45
introduce backward incompatibility on source-openweather for tests
alafanechere Aug 2, 2022
ad40d5b
bump version
alafanechere Aug 2, 2022
0de55f3
revert changes on source-openweather
alafanechere Aug 3, 2022
be1dfa0
revert changes on source-openweather
alafanechere Aug 3, 2022
40eb10d
fix typo in changelog
alafanechere Aug 3, 2022
1d7118e
fix caching of actual_connector_spec_fixture
alafanechere Aug 3, 2022
c6b1b07
improve test config to validate connector version against semver regex
alafanechere Aug 3, 2022
0ce08e5
improve test skipping logic
alafanechere Aug 3, 2022
592e3c9
check addition of new field with enum does not fail
alafanechere Aug 3, 2022
03ef202
add type change detection tests
alafanechere Aug 3, 2022
cb4a6c1
cover additional cases
alafanechere Aug 3, 2022
8c7c4a1
update source-acceptance-tests-reference.md
alafanechere Aug 3, 2022
477a50b
remove todo
alafanechere Aug 4, 2022
df2bb1d
clearer error message for enum field declaration
alafanechere Aug 4, 2022
27a2f65
allow type expansion
alafanechere Aug 4, 2022
0c12a6a
add oneof test cases
alafanechere Aug 4, 2022
abd6301
change None to null
alafanechere Aug 4, 2022
1c5215e
Merge branch 'master' into augustin/sat/spec-backward-compatibility/s…
alafanechere Aug 4, 2022
3caff13
clean, remove breakpoint
alafanechere Aug 4, 2022
d992c35
clean comment
alafanechere Aug 4, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 0.1.59
Backward compatibility tests: add syntactic validation of specs [#15194](https://github.com/airbytehq/airbyte/pull/15194/).

## 0.1.58
Bootstrap spec backward compatibility tests. Add fixtures to retrieve a previous connector version spec [#14954](https://github.com/airbytehq/airbyte/pull/14954/).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ COPY pytest.ini setup.py ./
COPY source_acceptance_test ./source_acceptance_test
RUN pip install .

LABEL io.airbyte.version=0.1.58
LABEL io.airbyte.version=0.1.59
LABEL io.airbyte.name=airbyte/source-acceptance-test

ENTRYPOINT ["python", "-m", "pytest", "-p", "source_acceptance_test.plugin", "-r", "fEsx"]
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ testpaths =

markers =
default_timeout
backward_compatibility
1 change: 1 addition & 0 deletions airbyte-integrations/bases/source-acceptance-test/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dpath~=2.0.1",
"jsonschema~=3.2.0",
"jsonref==0.2",
"deepdiff~=5.8.0",
]

setuptools.setup(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
configured_catalog_path: Optional[str] = Field(default=None, description="Path to configured catalog")
timeout_seconds: int = Field(default=None, description="Test execution timeout_seconds", ge=0)

SEMVER_REGEX = r"(0|(?:[1-9]\d*))(?:\.(0|(?:[1-9]\d*))(?:\.(0|(?:[1-9]\d*)))?(?:\-([\w][\w\.\-_]*))?)?"


class BaseConfig(BaseModel):
class Config:
Expand All @@ -25,10 +27,10 @@ class Config:

class BackwardCompatibilityTestsConfig(BaseConfig):
previous_connector_version: str = Field(
default="latest", description="Previous connector version to use for backward compatibility tests."
regex=SEMVER_REGEX, default="latest", description="Previous connector version to use for backward compatibility tests."
alafanechere marked this conversation as resolved.
Show resolved Hide resolved
)
disable_backward_compatibility_tests_for_version: Optional[str] = Field(
default=None, description="Disable backward compatibility tests for a specific connector version."
disable_for_version: Optional[str] = Field(
regex=SEMVER_REGEX, default=None, description="Disable backward compatibility tests for a specific connector version."
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@
Type,
)
from docker import errors
from source_acceptance_test.base import BaseTest
from source_acceptance_test.config import Config
from source_acceptance_test.utils import ConnectorRunner, SecretDict, load_config, load_yaml_or_json_path
from source_acceptance_test.utils import ConnectorRunner, SecretDict, filter_output, load_config, load_yaml_or_json_path


@pytest.fixture(name="base_path")
Expand Down Expand Up @@ -210,6 +211,35 @@ def detailed_logger() -> Logger:
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

def pytest_configure(config):
config.addinivalue_line("markers", "default_timeout: mark test to be wrapped by `timeout` decorator with default value")
config.addinivalue_line(
"markers",
"backward_compatibility: mark test to be part of the backward compatibility tests suite (deselect with '-m \"not backward_compatibility\"')",
)


def pytest_load_initial_conftests(early_config: Config, parser: Parser, args: List[str]):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from collections import Counter, defaultdict
from functools import reduce
from logging import Logger
from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Set
from typing import Any, Dict, List, Mapping, MutableMapping, Set

import dpath.util
import jsonschema
Expand All @@ -24,6 +24,7 @@
TraceType,
Type,
)
from deepdiff import DeepDiff
from docker.errors import ContainerError
from jsonschema._utils import flatten
from source_acceptance_test.base import BaseTest
Expand All @@ -38,41 +39,38 @@ def connector_spec_dict_fixture(actual_connector_spec):
return json.loads(actual_connector_spec.json())


@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.spec_cache = spec
return request.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


@pytest.mark.default_timeout(10)
class TestSpec(BaseTest):

spec_cache: ConnectorSpecification = None
previous_spec_cache: ConnectorSpecification = None

@staticmethod
def compute_spec_diff(actual_connector_spec: ConnectorSpecification, previous_connector_spec: ConnectorSpecification):
return DeepDiff(previous_connector_spec.dict(), actual_connector_spec.dict(), view="tree", ignore_order=True)

@pytest.fixture(name="skip_backward_compatibility_tests")
def skip_backward_compatibility_tests_fixture(self, inputs: SpecTestConfig, previous_connector_docker_runner: ConnectorRunner) -> bool:
if previous_connector_docker_runner is None:
pytest.skip("The previous connector image could not be retrieved.")

# Get the real connector version in case 'latest' is used in the config:
previous_connector_version = previous_connector_docker_runner._image.labels.get("io.airbyte.version")

if previous_connector_version == inputs.backward_compatibility_tests_config.disable_for_version:
pytest.skip(f"Backward compatibility tests are disabled for version {previous_connector_version}.")
return False

@pytest.fixture(name="spec_diff")
def spec_diff_fixture(
self,
skip_backward_compatibility_tests: bool,
actual_connector_spec: ConnectorSpecification,
previous_connector_spec: ConnectorSpecification,
) -> DeepDiff:
assert isinstance(actual_connector_spec, ConnectorSpecification) and isinstance(previous_connector_spec, ConnectorSpecification)
return self.compute_spec_diff(actual_connector_spec, previous_connector_spec)

def test_config_match_spec(self, actual_connector_spec: ConnectorSpecification, connector_config: SecretDict):
"""Check that config matches the actual schema from the spec call"""
# Getting rid of technical variables that start with an underscore
Expand Down Expand Up @@ -182,7 +180,109 @@ def test_oauth_flow_parameters(self, actual_connector_spec: ConnectorSpecificati
diff = params - schema_path
assert diff == set(), f"Specified oauth fields are missed from spec schema: {diff}"

def test_additional_properties_is_true(self, actual_connector_spec):
@pytest.mark.default_timeout(60)
@pytest.mark.backward_compatibility
def test_new_required_field_declaration(self, spec_diff: DeepDiff):
"""Check if a 'required' field was added to the spec."""
added_required_fields = [
addition for addition in spec_diff.get("dictionary_item_added", []) if addition.path(output_format="list")[-1] == "required"
]
assert len(added_required_fields) == 0, f"The current spec has a new required field: {spec_diff.pretty()}"

@pytest.mark.default_timeout(60)
@pytest.mark.backward_compatibility
def test_new_required_property(self, spec_diff: DeepDiff):
"""Check if a a new property was added to the 'required' field."""
added_required_properties = [
addition for addition in spec_diff.get("iterable_item_added", []) if addition.up.path(output_format="list")[-1] == "required"
]
assert len(added_required_properties) == 0, f"The current spec has a new required property: {spec_diff.pretty()}"

@pytest.mark.default_timeout(60)
@pytest.mark.backward_compatibility
def test_field_type_changed(self, spec_diff: DeepDiff):
"""Check if the current spec is changing the types of properties."""
# TODO alafanechere: Do we have allowed non-breaking type transitions?
# e.g. Does changing a field type from int to string is breaking the spec?
# I assumed it does
alafanechere marked this conversation as resolved.
Show resolved Hide resolved

common_error_message = f"The current spec changed the value of a 'type' field: {spec_diff.pretty()}"
# Detect type value change in case type field is declared as a string (e.g "str" -> "int"):
type_values_changed = [change for change in spec_diff.get("values_changed", []) if change.path(output_format="list")[-1] == "type"]

# Detect type value change in case type field is declared as a single item list (e.g ["str"] -> ["int"]):
type_values_changed_in_list = [
change for change in spec_diff.get("values_changed", []) if change.path(output_format="list")[-2] == "type"
]

assert len(type_values_changed_in_list) == 0 and len(type_values_changed) == 0, common_error_message

# Detect type value added to type list if new type value is not None (e.g ["str"] -> ["str", "int"]):
new_values_in_type_list = [
change
for change in spec_diff.get("iterable_item_added", [])
if change.path(output_format="list")[-2] == "type"
if change.t2 is not None
] # It works because we compute the diff with 'ignore_order=True'
assert len(new_values_in_type_list) == 0, common_error_message

# Detect the change of type of a type field
# e.g:
# - "str" -> ["str"] VALID
# - "str" -> ["str", None] VALID
# - "str" -> ["str", "int"] INVALID
alafanechere marked this conversation as resolved.
Show resolved Hide resolved
# - "str" -> 1 INVALID
# - ["str"] -> "str" VALID
# - ["str"] -> "int" INVALID
# - ["str"] -> 1 INVALID
type_changes = [change for change in spec_diff.get("type_changes", []) if change.path(output_format="list")[-1] == "type"]
for change in type_changes:
# We only accept change on the type field if the new type for this field is list or string
# This might be something already guaranteed by JSON schema validation.
if isinstance(change.t1, str):
assert isinstance(
change.t2, list
), f"The current spec change a type field from string to an invalid value: {spec_diff.pretty()}"
assert (
0 < len(change.t2) <= 2
), f"The current spec change a type field from string to an invalid value. The type list length should not be empty and have a maximum of two items {spec_diff.pretty()}."
# If the new type field is a list we want to make sure it only has the original type (t1) and None: e.g. "str" -> ["str", None]
# We want to raise an error otherwise.
t2_not_null_types = [_type for _type in change.t2 if _type is not None]
assert (
len(t2_not_null_types) == 1 and t2_not_null_types[0] == change.t1
), "The current spec change a type field to a list with multiple invalid values."
if isinstance(change.t1, list):
assert isinstance(
change.t2, str
), f"The current spec change a type field from list to an invalid value: {spec_diff.pretty()}"
assert len(change.t1) == 1 and change.t2 == change.t1[0], f"The current spec narrowed a field type: {spec_diff.pretty()}"

# Detect when field was made not nullable but is still a list: e.g ["string", None] -> ["string"]
removed_nullable = [
change for change in spec_diff.get("iterable_item_removed", []) if change.path(output_format="list")[-2] == "type"
]
assert len(removed_nullable) == 0, f"The current spec narrowed a field type: {spec_diff.pretty()}"

@pytest.mark.default_timeout(60)
@pytest.mark.backward_compatibility
def test_enum_field_has_narrowed(self, spec_diff: DeepDiff):
"""Check if the list of values in a enum was shortened in a spec."""
removals = [
removal for removal in spec_diff.get("iterable_item_removed", []) if removal.up.path(output_format="list")[-1] == "enum"
]
assert len(removals) == 0, f"The current spec narrowed a field enum: {spec_diff.pretty()}"

@pytest.mark.default_timeout(60)
@pytest.mark.backward_compatibility
def test_new_enum_field_declaration(self, spec_diff: DeepDiff):
"""Check if an 'enum' field was added to the spec."""
added_enum_fields = [
addition for addition in spec_diff.get("dictionary_item_added", []) if addition.path(output_format="list")[-1] == "enum"
]
assert len(added_enum_fields) == 0, f"The current spec has a new enum field: {spec_diff.pretty()}"
pedroslopez marked this conversation as resolved.
Show resolved Hide resolved
pedroslopez marked this conversation as resolved.
Show resolved Hide resolved

def test_additional_properties_is_true(self, actual_connector_spec: ConnectorSpecification):
"""Check that value of the "additionalProperties" field is always true.
A spec declaring "additionalProperties": false introduces the risk of accidental breaking changes.
Specifically, when removing a property from the spec, existing connector configs will no longer be valid.
Expand All @@ -196,24 +296,6 @@ def test_additional_properties_is_true(self, actual_connector_spec):
[additional_properties_value is True for additional_properties_value in additional_properties_values]
), "When set, additionalProperties field value must be true for backward compatibility."

@pytest.mark.default_timeout(60) # Pulling the previous connector image can take more than 10 sec.
@pytest.mark.spec_backward_compatibility
def test_backward_compatibility(
self, inputs: SpecTestConfig, actual_connector_spec: ConnectorSpecification, previous_connector_spec: ConnectorSpecification
):
"""Run multiple checks to make sure the actual_connector_spec is backward compatible with the previous_connector_spec"""
if (
inputs.backward_compatibility_tests_config.disable_backward_compatibility_tests_for_version
== inputs.backward_compatibility_tests_config.previous_connector_version
):
pytest.skip(
f"Backward compatibility tests are disabled for version {inputs.backward_compatibility_tests_config.disable_backward_compatibility_tests_for_version}."
)
if previous_connector_spec is None:
pytest.skip("The previous connector spec could not be retrieved.")
assert isinstance(actual_connector_spec, ConnectorSpecification) and isinstance(previous_connector_spec, ConnectorSpecification)
# TODO alafanechere: add the actual tests for backward compatibility below or in a dedicated module.


@pytest.mark.default_timeout(30)
class TestConnection(BaseTest):
Expand Down
Loading