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.
Files changed (53) hide show
  1. hotglue_singer_sdk/__init__.py +34 -0
  2. hotglue_singer_sdk/authenticators.py +554 -0
  3. hotglue_singer_sdk/cli/__init__.py +1 -0
  4. hotglue_singer_sdk/cli/common_options.py +37 -0
  5. hotglue_singer_sdk/configuration/__init__.py +1 -0
  6. hotglue_singer_sdk/configuration/_dict_config.py +101 -0
  7. hotglue_singer_sdk/exceptions.py +52 -0
  8. hotglue_singer_sdk/helpers/__init__.py +1 -0
  9. hotglue_singer_sdk/helpers/_catalog.py +122 -0
  10. hotglue_singer_sdk/helpers/_classproperty.py +18 -0
  11. hotglue_singer_sdk/helpers/_compat.py +15 -0
  12. hotglue_singer_sdk/helpers/_flattening.py +374 -0
  13. hotglue_singer_sdk/helpers/_schema.py +100 -0
  14. hotglue_singer_sdk/helpers/_secrets.py +41 -0
  15. hotglue_singer_sdk/helpers/_simpleeval.py +678 -0
  16. hotglue_singer_sdk/helpers/_singer.py +280 -0
  17. hotglue_singer_sdk/helpers/_state.py +282 -0
  18. hotglue_singer_sdk/helpers/_typing.py +231 -0
  19. hotglue_singer_sdk/helpers/_util.py +27 -0
  20. hotglue_singer_sdk/helpers/capabilities.py +240 -0
  21. hotglue_singer_sdk/helpers/jsonpath.py +39 -0
  22. hotglue_singer_sdk/io_base.py +134 -0
  23. hotglue_singer_sdk/mapper.py +691 -0
  24. hotglue_singer_sdk/mapper_base.py +156 -0
  25. hotglue_singer_sdk/plugin_base.py +415 -0
  26. hotglue_singer_sdk/py.typed +0 -0
  27. hotglue_singer_sdk/sinks/__init__.py +14 -0
  28. hotglue_singer_sdk/sinks/batch.py +90 -0
  29. hotglue_singer_sdk/sinks/core.py +412 -0
  30. hotglue_singer_sdk/sinks/record.py +66 -0
  31. hotglue_singer_sdk/sinks/sql.py +299 -0
  32. hotglue_singer_sdk/streams/__init__.py +14 -0
  33. hotglue_singer_sdk/streams/core.py +1294 -0
  34. hotglue_singer_sdk/streams/graphql.py +74 -0
  35. hotglue_singer_sdk/streams/rest.py +611 -0
  36. hotglue_singer_sdk/streams/sql.py +1023 -0
  37. hotglue_singer_sdk/tap_base.py +580 -0
  38. hotglue_singer_sdk/target_base.py +554 -0
  39. hotglue_singer_sdk/target_sdk/__init__.py +0 -0
  40. hotglue_singer_sdk/target_sdk/auth.py +124 -0
  41. hotglue_singer_sdk/target_sdk/client.py +286 -0
  42. hotglue_singer_sdk/target_sdk/common.py +13 -0
  43. hotglue_singer_sdk/target_sdk/lambda.py +121 -0
  44. hotglue_singer_sdk/target_sdk/rest.py +108 -0
  45. hotglue_singer_sdk/target_sdk/sinks.py +16 -0
  46. hotglue_singer_sdk/target_sdk/target.py +570 -0
  47. hotglue_singer_sdk/target_sdk/target_base.py +627 -0
  48. hotglue_singer_sdk/testing.py +198 -0
  49. hotglue_singer_sdk/typing.py +603 -0
  50. hotglue_singer_sdk-1.0.2.dist-info/METADATA +53 -0
  51. hotglue_singer_sdk-1.0.2.dist-info/RECORD +53 -0
  52. hotglue_singer_sdk-1.0.2.dist-info/WHEEL +4 -0
  53. 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
+