airbyte-internal-ops 0.4.2__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {airbyte_internal_ops-0.4.2.dist-info → airbyte_internal_ops-0.5.0.dist-info}/METADATA +1 -1
- {airbyte_internal_ops-0.4.2.dist-info → airbyte_internal_ops-0.5.0.dist-info}/RECORD +13 -52
- airbyte_ops_mcp/cli/cloud.py +27 -0
- airbyte_ops_mcp/cloud_admin/api_client.py +473 -0
- airbyte_ops_mcp/cloud_admin/models.py +56 -0
- airbyte_ops_mcp/mcp/cloud_connector_versions.py +460 -0
- airbyte_ops_mcp/mcp/prerelease.py +5 -44
- airbyte_ops_mcp/regression_tests/ci_output.py +8 -4
- airbyte_ops_mcp/regression_tests/http_metrics.py +21 -2
- airbyte_ops_mcp/regression_tests/models.py +6 -0
- airbyte_ops_mcp/telemetry.py +162 -0
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/.gitignore +0 -1
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/README.md +0 -420
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/__init__.py +0 -2
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/__init__.py +0 -1
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/backends/__init__.py +0 -8
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/backends/base_backend.py +0 -16
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/backends/duckdb_backend.py +0 -87
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/backends/file_backend.py +0 -165
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/connection_objects_retrieval.py +0 -377
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/connector_runner.py +0 -247
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/errors.py +0 -7
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/evaluation_modes.py +0 -25
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/hacks.py +0 -23
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/json_schema_helper.py +0 -384
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/mitm_addons.py +0 -37
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/models.py +0 -595
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/proxy.py +0 -207
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/secret_access.py +0 -47
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/segment_tracking.py +0 -45
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/commons/utils.py +0 -214
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/conftest.py.disabled +0 -751
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/consts.py +0 -4
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/poetry.lock +0 -4480
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/pytest.ini +0 -9
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/regression_tests/__init__.py +0 -1
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/regression_tests/test_check.py +0 -61
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/regression_tests/test_discover.py +0 -117
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/regression_tests/test_read.py +0 -627
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/regression_tests/test_spec.py +0 -43
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/report.py +0 -542
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/stash_keys.py +0 -38
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/templates/__init__.py +0 -0
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/templates/private_details.html.j2 +0 -305
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/templates/report.html.j2 +0 -515
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/utils.py +0 -187
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/validation_tests/__init__.py +0 -0
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/validation_tests/test_check.py +0 -61
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/validation_tests/test_discover.py +0 -217
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/validation_tests/test_read.py +0 -177
- airbyte_ops_mcp/_legacy/airbyte_ci/connector_live_tests/validation_tests/test_spec.py +0 -631
- {airbyte_internal_ops-0.4.2.dist-info → airbyte_internal_ops-0.5.0.dist-info}/WHEEL +0 -0
- {airbyte_internal_ops-0.4.2.dist-info → airbyte_internal_ops-0.5.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,751 +0,0 @@
|
|
|
1
|
-
#
|
|
2
|
-
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
from __future__ import annotations
|
|
6
|
-
|
|
7
|
-
import logging
|
|
8
|
-
import os
|
|
9
|
-
import textwrap
|
|
10
|
-
import time
|
|
11
|
-
import webbrowser
|
|
12
|
-
from collections.abc import AsyncIterable, Callable, Generator, Iterable
|
|
13
|
-
from itertools import product
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from typing import TYPE_CHECKING, List, Optional
|
|
16
|
-
|
|
17
|
-
import dagger
|
|
18
|
-
import pytest
|
|
19
|
-
from airbyte_protocol.models import ConfiguredAirbyteCatalog # type: ignore
|
|
20
|
-
from connection_retriever.audit_logging import get_user_email # type: ignore
|
|
21
|
-
from connection_retriever.retrieval import (
|
|
22
|
-
ConnectionNotFoundError,
|
|
23
|
-
get_current_docker_image_tag,
|
|
24
|
-
) # type: ignore
|
|
25
|
-
from live_tests import stash_keys
|
|
26
|
-
from live_tests.commons.connection_objects_retrieval import (
|
|
27
|
-
ConnectionObject,
|
|
28
|
-
InvalidConnectionError,
|
|
29
|
-
get_connection_objects,
|
|
30
|
-
)
|
|
31
|
-
from live_tests.commons.connector_runner import ConnectorRunner, Proxy
|
|
32
|
-
from live_tests.commons.evaluation_modes import TestEvaluationMode
|
|
33
|
-
from live_tests.commons.models import (
|
|
34
|
-
ActorType,
|
|
35
|
-
Command,
|
|
36
|
-
ConnectionObjects,
|
|
37
|
-
ConnectionSubset,
|
|
38
|
-
ConnectorUnderTest,
|
|
39
|
-
ExecutionInputs,
|
|
40
|
-
ExecutionResult,
|
|
41
|
-
SecretDict,
|
|
42
|
-
TargetOrControl,
|
|
43
|
-
)
|
|
44
|
-
from live_tests.commons.secret_access import get_airbyte_api_key
|
|
45
|
-
from live_tests.commons.segment_tracking import track_usage
|
|
46
|
-
from live_tests.commons.utils import clean_up_artifacts
|
|
47
|
-
from live_tests.report import PrivateDetailsReport, ReportState, TestReport
|
|
48
|
-
from rich.prompt import Confirm, Prompt
|
|
49
|
-
|
|
50
|
-
if TYPE_CHECKING:
|
|
51
|
-
from _pytest.config import Config
|
|
52
|
-
from _pytest.config.argparsing import Parser
|
|
53
|
-
from _pytest.fixtures import SubRequest
|
|
54
|
-
from pytest_sugar import SugarTerminalReporter # type: ignore
|
|
55
|
-
|
|
56
|
-
# CONSTS
|
|
57
|
-
LOGGER = logging.getLogger("live-tests")
|
|
58
|
-
MAIN_OUTPUT_DIRECTORY = Path("/tmp/live_tests_artifacts")
|
|
59
|
-
|
|
60
|
-
# It's used by Dagger and its very verbose
|
|
61
|
-
logging.getLogger("httpx").setLevel(logging.ERROR)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
# PYTEST HOOKS
|
|
65
|
-
def pytest_addoption(parser: Parser) -> None:
|
|
66
|
-
parser.addoption(
|
|
67
|
-
"--connector-image",
|
|
68
|
-
help="The connector image name on which the tests will run: e.g. airbyte/source-faker",
|
|
69
|
-
)
|
|
70
|
-
parser.addoption(
|
|
71
|
-
"--control-version",
|
|
72
|
-
help="The control version used for regression testing.",
|
|
73
|
-
)
|
|
74
|
-
parser.addoption(
|
|
75
|
-
"--target-version",
|
|
76
|
-
default="dev",
|
|
77
|
-
help="The target version used for regression and validation testing. Defaults to dev.",
|
|
78
|
-
)
|
|
79
|
-
parser.addoption("--config-path")
|
|
80
|
-
parser.addoption("--catalog-path")
|
|
81
|
-
parser.addoption("--state-path")
|
|
82
|
-
parser.addoption("--connection-id")
|
|
83
|
-
parser.addoption("--pr-url", help="The URL of the PR you are testing")
|
|
84
|
-
parser.addoption(
|
|
85
|
-
"--stream",
|
|
86
|
-
help="The stream to run the tests on. (Can be used multiple times)",
|
|
87
|
-
action="append",
|
|
88
|
-
)
|
|
89
|
-
# Required when running in CI
|
|
90
|
-
parser.addoption("--run-id", type=str)
|
|
91
|
-
parser.addoption(
|
|
92
|
-
"--should-read-with-state",
|
|
93
|
-
type=bool,
|
|
94
|
-
help="Whether to run the `read` command with state. \n"
|
|
95
|
-
"We recommend reading with state to properly test incremental sync. \n"
|
|
96
|
-
"But if the target version introduces a breaking change in the state, you might want to run without state. \n",
|
|
97
|
-
)
|
|
98
|
-
parser.addoption(
|
|
99
|
-
"--test-evaluation-mode",
|
|
100
|
-
choices=[e.value for e in TestEvaluationMode],
|
|
101
|
-
default=TestEvaluationMode.STRICT.value,
|
|
102
|
-
help='If "diagnostic" mode is selected, all tests will pass as long as there is no exception; warnings will be logged. In "strict" mode, tests may fail.',
|
|
103
|
-
)
|
|
104
|
-
parser.addoption(
|
|
105
|
-
"--connection-subset",
|
|
106
|
-
choices=[c.value for c in ConnectionSubset],
|
|
107
|
-
default=ConnectionSubset.SANDBOXES.value,
|
|
108
|
-
help="Whether to select from sandbox accounts only.",
|
|
109
|
-
)
|
|
110
|
-
parser.addoption(
|
|
111
|
-
"--max-connections",
|
|
112
|
-
default=None,
|
|
113
|
-
help="The maximum number of connections to retrieve and use for testing.",
|
|
114
|
-
)
|
|
115
|
-
parser.addoption(
|
|
116
|
-
"--disable-proxy",
|
|
117
|
-
type=bool,
|
|
118
|
-
default=False,
|
|
119
|
-
help="If a connector uses provider-specific libraries (e.g., facebook-business), it is better to disable the proxy.",
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def pytest_configure(config: Config) -> None:
|
|
124
|
-
user_email = get_user_email()
|
|
125
|
-
config.stash[stash_keys.RUN_IN_AIRBYTE_CI] = bool(
|
|
126
|
-
os.getenv("RUN_IN_AIRBYTE_CI", False)
|
|
127
|
-
)
|
|
128
|
-
config.stash[stash_keys.IS_PRODUCTION_CI] = bool(os.getenv("CI", False))
|
|
129
|
-
|
|
130
|
-
if not config.stash[stash_keys.RUN_IN_AIRBYTE_CI]:
|
|
131
|
-
prompt_for_confirmation(user_email)
|
|
132
|
-
|
|
133
|
-
track_usage(
|
|
134
|
-
"production-ci"
|
|
135
|
-
if config.stash[stash_keys.IS_PRODUCTION_CI]
|
|
136
|
-
else "local-ci"
|
|
137
|
-
if config.stash[stash_keys.RUN_IN_AIRBYTE_CI]
|
|
138
|
-
else user_email,
|
|
139
|
-
vars(config.option),
|
|
140
|
-
)
|
|
141
|
-
config.stash[stash_keys.AIRBYTE_API_KEY] = get_airbyte_api_key()
|
|
142
|
-
config.stash[stash_keys.USER] = user_email
|
|
143
|
-
config.stash[stash_keys.SESSION_RUN_ID] = config.getoption("--run-id") or str(
|
|
144
|
-
int(time.time())
|
|
145
|
-
)
|
|
146
|
-
test_artifacts_directory = get_artifacts_directory(config)
|
|
147
|
-
duckdb_path = test_artifacts_directory / "duckdb.db"
|
|
148
|
-
config.stash[stash_keys.DUCKDB_PATH] = duckdb_path
|
|
149
|
-
test_artifacts_directory.mkdir(parents=True, exist_ok=True)
|
|
150
|
-
dagger_log_path = test_artifacts_directory / "dagger.log"
|
|
151
|
-
config.stash[stash_keys.IS_PERMITTED_BOOL] = False
|
|
152
|
-
report_path = test_artifacts_directory / "report.html"
|
|
153
|
-
private_details_path = test_artifacts_directory / "private_details.html"
|
|
154
|
-
config.stash[stash_keys.TEST_ARTIFACT_DIRECTORY] = test_artifacts_directory
|
|
155
|
-
dagger_log_path.touch()
|
|
156
|
-
LOGGER.info("Dagger log path: %s", dagger_log_path)
|
|
157
|
-
config.stash[stash_keys.DAGGER_LOG_PATH] = dagger_log_path
|
|
158
|
-
config.stash[stash_keys.PR_URL] = get_option_or_fail(config, "--pr-url")
|
|
159
|
-
_connection_id = config.getoption("--connection-id")
|
|
160
|
-
config.stash[stash_keys.AUTO_SELECT_CONNECTION] = _connection_id == "auto"
|
|
161
|
-
config.stash[stash_keys.CONNECTOR_IMAGE] = get_option_or_fail(
|
|
162
|
-
config, "--connector-image"
|
|
163
|
-
)
|
|
164
|
-
config.stash[stash_keys.TARGET_VERSION] = get_option_or_fail(
|
|
165
|
-
config, "--target-version"
|
|
166
|
-
)
|
|
167
|
-
config.stash[stash_keys.CONTROL_VERSION] = get_control_version(config)
|
|
168
|
-
config.stash[stash_keys.CONNECTION_SUBSET] = ConnectionSubset(
|
|
169
|
-
get_option_or_fail(config, "--connection-subset")
|
|
170
|
-
)
|
|
171
|
-
custom_source_config_path = config.getoption("--config-path")
|
|
172
|
-
custom_configured_catalog_path = config.getoption("--catalog-path")
|
|
173
|
-
custom_state_path = config.getoption("--state-path")
|
|
174
|
-
config.stash[stash_keys.SELECTED_STREAMS] = set(config.getoption("--stream") or [])
|
|
175
|
-
config.stash[stash_keys.TEST_EVALUATION_MODE] = TestEvaluationMode(
|
|
176
|
-
config.getoption("--test-evaluation-mode", "strict")
|
|
177
|
-
)
|
|
178
|
-
config.stash[stash_keys.MAX_CONNECTIONS] = config.getoption("--max-connections")
|
|
179
|
-
config.stash[stash_keys.MAX_CONNECTIONS] = (
|
|
180
|
-
int(config.stash[stash_keys.MAX_CONNECTIONS])
|
|
181
|
-
if config.stash[stash_keys.MAX_CONNECTIONS]
|
|
182
|
-
else None
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
config.stash[stash_keys.DISABLE_PROXY] = config.getoption("--disable-proxy")
|
|
186
|
-
|
|
187
|
-
if config.stash[stash_keys.RUN_IN_AIRBYTE_CI]:
|
|
188
|
-
config.stash[stash_keys.SHOULD_READ_WITH_STATE] = bool(
|
|
189
|
-
config.getoption("--should-read-with-state")
|
|
190
|
-
)
|
|
191
|
-
elif _should_read_with_state := config.getoption("--should-read-with-state"):
|
|
192
|
-
config.stash[stash_keys.SHOULD_READ_WITH_STATE] = _should_read_with_state
|
|
193
|
-
else:
|
|
194
|
-
config.stash[stash_keys.SHOULD_READ_WITH_STATE] = (
|
|
195
|
-
prompt_for_read_with_or_without_state()
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
retrieval_reason = f"Running live tests on connection for connector {config.stash[stash_keys.CONNECTOR_IMAGE]} on target versions ({config.stash[stash_keys.TARGET_VERSION]})."
|
|
199
|
-
|
|
200
|
-
try:
|
|
201
|
-
config.stash[stash_keys.ALL_CONNECTION_OBJECTS] = get_connection_objects(
|
|
202
|
-
{
|
|
203
|
-
ConnectionObject.SOURCE_CONFIG,
|
|
204
|
-
ConnectionObject.CATALOG,
|
|
205
|
-
ConnectionObject.CONFIGURED_CATALOG,
|
|
206
|
-
ConnectionObject.STATE,
|
|
207
|
-
ConnectionObject.WORKSPACE_ID,
|
|
208
|
-
ConnectionObject.SOURCE_DOCKER_IMAGE,
|
|
209
|
-
ConnectionObject.SOURCE_ID,
|
|
210
|
-
ConnectionObject.DESTINATION_ID,
|
|
211
|
-
},
|
|
212
|
-
None if _connection_id == "auto" else _connection_id,
|
|
213
|
-
Path(custom_source_config_path) if custom_source_config_path else None,
|
|
214
|
-
Path(custom_configured_catalog_path)
|
|
215
|
-
if custom_configured_catalog_path
|
|
216
|
-
else None,
|
|
217
|
-
Path(custom_state_path) if custom_state_path else None,
|
|
218
|
-
retrieval_reason,
|
|
219
|
-
connector_image=config.stash[stash_keys.CONNECTOR_IMAGE],
|
|
220
|
-
connector_version=config.stash[stash_keys.CONTROL_VERSION],
|
|
221
|
-
auto_select_connections=config.stash[stash_keys.AUTO_SELECT_CONNECTION],
|
|
222
|
-
selected_streams=config.stash[stash_keys.SELECTED_STREAMS],
|
|
223
|
-
connection_subset=config.stash[stash_keys.CONNECTION_SUBSET],
|
|
224
|
-
max_connections=config.stash[stash_keys.MAX_CONNECTIONS],
|
|
225
|
-
)
|
|
226
|
-
config.stash[stash_keys.IS_PERMITTED_BOOL] = True
|
|
227
|
-
except (ConnectionNotFoundError, InvalidConnectionError) as exc:
|
|
228
|
-
clean_up_artifacts(MAIN_OUTPUT_DIRECTORY, LOGGER)
|
|
229
|
-
LOGGER.error(
|
|
230
|
-
f"Failed to retrieve a valid a connection which is using the control version {config.stash[stash_keys.CONTROL_VERSION]}."
|
|
231
|
-
)
|
|
232
|
-
pytest.exit(str(exc))
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
config.stash[stash_keys.CONTROL_VERSION]
|
|
236
|
-
== config.stash[stash_keys.TARGET_VERSION]
|
|
237
|
-
):
|
|
238
|
-
pytest.exit(
|
|
239
|
-
f"Control and target versions are the same: {control_version}. Please provide different versions."
|
|
240
|
-
)
|
|
241
|
-
|
|
242
|
-
config.stash[stash_keys.PRIVATE_DETAILS_REPORT] = PrivateDetailsReport(
|
|
243
|
-
private_details_path,
|
|
244
|
-
config,
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
config.stash[stash_keys.TEST_REPORT] = TestReport(
|
|
248
|
-
report_path,
|
|
249
|
-
config,
|
|
250
|
-
private_details_url=config.stash[stash_keys.PRIVATE_DETAILS_REPORT]
|
|
251
|
-
.path.resolve()
|
|
252
|
-
.as_uri(),
|
|
253
|
-
)
|
|
254
|
-
|
|
255
|
-
webbrowser.open_new_tab(
|
|
256
|
-
config.stash[stash_keys.TEST_REPORT].path.resolve().as_uri()
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def get_artifacts_directory(config: pytest.Config) -> Path:
|
|
261
|
-
run_id = config.stash[stash_keys.SESSION_RUN_ID]
|
|
262
|
-
return MAIN_OUTPUT_DIRECTORY / f"session_{run_id}"
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def pytest_collection_modifyitems(
|
|
266
|
-
config: pytest.Config, items: list[pytest.Item]
|
|
267
|
-
) -> None:
|
|
268
|
-
for item in items:
|
|
269
|
-
if (
|
|
270
|
-
config.stash[stash_keys.SHOULD_READ_WITH_STATE]
|
|
271
|
-
and "without_state" in item.keywords
|
|
272
|
-
):
|
|
273
|
-
item.add_marker(
|
|
274
|
-
pytest.mark.skip(reason="Test is marked with without_state marker")
|
|
275
|
-
)
|
|
276
|
-
if (
|
|
277
|
-
not config.stash[stash_keys.SHOULD_READ_WITH_STATE]
|
|
278
|
-
and "with_state" in item.keywords
|
|
279
|
-
):
|
|
280
|
-
item.add_marker(
|
|
281
|
-
pytest.mark.skip(reason="Test is marked with with_state marker")
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def pytest_terminal_summary(
|
|
286
|
-
terminalreporter: SugarTerminalReporter, exitstatus: int, config: Config
|
|
287
|
-
) -> None:
|
|
288
|
-
config.stash[stash_keys.TEST_REPORT].update(ReportState.FINISHED)
|
|
289
|
-
config.stash[stash_keys.PRIVATE_DETAILS_REPORT].update(ReportState.FINISHED)
|
|
290
|
-
if not config.stash.get(stash_keys.IS_PERMITTED_BOOL, False):
|
|
291
|
-
# Don't display the prompt if the tests were not run due to inability to fetch config
|
|
292
|
-
clean_up_artifacts(MAIN_OUTPUT_DIRECTORY, LOGGER)
|
|
293
|
-
pytest.exit(str(NotPermittedError))
|
|
294
|
-
|
|
295
|
-
terminalreporter.ensure_newline()
|
|
296
|
-
terminalreporter.section("Test artifacts", sep="=", bold=True, blue=True)
|
|
297
|
-
terminalreporter.line(
|
|
298
|
-
f"All tests artifacts for this sessions should be available in {config.stash[stash_keys.TEST_ARTIFACT_DIRECTORY].resolve()}"
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
if not config.stash[stash_keys.RUN_IN_AIRBYTE_CI]:
|
|
302
|
-
try:
|
|
303
|
-
Prompt.ask(
|
|
304
|
-
textwrap.dedent(
|
|
305
|
-
"""
|
|
306
|
-
Test artifacts will be destroyed after this prompt.
|
|
307
|
-
Press enter when you're done reading them.
|
|
308
|
-
🚨 Do not copy them elsewhere on your disk!!! 🚨
|
|
309
|
-
"""
|
|
310
|
-
)
|
|
311
|
-
)
|
|
312
|
-
finally:
|
|
313
|
-
clean_up_artifacts(MAIN_OUTPUT_DIRECTORY, LOGGER)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def pytest_keyboard_interrupt(excinfo: Exception) -> None:
|
|
317
|
-
LOGGER.error(
|
|
318
|
-
"Test execution was interrupted by the user. Cleaning up test artifacts."
|
|
319
|
-
)
|
|
320
|
-
clean_up_artifacts(MAIN_OUTPUT_DIRECTORY, LOGGER)
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
|
324
|
-
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo) -> Generator:
|
|
325
|
-
outcome = yield
|
|
326
|
-
report = outcome.get_result()
|
|
327
|
-
|
|
328
|
-
# Overwrite test failures with passes for tests being run in diagnostic mode
|
|
329
|
-
if (
|
|
330
|
-
item.config.stash.get(
|
|
331
|
-
stash_keys.TEST_EVALUATION_MODE, TestEvaluationMode.STRICT
|
|
332
|
-
)
|
|
333
|
-
== TestEvaluationMode.DIAGNOSTIC
|
|
334
|
-
and "allow_diagnostic_mode" in item.keywords
|
|
335
|
-
):
|
|
336
|
-
if call.when == "call":
|
|
337
|
-
if call.excinfo:
|
|
338
|
-
if report.outcome == "failed":
|
|
339
|
-
report.outcome = "passed"
|
|
340
|
-
|
|
341
|
-
# This is to add skipped or failed tests due to upstream fixture failures on setup
|
|
342
|
-
if report.outcome in ["failed", "skipped"] or report.when == "call":
|
|
343
|
-
item.config.stash[stash_keys.TEST_REPORT].add_test_result(
|
|
344
|
-
report,
|
|
345
|
-
item.function.__doc__, # type: ignore
|
|
346
|
-
)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
# HELPERS
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
def get_option_or_fail(config: pytest.Config, option: str) -> str:
|
|
353
|
-
if option_value := config.getoption(option):
|
|
354
|
-
return option_value
|
|
355
|
-
pytest.fail(f"Missing required option: {option}")
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
def get_control_version(config: pytest.Config) -> str:
|
|
359
|
-
if control_version := config.getoption("--control-version"):
|
|
360
|
-
return control_version
|
|
361
|
-
if connector_docker_repository := config.getoption("--connector-image"):
|
|
362
|
-
return get_current_docker_image_tag(connector_docker_repository)
|
|
363
|
-
raise ValueError(
|
|
364
|
-
"The control version can't be determined, please pass a --control-version or a --connector-image"
|
|
365
|
-
)
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
def prompt_for_confirmation(user_email: str) -> None:
|
|
369
|
-
message = textwrap.dedent(
|
|
370
|
-
f"""
|
|
371
|
-
👮 This program is running on live Airbyte Cloud connection.
|
|
372
|
-
It means that it might induce costs or rate limits on the source.
|
|
373
|
-
This program is storing tests artifacts in {MAIN_OUTPUT_DIRECTORY.resolve()} that you can use for debugging. They will get destroyed after the program execution.
|
|
374
|
-
|
|
375
|
-
By approving this prompt, you ({user_email}) confirm that:
|
|
376
|
-
1. You understand the implications of running this test suite.
|
|
377
|
-
2. You have selected the correct target and control versions.
|
|
378
|
-
3. You have selected the right tests according to your testing needs.
|
|
379
|
-
4. You will not copy the test artifacts content.
|
|
380
|
-
5. You want to run the program on the passed connection ID.
|
|
381
|
-
|
|
382
|
-
Usage of this tool is tracked and logged.
|
|
383
|
-
|
|
384
|
-
Do you want to continue?
|
|
385
|
-
"""
|
|
386
|
-
)
|
|
387
|
-
if not os.environ.get("CI") and not Confirm.ask(message):
|
|
388
|
-
pytest.exit("Test execution was interrupted by the user.")
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
def prompt_for_read_with_or_without_state() -> bool:
|
|
392
|
-
message = textwrap.dedent(
|
|
393
|
-
"""
|
|
394
|
-
📖 Do you want to run the read command with or without state?
|
|
395
|
-
1. Run the read command with state
|
|
396
|
-
2. Run the read command without state
|
|
397
|
-
|
|
398
|
-
We recommend reading with state to properly test incremental sync.
|
|
399
|
-
But if the target version introduces a breaking change in the state, you might want to run without state.
|
|
400
|
-
"""
|
|
401
|
-
)
|
|
402
|
-
return Prompt.ask(message) == "1"
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
# FIXTURES
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
@pytest.fixture(scope="session")
|
|
409
|
-
def anyio_backend() -> str:
|
|
410
|
-
return "asyncio"
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
@pytest.fixture(scope="session")
|
|
414
|
-
def test_artifacts_directory(request: SubRequest) -> Path:
|
|
415
|
-
return request.config.stash[stash_keys.TEST_ARTIFACT_DIRECTORY]
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
@pytest.fixture(scope="session")
|
|
419
|
-
def connector_image(request: SubRequest) -> str:
|
|
420
|
-
return request.config.stash[stash_keys.CONNECTOR_IMAGE]
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
@pytest.fixture(scope="session")
|
|
424
|
-
def control_version(request: SubRequest) -> str:
|
|
425
|
-
return request.config.stash[stash_keys.CONTROL_VERSION]
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
@pytest.fixture(scope="session")
|
|
429
|
-
def target_version(request: SubRequest) -> str:
|
|
430
|
-
return request.config.stash[stash_keys.TARGET_VERSION]
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
@pytest.fixture(scope="session")
|
|
434
|
-
def all_connection_objects(request: SubRequest) -> List[ConnectionObjects]:
|
|
435
|
-
return request.config.stash[stash_keys.ALL_CONNECTION_OBJECTS]
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
def get_connector_config(
|
|
439
|
-
connection_objects: ConnectionObjects, control_connector: ConnectorUnderTest
|
|
440
|
-
) -> Optional[SecretDict]:
|
|
441
|
-
if control_connector.actor_type is ActorType.SOURCE:
|
|
442
|
-
return connection_objects.source_config
|
|
443
|
-
elif control_connector.actor_type is ActorType.DESTINATION:
|
|
444
|
-
return connection_objects.destination_config
|
|
445
|
-
else:
|
|
446
|
-
raise ValueError(f"Actor type {control_connector.actor_type} is not supported")
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
@pytest.fixture(scope="session")
|
|
450
|
-
def actor_id(
|
|
451
|
-
connection_objects: ConnectionObjects, control_connector: ConnectorUnderTest
|
|
452
|
-
) -> str | None:
|
|
453
|
-
if control_connector.actor_type is ActorType.SOURCE:
|
|
454
|
-
return connection_objects.source_id
|
|
455
|
-
elif control_connector.actor_type is ActorType.DESTINATION:
|
|
456
|
-
return connection_objects.destination_id
|
|
457
|
-
else:
|
|
458
|
-
raise ValueError(f"Actor type {control_connector.actor_type} is not supported")
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
def get_actor_id(
|
|
462
|
-
connection_objects: ConnectionObjects, control_connector: ConnectorUnderTest
|
|
463
|
-
) -> str | None:
|
|
464
|
-
if control_connector.actor_type is ActorType.SOURCE:
|
|
465
|
-
return connection_objects.source_id
|
|
466
|
-
elif control_connector.actor_type is ActorType.DESTINATION:
|
|
467
|
-
return connection_objects.destination_id
|
|
468
|
-
else:
|
|
469
|
-
raise ValueError(f"Actor type {control_connector.actor_type} is not supported")
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
@pytest.fixture(scope="session")
|
|
473
|
-
def configured_streams(
|
|
474
|
-
configured_catalog: ConfiguredAirbyteCatalog,
|
|
475
|
-
) -> Iterable[str]:
|
|
476
|
-
return {stream.stream.name for stream in configured_catalog.streams}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
@pytest.fixture(scope="session")
|
|
480
|
-
def state(connection_objects: ConnectionObjects) -> Optional[dict]:
|
|
481
|
-
return connection_objects.state
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
@pytest.fixture(scope="session")
|
|
485
|
-
def dagger_connection(request: SubRequest) -> dagger.Connection:
|
|
486
|
-
return dagger.Connection(
|
|
487
|
-
dagger.Config(
|
|
488
|
-
log_output=request.config.stash[stash_keys.DAGGER_LOG_PATH].open("w")
|
|
489
|
-
)
|
|
490
|
-
)
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
@pytest.fixture(scope="session", autouse=True)
|
|
494
|
-
async def dagger_client(
|
|
495
|
-
dagger_connection: dagger.Connection,
|
|
496
|
-
) -> AsyncIterable[dagger.Client]:
|
|
497
|
-
async with dagger_connection as client:
|
|
498
|
-
yield client
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
@pytest.fixture(scope="session")
|
|
502
|
-
async def control_connector(
|
|
503
|
-
dagger_client: dagger.Client, connector_image: str, control_version: str
|
|
504
|
-
) -> ConnectorUnderTest:
|
|
505
|
-
return await ConnectorUnderTest.from_image_name(
|
|
506
|
-
dagger_client, f"{connector_image}:{control_version}", TargetOrControl.CONTROL
|
|
507
|
-
)
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
@pytest.fixture(scope="session")
|
|
511
|
-
async def target_connector(
|
|
512
|
-
dagger_client: dagger.Client, connector_image: str, target_version: str
|
|
513
|
-
) -> ConnectorUnderTest:
|
|
514
|
-
return await ConnectorUnderTest.from_image_name(
|
|
515
|
-
dagger_client, f"{connector_image}:{target_version}", TargetOrControl.TARGET
|
|
516
|
-
)
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
@pytest.fixture(scope="session")
|
|
520
|
-
def duckdb_path(request: SubRequest) -> Path:
|
|
521
|
-
return request.config.stash[stash_keys.DUCKDB_PATH]
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
def get_execution_inputs_for_command(
|
|
525
|
-
command: Command,
|
|
526
|
-
connection_objects: ConnectionObjects,
|
|
527
|
-
control_connector: ConnectorUnderTest,
|
|
528
|
-
test_artifacts_directory: Path,
|
|
529
|
-
duckdb_path: Path,
|
|
530
|
-
) -> ExecutionInputs:
|
|
531
|
-
"""Get the execution inputs for the given command and connection objects."""
|
|
532
|
-
actor_id = get_actor_id(connection_objects, control_connector)
|
|
533
|
-
|
|
534
|
-
inputs_arguments = {
|
|
535
|
-
"hashed_connection_id": connection_objects.hashed_connection_id,
|
|
536
|
-
"connector_under_test": control_connector,
|
|
537
|
-
"actor_id": actor_id,
|
|
538
|
-
"global_output_dir": test_artifacts_directory,
|
|
539
|
-
"command": command,
|
|
540
|
-
"duckdb_path": duckdb_path,
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
if command.needs_config:
|
|
544
|
-
connector_config = get_connector_config(connection_objects, control_connector)
|
|
545
|
-
if not connector_config:
|
|
546
|
-
pytest.skip("Config is not provided. The config fixture can't be used.")
|
|
547
|
-
inputs_arguments["config"] = connector_config
|
|
548
|
-
if command.needs_catalog:
|
|
549
|
-
configured_catalog = connection_objects.configured_catalog
|
|
550
|
-
if not configured_catalog:
|
|
551
|
-
pytest.skip("Catalog is not provided. The catalog fixture can't be used.")
|
|
552
|
-
inputs_arguments["configured_catalog"] = connection_objects.configured_catalog
|
|
553
|
-
if command.needs_state:
|
|
554
|
-
state = connection_objects.state
|
|
555
|
-
if not state:
|
|
556
|
-
pytest.skip("State is not provided. The state fixture can't be used.")
|
|
557
|
-
inputs_arguments["state"] = state
|
|
558
|
-
|
|
559
|
-
return ExecutionInputs(**inputs_arguments)
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
async def run_command(
|
|
563
|
-
dagger_client: dagger.Client,
|
|
564
|
-
command: Command,
|
|
565
|
-
connection_objects: ConnectionObjects,
|
|
566
|
-
connector: ConnectorUnderTest,
|
|
567
|
-
test_artifacts_directory: Path,
|
|
568
|
-
duckdb_path: Path,
|
|
569
|
-
runs_in_ci,
|
|
570
|
-
disable_proxy: bool = False,
|
|
571
|
-
) -> ExecutionResult:
|
|
572
|
-
"""Run the given command for the given connector and connection objects."""
|
|
573
|
-
execution_inputs = get_execution_inputs_for_command(
|
|
574
|
-
command, connection_objects, connector, test_artifacts_directory, duckdb_path
|
|
575
|
-
)
|
|
576
|
-
logging.info(
|
|
577
|
-
f"Running {command} for {connector.target_or_control.value} connector {execution_inputs.connector_under_test.name}"
|
|
578
|
-
)
|
|
579
|
-
proxy = None
|
|
580
|
-
|
|
581
|
-
if not disable_proxy:
|
|
582
|
-
proxy_hostname = f"proxy_server_{command.value}_{execution_inputs.connector_under_test.version.replace('.', '_')}"
|
|
583
|
-
proxy = Proxy(dagger_client, proxy_hostname, connection_objects.connection_id)
|
|
584
|
-
|
|
585
|
-
runner = ConnectorRunner(
|
|
586
|
-
dagger_client, execution_inputs, runs_in_ci, http_proxy=proxy
|
|
587
|
-
)
|
|
588
|
-
execution_result = await runner.run()
|
|
589
|
-
return execution_result, proxy
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
async def run_command_and_add_to_report(
|
|
593
|
-
dagger_client: dagger.Client,
|
|
594
|
-
command: Command,
|
|
595
|
-
connection_objects: ConnectionObjects,
|
|
596
|
-
connector: ConnectorUnderTest,
|
|
597
|
-
test_artifacts_directory: Path,
|
|
598
|
-
duckdb_path: Path,
|
|
599
|
-
runs_in_ci,
|
|
600
|
-
test_report: TestReport,
|
|
601
|
-
private_details_report: PrivateDetailsReport,
|
|
602
|
-
disable_proxy: bool = False,
|
|
603
|
-
) -> ExecutionResult:
|
|
604
|
-
"""Run the given command for the given connector and connection objects and add the results to the test report."""
|
|
605
|
-
execution_result, proxy = await run_command(
|
|
606
|
-
dagger_client,
|
|
607
|
-
command,
|
|
608
|
-
connection_objects,
|
|
609
|
-
connector,
|
|
610
|
-
test_artifacts_directory,
|
|
611
|
-
duckdb_path,
|
|
612
|
-
runs_in_ci,
|
|
613
|
-
disable_proxy=disable_proxy,
|
|
614
|
-
)
|
|
615
|
-
if connector.target_or_control is TargetOrControl.CONTROL:
|
|
616
|
-
test_report.add_control_execution_result(execution_result)
|
|
617
|
-
private_details_report.add_control_execution_result(execution_result)
|
|
618
|
-
if connector.target_or_control is TargetOrControl.TARGET:
|
|
619
|
-
test_report.add_target_execution_result(execution_result)
|
|
620
|
-
private_details_report.add_target_execution_result(execution_result)
|
|
621
|
-
return execution_result, proxy
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
def generate_execution_results_fixture(
|
|
625
|
-
command: Command, control_or_target: str
|
|
626
|
-
) -> Callable:
|
|
627
|
-
"""Dynamically generate the fixture for the given command and control/target.
|
|
628
|
-
This is mainly to avoid code duplication and to make the code more maintainable.
|
|
629
|
-
Declaring this explicitly for each command and control/target combination would be cumbersome.
|
|
630
|
-
"""
|
|
631
|
-
|
|
632
|
-
if control_or_target not in ["control", "target"]:
|
|
633
|
-
raise ValueError("control_or_target should be either 'control' or 'target'")
|
|
634
|
-
if command not in [
|
|
635
|
-
Command.SPEC,
|
|
636
|
-
Command.CHECK,
|
|
637
|
-
Command.DISCOVER,
|
|
638
|
-
Command.READ,
|
|
639
|
-
Command.READ_WITH_STATE,
|
|
640
|
-
]:
|
|
641
|
-
raise ValueError(
|
|
642
|
-
"command should be either 'spec', 'check', 'discover', 'read' or 'read_with_state'"
|
|
643
|
-
)
|
|
644
|
-
|
|
645
|
-
if control_or_target == "control":
|
|
646
|
-
|
|
647
|
-
@pytest.fixture(scope="session")
|
|
648
|
-
async def generated_fixture(
|
|
649
|
-
request: SubRequest,
|
|
650
|
-
dagger_client: dagger.Client,
|
|
651
|
-
control_connector: ConnectorUnderTest,
|
|
652
|
-
test_artifacts_directory: Path,
|
|
653
|
-
) -> ExecutionResult:
|
|
654
|
-
connection_objects = request.param
|
|
655
|
-
disable_proxy = request.config.stash[stash_keys.DISABLE_PROXY]
|
|
656
|
-
|
|
657
|
-
execution_results, proxy = await run_command_and_add_to_report(
|
|
658
|
-
dagger_client,
|
|
659
|
-
command,
|
|
660
|
-
connection_objects,
|
|
661
|
-
control_connector,
|
|
662
|
-
test_artifacts_directory,
|
|
663
|
-
request.config.stash[stash_keys.DUCKDB_PATH],
|
|
664
|
-
request.config.stash[stash_keys.RUN_IN_AIRBYTE_CI],
|
|
665
|
-
request.config.stash[stash_keys.TEST_REPORT],
|
|
666
|
-
request.config.stash[stash_keys.PRIVATE_DETAILS_REPORT],
|
|
667
|
-
disable_proxy=disable_proxy,
|
|
668
|
-
)
|
|
669
|
-
|
|
670
|
-
yield execution_results
|
|
671
|
-
|
|
672
|
-
if not disable_proxy:
|
|
673
|
-
await proxy.clear_cache_volume()
|
|
674
|
-
|
|
675
|
-
else:
|
|
676
|
-
|
|
677
|
-
@pytest.fixture(scope="session")
|
|
678
|
-
async def generated_fixture(
|
|
679
|
-
request: SubRequest,
|
|
680
|
-
dagger_client: dagger.Client,
|
|
681
|
-
target_connector: ConnectorUnderTest,
|
|
682
|
-
test_artifacts_directory: Path,
|
|
683
|
-
) -> ExecutionResult:
|
|
684
|
-
connection_objects = request.param
|
|
685
|
-
disable_proxy = request.config.stash[stash_keys.DISABLE_PROXY]
|
|
686
|
-
|
|
687
|
-
execution_results, proxy = await run_command_and_add_to_report(
|
|
688
|
-
dagger_client,
|
|
689
|
-
command,
|
|
690
|
-
connection_objects,
|
|
691
|
-
target_connector,
|
|
692
|
-
test_artifacts_directory,
|
|
693
|
-
request.config.stash[stash_keys.DUCKDB_PATH],
|
|
694
|
-
request.config.stash[stash_keys.RUN_IN_AIRBYTE_CI],
|
|
695
|
-
request.config.stash[stash_keys.TEST_REPORT],
|
|
696
|
-
request.config.stash[stash_keys.PRIVATE_DETAILS_REPORT],
|
|
697
|
-
disable_proxy=disable_proxy,
|
|
698
|
-
)
|
|
699
|
-
|
|
700
|
-
yield execution_results
|
|
701
|
-
|
|
702
|
-
if not disable_proxy:
|
|
703
|
-
await proxy.clear_cache_volume()
|
|
704
|
-
|
|
705
|
-
return generated_fixture
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
def inject_fixtures() -> set[str]:
|
|
709
|
-
"""Dynamically generate th execution result fixtures for all the combinations of commands and control/target.
|
|
710
|
-
The fixtures will be named as <command>_<control/target>_execution_result
|
|
711
|
-
Add the generated fixtures to the global namespace.
|
|
712
|
-
"""
|
|
713
|
-
execution_result_fixture_names = []
|
|
714
|
-
for command, control_or_target in product(
|
|
715
|
-
[command for command in Command], ["control", "target"]
|
|
716
|
-
):
|
|
717
|
-
fixture_name = f"{command.name.lower()}_{control_or_target}_execution_result"
|
|
718
|
-
globals()[fixture_name] = generate_execution_results_fixture(
|
|
719
|
-
command, control_or_target
|
|
720
|
-
)
|
|
721
|
-
execution_result_fixture_names.append(fixture_name)
|
|
722
|
-
return set(execution_result_fixture_names)
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
EXECUTION_RESULT_FIXTURES = inject_fixtures()
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
def pytest_generate_tests(metafunc):
|
|
729
|
-
"""This function is called for each test function.
|
|
730
|
-
It helps in parameterizing the test functions with the connection objects.
|
|
731
|
-
It will provide the connection objects to the "*_execution_result" fixtures as parameters.
|
|
732
|
-
This will make sure that the tests are run for all the connection objects available in the configuration.
|
|
733
|
-
"""
|
|
734
|
-
all_connection_objects = metafunc.config.stash[stash_keys.ALL_CONNECTION_OBJECTS]
|
|
735
|
-
requested_fixtures = [
|
|
736
|
-
fixture_name
|
|
737
|
-
for fixture_name in metafunc.fixturenames
|
|
738
|
-
if fixture_name in EXECUTION_RESULT_FIXTURES
|
|
739
|
-
]
|
|
740
|
-
assert isinstance(all_connection_objects, list), (
|
|
741
|
-
"all_connection_objects should be a list"
|
|
742
|
-
)
|
|
743
|
-
|
|
744
|
-
if not requested_fixtures:
|
|
745
|
-
return
|
|
746
|
-
metafunc.parametrize(
|
|
747
|
-
requested_fixtures,
|
|
748
|
-
[[c] * len(requested_fixtures) for c in all_connection_objects],
|
|
749
|
-
indirect=requested_fixtures,
|
|
750
|
-
ids=[f"CONNECTION {c.connection_id[:8]}" for c in all_connection_objects],
|
|
751
|
-
)
|