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,156 @@
1
+ """Abstract base class for stream mapper plugins."""
2
+
3
+ import abc
4
+ from io import FileIO
5
+ from typing import Callable, Iterable, List, Tuple
6
+
7
+ import click
8
+ import singer
9
+
10
+ from hotglue_singer_sdk.cli import common_options
11
+ from hotglue_singer_sdk.configuration._dict_config import merge_config_sources
12
+ from hotglue_singer_sdk.helpers._classproperty import classproperty
13
+ from hotglue_singer_sdk.helpers.capabilities import CapabilitiesEnum, PluginCapabilities
14
+ from hotglue_singer_sdk.io_base import SingerReader
15
+ from hotglue_singer_sdk.plugin_base import PluginBase
16
+
17
+
18
+ class InlineMapper(PluginBase, SingerReader, metaclass=abc.ABCMeta):
19
+ """Abstract base class for inline mappers."""
20
+
21
+ @classproperty
22
+ def _env_prefix(cls) -> str:
23
+ return f"{cls.name.upper().replace('-', '_')}_"
24
+
25
+ @classproperty
26
+ def capabilities(self) -> List[CapabilitiesEnum]:
27
+ """Get capabilities.
28
+
29
+ Returns:
30
+ A list of plugin capabilities.
31
+ """
32
+ return [
33
+ PluginCapabilities.STREAM_MAPS,
34
+ ]
35
+
36
+ @staticmethod
37
+ def _write_messages(messages: Iterable[singer.Message]) -> None:
38
+ for message in messages:
39
+ singer.write_message(message)
40
+
41
+ def _process_schema_message(self, message_dict: dict) -> None:
42
+ self._write_messages(self.map_schema_message(message_dict))
43
+
44
+ def _process_record_message(self, message_dict: dict) -> None:
45
+ self._write_messages(self.map_record_message(message_dict))
46
+
47
+ def _process_state_message(self, message_dict: dict) -> None:
48
+ self._write_messages(self.map_state_message(message_dict))
49
+
50
+ def _process_activate_version_message(self, message_dict: dict) -> None:
51
+ self._write_messages(self.map_activate_version_message(message_dict))
52
+
53
+ @abc.abstractmethod
54
+ def map_schema_message(self, message_dict: dict) -> Iterable[singer.Message]:
55
+ """Map a schema message to zero or more new messages.
56
+
57
+ Args:
58
+ message_dict: A SCHEMA message JSON dictionary.
59
+ """
60
+ ...
61
+
62
+ @abc.abstractmethod
63
+ def map_record_message(self, message_dict: dict) -> Iterable[singer.Message]:
64
+ """Map a record message to zero or more new messages.
65
+
66
+ Args:
67
+ message_dict: A RECORD message JSON dictionary.
68
+ """
69
+ ...
70
+
71
+ @abc.abstractmethod
72
+ def map_state_message(self, message_dict: dict) -> Iterable[singer.Message]:
73
+ """Map a state message to zero or more new messages.
74
+
75
+ Args:
76
+ message_dict: A STATE message JSON dictionary.
77
+ """
78
+ ...
79
+
80
+ @abc.abstractmethod
81
+ def map_activate_version_message(
82
+ self,
83
+ message_dict: dict,
84
+ ) -> Iterable[singer.Message]:
85
+ """Map a version message to zero or more new messages.
86
+
87
+ Args:
88
+ message_dict: An ACTIVATE_VERSION message JSON dictionary.
89
+ """
90
+ ...
91
+
92
+ @classproperty
93
+ def cli(cls) -> Callable:
94
+ """Execute standard CLI handler for inline mappers.
95
+
96
+ Returns:
97
+ A callable CLI object.
98
+ """
99
+
100
+ @common_options.PLUGIN_VERSION
101
+ @common_options.PLUGIN_ABOUT
102
+ @common_options.PLUGIN_ABOUT_FORMAT
103
+ @common_options.PLUGIN_CONFIG
104
+ @common_options.PLUGIN_FILE_INPUT
105
+ @click.command(
106
+ help="Execute the Singer mapper.",
107
+ context_settings={"help_option_names": ["--help"]},
108
+ )
109
+ def cli(
110
+ version: bool = False,
111
+ about: bool = False,
112
+ config: Tuple[str, ...] = (),
113
+ format: str = None,
114
+ file_input: FileIO = None,
115
+ ) -> None:
116
+ """Handle command line execution.
117
+
118
+ Args:
119
+ version: Display the package version.
120
+ about: Display package metadata and settings.
121
+ format: Specify output style for `--about`.
122
+ config: Configuration file location or 'ENV' to use environment
123
+ variables. Accepts multiple inputs as a tuple.
124
+ file_input: Specify a path to an input file to read messages from.
125
+ Defaults to standard in if unspecified.
126
+ """
127
+ if version:
128
+ cls.print_version()
129
+ return
130
+
131
+ if not about:
132
+ cls.print_version(print_fn=cls.logger.info)
133
+
134
+ validate_config: bool = True
135
+ if about:
136
+ validate_config = False
137
+
138
+ cls.print_version(print_fn=cls.logger.info)
139
+
140
+ config_dict = merge_config_sources(
141
+ config,
142
+ cls.config_jsonschema,
143
+ cls._env_prefix,
144
+ )
145
+
146
+ mapper = cls( # type: ignore # Ignore 'type not callable'
147
+ config=config_dict,
148
+ validate_config=validate_config,
149
+ )
150
+
151
+ if about:
152
+ mapper.print_about(format)
153
+ else:
154
+ mapper.listen(file_input)
155
+
156
+ return cli
@@ -0,0 +1,415 @@
1
+ """Shared parent class for Tap, Target (future), and Transform (future)."""
2
+
3
+ import abc
4
+ import json
5
+ import logging
6
+ import os
7
+ from collections import OrderedDict
8
+ from pathlib import PurePath
9
+ from types import MappingProxyType
10
+ from typing import (
11
+ Any,
12
+ Callable,
13
+ Dict,
14
+ List,
15
+ Mapping,
16
+ Optional,
17
+ Tuple,
18
+ Type,
19
+ Union,
20
+ cast,
21
+ )
22
+
23
+ import click
24
+ from jsonschema import Draft4Validator, SchemaError, ValidationError
25
+
26
+ from hotglue_singer_sdk.configuration._dict_config import parse_environment_config
27
+ from hotglue_singer_sdk.exceptions import ConfigValidationError
28
+ from hotglue_singer_sdk.helpers._classproperty import classproperty
29
+ from hotglue_singer_sdk.helpers._compat import metadata
30
+ from hotglue_singer_sdk.helpers._secrets import SecretString, is_common_secret_key
31
+ from hotglue_singer_sdk.helpers._util import read_json_file
32
+ from hotglue_singer_sdk.helpers.capabilities import (
33
+ FLATTENING_CONFIG,
34
+ STREAM_MAPS_CONFIG,
35
+ CapabilitiesEnum,
36
+ PluginCapabilities,
37
+ AlertingLevel,
38
+ )
39
+ from hotglue_singer_sdk.mapper import PluginMapper
40
+ from hotglue_singer_sdk.typing import extend_validator_with_defaults
41
+
42
+ SDK_PACKAGE_NAME = "hotglue_singer_sdk"
43
+
44
+
45
+ JSONSchemaValidator = extend_validator_with_defaults(Draft4Validator)
46
+
47
+
48
+ class PluginBase(metaclass=abc.ABCMeta):
49
+ """Abstract base class for taps."""
50
+
51
+ name: str # The executable name of the tap or target plugin.
52
+ alerting_level: AlertingLevel = AlertingLevel.NONE
53
+
54
+ config_jsonschema: dict = {}
55
+ # A JSON Schema object defining the config options that this tap will accept.
56
+
57
+ _config: dict
58
+
59
+ @classproperty
60
+ def logger(cls) -> logging.Logger:
61
+ """Get logger.
62
+
63
+ Returns:
64
+ Plugin logger.
65
+ """
66
+ # Get the level from <PLUGIN_NAME>_LOGLEVEL or LOGLEVEL environment variables
67
+ LOGLEVEL = (
68
+ os.environ.get(f"{cls.name.upper()}_LOGLEVEL")
69
+ or os.environ.get("LOGLEVEL")
70
+ or "INFO"
71
+ ).upper()
72
+
73
+ assert (
74
+ LOGLEVEL in logging._levelToName.values()
75
+ ), f"Invalid LOGLEVEL configuration: {LOGLEVEL}"
76
+ logger = logging.getLogger(cls.name)
77
+ logger.setLevel(LOGLEVEL)
78
+ return logger
79
+
80
+ # Constructor
81
+
82
+ def __init__(
83
+ self,
84
+ config: Optional[Union[dict, PurePath, str, List[Union[PurePath, str]]]] = None,
85
+ parse_env_config: bool = False,
86
+ validate_config: bool = True,
87
+ ) -> None:
88
+ """Create the tap or target.
89
+
90
+ Args:
91
+ config: May be one or more paths, either as str or PurePath objects, or
92
+ it can be a predetermined config dict.
93
+ parse_env_config: True to parse settings from env vars.
94
+ validate_config: True to require validation of config settings.
95
+
96
+ Raises:
97
+ ValueError: If config is not a dict or path string.
98
+ """
99
+ if not config:
100
+ config_dict = {}
101
+ elif isinstance(config, str) or isinstance(config, PurePath):
102
+ config_dict = read_json_file(config)
103
+ elif isinstance(config, list):
104
+ config_dict = {}
105
+ for config_path in config:
106
+ # Read each config file sequentially. Settings from files later in the
107
+ # list will override those of earlier ones.
108
+ config_dict.update(read_json_file(config_path))
109
+ elif isinstance(config, dict):
110
+ config_dict = config
111
+ else:
112
+ raise ValueError(f"Error parsing config of type '{type(config).__name__}'.")
113
+ if parse_env_config:
114
+ self.logger.info("Parsing env var for settings config...")
115
+ config_dict.update(self._env_var_config)
116
+ else:
117
+ self.logger.info("Skipping parse of env var settings...")
118
+ for k, v in config_dict.items():
119
+ if self._is_secret_config(k):
120
+ config_dict[k] = SecretString(v)
121
+ self._config = config_dict
122
+ self._validate_config(raise_errors=validate_config)
123
+ self.mapper: PluginMapper
124
+
125
+ @classproperty
126
+ def capabilities(self) -> List[CapabilitiesEnum]:
127
+ """Get capabilities.
128
+
129
+ Developers may override this property in oder to add or remove
130
+ advertised capabilities for this plugin.
131
+
132
+ Returns:
133
+ A list of plugin capabilities.
134
+ """
135
+ return [
136
+ PluginCapabilities.STREAM_MAPS,
137
+ PluginCapabilities.FLATTENING,
138
+ ]
139
+
140
+ @classproperty
141
+ def _env_var_config(cls) -> Dict[str, Any]:
142
+ """Return any config specified in environment variables.
143
+
144
+ Variables must match the convention "<PLUGIN_NAME>_<SETTING_NAME>",
145
+ all uppercase with dashes converted to underscores.
146
+
147
+ Returns:
148
+ Dictionary of configuration parsed from the environment.
149
+ """
150
+ plugin_env_prefix = f"{cls.name.upper().replace('-', '_')}_"
151
+ config_jsonschema = cls.config_jsonschema
152
+ cls.append_builtin_config(config_jsonschema)
153
+
154
+ return parse_environment_config(config_jsonschema, plugin_env_prefix)
155
+
156
+ # Core plugin metadata:
157
+
158
+ @classproperty
159
+ def plugin_version(cls) -> str:
160
+ """Get version.
161
+
162
+ Returns:
163
+ The package version number.
164
+ """
165
+ try:
166
+ version = metadata.version(cls.name)
167
+ except metadata.PackageNotFoundError:
168
+ version = "[could not be detected]"
169
+ return version
170
+
171
+ @classproperty
172
+ def sdk_version(cls) -> str:
173
+ """Return the package version number.
174
+
175
+ Returns:
176
+ Meltano SDK version number.
177
+ """
178
+ try:
179
+ version = metadata.version(SDK_PACKAGE_NAME)
180
+ except metadata.PackageNotFoundError:
181
+ version = "[could not be detected]"
182
+ return version
183
+
184
+ # Abstract methods:
185
+
186
+ @property
187
+ def state(self) -> dict:
188
+ """Get state.
189
+
190
+ Raises:
191
+ NotImplementedError: If the derived plugin doesn't override this method.
192
+ """
193
+ raise NotImplementedError()
194
+
195
+ # Core plugin config:
196
+
197
+ @property
198
+ def config(self) -> Mapping[str, Any]:
199
+ """Get config.
200
+
201
+ Returns:
202
+ A frozen (read-only) config dictionary map.
203
+ """
204
+ return cast(Dict, MappingProxyType(self._config))
205
+
206
+ @staticmethod
207
+ def _is_secret_config(config_key: str) -> bool:
208
+ """Check if config key is secret.
209
+
210
+ This prevents accidental printing to logs.
211
+
212
+ Args:
213
+ config_key: Configuration key name to match against common secret names.
214
+
215
+ Returns:
216
+ True if a config value should be treated as a secret.
217
+ """
218
+ return is_common_secret_key(config_key)
219
+
220
+ def _validate_config(
221
+ self, raise_errors: bool = True, warnings_as_errors: bool = False
222
+ ) -> Tuple[List[str], List[str]]:
223
+ """Validate configuration input against the plugin configuration JSON schema.
224
+
225
+ Args:
226
+ raise_errors: Flag to throw an exception if any validation errors are found.
227
+ warnings_as_errors: Flag to throw an exception if any warnings were emitted.
228
+
229
+ Returns:
230
+ A tuple of configuration validation warnings and errors.
231
+
232
+ Raises:
233
+ ConfigValidationError: If raise_errors is True and validation fails.
234
+ """
235
+ warnings: List[str] = []
236
+ errors: List[str] = []
237
+ log_fn = self.logger.info
238
+ config_jsonschema = self.config_jsonschema
239
+ if config_jsonschema:
240
+ self.append_builtin_config(config_jsonschema)
241
+ try:
242
+ self.logger.debug(
243
+ f"Validating config using jsonschema: {config_jsonschema}"
244
+ )
245
+ validator = JSONSchemaValidator(config_jsonschema)
246
+ validator.validate(self._config)
247
+ except (ValidationError, SchemaError) as ex:
248
+ errors.append(str(ex.message))
249
+ if errors:
250
+ summary = (
251
+ f"Config validation failed: {f'; '.join(errors)}\n"
252
+ f"JSONSchema was: {config_jsonschema}"
253
+ )
254
+ if raise_errors:
255
+ raise ConfigValidationError(summary)
256
+
257
+ log_fn = self.logger.warning
258
+ else:
259
+ summary = f"Config validation passed with {len(warnings)} warnings."
260
+ for warning in warnings:
261
+ summary += f"\n{warning}"
262
+ if warnings_as_errors and raise_errors and warnings:
263
+ raise ConfigValidationError(
264
+ f"One or more warnings ocurred during validation: {warnings}"
265
+ )
266
+ log_fn(summary)
267
+ return warnings, errors
268
+
269
+ @classmethod
270
+ def print_version(
271
+ cls: Type["PluginBase"],
272
+ print_fn: Callable[[Any], None] = print,
273
+ ) -> None:
274
+ """Print help text for the tap.
275
+
276
+ Args:
277
+ print_fn: A function to use to display the plugin version.
278
+ Defaults to :function:`print`.
279
+ """
280
+ print_fn(f"{cls.name} v{cls.plugin_version}, Meltano SDK v{cls.sdk_version}")
281
+
282
+ @classmethod
283
+ def _get_about_info(cls: Type["PluginBase"]) -> Dict[str, Any]:
284
+ """Returns capabilities and other tap metadata.
285
+
286
+ Returns:
287
+ A dictionary containing the relevant 'about' information.
288
+ """
289
+ info: Dict[str, Any] = OrderedDict({})
290
+ info["name"] = cls.name
291
+ info["description"] = cls.__doc__
292
+ info["version"] = cls.plugin_version
293
+ info["sdk_version"] = cls.sdk_version
294
+ info["capabilities"] = cls.capabilities
295
+ info["alerting_level"] = cls.alerting_level.value
296
+
297
+ config_jsonschema = cls.config_jsonschema
298
+ cls.append_builtin_config(config_jsonschema)
299
+ info["settings"] = config_jsonschema
300
+ return info
301
+
302
+ @classmethod
303
+ def append_builtin_config(cls: Type["PluginBase"], config_jsonschema: dict) -> None:
304
+ """Appends built-in config to `config_jsonschema` if not already set.
305
+
306
+ To customize or disable this behavior, developers may either override this class
307
+ method or override the `capabilities` property to disabled any unwanted
308
+ built-in capabilities.
309
+
310
+ For all except very advanced use cases, we recommend leaving these
311
+ implementations "as-is", since this provides the most choice to users and is
312
+ the most "future proof" in terms of taking advantage of built-in capabilities
313
+ which may be added in the future.
314
+
315
+ Args:
316
+ config_jsonschema: [description]
317
+ """
318
+
319
+ def _merge_missing(source_jsonschema: dict, target_jsonschema: dict) -> None:
320
+ # Append any missing properties in the target with those from source.
321
+ for k, v in source_jsonschema["properties"].items():
322
+ if k not in target_jsonschema["properties"]:
323
+ target_jsonschema["properties"][k] = v
324
+
325
+ capabilities = cls.capabilities
326
+ if PluginCapabilities.STREAM_MAPS in capabilities:
327
+ _merge_missing(STREAM_MAPS_CONFIG, config_jsonschema)
328
+
329
+ if PluginCapabilities.FLATTENING in capabilities:
330
+ _merge_missing(FLATTENING_CONFIG, config_jsonschema)
331
+
332
+ @classmethod
333
+ def print_about(cls: Type["PluginBase"], format: Optional[str] = None) -> None:
334
+ """Print capabilities and other tap metadata.
335
+
336
+ Args:
337
+ format: Render option for the plugin information.
338
+ """
339
+ info = cls._get_about_info()
340
+
341
+ if format == "json":
342
+ print(json.dumps(info, indent=2, default=str))
343
+
344
+ elif format == "markdown":
345
+ max_setting_len = cast(
346
+ int, max(len(k) for k in info["settings"]["properties"].keys())
347
+ )
348
+
349
+ # Set table base for markdown
350
+ table_base = (
351
+ f"| {'Setting':{max_setting_len}}| Required | Default | Description |\n"
352
+ f"|:{'-' * max_setting_len}|:--------:|:-------:|:------------|\n"
353
+ )
354
+
355
+ # Empty list for string parts
356
+ md_list = []
357
+ # Get required settings for table
358
+ required_settings = info["settings"].get("required", [])
359
+
360
+ # Iterate over Dict to set md
361
+ md_list.append(
362
+ f"# `{info['name']}`\n\n"
363
+ f"{info['description']}\n\n"
364
+ f"Built with the [Meltano SDK](https://sdk.meltano.com) for "
365
+ "Singer Taps and Targets.\n\n"
366
+ )
367
+ for key, value in info.items():
368
+
369
+ if key == "capabilities":
370
+ capabilities = f"## {key.title()}\n\n"
371
+ capabilities += "\n".join([f"* `{v}`" for v in value])
372
+ capabilities += "\n\n"
373
+ md_list.append(capabilities)
374
+
375
+ if key == "settings":
376
+ setting = f"## {key.title()}\n\n"
377
+ for k, v in info["settings"].get("properties", {}).items():
378
+ md_description = v.get("description", "").replace("\n", "<BR/>")
379
+ table_base += (
380
+ f"| {k}{' ' * (max_setting_len - len(k))}"
381
+ f"| {'True' if k in required_settings else 'False':8} | "
382
+ f"{v.get('default', 'None'):7} | "
383
+ f"{md_description:11} |\n"
384
+ )
385
+ setting += table_base
386
+ setting += (
387
+ "\n"
388
+ + "\n".join(
389
+ [
390
+ "A full list of supported settings and capabilities "
391
+ f"is available by running: `{info['name']} --about`"
392
+ ]
393
+ )
394
+ + "\n"
395
+ )
396
+ md_list.append(setting)
397
+
398
+ print("".join(md_list))
399
+ else:
400
+ formatted = "\n".join([f"{k.title()}: {v}" for k, v in info.items()])
401
+ print(formatted)
402
+
403
+ @classproperty
404
+ def cli(cls) -> Callable:
405
+ """Handle command line execution.
406
+
407
+ Returns:
408
+ A callable CLI object.
409
+ """
410
+
411
+ @click.command()
412
+ def cli() -> None:
413
+ pass
414
+
415
+ return cli
File without changes
@@ -0,0 +1,14 @@
1
+ """Sink classes for targets."""
2
+
3
+ from hotglue_singer_sdk.sinks.batch import BatchSink
4
+ from hotglue_singer_sdk.sinks.core import Sink
5
+ from hotglue_singer_sdk.sinks.record import RecordSink
6
+ from hotglue_singer_sdk.sinks.sql import SQLConnector, SQLSink
7
+
8
+ __all__ = [
9
+ "BatchSink",
10
+ "RecordSink",
11
+ "Sink",
12
+ "SQLSink",
13
+ "SQLConnector",
14
+ ]
@@ -0,0 +1,90 @@
1
+ """Sink classes load data to a target."""
2
+
3
+ import abc
4
+ import datetime
5
+ import uuid
6
+
7
+ from hotglue_singer_sdk.sinks.core import Sink
8
+
9
+
10
+ class BatchSink(Sink):
11
+ """Base class for batched record writers."""
12
+
13
+ def _get_context(self, record: dict) -> dict:
14
+ """Return a batch context. If no batch is active, return a new batch context.
15
+
16
+ The SDK-generated context will contain `batch_id` (GUID string) and
17
+ `batch_start_time` (datetime).
18
+
19
+ NOTE: Future versions of the SDK may expand the available context attributes.
20
+
21
+ Args:
22
+ record: Individual record in the stream.
23
+
24
+ Returns:
25
+ TODO
26
+ """
27
+ if self._pending_batch is None:
28
+ new_context = {
29
+ "batch_id": str(uuid.uuid4()),
30
+ "batch_start_time": datetime.datetime.now(),
31
+ }
32
+ self.start_batch(new_context)
33
+ self._pending_batch = new_context
34
+
35
+ return self._pending_batch
36
+
37
+ def start_batch(self, context: dict) -> None:
38
+ """Start a new batch with the given context.
39
+
40
+ The SDK-generated context will contain `batch_id` (GUID string) and
41
+ `batch_start_time` (datetime).
42
+
43
+ Developers may optionally override this method to add custom markers to the
44
+ `context` dict and/or to initialize batch resources - such as initializing a
45
+ local temp file to hold batch records before uploading.
46
+
47
+ Args:
48
+ context: Stream partition or context dictionary.
49
+ """
50
+ pass
51
+
52
+ def process_record(self, record: dict, context: dict) -> None:
53
+ """Load the latest record from the stream.
54
+
55
+ Developers may either load to the `context` dict for staging (the
56
+ default behavior for Batch types), or permanently write out to the target.
57
+
58
+ If this method is not overridden, the default implementation will create a
59
+ `context["records"]` list and append all records for processing during
60
+ :meth:`~hotglue_singer_sdk.BatchSink.process_batch()`.
61
+
62
+ If duplicates are merged, these can be tracked via
63
+ :meth:`~hotglue_singer_sdk.Sink.tally_duplicate_merged()`.
64
+
65
+ Args:
66
+ record: Individual record in the stream.
67
+ context: Stream partition or context dictionary.
68
+ """
69
+ if "records" not in context:
70
+ context["records"] = []
71
+
72
+ context["records"].append(record)
73
+
74
+ @abc.abstractmethod
75
+ def process_batch(self, context: dict) -> None:
76
+ """Process a batch with the given batch context.
77
+
78
+ This method must be overridden.
79
+
80
+ If :meth:`~hotglue_singer_sdk.BatchSink.process_record()` is not overridden,
81
+ the `context["records"]` list will contain all records from the given batch
82
+ context.
83
+
84
+ If duplicates are merged, these can be tracked via
85
+ :meth:`~hotglue_singer_sdk.Sink.tally_duplicate_merged()`.
86
+
87
+ Args:
88
+ context: Stream partition or context dictionary.
89
+ """
90
+ pass