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,70 @@
1
+ import re
2
+
3
+ TESTER_VER = "0.25.0521.001"
4
+
5
+ WIN_OS = "windows"
6
+ ARM_64 = "arm64"
7
+ X64 = "x64"
8
+
9
+ OS_MAP = {
10
+ "darwin": "mac",
11
+ "linux": "linux",
12
+ WIN_OS: WIN_OS
13
+ }
14
+
15
+ ARCH_MAP = {
16
+ "x86_64": X64,
17
+ "amd64": X64,
18
+ ARM_64: ARM_64,
19
+ "aarch64": ARM_64
20
+ }
21
+
22
+ # Global constants - use constants.<Global_variable> to access them as they can be overridden in the
23
+ DEBUGGING = False
24
+ EXECUTED_VIA_CLI = False
25
+ TABLES = {}
26
+
27
+ TESTER_FILENAME = "run_sdk_tester.jar"
28
+ VERSION_FILENAME = "version.txt"
29
+ UPLOAD_FILENAME = "code.zip"
30
+ LAST_VERSION_CHECK_FILE = "_last_version_check"
31
+ ROOT_LOCATION = ".ft_sdk_connector_tester"
32
+ CONFIG_FILE = "_config.json"
33
+ OUTPUT_FILES_DIR = "files"
34
+ REQUIREMENTS_TXT = "requirements.txt"
35
+ PYPI_PACKAGE_DETAILS_URL = "https://pypi.org/pypi/fivetran_connector_sdk/json"
36
+ ONE_DAY_IN_SEC = 24 * 60 * 60
37
+ MAX_RETRIES = 3
38
+ LOGGING_PREFIX = "Fivetran-Connector-SDK"
39
+ LOGGING_DELIMITER = ": "
40
+ VIRTUAL_ENV_CONFIG = "pyvenv.cfg"
41
+ ROOT_FILENAME = "connector.py"
42
+
43
+ # Compile patterns used in the implementation
44
+ WORD_DASH_DOT_PATTERN = re.compile(r'^[\w.-]*$')
45
+ NON_WORD_PATTERN = re.compile(r'\W')
46
+ WORD_OR_DOLLAR_PATTERN = re.compile(r'[\w$]')
47
+ DROP_LEADING_UNDERSCORE = re.compile(r'_+([a-zA-Z]\w*)')
48
+ WORD_PATTERN = re.compile(r'\w')
49
+
50
+ EXCLUDED_DIRS = ["__pycache__", "lib", "include", OUTPUT_FILES_DIR]
51
+ EXCLUDED_PIPREQS_DIRS = ["bin,etc,include,lib,Lib,lib64,Scripts,share"]
52
+ VALID_COMMANDS = ["debug", "deploy", "reset", "version"]
53
+ MAX_ALLOWED_EDIT_DISTANCE_FROM_VALID_COMMAND = 3
54
+ COMMANDS_AND_SYNONYMS = {
55
+ "debug": {"test", "verify", "diagnose", "check"},
56
+ "deploy": {"upload", "ship", "launch", "release"},
57
+ "reset": {"reinitialize", "reinitialise", "re-initialize", "re-initialise", "restart", "restore"},
58
+ }
59
+
60
+ CONNECTION_SCHEMA_NAME_PATTERN = r'^[_a-z][_a-z0-9]*$'
61
+ PRODUCTION_BASE_URL = "https://api.fivetran.com"
62
+ INSTALLATION_SCRIPT_MISSING_MESSAGE = "The 'installation.sh' file is missing in the 'drivers' directory. Please ensure that 'installation.sh' is present to properly configure drivers."
63
+ INSTALLATION_SCRIPT = "installation.sh"
64
+ DRIVERS = "drivers"
65
+ JAVA_LONG_MAX_VALUE = 9223372036854775807
66
+ MAX_CONFIG_FIELDS = 100
67
+ SUPPORTED_PYTHON_VERSIONS = ["3.12", "3.11", "3.10", "3.9"]
68
+ DEFAULT_PYTHON_VERSION = "3.12"
69
+ FIVETRAN_HD_AGENT_ID = "FIVETRAN_HD_AGENT_ID"
70
+ UTF_8 = "utf-8"
@@ -0,0 +1,321 @@
1
+ import re
2
+ import os
3
+ import shutil
4
+ import sys
5
+ import json
6
+ import unicodedata
7
+ import importlib.util
8
+ from datetime import datetime
9
+ from unidecode import unidecode
10
+
11
+ from fivetran_connector_sdk.logger import Logging
12
+ from fivetran_connector_sdk import constants
13
+ from fivetran_connector_sdk.constants import (
14
+ LOGGING_PREFIX,
15
+ LOGGING_DELIMITER,
16
+ WORD_DASH_DOT_PATTERN,
17
+ NON_WORD_PATTERN,
18
+ WORD_OR_DOLLAR_PATTERN,
19
+ DROP_LEADING_UNDERSCORE,
20
+ WORD_PATTERN,
21
+ ROOT_FILENAME,
22
+ MAX_CONFIG_FIELDS,
23
+ MAX_ALLOWED_EDIT_DISTANCE_FROM_VALID_COMMAND,
24
+ COMMANDS_AND_SYNONYMS,
25
+ VALID_COMMANDS,
26
+ OUTPUT_FILES_DIR, UTF_8
27
+ )
28
+
29
+ RENAMED_TABLE_NAMES = {}
30
+ RENAMED_COL_NAMES = {}
31
+
32
+ def print_library_log(message: str, level: Logging.Level = Logging.Level.INFO):
33
+ """Logs a library message with the specified logging level.
34
+
35
+ Args:
36
+ level (Logging.Level): The logging level.
37
+ message (str): The message to log.
38
+ """
39
+ if constants.DEBUGGING or constants.EXECUTED_VIA_CLI:
40
+ current_time = datetime.now().strftime("%b %d, %Y %I:%M:%S %p")
41
+ print(f"{Logging.get_color(level)}{current_time} {level.name} {LOGGING_PREFIX}: {message} {Logging.reset_color()}")
42
+ else:
43
+ escaped_message = json.dumps(LOGGING_PREFIX + LOGGING_DELIMITER + message)
44
+ log_message = f'{{"level":"{level.name}", "message": {escaped_message}, "message_origin": "library"}}'
45
+ print(log_message)
46
+
47
+ def is_special(c):
48
+ """Check if the character is a special character."""
49
+ return not WORD_OR_DOLLAR_PATTERN.fullmatch(c)
50
+
51
+
52
+ def starts_word(previous, current):
53
+ """
54
+ Check if the current character starts a new word based on the previous character.
55
+ """
56
+ return (previous and previous.islower() and current.isupper()) or (
57
+ previous and previous.isdigit() != current.isdigit()
58
+ )
59
+
60
+
61
+ def underscore_invalid_leading_character(name, valid_leading_regex):
62
+ """
63
+ Ensure the name starts with a valid leading character.
64
+ """
65
+ if name and not valid_leading_regex.match(name[0]):
66
+ name = f'_{name}'
67
+ return name
68
+
69
+
70
+ def single_underscore_case(name):
71
+ """
72
+ Convert the input name to single underscore case, replacing special characters and spaces.
73
+ """
74
+ acc = []
75
+ previous = None
76
+
77
+ for char_index, c in enumerate(name):
78
+ if char_index == 0 and c == '$':
79
+ acc.append('_')
80
+ elif is_special(c):
81
+ acc.append('_')
82
+ elif c == ' ':
83
+ acc.append('_')
84
+ elif starts_word(previous, c):
85
+ acc.append('_')
86
+ acc.append(c.lower())
87
+ else:
88
+ acc.append(c.lower())
89
+
90
+ previous = c
91
+
92
+ name = ''.join(acc)
93
+ return re.sub(r'_+', '_', name)
94
+
95
+
96
+ def contains_only_word_dash_dot(name):
97
+ """
98
+ Check if the name contains only word characters, dashes, and dots.
99
+ """
100
+ return bool(WORD_DASH_DOT_PATTERN.fullmatch(name))
101
+
102
+
103
+ def transliterate(name):
104
+ """
105
+ Transliterate the input name if it contains non-word, dash, or dot characters.
106
+ """
107
+ if contains_only_word_dash_dot(name):
108
+ return name
109
+ # Step 1: Normalize the name to NFD form (decomposed form)
110
+ normalized_name = unicodedata.normalize('NFD', name)
111
+ # Step 2: Remove combining characters (diacritics, accents, etc.)
112
+ normalized_name = ''.join(char for char in normalized_name if not unicodedata.combining(char))
113
+ # Step 3: Normalize back to NFC form (composed form)
114
+ normalized_name = unicodedata.normalize('NFC', normalized_name)
115
+ # Step 4: Convert the string to ASCII using `unidecode` (removes any remaining non-ASCII characters)
116
+ normalized_name = unidecode(normalized_name)
117
+ # Step 5: Return the normalized name
118
+ return normalized_name
119
+
120
+
121
+ def redshift_safe(name):
122
+ """
123
+ Make the name safe for use in Redshift.
124
+ """
125
+ name = transliterate(name)
126
+ name = NON_WORD_PATTERN.sub('_', name)
127
+ name = single_underscore_case(name)
128
+ name = underscore_invalid_leading_character(name, WORD_PATTERN)
129
+ return name
130
+
131
+
132
+ def safe_drop_underscores(name):
133
+ """
134
+ Drop leading underscores if the name starts with valid characters after sanitization.
135
+ """
136
+ safe_name = redshift_safe(name)
137
+ match = DROP_LEADING_UNDERSCORE.match(safe_name)
138
+ if match:
139
+ return match.group(1)
140
+ return safe_name
141
+
142
+
143
+ def get_renamed_table_name(source_table):
144
+ """
145
+ Process a source table name to ensure it conforms to naming rules.
146
+ """
147
+ if source_table not in RENAMED_TABLE_NAMES:
148
+ RENAMED_TABLE_NAMES[source_table] = safe_drop_underscores(source_table)
149
+
150
+ return RENAMED_TABLE_NAMES[source_table]
151
+
152
+
153
+ def get_renamed_column_name(source_column):
154
+ """
155
+ Process a source column name to ensure it conforms to naming rules.
156
+ """
157
+ if source_column not in RENAMED_COL_NAMES:
158
+ RENAMED_COL_NAMES[source_column] = redshift_safe(source_column)
159
+
160
+ return RENAMED_COL_NAMES[source_column]
161
+
162
+
163
+ # Functions used by main method only
164
+
165
+ def find_connector_object(project_path):
166
+ """Finds the connector object in the given project path.
167
+ Args:
168
+ project_path (str): The path to the project.
169
+ """
170
+
171
+ sys.path.append(project_path) # Allows python interpreter to search for modules in this path
172
+ module_name = "connector_connector_code"
173
+ connector_py = os.path.join(project_path, ROOT_FILENAME)
174
+ try:
175
+ spec = importlib.util.spec_from_file_location(module_name, connector_py)
176
+ module = importlib.util.module_from_spec(spec)
177
+ sys.modules[module_name] = module
178
+ spec.loader.exec_module(module)
179
+ for obj in dir(module):
180
+ if not obj.startswith('__'): # Exclude built-in attributes
181
+ obj_attr = getattr(module, obj)
182
+ if '<fivetran_connector_sdk.Connector object at' in str(obj_attr):
183
+ return obj_attr
184
+ except FileNotFoundError:
185
+ print_library_log(
186
+ "The connector object is missing in the current directory. Please ensure that you are running the command from correct directory or that you have defined a connector object using the correct syntax in your `connector.py` file. Reference: https://fivetran.com/docs/connectors/connector-sdk/technical-reference#technicaldetailsrequiredobjectconnector",
187
+ Logging.Level.SEVERE)
188
+ return None
189
+
190
+ print_library_log(
191
+ "The connector object is missing. Please ensure that you have defined a connector object using the correct syntax in your `connector.py` file. Reference: https://fivetran.com/docs/connectors/connector-sdk/technical-reference#technicaldetailsrequiredobjectconnector",
192
+ Logging.Level.SEVERE)
193
+ return None
194
+
195
+
196
+ def suggest_correct_command(input_command: str) -> bool:
197
+ # for typos
198
+ # calculate the edit distance of the input command (lowercased) with each of the valid commands
199
+ edit_distances_of_commands = sorted(
200
+ [(command, edit_distance(command, input_command.lower())) for command in VALID_COMMANDS], key=lambda x: x[1])
201
+
202
+ if edit_distances_of_commands[0][1] <= MAX_ALLOWED_EDIT_DISTANCE_FROM_VALID_COMMAND:
203
+ # if the closest command is within the max allowed edit distance, we suggest that command
204
+ # threshold is kept to prevent suggesting a valid command for an obvious wrong command like `fivetran iknowthisisntacommandbuttryanyway`
205
+ print_suggested_command_message(edit_distances_of_commands[0][0], input_command)
206
+ return True
207
+
208
+ # for synonyms
209
+ for (command, synonyms) in COMMANDS_AND_SYNONYMS.items():
210
+ # check if the input command (lowercased) is a recognised synonym of the valid commands, if yes, suggest that command
211
+ if input_command.lower() in synonyms:
212
+ print_suggested_command_message(command, input_command)
213
+ return True
214
+
215
+ return False
216
+
217
+
218
+ def print_suggested_command_message(valid_command: str, input_command: str) -> None:
219
+ print_library_log(f"`fivetran {input_command}` is not a valid command.", Logging.Level.SEVERE)
220
+ print_library_log(f"Did you mean `fivetran {valid_command}`?", Logging.Level.SEVERE)
221
+ print_library_log("Use `fivetran --help` for more details.", Logging.Level.SEVERE)
222
+
223
+
224
+ def edit_distance(first_string: str, second_string: str) -> int:
225
+ first_string_length: int = len(first_string)
226
+ second_string_length: int = len(second_string)
227
+
228
+ # Initialize the previous row of distances (for the base case of an empty first string) 'previous_row[j]' holds
229
+ # the edit distance between an empty prefix of 'first_string' and the first 'j' characters of 'second_string'.
230
+ # The first row is filled with values [0, 1, 2, ..., second_string_length]
231
+ previous_row: list[int] = list(range(second_string_length + 1))
232
+
233
+ # Rest of the rows
234
+ for first_string_index in range(1, first_string_length + 1):
235
+ # Start the current row with the distance for an empty second string
236
+ current_row: list[int] = [first_string_index]
237
+
238
+ # Iterate over each character in the second string
239
+ for second_string_index in range(1, second_string_length + 1):
240
+ if first_string[first_string_index - 1] == second_string[second_string_index - 1]:
241
+ # If characters match, no additional cost
242
+ current_row.append(previous_row[second_string_index - 1])
243
+ else:
244
+ # Minimum cost of insertion, deletion, or substitution
245
+ current_row.append(
246
+ 1 + min(current_row[-1], previous_row[second_string_index], previous_row[second_string_index - 1]))
247
+
248
+ # Move to the next row
249
+ previous_row = current_row
250
+
251
+ # The last value in the last row is the edit distance
252
+ return previous_row[second_string_length]
253
+
254
+
255
+ def get_input_from_cli(prompt: str, default_value: str) -> str:
256
+ """
257
+ Prompts the user for input.
258
+ """
259
+ if default_value:
260
+ value = input(f"{prompt} [Default : {default_value}]: ").strip() or default_value
261
+ else:
262
+ value = input(f"{prompt}: ").strip()
263
+
264
+ if not value:
265
+ raise ValueError("Missing required input: Expected a value but received None")
266
+ return value
267
+
268
+ def validate_and_load_configuration(args, configuration):
269
+ if configuration:
270
+ json_filepath = os.path.join(args.project_path, args.configuration)
271
+ if os.path.isfile(json_filepath):
272
+ with open(json_filepath, 'r', encoding=UTF_8) as fi:
273
+ configuration = json.load(fi)
274
+ if len(configuration) > MAX_CONFIG_FIELDS:
275
+ raise ValueError(f"Configuration field count exceeds maximum of {MAX_CONFIG_FIELDS}. Reduce the field count.")
276
+ else:
277
+ raise ValueError(
278
+ "Configuration must be provided as a JSON file. Please check your input. Reference: "
279
+ "https://fivetran.com/docs/connectors/connector-sdk/detailed-guide#workingwithconfigurationjsonfile")
280
+ else:
281
+ json_filepath = os.path.join(args.project_path, "configuration.json")
282
+ if os.path.exists(json_filepath):
283
+ print_library_log("Configuration file detected in the project, but no configuration input provided via the command line", Logging.Level.WARNING)
284
+ configuration = {}
285
+ return configuration
286
+
287
+
288
+ def validate_and_load_state(args, state):
289
+ if state:
290
+ json_filepath = os.path.join(args.project_path, args.state)
291
+ else:
292
+ json_filepath = os.path.join(args.project_path, "files", "state.json")
293
+
294
+ if os.path.exists(json_filepath):
295
+ if os.path.isfile(json_filepath):
296
+ with open(json_filepath, 'r', encoding=UTF_8) as fi:
297
+ state = json.load(fi)
298
+ elif state.lstrip().startswith("{"):
299
+ state = json.loads(state)
300
+ else:
301
+ state = {}
302
+ return state
303
+
304
+
305
+ def reset_local_file_directory(args):
306
+ files_path = os.path.join(args.project_path, OUTPUT_FILES_DIR)
307
+ if args.force:
308
+ confirm = "y"
309
+ else:
310
+ confirm = input(
311
+ "This will delete your current state and `warehouse.db` files. Do you want to continue? (Y/N): ")
312
+ if confirm.lower() != "y":
313
+ print_library_log("Reset canceled")
314
+ else:
315
+ try:
316
+ if os.path.exists(files_path) and os.path.isdir(files_path):
317
+ shutil.rmtree(files_path)
318
+ print_library_log("Reset Successful")
319
+ except Exception as e:
320
+ print_library_log("Reset Failed", Logging.Level.SEVERE)
321
+ raise e
@@ -0,0 +1,96 @@
1
+ import json
2
+ import traceback
3
+ from enum import IntEnum
4
+ from datetime import datetime
5
+
6
+ from fivetran_connector_sdk import constants
7
+
8
+ class Logging:
9
+ class Level(IntEnum):
10
+ FINE = 1
11
+ INFO = 2
12
+ WARNING = 3
13
+ SEVERE = 4
14
+
15
+ LOG_LEVEL = None
16
+
17
+ @staticmethod
18
+ def __log(level: Level, message: str):
19
+ """Logs a message with the specified logging level.
20
+
21
+ Args:
22
+ level (Logging.Level): The logging level.
23
+ message (str): The message to log.
24
+ """
25
+ if constants.DEBUGGING:
26
+ current_time = datetime.now().strftime("%b %d, %Y %I:%M:%S %p")
27
+ prefix = f"{current_time} {level.name}: "
28
+ message = Logging.get_formatted_log(message, prefix)
29
+ print(f"{Logging.get_color(level)}{prefix}{message} {Logging.reset_color()}")
30
+ else:
31
+ escaped_message = json.dumps(message)
32
+ log_message = f'{{"level":"{level.name}", "message": {escaped_message}, "message_origin": "connector_sdk"}}'
33
+ print(log_message)
34
+
35
+ @staticmethod
36
+ def get_formatted_log(message, prefix):
37
+ lines = message.split('\n')
38
+ padding = "\n" + " " * len(prefix)
39
+ return padding.join(lines)
40
+
41
+ @staticmethod
42
+ def get_color(level):
43
+ if level == Logging.Level.WARNING:
44
+ return "\033[93m" # Yellow
45
+ elif level == Logging.Level.SEVERE:
46
+ return "\033[91m" # Red
47
+ return ""
48
+
49
+ @staticmethod
50
+ def reset_color():
51
+ return "\033[0m"
52
+
53
+ @staticmethod
54
+ def fine(message: str):
55
+ """Logs a fine-level message.
56
+
57
+ Args:
58
+ message (str): The message to log.
59
+ """
60
+ if constants.DEBUGGING and Logging.LOG_LEVEL == Logging.Level.FINE:
61
+ Logging.__log(Logging.Level.FINE, message)
62
+
63
+ @staticmethod
64
+ def info(message: str):
65
+ """Logs an info-level message.
66
+
67
+ Args:
68
+ message (str): The message to log.
69
+ """
70
+ if Logging.LOG_LEVEL <= Logging.Level.INFO:
71
+ Logging.__log(Logging.Level.INFO, message)
72
+
73
+ @staticmethod
74
+ def warning(message: str):
75
+ """Logs a warning-level message.
76
+
77
+ Args:
78
+ message (str): The message to log.
79
+ """
80
+ if Logging.LOG_LEVEL <= Logging.Level.WARNING:
81
+ Logging.__log(Logging.Level.WARNING, message)
82
+
83
+ @staticmethod
84
+ def severe(message: str, exception: Exception = None):
85
+ """Logs a severe-level message.
86
+
87
+ Args:
88
+ message (str): The message to log.
89
+ exception (Exception, optional): Exception to be logged if provided.
90
+ """
91
+ if Logging.LOG_LEVEL <= Logging.Level.SEVERE:
92
+ if exception:
93
+ exc_type, exc_value, exc_traceback = type(exception), exception, exception.__traceback__
94
+ tb_str = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback, limit=1))
95
+ message += "\n" + tb_str
96
+ Logging.__log(Logging.Level.SEVERE, message)