hotglue-singer-sdk 1.0.2__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.
- hotglue_singer_sdk/__init__.py +34 -0
- hotglue_singer_sdk/authenticators.py +554 -0
- hotglue_singer_sdk/cli/__init__.py +1 -0
- hotglue_singer_sdk/cli/common_options.py +37 -0
- hotglue_singer_sdk/configuration/__init__.py +1 -0
- hotglue_singer_sdk/configuration/_dict_config.py +101 -0
- hotglue_singer_sdk/exceptions.py +52 -0
- hotglue_singer_sdk/helpers/__init__.py +1 -0
- hotglue_singer_sdk/helpers/_catalog.py +122 -0
- hotglue_singer_sdk/helpers/_classproperty.py +18 -0
- hotglue_singer_sdk/helpers/_compat.py +15 -0
- hotglue_singer_sdk/helpers/_flattening.py +374 -0
- hotglue_singer_sdk/helpers/_schema.py +100 -0
- hotglue_singer_sdk/helpers/_secrets.py +41 -0
- hotglue_singer_sdk/helpers/_simpleeval.py +678 -0
- hotglue_singer_sdk/helpers/_singer.py +280 -0
- hotglue_singer_sdk/helpers/_state.py +282 -0
- hotglue_singer_sdk/helpers/_typing.py +231 -0
- hotglue_singer_sdk/helpers/_util.py +27 -0
- hotglue_singer_sdk/helpers/capabilities.py +240 -0
- hotglue_singer_sdk/helpers/jsonpath.py +39 -0
- hotglue_singer_sdk/io_base.py +134 -0
- hotglue_singer_sdk/mapper.py +691 -0
- hotglue_singer_sdk/mapper_base.py +156 -0
- hotglue_singer_sdk/plugin_base.py +415 -0
- hotglue_singer_sdk/py.typed +0 -0
- hotglue_singer_sdk/sinks/__init__.py +14 -0
- hotglue_singer_sdk/sinks/batch.py +90 -0
- hotglue_singer_sdk/sinks/core.py +412 -0
- hotglue_singer_sdk/sinks/record.py +66 -0
- hotglue_singer_sdk/sinks/sql.py +299 -0
- hotglue_singer_sdk/streams/__init__.py +14 -0
- hotglue_singer_sdk/streams/core.py +1294 -0
- hotglue_singer_sdk/streams/graphql.py +74 -0
- hotglue_singer_sdk/streams/rest.py +611 -0
- hotglue_singer_sdk/streams/sql.py +1023 -0
- hotglue_singer_sdk/tap_base.py +580 -0
- hotglue_singer_sdk/target_base.py +554 -0
- hotglue_singer_sdk/target_sdk/__init__.py +0 -0
- hotglue_singer_sdk/target_sdk/auth.py +124 -0
- hotglue_singer_sdk/target_sdk/client.py +286 -0
- hotglue_singer_sdk/target_sdk/common.py +13 -0
- hotglue_singer_sdk/target_sdk/lambda.py +121 -0
- hotglue_singer_sdk/target_sdk/rest.py +108 -0
- hotglue_singer_sdk/target_sdk/sinks.py +16 -0
- hotglue_singer_sdk/target_sdk/target.py +570 -0
- hotglue_singer_sdk/target_sdk/target_base.py +627 -0
- hotglue_singer_sdk/testing.py +198 -0
- hotglue_singer_sdk/typing.py +603 -0
- hotglue_singer_sdk-1.0.2.dist-info/METADATA +53 -0
- hotglue_singer_sdk-1.0.2.dist-info/RECORD +53 -0
- hotglue_singer_sdk-1.0.2.dist-info/WHEEL +4 -0
- hotglue_singer_sdk-1.0.2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""HotglueTarget target class."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import copy
|
|
5
|
+
import time
|
|
6
|
+
import os
|
|
7
|
+
import pydantic
|
|
8
|
+
import json
|
|
9
|
+
from abc import abstractmethod
|
|
10
|
+
from io import FileIO
|
|
11
|
+
from pathlib import Path, PurePath
|
|
12
|
+
from typing import Callable, Dict, List, Optional, Tuple, Type, Union, IO
|
|
13
|
+
from hotglue_singer_sdk.sinks import Sink
|
|
14
|
+
# from hotglue_singer_sdk.target_base import Target
|
|
15
|
+
from hotglue_singer_sdk.mapper import PluginMapper
|
|
16
|
+
from hotglue_singer_sdk.cli import common_options
|
|
17
|
+
from hotglue_singer_sdk.helpers._classproperty import classproperty
|
|
18
|
+
from hotglue_singer_sdk.helpers._secrets import SecretString
|
|
19
|
+
from hotglue_singer_sdk.helpers._util import read_json_file
|
|
20
|
+
|
|
21
|
+
from hotglue_singer_sdk.target_sdk.target_base import Target
|
|
22
|
+
from hotglue_singer_sdk.target_sdk.sinks import ModelSink
|
|
23
|
+
import pandas as pd
|
|
24
|
+
import os
|
|
25
|
+
from collections import Counter, defaultdict
|
|
26
|
+
import threading
|
|
27
|
+
import signal
|
|
28
|
+
from hotglue_singer_sdk.io_base import SingerMessageType
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
job_id = os.environ.get('JOB_ID')
|
|
32
|
+
flow_id = os.environ.get('FLOW')
|
|
33
|
+
SNAPSHOT_DIR = os.environ.get('SNAPSHOT_DIR') or f"/home/hotglue/{job_id}/snapshots"
|
|
34
|
+
|
|
35
|
+
class SignalListenerThread(threading.Thread):
|
|
36
|
+
"""Simple background thread that listens for SIGUSR1 signals."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, shutdown_event):
|
|
39
|
+
super().__init__(daemon=True)
|
|
40
|
+
self.shutdown_event = shutdown_event
|
|
41
|
+
self.signal_listener_file = "user_signal.listener"
|
|
42
|
+
|
|
43
|
+
def run(self):
|
|
44
|
+
"""Create signal listener file and listen for SIGUSR1."""
|
|
45
|
+
# Create the signal listener file
|
|
46
|
+
try:
|
|
47
|
+
root_dir = "/tmp" if os.environ.get("JOB_ID") else f"../.secrets"
|
|
48
|
+
with open(f"{root_dir}/{self.signal_listener_file}", 'w') as f:
|
|
49
|
+
f.write(f"Signal listener started at {time.time()}\n")
|
|
50
|
+
f.write(f"Thread ID: {threading.get_ident()}\n")
|
|
51
|
+
f.write(f"Process ID: {os.getpid()}\n")
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print(f"Failed to create signal listener file: {e}")
|
|
54
|
+
|
|
55
|
+
# Wait for shutdown event
|
|
56
|
+
self.shutdown_event.wait()
|
|
57
|
+
|
|
58
|
+
class TargetHotglue(Target):
|
|
59
|
+
"""Sample target for Hotglue."""
|
|
60
|
+
|
|
61
|
+
MAX_PARALLELISM = 8
|
|
62
|
+
EXTERNAL_ID_KEY = "externalId"
|
|
63
|
+
GLOBAL_PRIMARY_KEY = "id"
|
|
64
|
+
incremental_target_state_path = f"/home/hotglue/{job_id}/incremental_target_state.json" if job_id else f"../.secrets/incremental_target_state.json"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def name(self) -> str:
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def SINK_TYPES(self) -> List[ModelSink]:
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
config: Optional[Union[dict, PurePath, str, List[Union[PurePath, str]]]] = None,
|
|
79
|
+
parse_env_config: bool = False,
|
|
80
|
+
validate_config: bool = True,
|
|
81
|
+
state: str = None
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Initialize the target.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
config: Target configuration. Can be a dictionary, a single path to a
|
|
87
|
+
configuration file, or a list of paths to multiple configuration
|
|
88
|
+
files.
|
|
89
|
+
parse_env_config: Whether to look for configuration values in environment
|
|
90
|
+
variables.
|
|
91
|
+
validate_config: True to require validation of config settings.
|
|
92
|
+
"""
|
|
93
|
+
config_file_path = None
|
|
94
|
+
if not state:
|
|
95
|
+
state_dict = {}
|
|
96
|
+
elif isinstance(state, str) or isinstance(state, PurePath):
|
|
97
|
+
state_dict = read_json_file(state)
|
|
98
|
+
if not config:
|
|
99
|
+
config_dict = {}
|
|
100
|
+
elif isinstance(config, str) or isinstance(config, PurePath):
|
|
101
|
+
config_dict = read_json_file(config)
|
|
102
|
+
config_file_path = str(config)
|
|
103
|
+
elif isinstance(config, list):
|
|
104
|
+
config_dict = {}
|
|
105
|
+
config_file_path = str(config[0])
|
|
106
|
+
for config_path in config:
|
|
107
|
+
# Read each config file sequentially. Settings from files later in the
|
|
108
|
+
# list will override those of earlier ones.
|
|
109
|
+
config_dict.update(read_json_file(config_path))
|
|
110
|
+
elif isinstance(config, dict):
|
|
111
|
+
config_dict = config
|
|
112
|
+
else:
|
|
113
|
+
raise ValueError(f"Error parsing config of type '{type(config).__name__}'.")
|
|
114
|
+
if parse_env_config:
|
|
115
|
+
self.logger.info("Parsing env var for settings config...")
|
|
116
|
+
config_dict.update(self._env_var_config)
|
|
117
|
+
else:
|
|
118
|
+
self.logger.info("Skipping parse of env var settings...")
|
|
119
|
+
for k, v in config_dict.items():
|
|
120
|
+
if self._is_secret_config(k):
|
|
121
|
+
config_dict[k] = SecretString(v)
|
|
122
|
+
self._config = config_dict
|
|
123
|
+
self._state = state_dict
|
|
124
|
+
self._config_file_path = config_file_path
|
|
125
|
+
self._validate_config(raise_errors=validate_config)
|
|
126
|
+
self.mapper: PluginMapper
|
|
127
|
+
self.streaming_job = os.environ.get('STREAMING_JOB') == 'True'
|
|
128
|
+
if self.streaming_job:
|
|
129
|
+
self._latest_state: Dict[str, dict] = { "tap": {}, "target": {} }
|
|
130
|
+
else:
|
|
131
|
+
self._latest_state: Dict[str, dict] = {}
|
|
132
|
+
self._drained_state: Dict[str, dict] = {}
|
|
133
|
+
self._sinks_active: Dict[str, Sink] = {}
|
|
134
|
+
self._sinks_to_clear: List[Sink] = []
|
|
135
|
+
self._max_parallelism: Optional[int] = self.MAX_PARALLELISM
|
|
136
|
+
|
|
137
|
+
# Approximated for max record age enforcement
|
|
138
|
+
self._last_full_drain_at: float = time.time()
|
|
139
|
+
|
|
140
|
+
# Initialize mapper
|
|
141
|
+
self.mapper: PluginMapper
|
|
142
|
+
self.mapper = PluginMapper(
|
|
143
|
+
plugin_config=dict(self.config),
|
|
144
|
+
logger=self.logger,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Initialize shutdown event and signal handling
|
|
148
|
+
self._shutdown_requested = threading.Event()
|
|
149
|
+
|
|
150
|
+
# Set up signal handler in main thread (signal handling only works in main thread)
|
|
151
|
+
def signal_handler(signum, frame):
|
|
152
|
+
print(f"Received signal {signum}")
|
|
153
|
+
self.logger.info(f"Received signal {signum}. Initiating graceful shutdown...")
|
|
154
|
+
self._shutdown_requested.set()
|
|
155
|
+
|
|
156
|
+
# Register signal handler for SIGUSR1 in main thread
|
|
157
|
+
signal.signal(signal.SIGUSR1, signal_handler)
|
|
158
|
+
|
|
159
|
+
# Start signal listener thread
|
|
160
|
+
self.signal_listener_thread = SignalListenerThread(self._shutdown_requested)
|
|
161
|
+
self.signal_listener_thread.start()
|
|
162
|
+
|
|
163
|
+
def _save_failed_job_state(self):
|
|
164
|
+
"""Save the latest state to a file when the job fails."""
|
|
165
|
+
self.logger.info("Saving current state to file.")
|
|
166
|
+
try:
|
|
167
|
+
# Get the current state
|
|
168
|
+
current_state = self._latest_state or {}
|
|
169
|
+
|
|
170
|
+
if current_state:
|
|
171
|
+
# Save to file
|
|
172
|
+
with open(self.incremental_target_state_path, 'w') as f:
|
|
173
|
+
json.dump(current_state, f, indent=2, default=str)
|
|
174
|
+
self.logger.info(f"Current state saved to file {self.incremental_target_state_path}")
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
self.logger.error(f"Failed to save state on failure: {str(e)}")
|
|
178
|
+
|
|
179
|
+
def get_sink_class(self, stream_name: str) -> Type[Sink]:
|
|
180
|
+
"""Get sink for a stream."""
|
|
181
|
+
return next(
|
|
182
|
+
(
|
|
183
|
+
sink_class
|
|
184
|
+
for sink_class in self.SINK_TYPES
|
|
185
|
+
if sink_class.name.lower() == stream_name.lower()
|
|
186
|
+
),
|
|
187
|
+
None,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def get_record_id(self, sink_name, record, relation_fields=None):
|
|
191
|
+
external_id = record.get(self.EXTERNAL_ID_KEY)
|
|
192
|
+
if external_id and not record.get(self.GLOBAL_PRIMARY_KEY):
|
|
193
|
+
sink_snapshot = None
|
|
194
|
+
snapshot_path_csv = f"{SNAPSHOT_DIR}/{sink_name}_{flow_id}.snapshot.csv"
|
|
195
|
+
snapshot_path_parquet = f"{SNAPSHOT_DIR}/{sink_name}_{flow_id}.snapshot.parquet"
|
|
196
|
+
if os.path.exists(snapshot_path_csv):
|
|
197
|
+
sink_snapshot = pd.read_csv(snapshot_path_csv)
|
|
198
|
+
elif os.path.exists(snapshot_path_parquet):
|
|
199
|
+
sink_snapshot = pd.read_parquet(snapshot_path_parquet)
|
|
200
|
+
|
|
201
|
+
if sink_snapshot is not None:
|
|
202
|
+
sink_snapshot["InputId"] = sink_snapshot["InputId"].astype(str)
|
|
203
|
+
external_id = sink_snapshot[sink_snapshot["InputId"] == str(external_id)]
|
|
204
|
+
if len(external_id):
|
|
205
|
+
record[self.GLOBAL_PRIMARY_KEY] = external_id["RemoteId"].iloc[0]
|
|
206
|
+
|
|
207
|
+
if isinstance(relation_fields, list):
|
|
208
|
+
for relation in relation_fields:
|
|
209
|
+
|
|
210
|
+
if not isinstance(relation, dict):
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
field = relation.get("field")
|
|
214
|
+
object_name = relation.get("objectName")
|
|
215
|
+
|
|
216
|
+
if (
|
|
217
|
+
not field or
|
|
218
|
+
not object_name or
|
|
219
|
+
not record.get(field)
|
|
220
|
+
):
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
relation_snapshot = None
|
|
224
|
+
|
|
225
|
+
relation_path_csv = f"{SNAPSHOT_DIR}/{object_name}_{flow_id}.snapshot.csv"
|
|
226
|
+
relation_path_parquet = f"{SNAPSHOT_DIR}/{object_name}_{flow_id}.snapshot.parquet"
|
|
227
|
+
|
|
228
|
+
if os.path.exists(relation_path_csv):
|
|
229
|
+
relation_snapshot = pd.read_csv(relation_path_csv)
|
|
230
|
+
elif os.path.exists(relation_path_parquet):
|
|
231
|
+
relation_snapshot = pd.read_parquet(relation_path_parquet)
|
|
232
|
+
|
|
233
|
+
if relation_snapshot is None:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
row = relation_snapshot[relation_snapshot["InputId"] == record.get(field)]
|
|
237
|
+
|
|
238
|
+
if len(row) > 0:
|
|
239
|
+
record[field] = row.iloc[0]["RemoteId"]
|
|
240
|
+
|
|
241
|
+
return record
|
|
242
|
+
|
|
243
|
+
def _process_state_message(self, message_dict: dict) -> None:
|
|
244
|
+
"""Process a state message. drain sinks if needed."""
|
|
245
|
+
# Determine where to store state based on streaming_job
|
|
246
|
+
if self.streaming_job:
|
|
247
|
+
self._assert_line_requires(message_dict, requires={"value"})
|
|
248
|
+
state = message_dict["value"]
|
|
249
|
+
current_state = self._latest_state["tap"]
|
|
250
|
+
if current_state == state:
|
|
251
|
+
return
|
|
252
|
+
self._latest_state["tap"] = state
|
|
253
|
+
else:
|
|
254
|
+
if not self._latest_state:
|
|
255
|
+
self._assert_line_requires(message_dict, requires={"value"})
|
|
256
|
+
state = message_dict["value"]
|
|
257
|
+
if self._latest_state == state:
|
|
258
|
+
return
|
|
259
|
+
self._latest_state = state
|
|
260
|
+
if self._max_record_age_in_minutes > self._MAX_RECORD_AGE_IN_MINUTES:
|
|
261
|
+
self.logger.info(
|
|
262
|
+
"One or more records have exceeded the max age of "
|
|
263
|
+
f"{self._MAX_RECORD_AGE_IN_MINUTES} minutes. Draining all sinks."
|
|
264
|
+
)
|
|
265
|
+
self.drain_all()
|
|
266
|
+
|
|
267
|
+
def _validate_unified_schema(self, sink: Sink, transformed_record: dict) -> dict:
|
|
268
|
+
"""Validate the unified schema for a sink."""
|
|
269
|
+
|
|
270
|
+
def flatten_dict_keys(dictionary: dict, parent_key=''):
|
|
271
|
+
keys = set()
|
|
272
|
+
for key, value in dictionary.items():
|
|
273
|
+
new_key = f"{parent_key}.{key}" if parent_key else key
|
|
274
|
+
if isinstance(value, dict):
|
|
275
|
+
keys.update(flatten_dict_keys(value, new_key))
|
|
276
|
+
elif isinstance(value, list):
|
|
277
|
+
for item in value:
|
|
278
|
+
if isinstance(item, dict):
|
|
279
|
+
keys.update(flatten_dict_keys(item, new_key))
|
|
280
|
+
else:
|
|
281
|
+
keys.add(new_key)
|
|
282
|
+
return keys
|
|
283
|
+
|
|
284
|
+
success = True
|
|
285
|
+
if hasattr(sink, 'auto_validate_unified_schema') and sink.auto_validate_unified_schema \
|
|
286
|
+
and hasattr(sink, 'unified_schema') and sink.unified_schema and issubclass(sink.unified_schema, pydantic.BaseModel):
|
|
287
|
+
try:
|
|
288
|
+
unified_record = sink.unified_schema.model_validate(transformed_record, strict=True)
|
|
289
|
+
unified_transformed = unified_record.model_dump(exclude_none=True, exclude_unset=True)
|
|
290
|
+
extra_fields = flatten_dict_keys(transformed_record) - flatten_dict_keys(unified_transformed)
|
|
291
|
+
if extra_fields:
|
|
292
|
+
self.logger.warning(f"Extra fields found in {sink.name} will be ignored: {', '.join(extra_fields)}")
|
|
293
|
+
transformed_record = unified_transformed
|
|
294
|
+
except pydantic.ValidationError as e:
|
|
295
|
+
error_msg_fields = "; ".join([f"'{'.'.join(map(str, err.get('loc', tuple())))}' -> {err.get('msg')} (got value {err.get('input')} of type {type(err.get('input')).__name__})" for err in e.errors()])
|
|
296
|
+
error_msg = f"Failed Structure/Datatype validation for {sink.name}: {error_msg_fields}"
|
|
297
|
+
self.logger.error(error_msg)
|
|
298
|
+
|
|
299
|
+
if not sink.latest_state:
|
|
300
|
+
sink.init_state()
|
|
301
|
+
|
|
302
|
+
state = {"success": False, "error": error_msg}
|
|
303
|
+
id = transformed_record.get("id")
|
|
304
|
+
if id:
|
|
305
|
+
state["id"] = str(id)
|
|
306
|
+
external_id = transformed_record.get("externalId")
|
|
307
|
+
if external_id:
|
|
308
|
+
state["externalId"] = external_id
|
|
309
|
+
sink.update_state(state)
|
|
310
|
+
success = False
|
|
311
|
+
|
|
312
|
+
return success, transformed_record
|
|
313
|
+
|
|
314
|
+
def _process_record_message(self, message_dict: dict) -> None:
|
|
315
|
+
"""Process a RECORD message."""
|
|
316
|
+
self._assert_line_requires(message_dict, requires={"stream", "record"})
|
|
317
|
+
|
|
318
|
+
stream_name = message_dict["stream"]
|
|
319
|
+
for stream_map in self.mapper.stream_maps[stream_name]:
|
|
320
|
+
# new_schema = helpers._float_to_decimal(new_schema)
|
|
321
|
+
raw_record = copy.copy(message_dict["record"])
|
|
322
|
+
|
|
323
|
+
lower_raw_record = {k.lower(): v for k, v in raw_record.items()}
|
|
324
|
+
|
|
325
|
+
external_id = lower_raw_record.get(self.EXTERNAL_ID_KEY.lower())
|
|
326
|
+
|
|
327
|
+
transformed_record = stream_map.transform(raw_record)
|
|
328
|
+
if transformed_record is None:
|
|
329
|
+
# Record was filtered out by the map transform
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
sink = self.get_sink(stream_map.stream_alias, record=transformed_record)
|
|
333
|
+
|
|
334
|
+
if not sink:
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
context = sink._get_context(transformed_record)
|
|
338
|
+
if sink.include_sdc_metadata_properties:
|
|
339
|
+
sink._add_sdc_metadata_to_record(
|
|
340
|
+
transformed_record, message_dict, context
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
sink._remove_sdc_metadata_from_record(transformed_record)
|
|
344
|
+
|
|
345
|
+
sink._validate_and_parse(transformed_record)
|
|
346
|
+
|
|
347
|
+
sink.tally_record_read()
|
|
348
|
+
|
|
349
|
+
validation_success, transformed_record = self._validate_unified_schema(sink, transformed_record)
|
|
350
|
+
if not validation_success:
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
transformed_record = self.get_record_id(sink.name, transformed_record, sink.relation_fields if hasattr(sink, 'relation_fields') else None)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
sink.process_record(transformed_record, context)
|
|
358
|
+
sink._after_process_record(context)
|
|
359
|
+
|
|
360
|
+
if sink.is_full:
|
|
361
|
+
self.logger.info(
|
|
362
|
+
f"Target sink for '{sink.stream_name}' is full. Draining..."
|
|
363
|
+
)
|
|
364
|
+
self.drain_one(sink)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
sink_latest_state = sink.latest_state or {}
|
|
368
|
+
if self.streaming_job:
|
|
369
|
+
if not self._latest_state:
|
|
370
|
+
# If "self._latest_state" is empty, save the value of "sink.latest_state"
|
|
371
|
+
self._latest_state["target"] = sink_latest_state
|
|
372
|
+
else:
|
|
373
|
+
# If "self._latest_state" is not empty, update all its fields with the
|
|
374
|
+
# fields from "sink.latest_state" (if they exist)
|
|
375
|
+
for key in self._latest_state["target"].keys():
|
|
376
|
+
if isinstance(self._latest_state[key], dict):
|
|
377
|
+
self._latest_state[key].update(sink_latest_state.get(key) or dict())
|
|
378
|
+
else:
|
|
379
|
+
if not self._latest_state:
|
|
380
|
+
# If "self._latest_state" is empty, save the value of "sink.latest_state"
|
|
381
|
+
self._latest_state = sink_latest_state
|
|
382
|
+
else:
|
|
383
|
+
# If "self._latest_state" is not empty, update all its fields with the
|
|
384
|
+
# fields from "sink.latest_state" (if they exist)
|
|
385
|
+
for key in self._latest_state.keys():
|
|
386
|
+
if isinstance(self._latest_state[key], dict):
|
|
387
|
+
self._latest_state[key].update(sink_latest_state.get(key) or dict())
|
|
388
|
+
|
|
389
|
+
def _graceful_shutdown(self):
|
|
390
|
+
"""Perform graceful shutdown with proper cleanup."""
|
|
391
|
+
self.logger.info("Initiating graceful shutdown...")
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
# Step 1: Drain all sinks to ensure buffered data is saved
|
|
395
|
+
self.logger.info("Draining all sinks to save buffered data...")
|
|
396
|
+
self.drain_all()
|
|
397
|
+
|
|
398
|
+
# Step 2: Save final state
|
|
399
|
+
self.logger.info("Saving final state...")
|
|
400
|
+
self._save_failed_job_state()
|
|
401
|
+
|
|
402
|
+
self.logger.info("Graceful shutdown completed successfully")
|
|
403
|
+
|
|
404
|
+
except Exception as e:
|
|
405
|
+
self.logger.error(f"Error during graceful shutdown: {e}")
|
|
406
|
+
finally:
|
|
407
|
+
# Always exit, even if cleanup fails
|
|
408
|
+
import sys
|
|
409
|
+
sys.exit(0)
|
|
410
|
+
|
|
411
|
+
# Message handling
|
|
412
|
+
|
|
413
|
+
def _process_lines(self, file_input: IO[str]) -> Counter[str]:
|
|
414
|
+
"""Internal method to process jsonl lines from a Singer tap.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
file_input: Readable stream of messages, each on a separate line.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
A counter object for the processed lines.
|
|
421
|
+
"""
|
|
422
|
+
self.logger.info(f"Target '{self.name}' is listening for input from tap.")
|
|
423
|
+
|
|
424
|
+
stats: dict[str, int] = defaultdict(int)
|
|
425
|
+
for line in file_input:
|
|
426
|
+
# Check if shutdown has been requested
|
|
427
|
+
if hasattr(self, '_shutdown_requested') and self._shutdown_requested.is_set():
|
|
428
|
+
self.logger.info("Shutdown requested, initiating graceful shutdown...")
|
|
429
|
+
self._graceful_shutdown()
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
line_dict = json.loads(line)
|
|
434
|
+
except json.decoder.JSONDecodeError as exc:
|
|
435
|
+
self.logger.error("Unable to parse:\n%s", line, exc_info=exc)
|
|
436
|
+
raise
|
|
437
|
+
|
|
438
|
+
self._assert_line_requires(line_dict, requires={"type"})
|
|
439
|
+
|
|
440
|
+
record_type: SingerMessageType = line_dict["type"]
|
|
441
|
+
if record_type == SingerMessageType.SCHEMA:
|
|
442
|
+
self._process_schema_message(line_dict)
|
|
443
|
+
|
|
444
|
+
elif record_type == SingerMessageType.RECORD:
|
|
445
|
+
self._process_record_message(line_dict)
|
|
446
|
+
|
|
447
|
+
elif record_type == SingerMessageType.ACTIVATE_VERSION:
|
|
448
|
+
self._process_activate_version_message(line_dict)
|
|
449
|
+
|
|
450
|
+
elif record_type == SingerMessageType.STATE:
|
|
451
|
+
self._process_state_message(line_dict)
|
|
452
|
+
|
|
453
|
+
else:
|
|
454
|
+
self._process_unknown_message(line_dict)
|
|
455
|
+
|
|
456
|
+
stats[record_type] += 1
|
|
457
|
+
|
|
458
|
+
counter = Counter(**stats)
|
|
459
|
+
|
|
460
|
+
line_count = sum(counter.values())
|
|
461
|
+
|
|
462
|
+
self.logger.info(
|
|
463
|
+
f"Target '{self.name}' completed reading {line_count} lines of input "
|
|
464
|
+
f"({counter[SingerMessageType.RECORD]} records, "
|
|
465
|
+
f"{counter[SingerMessageType.STATE]} state messages)."
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
return counter
|
|
469
|
+
|
|
470
|
+
@classproperty
|
|
471
|
+
def cli(cls) -> Callable:
|
|
472
|
+
"""Execute standard CLI handler for taps.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
A callable CLI object.
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
@common_options.PLUGIN_VERSION
|
|
479
|
+
@common_options.PLUGIN_ABOUT
|
|
480
|
+
@common_options.PLUGIN_ABOUT_FORMAT
|
|
481
|
+
@common_options.PLUGIN_CONFIG
|
|
482
|
+
@common_options.PLUGIN_FILE_INPUT
|
|
483
|
+
@click.option(
|
|
484
|
+
"--state",
|
|
485
|
+
multiple=False,
|
|
486
|
+
help="State file location.",
|
|
487
|
+
type=click.STRING,
|
|
488
|
+
default=(),
|
|
489
|
+
)
|
|
490
|
+
@click.command(
|
|
491
|
+
help="Execute the Singer target.",
|
|
492
|
+
context_settings={"help_option_names": ["--help"]},
|
|
493
|
+
)
|
|
494
|
+
def cli(
|
|
495
|
+
version: bool = False,
|
|
496
|
+
about: bool = False,
|
|
497
|
+
config: Tuple[str, ...] = (),
|
|
498
|
+
state: str = None,
|
|
499
|
+
format: str = None,
|
|
500
|
+
file_input: FileIO = None,
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Handle command line execution.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
version: Display the package version.
|
|
506
|
+
about: Display package metadata and settings.
|
|
507
|
+
format: Specify output style for `--about`.
|
|
508
|
+
config: Configuration file location or 'ENV' to use environment
|
|
509
|
+
variables. Accepts multiple inputs as a tuple.
|
|
510
|
+
file_input: Specify a path to an input file to read messages from.
|
|
511
|
+
Defaults to standard in if unspecified.
|
|
512
|
+
|
|
513
|
+
Raises:
|
|
514
|
+
FileNotFoundError: If the config file does not exist.
|
|
515
|
+
"""
|
|
516
|
+
if version:
|
|
517
|
+
cls.print_version()
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
if not about:
|
|
521
|
+
cls.print_version(print_fn=cls.logger.info)
|
|
522
|
+
else:
|
|
523
|
+
cls.print_about(format=format)
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
validate_config: bool = True
|
|
527
|
+
|
|
528
|
+
cls.print_version(print_fn=cls.logger.info)
|
|
529
|
+
|
|
530
|
+
parse_env_config = False
|
|
531
|
+
config_files: List[PurePath] = []
|
|
532
|
+
for config_path in config:
|
|
533
|
+
if config_path == "ENV":
|
|
534
|
+
# Allow parse from env vars:
|
|
535
|
+
parse_env_config = True
|
|
536
|
+
continue
|
|
537
|
+
|
|
538
|
+
# Validate config file paths before adding to list
|
|
539
|
+
if not Path(config_path).is_file():
|
|
540
|
+
raise FileNotFoundError(
|
|
541
|
+
f"Could not locate config file at '{config_path}'."
|
|
542
|
+
"Please check that the file exists."
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
config_files.append(Path(config_path))
|
|
546
|
+
state = None if state == "()" else state
|
|
547
|
+
target = cls( # type: ignore # Ignore 'type not callable'
|
|
548
|
+
config=config_files or None,
|
|
549
|
+
parse_env_config=parse_env_config,
|
|
550
|
+
validate_config=validate_config,
|
|
551
|
+
state=state,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
target.listen(file_input)
|
|
555
|
+
|
|
556
|
+
return cli
|
|
557
|
+
|
|
558
|
+
def listen(self, file_input=None):
|
|
559
|
+
"""Override listen method to add error handling and state saving."""
|
|
560
|
+
try:
|
|
561
|
+
# Call the parent listen method
|
|
562
|
+
super().listen(file_input)
|
|
563
|
+
except Exception as e:
|
|
564
|
+
self.logger.error(f"Job failed with error: {str(e)}")
|
|
565
|
+
# Save the latest state before re-raising the exception
|
|
566
|
+
self._save_failed_job_state()
|
|
567
|
+
|
|
568
|
+
# Re-raise the exception to maintain original behavior
|
|
569
|
+
raise
|
|
570
|
+
|