fivetran-connector-sdk 1.4.5__py3-none-any.whl → 1.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.
@@ -0,0 +1,928 @@
1
+ import os
2
+ import re
3
+ import ast
4
+ import json
5
+ import time
6
+ import socket
7
+ import platform
8
+ import subprocess
9
+ import requests as rq
10
+
11
+ from http import HTTPStatus
12
+ from typing import Optional, Tuple
13
+ from zipfile import ZipFile, ZIP_DEFLATED
14
+
15
+ from fivetran_connector_sdk.protos import common_pb2
16
+
17
+ from fivetran_connector_sdk import constants
18
+ from fivetran_connector_sdk.logger import Logging
19
+ from fivetran_connector_sdk.helpers import (
20
+ print_library_log,
21
+ get_renamed_table_name,
22
+ get_renamed_column_name
23
+ )
24
+ from fivetran_connector_sdk.constants import (
25
+ OS_MAP,
26
+ ARCH_MAP,
27
+ WIN_OS,
28
+ X64,
29
+ TESTER_FILENAME,
30
+ UPLOAD_FILENAME,
31
+ LAST_VERSION_CHECK_FILE,
32
+ ROOT_LOCATION,
33
+ CONFIG_FILE,
34
+ OUTPUT_FILES_DIR,
35
+ REQUIREMENTS_TXT,
36
+ PYPI_PACKAGE_DETAILS_URL,
37
+ ONE_DAY_IN_SEC,
38
+ MAX_RETRIES,
39
+ VIRTUAL_ENV_CONFIG,
40
+ ROOT_FILENAME,
41
+ EXCLUDED_DIRS,
42
+ EXCLUDED_PIPREQS_DIRS,
43
+ INSTALLATION_SCRIPT,
44
+ INSTALLATION_SCRIPT_MISSING_MESSAGE,
45
+ DRIVERS,
46
+ UTF_8,
47
+ CONNECTION_SCHEMA_NAME_PATTERN,
48
+ TABLES,
49
+ )
50
+
51
+ def check_newer_version(version: str):
52
+ """Periodically checks for a newer version of the SDK and notifies the user if one is available."""
53
+ tester_root_dir = tester_root_dir_helper()
54
+ last_check_file_path = os.path.join(tester_root_dir, LAST_VERSION_CHECK_FILE)
55
+ if not os.path.isdir(tester_root_dir):
56
+ os.makedirs(tester_root_dir, exist_ok=True)
57
+
58
+ if os.path.isfile(last_check_file_path):
59
+ # Is it time to check again?
60
+ with open(last_check_file_path, 'r', encoding=UTF_8) as f_in:
61
+ timestamp = int(f_in.read())
62
+ if (int(time.time()) - timestamp) < ONE_DAY_IN_SEC:
63
+ return
64
+
65
+ for index in range(MAX_RETRIES):
66
+ try:
67
+ # check version and save current time
68
+ response = rq.get(PYPI_PACKAGE_DETAILS_URL)
69
+ response.raise_for_status()
70
+ data = json.loads(response.text)
71
+ latest_version = data["info"]["version"]
72
+ if version < latest_version:
73
+ print_library_log(f"[notice] A new release of 'fivetran-connector-sdk' is available: {latest_version}")
74
+ print_library_log("[notice] To update, run: pip install --upgrade fivetran-connector-sdk")
75
+
76
+ with open(last_check_file_path, 'w', encoding=UTF_8) as f_out:
77
+ f_out.write(f"{int(time.time())}")
78
+ break
79
+ except Exception:
80
+ retry_after = 2 ** index
81
+ print_library_log(f"Unable to check if a newer version of `fivetran-connector-sdk` is available. "
82
+ f"Retrying again after {retry_after} seconds", Logging.Level.WARNING)
83
+ time.sleep(retry_after)
84
+
85
+
86
+ def tester_root_dir_helper() -> str:
87
+ """Returns the root directory for the tester."""
88
+ return os.path.join(os.path.expanduser("~"), ROOT_LOCATION)
89
+
90
+ def _warn_exit_usage(filename, line_no, func):
91
+ print_library_log(f"Avoid using {func} to exit from the Python code as this can cause the connector to become stuck. Throw a error if required " +
92
+ f"at: {filename}:{line_no}. See the Technical Reference for details: https://fivetran.com/docs/connector-sdk/technical-reference#handlingexceptions",
93
+ Logging.Level.WARNING)
94
+
95
+ def exit_check(project_path):
96
+ """Checks for the presence of 'exit()' in the calling code.
97
+ Args:
98
+ project_path: The absolute project_path to check exit in the connector.py file in the project.
99
+ """
100
+ # We expect the connector.py to catch errors or throw exceptions
101
+ # This is a warning shown to let the customer know that we expect either the yield call or error thrown
102
+ # exit() or sys.exit() in between some yields can cause the connector to be stuck without processing further upsert calls
103
+
104
+ filepath = os.path.join(project_path, ROOT_FILENAME)
105
+ with open(filepath, "r", encoding=UTF_8) as f:
106
+ try:
107
+ tree = ast.parse(f.read())
108
+ for node in ast.walk(tree):
109
+ if isinstance(node, ast.Call):
110
+ if isinstance(node.func, ast.Name) and node.func.id == "exit":
111
+ _warn_exit_usage(ROOT_FILENAME, node.lineno, "exit()")
112
+ elif isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
113
+ if node.func.attr == "_exit" and node.func.value.id == "os":
114
+ _warn_exit_usage(ROOT_FILENAME, node.lineno, "os._exit()")
115
+ if node.func.attr == "exit" and node.func.value.id == "sys":
116
+ _warn_exit_usage(ROOT_FILENAME, node.lineno, "sys.exit()")
117
+ except SyntaxError as e:
118
+ print_library_log(f"SyntaxError in {ROOT_FILENAME}: {e}", Logging.Level.SEVERE)
119
+
120
+
121
+ def check_dict(incoming: dict, string_only: bool = False) -> dict:
122
+ """Validates the incoming dictionary.
123
+ Args:
124
+ incoming (dict): The dictionary to validate.
125
+ string_only (bool): Whether to allow only string values.
126
+
127
+ Returns:
128
+ dict: The validated dictionary.
129
+ """
130
+
131
+ if not incoming:
132
+ return {}
133
+
134
+ if not isinstance(incoming, dict):
135
+ raise ValueError(
136
+ "Configuration must be provided as a JSON dictionary. Please check your input. Reference: https://fivetran.com/docs/connectors/connector-sdk/detailed-guide#workingwithconfigurationjsonfile")
137
+
138
+ if string_only:
139
+ for k, v in incoming.items():
140
+ if not isinstance(v, str):
141
+ print_library_log(
142
+ "All values in the configuration must be STRING. Please check your configuration and ensure that every value is a STRING.", Logging.Level.SEVERE)
143
+ os._exit(1)
144
+
145
+ return incoming
146
+
147
+
148
+ def is_connection_name_valid(connection: str):
149
+ """Validates if the incoming connection schema name is valid or not.
150
+ Args:
151
+ connection (str): The connection schema name being validated.
152
+
153
+ Returns:
154
+ bool: True if connection name is valid.
155
+ """
156
+
157
+ pattern = re.compile(CONNECTION_SCHEMA_NAME_PATTERN)
158
+ return pattern.match(connection)
159
+
160
+
161
+ def log_unused_deps_error(package_name: str, version: str):
162
+ print_library_log(f"Please remove `{package_name}` from requirements.txt."
163
+ f" The latest version of `{package_name}` is always available when executing your code."
164
+ f" Current version: {version}", Logging.Level.SEVERE)
165
+
166
+
167
+ def validate_deploy_parameters(connection, deploy_key):
168
+ if not deploy_key or not connection:
169
+ print_library_log("The deploy command needs the following parameters:"
170
+ "\n\tRequired:\n"
171
+ "\t\t--api-key <BASE64-ENCODED-FIVETRAN-API-KEY-FOR-DEPLOYMENT>\n"
172
+ "\t\t--connection <VALID-CONNECTOR-SCHEMA_NAME>\n"
173
+ "\t(Optional):\n"
174
+ "\t\t--destination <DESTINATION_NAME> (Becomes required if there are multiple destinations)\n"
175
+ "\t\t--configuration <CONFIGURATION_FILE> (Completely replaces the existing configuration)", Logging.Level.SEVERE)
176
+ os._exit(1)
177
+ elif not is_connection_name_valid(connection):
178
+ print_library_log(f"Connection name: {connection} is invalid!\n The connection name should start with an "
179
+ f"underscore or a lowercase letter (a-z), followed by any combination of underscores, lowercase "
180
+ f"letters, or digits (0-9). Uppercase characters are not allowed.", Logging.Level.SEVERE)
181
+ os._exit(1)
182
+
183
+
184
+ def is_port_in_use(port: int):
185
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
186
+ return s.connect_ex(('127.0.0.1', port)) == 0
187
+
188
+
189
+ def get_available_port():
190
+ for port in range(50051, 50061):
191
+ if not is_port_in_use(port):
192
+ return port
193
+ return None
194
+
195
+
196
+ def update_base_url_if_required():
197
+ config_file_path = os.path.join(tester_root_dir_helper(), CONFIG_FILE)
198
+ if os.path.isfile(config_file_path):
199
+ with open(config_file_path, 'r', encoding=UTF_8) as f:
200
+ data = json.load(f)
201
+ base_url = data.get('production_base_url')
202
+ if base_url is not None:
203
+ constants.PRODUCTION_BASE_URL = base_url
204
+ print_library_log(f"Updating PRODUCTION_BASE_URL to: {base_url}")
205
+
206
+ def fetch_requirements_from_file(file_path: str) -> list[str]:
207
+ """Reads a requirements file and returns a list of dependencies.
208
+
209
+ Args:
210
+ file_path (str): The path to the requirements file.
211
+
212
+ Returns:
213
+ list[str]: A list of dependencies as strings.
214
+ """
215
+ with open(file_path, 'r', encoding=UTF_8) as f:
216
+ return f.read().splitlines()
217
+
218
+ def fetch_requirements_as_dict(file_path: str) -> dict:
219
+ """Converts a list of dependencies from the requirements file into a dictionary.
220
+
221
+ Args:
222
+ file_path (str): The path to the requirements file.
223
+
224
+ Returns:
225
+ dict: A dictionary where keys are package names (lowercased) and
226
+ values are the full dependency strings.
227
+ """
228
+ requirements_dict = {}
229
+ if not os.path.exists(file_path):
230
+ return requirements_dict
231
+ for requirement in fetch_requirements_from_file(file_path):
232
+ requirement = requirement.strip()
233
+ if not requirement or requirement.startswith("#"): # Skip empty lines and comments
234
+ continue
235
+ try:
236
+ key = re.split(r"==|>=|<=|>|<", requirement)[0]
237
+ requirements_dict[key.lower().replace('-', '_')] = requirement.lower()
238
+ except ValueError:
239
+ print_library_log(f"Invalid requirement format: '{requirement}'", Logging.Level.SEVERE)
240
+ return requirements_dict
241
+
242
+ def validate_requirements_file(project_path: str, is_deploy: bool, version: str , force: bool = False,):
243
+ """Validates the `requirements.txt` file against the project's actual dependencies.
244
+
245
+ This method generates a temporary requirements file using `pipreqs`, compares
246
+ it with the existing `requirements.txt`, and checks for version mismatches,
247
+ missing dependencies, and unused dependencies. It will issue warnings, errors,
248
+ or even terminate the process depending on whether it's being run for deployment.
249
+
250
+ Args:
251
+ project_path (str): The path to the project directory containing the `requirements.txt`.
252
+ is_deploy (bool): If `True`, the method will exit the process on critical errors.
253
+ version (str): The current version of the connector.
254
+ force (bool): Force update an existing connection.
255
+
256
+ """
257
+ # Detect and exclude virtual environment directories
258
+ venv_dirs = [name for name in os.listdir(project_path)
259
+ if os.path.isdir(os.path.join(project_path, name)) and
260
+ VIRTUAL_ENV_CONFIG in os.listdir(os.path.join(project_path, name))]
261
+
262
+ ignored_dirs = EXCLUDED_PIPREQS_DIRS + venv_dirs if venv_dirs else EXCLUDED_PIPREQS_DIRS
263
+
264
+ # tmp_requirements is only generated when pipreqs command is successful
265
+ requirements_file_path = os.path.join(project_path, REQUIREMENTS_TXT)
266
+ tmp_requirements_file_path = os.path.join(project_path, 'tmp_requirements.txt')
267
+ # copying packages of requirements file to tmp file to handle pipreqs fail use-case
268
+ copy_requirements_file_to_tmp_requirements_file(requirements_file_path, tmp_requirements_file_path)
269
+ # Run the pipreqs command and capture stderr
270
+ attempt = 0
271
+ while attempt < MAX_RETRIES:
272
+ attempt += 1
273
+ result = subprocess.run(
274
+ ["pipreqs", project_path, "--savepath", tmp_requirements_file_path, "--ignore", ",".join(ignored_dirs)],
275
+ stderr=subprocess.PIPE,
276
+ text=True # Ensures output is in string format
277
+ )
278
+
279
+ if result.returncode == 0:
280
+ break
281
+
282
+ print_library_log(f"Attempt {attempt}: pipreqs check failed.", Logging.Level.WARNING)
283
+
284
+ if attempt < MAX_RETRIES:
285
+ retry_after = 3 ** attempt
286
+ print_library_log(f"Retrying in {retry_after} seconds...", Logging.Level.SEVERE)
287
+ time.sleep(retry_after)
288
+ else:
289
+ print_library_log(f"pipreqs failed after {MAX_RETRIES} attempts with:", Logging.Level.SEVERE)
290
+ print_library_log(result.stderr, Logging.Level.SEVERE)
291
+ print_library_log(f"Skipping validation of requirements.txt due to error connecting to PyPI (Python Package Index) APIs. Continuing with {'deploy' if is_deploy else 'debug'}...", Logging.Level.SEVERE)
292
+
293
+ tmp_requirements = fetch_requirements_as_dict(tmp_requirements_file_path)
294
+ remove_unwanted_packages(tmp_requirements)
295
+ delete_file_if_exists(tmp_requirements_file_path)
296
+
297
+ # remove corrupt requirements listed by pipreqs
298
+ corrupt_requirements = [key for key in tmp_requirements if key.startswith("~")]
299
+ for requirement in corrupt_requirements:
300
+ del tmp_requirements[requirement]
301
+
302
+ update_version_requirements = False
303
+ update_missing_requirements = False
304
+ update_unused_requirements = False
305
+ if len(tmp_requirements) > 0:
306
+ requirements = load_or_add_requirements_file(requirements_file_path)
307
+
308
+ version_mismatch_deps = {key: tmp_requirements[key] for key in
309
+ (requirements.keys() & tmp_requirements.keys())
310
+ if requirements[key] != tmp_requirements[key]}
311
+ if version_mismatch_deps:
312
+ print_library_log("We recommend using the current stable version for the following libraries:", Logging.Level.WARNING)
313
+ print(version_mismatch_deps)
314
+ if is_deploy and not force:
315
+ confirm = input(
316
+ f"Would you like us to update {REQUIREMENTS_TXT} to the current stable versions of the dependent libraries? (Y/N):")
317
+ if confirm.lower() == "y":
318
+ update_version_requirements = True
319
+ for requirement in version_mismatch_deps:
320
+ requirements[requirement] = tmp_requirements[requirement]
321
+ print_library_log(
322
+ f"Successfully updated {REQUIREMENTS_TXT} to the current stable versions of the dependent libraries.")
323
+ elif confirm.lower() == "n":
324
+ print_library_log(f"Changes identified for libraries with version conflicts have been ignored. These changes have NOT been made to {REQUIREMENTS_TXT}.")
325
+
326
+ missing_deps = {key: tmp_requirements[key] for key in (tmp_requirements.keys() - requirements.keys())}
327
+ if missing_deps:
328
+ handle_missing_deps(missing_deps)
329
+ if is_deploy and not force:
330
+ confirm = input(
331
+ f"Would you like us to update {REQUIREMENTS_TXT} to add missing dependent libraries? (Y/N):")
332
+ if confirm.lower() == "n":
333
+ print_library_log(f"Changes identified as missing dependencies for libraries have been ignored. These changes have NOT been made to {REQUIREMENTS_TXT}.")
334
+ elif confirm.lower() == "y":
335
+ update_missing_requirements = True
336
+ for requirement in missing_deps:
337
+ requirements[requirement] = tmp_requirements[requirement]
338
+ print_library_log(f"Successfully added missing dependencies to {REQUIREMENTS_TXT}.")
339
+
340
+ unused_deps = list(requirements.keys() - tmp_requirements.keys())
341
+ if unused_deps:
342
+ handle_unused_deps(unused_deps, version)
343
+ if is_deploy and not force:
344
+ confirm = input(f"Would you like us to update {REQUIREMENTS_TXT} to remove the unused libraries? (Y/N):")
345
+ if confirm.lower() == "n":
346
+ if 'fivetran_connector_sdk' in unused_deps or 'requests' in unused_deps:
347
+ print_library_log(
348
+ f"Please fix your {REQUIREMENTS_TXT} file by removing pre-installed dependencies [fivetran_connector_sdk, requests] to proceed with the deployment.")
349
+ os._exit(1)
350
+ print_library_log(f"Changes identified for unused libraries have been ignored. These changes have NOT been made to {REQUIREMENTS_TXT}.")
351
+ elif confirm.lower() == "y":
352
+ update_unused_requirements = True
353
+ for requirement in unused_deps:
354
+ del requirements[requirement]
355
+ print_library_log(f"Successfully removed unused libraries from {REQUIREMENTS_TXT}.")
356
+
357
+
358
+ if update_version_requirements or update_missing_requirements or update_unused_requirements:
359
+ with open(requirements_file_path, "w", encoding=UTF_8) as file:
360
+ file.write("\n".join(requirements.values()))
361
+ print_library_log(f"`{REQUIREMENTS_TXT}` has been updated successfully.")
362
+
363
+ else:
364
+ if os.path.exists(requirements_file_path):
365
+ print_library_log(f"{REQUIREMENTS_TXT} is not required as no additional "
366
+ "Python libraries are required or all required libraries for "
367
+ "your code are pre-installed.", Logging.Level.WARNING)
368
+ with open(requirements_file_path, 'w') as file:
369
+ file.write("")
370
+
371
+
372
+ if is_deploy: print_library_log(f"Validation of {REQUIREMENTS_TXT} completed.")
373
+
374
+ def handle_unused_deps(unused_deps, version):
375
+ if 'fivetran_connector_sdk' in unused_deps:
376
+ log_unused_deps_error("fivetran_connector_sdk", version)
377
+ if 'requests' in unused_deps:
378
+ log_unused_deps_error("requests", "2.32.3")
379
+ print_library_log("The following dependencies are not needed, "
380
+ f"they are not used or already installed. Please remove them from {REQUIREMENTS_TXT}:", Logging.Level.WARNING)
381
+ print(*unused_deps)
382
+
383
+ def handle_missing_deps(missing_deps):
384
+ print_library_log(f"Please include the following dependency libraries in {REQUIREMENTS_TXT}, to be used by "
385
+ "Fivetran production. "
386
+ "For more information, please visit: "
387
+ "https://fivetran.com/docs/connectors/connector-sdk/detailed-guide"
388
+ "#workingwithrequirementstxtfile", Logging.Level.SEVERE)
389
+ print(*list(missing_deps.values()))
390
+
391
+ def load_or_add_requirements_file(requirements_file_path):
392
+ if os.path.exists(requirements_file_path):
393
+ requirements = fetch_requirements_as_dict(requirements_file_path)
394
+ else:
395
+ with open(requirements_file_path, 'w', encoding=UTF_8):
396
+ pass
397
+ requirements = {}
398
+ print_library_log("Adding `requirements.txt` file to your project folder.", Logging.Level.WARNING)
399
+ return requirements
400
+
401
+ def copy_requirements_file_to_tmp_requirements_file(requirements_file_path: str, tmp_requirements_file_path):
402
+ if os.path.exists(requirements_file_path):
403
+ requirements_file_content = fetch_requirements_from_file(requirements_file_path)
404
+ with open(tmp_requirements_file_path, 'w') as file:
405
+ file.write("\n".join(requirements_file_content))
406
+
407
+ def remove_unwanted_packages(requirements: dict):
408
+ # remove the `fivetran_connector_sdk` and `requests` packages from requirements as we already pre-installed them.
409
+ if requirements.get("fivetran_connector_sdk") is not None:
410
+ requirements.pop("fivetran_connector_sdk")
411
+ if requirements.get('requests') is not None:
412
+ requirements.pop("requests")
413
+
414
+
415
+ def upload_project(project_path: str, deploy_key: str, group_id: str, group_name: str, connection: str):
416
+ print_library_log(
417
+ f"Deploying '{project_path}' to connection '{connection}' in destination '{group_name}'.\n")
418
+ upload_file_path = create_upload_file(project_path)
419
+ upload_result = upload(
420
+ upload_file_path, deploy_key, group_id, connection)
421
+ delete_file_if_exists(upload_file_path)
422
+ if not upload_result:
423
+ os._exit(1)
424
+
425
+
426
+ def cleanup_uploaded_project(deploy_key: str, group_id: str, connection: str):
427
+ cleanup_result = cleanup_uploaded_code(deploy_key, group_id, connection)
428
+ if not cleanup_result:
429
+ os._exit(1)
430
+
431
+ def update_connection(args: dict, id: str, name: str, group: str, config: dict, deploy_key: str, hd_agent_id: str):
432
+ """Updates the connection with the given ID, name, group, configuration, and deployment key.
433
+
434
+ Args:
435
+ args (dict): The command arguments.
436
+ id (str): The connection ID.
437
+ name (str): The connection name.
438
+ group (str): The group name.
439
+ config (dict): The configuration dictionary.
440
+ deploy_key (str): The deployment key.
441
+ hd_agent_id (str): The hybrid deployment agent ID within the Fivetran system.
442
+ """
443
+ if not args.configuration:
444
+ del config["secrets_list"]
445
+
446
+ json_payload = {
447
+ "config": config,
448
+ "run_setup_tests": True
449
+ }
450
+
451
+ # hybrid_deployment_agent_id is optional when redeploying your connection.
452
+ # Customer can use it to change existing hybrid_deployment_agent_id.
453
+ if hd_agent_id:
454
+ json_payload["hybrid_deployment_agent_id"] = hd_agent_id
455
+
456
+ response = rq.patch(f"{constants.PRODUCTION_BASE_URL}/v1/connectors/{id}",
457
+ headers={"Authorization": f"Basic {deploy_key}"},
458
+ json=json_payload)
459
+
460
+ if response.ok and response.status_code == HTTPStatus.OK:
461
+ if are_setup_tests_failing(response):
462
+ handle_failing_tests_message_and_exit(response,"The connection was updated, but setup tests failed!")
463
+ else:
464
+ print_library_log(f"Connection '{name}' in group '{group}' updated successfully.", Logging.Level.INFO)
465
+
466
+ else:
467
+ print_library_log(
468
+ f"Unable to update Connection '{name}' in destination '{group}', failed with error: '{response.json()['message']}'.",
469
+ Logging.Level.SEVERE)
470
+ os._exit(1)
471
+ return response
472
+
473
+ def handle_failing_tests_message_and_exit(resp, log_message):
474
+ print_library_log(log_message, Logging.Level.SEVERE)
475
+ print_failing_setup_tests(resp)
476
+ connection_id = resp.json().get('data', {}).get('id')
477
+ print_library_log(f"Connection ID: {connection_id}")
478
+ print_library_log("Please try again with the deploy command after resolving the issue!")
479
+ os._exit(1)
480
+
481
+ def are_setup_tests_failing(response) -> bool:
482
+ """Checks for failed setup tests in the response and returns True if any test has failed, otherwise False."""
483
+ response_json = response.json()
484
+ setup_tests = response_json.get("data", {}).get("setup_tests", [])
485
+
486
+ # Return True if any test has "FAILED" status, otherwise False
487
+ return any(test.get("status") == "FAILED" or test.get("status") == "JOB_FAILED" for test in setup_tests)
488
+
489
+ def print_failing_setup_tests(response):
490
+ """Checks for failed setup tests in the response and print errors."""
491
+ response_json = response.json()
492
+ setup_tests = response_json.get("data", {}).get("setup_tests", [])
493
+
494
+ # Collect failed setup tests
495
+ failed_tests = [test for test in setup_tests if
496
+ test.get("status") == "FAILED" or test.get("status") == "JOB_FAILED"]
497
+
498
+ if failed_tests:
499
+ print_library_log("Following setup tests have failed!", Logging.Level.WARNING)
500
+ for test in failed_tests:
501
+ print_library_log(f"Test: {test.get('title')}", Logging.Level.WARNING)
502
+ print_library_log(f"Status: {test.get('status')}", Logging.Level.WARNING)
503
+ print_library_log(f"Message: {test.get('message')}", Logging.Level.WARNING)
504
+
505
+ def get_connection_id(name: str, group: str, group_id: str, deploy_key: str) -> Optional[Tuple[str, str]]:
506
+ """Retrieves the connection ID for the specified connection schema name, group, and deployment key.
507
+
508
+ Args:
509
+ name (str): The connection name.
510
+ group (str): The group name.
511
+ group_id (str): The group ID.
512
+ deploy_key (str): The deployment key.
513
+
514
+ Returns:
515
+ str: The connection ID, or None
516
+ """
517
+ resp = rq.get(f"{constants.PRODUCTION_BASE_URL}/v1/groups/{group_id}/connectors",
518
+ headers={"Authorization": f"Basic {deploy_key}"},
519
+ params={"schema": name})
520
+ if not resp.ok:
521
+ print_library_log(
522
+ f"Unable to fetch connection list in destination '{group}'", Logging.Level.SEVERE)
523
+ os._exit(1)
524
+
525
+ if resp.json()['data']['items']:
526
+ return resp.json()['data']['items'][0]['id'], resp.json()['data']['items'][0]['service']
527
+
528
+ return None
529
+
530
+ def create_connection(deploy_key: str, group_id: str, config: dict, hd_agent_id: str) -> rq.Response:
531
+ """Creates a new connection with the given deployment key, group ID, and configuration.
532
+
533
+ Args:
534
+ deploy_key (str): The deployment key.
535
+ group_id (str): The group ID.
536
+ config (dict): The configuration dictionary.
537
+ hd_agent_id (str): The hybrid deployment agent ID within the Fivetran system.
538
+
539
+ Returns:
540
+ rq.Response: The response object.
541
+ """
542
+ response = rq.post(f"{constants.PRODUCTION_BASE_URL}/v1/connectors",
543
+ headers={"Authorization": f"Basic {deploy_key}"},
544
+ json={
545
+ "group_id": group_id,
546
+ "service": "connector_sdk",
547
+ "config": config,
548
+ "paused": True,
549
+ "run_setup_tests": True,
550
+ "sync_frequency": "360",
551
+ "hybrid_deployment_agent_id": hd_agent_id
552
+ })
553
+ return response
554
+
555
+
556
+ def create_upload_file(project_path: str) -> str:
557
+ """Creates an upload file for the given project path.
558
+
559
+ Args:
560
+ project_path (str): The path to the project.
561
+
562
+ Returns:
563
+ str: The path to the upload file.
564
+ """
565
+ print_library_log("Packaging your project for upload...")
566
+ zip_file_path = zip_folder(project_path)
567
+ print("✓")
568
+ return zip_file_path
569
+
570
+
571
+ def zip_folder(project_path: str) -> str:
572
+ """Zips the folder at the given project path.
573
+
574
+ Args:
575
+ project_path (str): The path to the project.
576
+
577
+ Returns:
578
+ str: The path to the zip file.
579
+ """
580
+ upload_filepath = os.path.join(project_path, UPLOAD_FILENAME)
581
+ connector_file_exists = False
582
+ custom_drivers_exists = False
583
+ custom_driver_installation_script_exists = False
584
+
585
+ with ZipFile(upload_filepath, 'w', ZIP_DEFLATED) as zipf:
586
+ for root, files in dir_walker(project_path):
587
+ if os.path.basename(root) == DRIVERS:
588
+ custom_drivers_exists = True
589
+ if INSTALLATION_SCRIPT in files:
590
+ custom_driver_installation_script_exists = True
591
+ for file in files:
592
+ if file == ROOT_FILENAME:
593
+ connector_file_exists = True
594
+ file_path = os.path.join(root, file)
595
+ arcname = os.path.relpath(file_path, project_path)
596
+ zipf.write(file_path, arcname)
597
+
598
+ if not connector_file_exists:
599
+ print_library_log(
600
+ "The 'connector.py' file is missing. Please ensure that 'connector.py' is present in your project directory, and that the file name is in lowercase letters. All custom connectors require this file because Fivetran calls it to start a sync.",
601
+ Logging.Level.SEVERE)
602
+ os._exit(1)
603
+
604
+ if custom_drivers_exists and not custom_driver_installation_script_exists:
605
+ print_library_log(INSTALLATION_SCRIPT_MISSING_MESSAGE, Logging.Level.SEVERE)
606
+ os._exit(1)
607
+
608
+ return upload_filepath
609
+
610
+
611
+ def dir_walker(top):
612
+ """Walks the directory tree starting at the given top directory.
613
+
614
+ Args:
615
+ top (str): The top directory to start the walk.
616
+
617
+ Yields:
618
+ tuple: A tuple containing the current directory path and a list of files.
619
+ """
620
+ dirs, files = [], []
621
+ for name in os.listdir(top):
622
+ path = os.path.join(top, name)
623
+ if os.path.isdir(path):
624
+ if (name not in EXCLUDED_DIRS) and (not name.startswith(".")):
625
+ if VIRTUAL_ENV_CONFIG not in os.listdir(path): # Check for virtual env indicator
626
+ dirs.append(name)
627
+ else:
628
+ # Include all files if in `drivers` folder
629
+ if os.path.basename(top) == DRIVERS:
630
+ files.append(name)
631
+ if name.endswith(".py") or name == "requirements.txt":
632
+ files.append(name)
633
+
634
+ yield top, files
635
+ for name in dirs:
636
+ new_path = os.path.join(top, name)
637
+ for x in dir_walker(new_path):
638
+ yield x
639
+
640
+ def upload(local_path: str, deploy_key: str, group_id: str, connection: str) -> bool:
641
+ """Uploads the local code file for the specified group and connection.
642
+
643
+ Args:
644
+ local_path (str): The local file path.
645
+ deploy_key (str): The deployment key.
646
+ group_id (str): The group ID.
647
+ connection (str): The connection name.
648
+
649
+ Returns:
650
+ bool: True if the upload was successful, False otherwise.
651
+ """
652
+ print_library_log("Uploading your project...")
653
+ response = rq.post(f"{constants.PRODUCTION_BASE_URL}/v1/deploy/{group_id}/{connection}",
654
+ files={'file': open(local_path, 'rb')},
655
+ headers={"Authorization": f"Basic {deploy_key}"})
656
+ if response.ok:
657
+ print("✓")
658
+ return True
659
+
660
+ print_library_log(f"Unable to upload the project, failed with error: {response.reason}", Logging.Level.SEVERE)
661
+ return False
662
+
663
+ def cleanup_uploaded_code(deploy_key: str, group_id: str, connection: str) -> bool:
664
+ """Cleans up the uploaded code file for the specified group and connection, if creation fails.
665
+
666
+ Args:
667
+ deploy_key (str): The deployment key.
668
+ group_id (str): The group ID.
669
+ connection (str): The connection name.
670
+
671
+ Returns:
672
+ bool: True if the cleanup was successful, False otherwise.
673
+ """
674
+ print_library_log("INFO: Cleaning up your uploaded project ")
675
+ response = rq.post(f"{constants.PRODUCTION_BASE_URL}/v1/cleanup_code/{group_id}/{connection}",
676
+ headers={"Authorization": f"Basic {deploy_key}"})
677
+ if response.ok:
678
+ print("✓")
679
+ return True
680
+
681
+ print_library_log(f"SEVERE: Unable to cleanup the project, failed with error: {response.reason}",
682
+ Logging.Level.SEVERE)
683
+ return False
684
+
685
+ def get_os_arch_suffix() -> str:
686
+ """
687
+ Returns the operating system and architecture suffix for the current operating system.
688
+ """
689
+ system = platform.system().lower()
690
+ machine = platform.machine().lower()
691
+
692
+ if system not in OS_MAP:
693
+ raise RuntimeError(f"Unsupported OS: {system}")
694
+
695
+ plat = OS_MAP[system]
696
+
697
+ if machine not in ARCH_MAP or (plat == WIN_OS and ARCH_MAP[machine] != X64):
698
+ raise RuntimeError(f"Unsupported architecture '{machine}' for {plat}")
699
+
700
+ return f"{plat}-{ARCH_MAP[machine]}"
701
+
702
+ def get_group_info(group: str, deploy_key: str) -> tuple[str, str]:
703
+ """Retrieves the group information for the specified group and deployment key.
704
+
705
+ Args:
706
+ group (str): The group name.
707
+ deploy_key (str): The deployment key.
708
+
709
+ Returns:
710
+ tuple[str, str]: A tuple containing the group ID and group name.
711
+ """
712
+ groups_url = f"{constants.PRODUCTION_BASE_URL}/v1/groups"
713
+
714
+ params = {"limit": 500}
715
+ headers = {"Authorization": f"Basic {deploy_key}"}
716
+ resp = rq.get(groups_url, headers=headers, params=params)
717
+
718
+ if not resp.ok:
719
+ print_library_log(
720
+ f"The request failed with status code: {resp.status_code}. Please ensure you're using a valid base64-encoded API key and try again.",
721
+ Logging.Level.SEVERE)
722
+ os._exit(1)
723
+
724
+ data = resp.json().get("data", {})
725
+ groups = data.get("items")
726
+
727
+ if not groups:
728
+ print_library_log("No destinations defined in the account", Logging.Level.SEVERE)
729
+ os._exit(1)
730
+
731
+ if not group:
732
+ if len(groups) == 1:
733
+ return groups[0]['id'], groups[0]['name']
734
+ else:
735
+ print_library_log(
736
+ "Destination name is required when there are multiple destinations in the account",
737
+ Logging.Level.SEVERE)
738
+ os._exit(1)
739
+ else:
740
+ while True:
741
+ for grp in groups:
742
+ if grp['name'] == group:
743
+ return grp['id'], grp['name']
744
+
745
+ next_cursor = data.get("next_cursor")
746
+ if not next_cursor:
747
+ break
748
+
749
+ params = {"cursor": next_cursor, "limit": 500}
750
+ resp = rq.get(groups_url, headers=headers, params=params)
751
+ data = resp.json().get("data", {})
752
+ groups = data.get("items", [])
753
+
754
+ print_library_log(
755
+ f"The specified destination '{group}' was not found in your account.", Logging.Level.SEVERE)
756
+ os._exit(1)
757
+
758
+ def java_exe_helper(location: str, os_arch_suffix: str) -> str:
759
+ """Returns the path to the Java executable.
760
+
761
+ Args:
762
+ location (str): The location of the Java executable.
763
+ os_arch_suffix (str): The name of the operating system and architecture
764
+
765
+ Returns:
766
+ str: The path to the Java executable.
767
+ """
768
+ java_exe_base = os.path.join(location, "bin", "java")
769
+ return f"{java_exe_base}.exe" if os_arch_suffix == f"{WIN_OS}-{X64}" else java_exe_base
770
+
771
+ def process_stream(stream):
772
+ """Processes a stream of text lines, replacing occurrences of a specified pattern.
773
+
774
+ This method reads each line from the provided stream, searches for occurrences of
775
+ a predefined pattern, and replaces them with a specified replacement string.
776
+
777
+ Args:
778
+ stream (iterable): An iterable stream of text lines, typically from a file or another input source.
779
+
780
+ Yields:
781
+ str: Each line from the stream after replacing the matched pattern with the replacement string.
782
+ """
783
+ pattern = r'com\.fivetran\.partner_sdk.*\.tools\.testers\.\S+'
784
+
785
+ for line in iter(stream.readline, ""):
786
+ if not re.search(pattern, line):
787
+ yield line
788
+
789
+ def run_tester(java_exe_str: str, root_dir: str, project_path: str, port: int, state_json: str,
790
+ configuration_json: str):
791
+ """Runs the connector tester.
792
+
793
+ Args:
794
+ java_exe_str (str): The path to the Java executable.
795
+ root_dir (str): The root directory.
796
+ project_path (str): The path to the project.
797
+ port (int): The port number to use for the tester.
798
+ state_json (str): The state JSON string to pass to the tester.
799
+ configuration_json (str): The configuration JSON string to pass to the tester.
800
+
801
+ Yields:
802
+ str: The log messages from the tester.
803
+ """
804
+ working_dir = os.path.join(project_path, OUTPUT_FILES_DIR)
805
+ try:
806
+ os.mkdir(working_dir)
807
+ except FileExistsError:
808
+ pass
809
+
810
+ cmd = [java_exe_str,
811
+ "-jar",
812
+ os.path.join(root_dir, TESTER_FILENAME),
813
+ "--connector-sdk=true",
814
+ f"--port={port}",
815
+ f"--working-dir={working_dir}",
816
+ "--tester-type=source",
817
+ f"--state={state_json}",
818
+ f"--configuration={configuration_json}"]
819
+
820
+ popen = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
821
+ for line in process_stream(popen.stderr):
822
+ yield _maybe_colorize_jar_output(line)
823
+
824
+ for line in process_stream(popen.stdout):
825
+ yield _maybe_colorize_jar_output(line)
826
+ popen.stdout.close()
827
+ return_code = popen.wait()
828
+ if return_code:
829
+ raise subprocess.CalledProcessError(return_code, cmd)
830
+
831
+ def _maybe_colorize_jar_output(line: str) -> str:
832
+ if not constants.DEBUGGING:
833
+ return line
834
+
835
+ if "SEVERE" in line or "ERROR" in line or "Exception" in line or "FAILED" in line:
836
+ return f"\033[91m{line}\033[0m" # Red
837
+ elif "WARN" in line or "WARNING" in line:
838
+ return f"\033[93m{line}\033[0m" # Yellow
839
+ return line
840
+
841
+ def process_tables(response, table_list):
842
+ for entry in response:
843
+ if 'table' not in entry:
844
+ raise ValueError("Entry missing table name: " + entry)
845
+
846
+ table_name = get_renamed_table_name(entry['table'])
847
+
848
+ if table_name in table_list:
849
+ raise ValueError("Table already defined: " + table_name)
850
+
851
+ table = common_pb2.Table(name=table_name)
852
+ columns = {}
853
+
854
+ if "primary_key" in entry:
855
+ process_primary_keys(columns, entry)
856
+
857
+ if "columns" in entry:
858
+ process_columns(columns, entry)
859
+
860
+ table.columns.extend(columns.values())
861
+ TABLES[table_name] = table
862
+ table_list[table_name] = table
863
+
864
+ def process_primary_keys(columns, entry):
865
+ for pkey_name in entry["primary_key"]:
866
+ column_name = get_renamed_column_name(pkey_name)
867
+ column = columns[column_name] if column_name in columns else common_pb2.Column(name=column_name)
868
+ column.primary_key = True
869
+ columns[column_name] = column
870
+
871
+ def process_columns(columns, entry):
872
+ for name, type in entry["columns"].items():
873
+ column_name = get_renamed_column_name(name)
874
+ column = columns[column_name] if column_name in columns else common_pb2.Column(name=column_name)
875
+
876
+ if isinstance(type, str):
877
+ process_data_type(column, type)
878
+
879
+ elif isinstance(type, dict):
880
+ if type['type'].upper() != "DECIMAL":
881
+ raise ValueError("Expecting DECIMAL data type")
882
+ column.type = common_pb2.DataType.DECIMAL
883
+ column.decimal.precision = type['precision']
884
+ column.decimal.scale = type['scale']
885
+
886
+ else:
887
+ raise ValueError("Unrecognized column type: ", str(type))
888
+
889
+ if "primary_key" in entry and name in entry["primary_key"]:
890
+ column.primary_key = True
891
+
892
+ columns[column_name] = column
893
+
894
+ def process_data_type(column, type):
895
+ if type.upper() == "BOOLEAN":
896
+ column.type = common_pb2.DataType.BOOLEAN
897
+ elif type.upper() == "SHORT":
898
+ column.type = common_pb2.DataType.SHORT
899
+ elif type.upper() == "INT":
900
+ column.type = common_pb2.DataType.INT
901
+ elif type.upper() == "LONG":
902
+ column.type = common_pb2.DataType.LONG
903
+ elif type.upper() == "DECIMAL":
904
+ raise ValueError("DECIMAL data type missing precision and scale")
905
+ elif type.upper() == "FLOAT":
906
+ column.type = common_pb2.DataType.FLOAT
907
+ elif type.upper() == "DOUBLE":
908
+ column.type = common_pb2.DataType.DOUBLE
909
+ elif type.upper() == "NAIVE_DATE":
910
+ column.type = common_pb2.DataType.NAIVE_DATE
911
+ elif type.upper() == "NAIVE_DATETIME":
912
+ column.type = common_pb2.DataType.NAIVE_DATETIME
913
+ elif type.upper() == "UTC_DATETIME":
914
+ column.type = common_pb2.DataType.UTC_DATETIME
915
+ elif type.upper() == "BINARY":
916
+ column.type = common_pb2.DataType.BINARY
917
+ elif type.upper() == "XML":
918
+ column.type = common_pb2.DataType.XML
919
+ elif type.upper() == "STRING":
920
+ column.type = common_pb2.DataType.STRING
921
+ elif type.upper() == "JSON":
922
+ column.type = common_pb2.DataType.JSON
923
+ else:
924
+ raise ValueError("Unrecognized column type encountered:: ", str(type))
925
+
926
+ def delete_file_if_exists(file_path):
927
+ if os.path.exists(file_path):
928
+ os.remove(file_path)