airbyte.sources.base
Base class implementation for sources.
1# Copyright (c) 2023 Airbyte, Inc., all rights reserved. 2"""Base class implementation for sources.""" 3 4from __future__ import annotations 5 6import sys 7import threading 8import time 9import warnings 10from itertools import islice 11from typing import TYPE_CHECKING, Any, Literal 12 13import yaml 14from rich import print # noqa: A004 # Allow shadowing the built-in 15from rich.console import Console 16from rich.markdown import Markdown 17from rich.markup import escape 18from rich.table import Table 19from typing_extensions import override 20 21from airbyte_protocol.models import ( 22 AirbyteCatalog, 23 AirbyteMessage, 24 ConfiguredAirbyteCatalog, 25 ConfiguredAirbyteStream, 26 DestinationSyncMode, 27 SyncMode, 28 Type, 29) 30 31from airbyte import exceptions as exc 32from airbyte._connector_base import ConnectorBase 33from airbyte._message_iterators import AirbyteMessageIterator 34from airbyte._util.temp_files import as_temp_files 35from airbyte.caches.util import get_default_cache 36from airbyte.datasets._lazy import LazyDataset 37from airbyte.progress import ProgressStyle, ProgressTracker 38from airbyte.records import StreamRecord, StreamRecordHandler 39from airbyte.results import ReadResult 40from airbyte.shared.catalog_providers import CatalogProvider 41from airbyte.strategies import WriteStrategy 42 43 44if TYPE_CHECKING: 45 from collections.abc import Generator, Iterable, Iterator 46 47 from airbyte_protocol.models import ( 48 AirbyteStream, 49 ConnectorSpecification, 50 ) 51 52 from airbyte._executors.base import Executor 53 from airbyte.caches import CacheBase 54 from airbyte.callbacks import ConfigChangeCallback 55 from airbyte.datasets._inmemory import InMemoryDataset 56 from airbyte.documents import Document 57 from airbyte.shared.state_providers import StateProviderBase 58 from airbyte.shared.state_writers import StateWriterBase 59 60from airbyte.constants import ( 61 AB_EXTRACTED_AT_COLUMN, 62 AB_META_COLUMN, 63 AB_RAW_ID_COLUMN, 64) 65 66 67class Source(ConnectorBase): # noqa: PLR0904 68 """A class representing a source that can be called.""" 69 70 connector_type = "source" 71 72 def __init__( 73 self, 74 executor: Executor, 75 name: str, 76 config: dict[str, Any] | None = None, 77 *, 78 config_change_callback: ConfigChangeCallback | None = None, 79 streams: str | list[str] | None = None, 80 validate: bool = False, 81 cursor_key_overrides: dict[str, str] | None = None, 82 primary_key_overrides: dict[str, str | list[str]] | None = None, 83 ) -> None: 84 """Initialize the source. 85 86 If config is provided, it will be validated against the spec if validate is True. 87 """ 88 self._to_be_selected_streams: list[str] | str = [] 89 """Used to hold selection criteria before catalog is known.""" 90 91 super().__init__( 92 executor=executor, 93 name=name, 94 config=config, 95 config_change_callback=config_change_callback, 96 validate=validate, 97 ) 98 self._config_dict: dict[str, Any] | None = None 99 self._last_log_messages: list[str] = [] 100 self._discovered_catalog: AirbyteCatalog | None = None 101 self._selected_stream_names: list[str] = [] 102 103 self._cursor_key_overrides: dict[str, str] = {} 104 """A mapping of lower-cased stream names to cursor key overrides.""" 105 106 self._primary_key_overrides: dict[str, list[str]] = {} 107 """A mapping of lower-cased stream names to primary key overrides.""" 108 109 if config is not None: 110 self.set_config(config, validate=validate) 111 if streams is not None: 112 self.select_streams(streams) 113 if cursor_key_overrides is not None: 114 self.set_cursor_keys(**cursor_key_overrides) 115 if primary_key_overrides is not None: 116 self.set_primary_keys(**primary_key_overrides) 117 118 def set_streams(self, streams: list[str]) -> None: 119 """Deprecated. See select_streams().""" 120 warnings.warn( 121 "The 'set_streams' method is deprecated and will be removed in a future version. " 122 "Please use the 'select_streams' method instead.", 123 DeprecationWarning, 124 stacklevel=2, 125 ) 126 self.select_streams(streams) 127 128 def set_cursor_key( 129 self, 130 stream_name: str, 131 cursor_key: str, 132 ) -> None: 133 """Set the cursor for a single stream. 134 135 Note: 136 - This does not unset previously set cursors. 137 - The cursor key must be a single field name. 138 - Not all streams support custom cursors. If a stream does not support custom cursors, 139 the override may be ignored. 140 - Stream names are case insensitive, while field names are case sensitive. 141 - Stream names are not validated by PyAirbyte. If the stream name 142 does not exist in the catalog, the override may be ignored. 143 """ 144 self._cursor_key_overrides[stream_name.lower()] = cursor_key 145 146 def set_cursor_keys( 147 self, 148 **kwargs: str, 149 ) -> None: 150 """Override the cursor key for one or more streams. 151 152 Usage: 153 ```python 154 source.set_cursor_keys( 155 stream1="cursor1", 156 stream2="cursor2", 157 ) 158 ``` 159 160 Note: 161 - This does not unset previously set cursors. 162 - The cursor key must be a single field name. 163 - Not all streams support custom cursors. If a stream does not support custom cursors, 164 the override may be ignored. 165 - Stream names are case insensitive, while field names are case sensitive. 166 - Stream names are not validated by PyAirbyte. If the stream name 167 does not exist in the catalog, the override may be ignored. 168 """ 169 self._cursor_key_overrides.update({k.lower(): v for k, v in kwargs.items()}) 170 171 def set_primary_key( 172 self, 173 stream_name: str, 174 primary_key: str | list[str], 175 ) -> None: 176 """Set the primary key for a single stream. 177 178 Note: 179 - This does not unset previously set primary keys. 180 - The primary key must be a single field name or a list of field names. 181 - Not all streams support overriding primary keys. If a stream does not support overriding 182 primary keys, the override may be ignored. 183 - Stream names are case insensitive, while field names are case sensitive. 184 - Stream names are not validated by PyAirbyte. If the stream name 185 does not exist in the catalog, the override may be ignored. 186 """ 187 self._primary_key_overrides[stream_name.lower()] = ( 188 primary_key if isinstance(primary_key, list) else [primary_key] 189 ) 190 191 def set_primary_keys( 192 self, 193 **kwargs: str | list[str], 194 ) -> None: 195 """Override the primary keys for one or more streams. 196 197 This does not unset previously set primary keys. 198 199 Usage: 200 ```python 201 source.set_primary_keys( 202 stream1="pk1", 203 stream2=["pk1", "pk2"], 204 ) 205 ``` 206 207 Note: 208 - This does not unset previously set primary keys. 209 - The primary key must be a single field name or a list of field names. 210 - Not all streams support overriding primary keys. If a stream does not support overriding 211 primary keys, the override may be ignored. 212 - Stream names are case insensitive, while field names are case sensitive. 213 - Stream names are not validated by PyAirbyte. If the stream name 214 does not exist in the catalog, the override may be ignored. 215 """ 216 self._primary_key_overrides.update( 217 {k.lower(): v if isinstance(v, list) else [v] for k, v in kwargs.items()} 218 ) 219 220 def _log_warning_preselected_stream(self, streams: str | list[str]) -> None: 221 """Logs a warning message indicating stream selection which are not selected yet.""" 222 if streams == "*": 223 print( 224 "Warning: Config is not set yet. All streams will be selected after config is set.", 225 file=sys.stderr, 226 ) 227 else: 228 print( 229 "Warning: Config is not set yet. " 230 f"Streams to be selected after config is set: {streams}", 231 file=sys.stderr, 232 ) 233 234 def select_all_streams(self) -> None: 235 """Select all streams. 236 237 This is a more streamlined equivalent to: 238 > source.select_streams(source.get_available_streams()). 239 """ 240 if self._config_dict is None: 241 self._to_be_selected_streams = "*" 242 self._log_warning_preselected_stream(self._to_be_selected_streams) 243 return 244 245 self._selected_stream_names = self.get_available_streams() 246 247 def select_streams(self, streams: str | list[str]) -> None: 248 """Select the stream names that should be read from the connector. 249 250 Args: 251 streams: A list of stream names to select. If set to "*", all streams will be selected. 252 253 Currently, if this is not set, all streams will be read. 254 """ 255 if self._config_dict is None: 256 self._to_be_selected_streams = streams 257 self._log_warning_preselected_stream(streams) 258 return 259 260 if streams == "*": 261 self.select_all_streams() 262 return 263 264 if isinstance(streams, str): 265 # If a single stream is provided, convert it to a one-item list 266 streams = [streams] 267 268 available_streams = self.get_available_streams() 269 for stream in streams: 270 if stream not in available_streams: 271 raise exc.AirbyteStreamNotFoundError( 272 stream_name=stream, 273 connector_name=self.name, 274 available_streams=available_streams, 275 ) 276 self._selected_stream_names = streams 277 278 def get_selected_streams(self) -> list[str]: 279 """Get the selected streams. 280 281 If no streams are selected, return an empty list. 282 """ 283 return self._selected_stream_names 284 285 def set_config( 286 self, 287 config: dict[str, Any], 288 *, 289 validate: bool = True, 290 ) -> None: 291 """Set the config for the connector. 292 293 If validate is True, raise an exception if the config fails validation. 294 295 If validate is False, validation will be deferred until check() or validate_config() 296 is called. 297 """ 298 if validate: 299 self.validate_config(config) 300 301 self._config_dict = config 302 303 if self._to_be_selected_streams: 304 self.select_streams(self._to_be_selected_streams) 305 self._to_be_selected_streams = [] 306 307 def _discover(self) -> AirbyteCatalog: 308 """Call discover on the connector. 309 310 This involves the following steps: 311 - Write the config to a temporary file 312 - execute the connector with discover --config <config_file> 313 - Listen to the messages and return the first AirbyteCatalog that comes along. 314 - Make sure the subprocess is killed when the function returns. 315 """ 316 with as_temp_files([self._hydrated_config]) as [config_file]: 317 for msg in self._execute(["discover", "--config", config_file]): 318 if msg.type == Type.CATALOG and msg.catalog: 319 return msg.catalog 320 raise exc.AirbyteConnectorMissingCatalogError( 321 connector_name=self.name, 322 log_text=self._last_log_messages, 323 ) 324 325 def get_available_streams(self) -> list[str]: 326 """Get the available streams from the spec.""" 327 return [s.name for s in self.discovered_catalog.streams] 328 329 def _get_incremental_stream_names(self) -> list[str]: 330 """Get the name of streams that support incremental sync.""" 331 return [ 332 stream.name 333 for stream in self.discovered_catalog.streams 334 if SyncMode.incremental in stream.supported_sync_modes 335 ] 336 337 @override 338 def _get_spec(self, *, force_refresh: bool = False) -> ConnectorSpecification: 339 """Call spec on the connector. 340 341 This involves the following steps: 342 * execute the connector with spec 343 * Listen to the messages and return the first AirbyteCatalog that comes along. 344 * Make sure the subprocess is killed when the function returns. 345 """ 346 if force_refresh or self._spec is None: 347 for msg in self._execute(["spec"]): 348 if msg.type == Type.SPEC and msg.spec: 349 self._spec = msg.spec 350 break 351 352 if self._spec: 353 return self._spec 354 355 raise exc.AirbyteConnectorMissingSpecError( 356 connector_name=self.name, 357 log_text=self._last_log_messages, 358 ) 359 360 @property 361 def config_spec(self) -> dict[str, Any]: 362 """Generate a configuration spec for this connector, as a JSON Schema definition. 363 364 This function generates a JSON Schema dictionary with configuration specs for the 365 current connector, as a dictionary. 366 367 Returns: 368 dict: The JSON Schema configuration spec as a dictionary. 369 """ 370 return self._get_spec(force_refresh=True).connectionSpecification 371 372 @property 373 def _yaml_spec(self) -> str: 374 """Get the spec as a yaml string. 375 376 For now, the primary use case is for writing and debugging a valid config for a source. 377 378 This is private for now because we probably want better polish before exposing this 379 as a stable interface. This will also get easier when we have docs links with this info 380 for each connector. 381 """ 382 spec_obj: ConnectorSpecification = self._get_spec() 383 spec_dict: dict[str, Any] = spec_obj.model_dump(exclude_unset=True) 384 # convert to a yaml string 385 return yaml.dump(spec_dict) 386 387 @property 388 def docs_url(self) -> str: 389 """Get the URL to the connector's documentation.""" 390 return "https://docs.airbyte.com/integrations/sources/" + self.name.lower().replace( 391 "source-", "" 392 ) 393 394 @property 395 def discovered_catalog(self) -> AirbyteCatalog: 396 """Get the raw catalog for the given streams. 397 398 If the catalog is not yet known, we call discover to get it. 399 """ 400 if self._discovered_catalog is None: 401 self._discovered_catalog = self._discover() 402 403 return self._discovered_catalog 404 405 @property 406 def configured_catalog(self) -> ConfiguredAirbyteCatalog: 407 """Get the configured catalog for the given streams. 408 409 If the raw catalog is not yet known, we call discover to get it. 410 411 If no specific streams are selected, we return a catalog that syncs all available streams. 412 413 TODO: We should consider disabling by default the streams that the connector would 414 disable by default. (For instance, streams that require a premium license are sometimes 415 disabled by default within the connector.) 416 """ 417 # Ensure discovered catalog is cached before we start 418 _ = self.discovered_catalog 419 420 # Filter for selected streams if set, otherwise use all available streams: 421 streams_filter: list[str] = self._selected_stream_names or self.get_available_streams() 422 return self.get_configured_catalog(streams=streams_filter) 423 424 def get_configured_catalog( 425 self, 426 streams: Literal["*"] | list[str] | None = None, 427 *, 428 force_full_refresh: bool = False, 429 ) -> ConfiguredAirbyteCatalog: 430 """Get a configured catalog for the given streams. 431 432 If no streams are provided, the selected streams will be used. If no streams are selected, 433 all available streams will be used. 434 435 If '*' is provided, all available streams will be used. 436 437 If force_full_refresh is True, streams will be configured with full_refresh sync mode 438 when supported by the stream. Otherwise, incremental sync mode is used when supported. 439 """ 440 selected_streams: list[str] = [] 441 if streams is None: 442 selected_streams = self._selected_stream_names or self.get_available_streams() 443 elif streams == "*": 444 selected_streams = self.get_available_streams() 445 elif isinstance(streams, list): 446 selected_streams = streams 447 else: 448 raise exc.PyAirbyteInputError( 449 message="Invalid streams argument.", 450 input_value=streams, 451 ) 452 453 def _get_sync_mode(stream: AirbyteStream) -> SyncMode: 454 """Determine the sync mode for a stream based on force_full_refresh and support.""" 455 # Use getattr to handle mocks or streams without supported_sync_modes attribute 456 supported_modes = getattr(stream, "supported_sync_modes", None) 457 458 if force_full_refresh: 459 # When force_full_refresh is True, prefer full_refresh if supported 460 if supported_modes and SyncMode.full_refresh in supported_modes: 461 return SyncMode.full_refresh 462 # Fall back to incremental if full_refresh is not supported 463 return SyncMode.incremental 464 465 # Default behavior: preserve previous semantics (always incremental) 466 return SyncMode.incremental 467 468 return ConfiguredAirbyteCatalog( 469 streams=[ 470 ConfiguredAirbyteStream( 471 stream=stream, 472 destination_sync_mode=DestinationSyncMode.overwrite, 473 sync_mode=_get_sync_mode(stream), 474 primary_key=( 475 [self._primary_key_overrides[stream.name.lower()]] 476 if stream.name.lower() in self._primary_key_overrides 477 else stream.source_defined_primary_key 478 ), 479 cursor_field=( 480 [self._cursor_key_overrides[stream.name.lower()]] 481 if stream.name.lower() in self._cursor_key_overrides 482 else stream.default_cursor_field 483 ), 484 # These are unused in the current implementation: 485 generation_id=None, 486 minimum_generation_id=None, 487 sync_id=None, 488 ) 489 for stream in self.discovered_catalog.streams 490 if stream.name in selected_streams 491 ], 492 ) 493 494 def get_stream_json_schema(self, stream_name: str) -> dict[str, Any]: 495 """Return the JSON Schema spec for the specified stream name.""" 496 catalog: AirbyteCatalog = self.discovered_catalog 497 found: list[AirbyteStream] = [ 498 stream for stream in catalog.streams if stream.name == stream_name 499 ] 500 501 if len(found) == 0: 502 raise exc.PyAirbyteInputError( 503 message="Stream name does not exist in catalog.", 504 input_value=stream_name, 505 ) 506 507 if len(found) > 1: 508 raise exc.PyAirbyteInternalError( 509 message="Duplicate streams found with the same name.", 510 context={ 511 "found_streams": found, 512 }, 513 ) 514 515 return found[0].json_schema 516 517 def get_records( 518 self, 519 stream: str, 520 *, 521 limit: int | None = None, 522 stop_event: threading.Event | None = None, 523 normalize_field_names: bool = False, 524 prune_undeclared_fields: bool = True, 525 ) -> LazyDataset: 526 """Read a stream from the connector. 527 528 Args: 529 stream: The name of the stream to read. 530 limit: The maximum number of records to read. If None, all records will be read. 531 stop_event: If set, the event can be triggered by the caller to stop reading records 532 and terminate the process. 533 normalize_field_names: When `True`, field names will be normalized to lower case, with 534 special characters removed. This matches the behavior of PyAirbyte caches and most 535 Airbyte destinations. 536 prune_undeclared_fields: When `True`, undeclared fields will be pruned from the records, 537 which generally matches the behavior of PyAirbyte caches and most Airbyte 538 destinations, specifically when you expect the catalog may be stale. You can disable 539 this to keep all fields in the records. 540 541 This involves the following steps: 542 * Call discover to get the catalog 543 * Generate a configured catalog that syncs the given stream in full_refresh mode 544 * Write the configured catalog and the config to a temporary file 545 * execute the connector with read --config <config_file> --catalog <catalog_file> 546 * Listen to the messages and return the first AirbyteRecordMessages that come along. 547 * Make sure the subprocess is killed when the function returns. 548 """ 549 stop_event = stop_event or threading.Event() 550 configured_catalog = self.get_configured_catalog(streams=[stream]) 551 if len(configured_catalog.streams) == 0: 552 raise exc.PyAirbyteInputError( 553 message="Requested stream does not exist.", 554 context={ 555 "stream": stream, 556 "available_streams": self.get_available_streams(), 557 "connector_name": self.name, 558 }, 559 ) from KeyError(stream) 560 561 configured_stream = configured_catalog.streams[0] 562 563 def _with_logging(records: Iterable[dict[str, Any]]) -> Iterator[dict[str, Any]]: 564 yield from records 565 566 stream_record_handler = StreamRecordHandler( 567 json_schema=self.get_stream_json_schema(stream), 568 prune_extra_fields=prune_undeclared_fields, 569 normalize_keys=normalize_field_names, 570 ) 571 572 # This method is non-blocking, so we use "PLAIN" to avoid a live progress display 573 progress_tracker = ProgressTracker( 574 ProgressStyle.PLAIN, 575 source=self, 576 cache=None, 577 destination=None, 578 expected_streams=[stream], 579 ) 580 581 iterator: Iterator[dict[str, Any]] = ( 582 StreamRecord.from_record_message( 583 record_message=record.record, 584 stream_record_handler=stream_record_handler, 585 ) 586 for record in self._read_with_catalog( 587 catalog=configured_catalog, 588 progress_tracker=progress_tracker, 589 stop_event=stop_event, 590 ) 591 if record.record 592 ) 593 if limit is not None: 594 # Stop the iterator after the limit is reached 595 iterator = islice(iterator, limit) 596 597 return LazyDataset( 598 iterator, 599 stream_metadata=configured_stream, 600 stop_event=stop_event, 601 progress_tracker=progress_tracker, 602 ) 603 604 def get_documents( 605 self, 606 stream: str, 607 title_property: str | None = None, 608 content_properties: list[str] | None = None, 609 metadata_properties: list[str] | None = None, 610 *, 611 render_metadata: bool = False, 612 ) -> Iterable[Document]: 613 """Read a stream from the connector and return the records as documents. 614 615 If metadata_properties is not set, all properties that are not content will be added to 616 the metadata. 617 618 If render_metadata is True, metadata will be rendered in the document, as well as the 619 the main content. 620 """ 621 return self.get_records(stream).to_documents( 622 title_property=title_property, 623 content_properties=content_properties, 624 metadata_properties=metadata_properties, 625 render_metadata=render_metadata, 626 ) 627 628 def get_samples( 629 self, 630 streams: list[str] | Literal["*"] | None = None, 631 *, 632 limit: int = 5, 633 on_error: Literal["raise", "ignore", "log"] = "raise", 634 ) -> dict[str, InMemoryDataset | None]: 635 """Get a sample of records from the given streams.""" 636 if streams == "*": 637 streams = self.get_available_streams() 638 elif streams is None: 639 streams = self.get_selected_streams() 640 641 results: dict[str, InMemoryDataset | None] = {} 642 for stream in streams: 643 stop_event = threading.Event() 644 try: 645 results[stream] = self.get_records( 646 stream, 647 limit=limit, 648 stop_event=stop_event, 649 ).fetch_all() 650 stop_event.set() 651 except Exception as ex: 652 results[stream] = None 653 if on_error == "ignore": 654 continue 655 656 if on_error == "raise": 657 raise ex from None 658 659 if on_error == "log": 660 print(f"Error fetching sample for stream '{stream}': {ex}") 661 662 return results 663 664 def print_samples( 665 self, 666 streams: list[str] | Literal["*"] | None = None, 667 *, 668 limit: int = 5, 669 on_error: Literal["raise", "ignore", "log"] = "log", 670 ) -> None: 671 """Print a sample of records from the given streams.""" 672 internal_cols: list[str] = [ 673 AB_EXTRACTED_AT_COLUMN, 674 AB_META_COLUMN, 675 AB_RAW_ID_COLUMN, 676 ] 677 col_limit = 10 678 if streams == "*": 679 streams = self.get_available_streams() 680 elif streams is None: 681 streams = self.get_selected_streams() 682 683 console = Console() 684 685 console.print( 686 Markdown( 687 f"# Sample Records from `{self.name}` ({len(streams)} selected streams)", 688 justify="left", 689 ) 690 ) 691 692 for stream in streams: 693 console.print(Markdown(f"## `{stream}` Stream Sample", justify="left")) 694 samples = self.get_samples( 695 streams=[stream], 696 limit=limit, 697 on_error=on_error, 698 ) 699 dataset = samples[stream] 700 701 table = Table( 702 show_header=True, 703 show_lines=True, 704 ) 705 if dataset is None: 706 console.print( 707 Markdown("**⚠️ `Error fetching sample records.` ⚠️**"), 708 ) 709 continue 710 711 if len(dataset.column_names) > col_limit: 712 # We'll pivot the columns so each column is its own row 713 table.add_column("Column Name") 714 for _ in range(len(dataset)): 715 table.add_column(overflow="fold") 716 for col in dataset.column_names: 717 table.add_row( 718 Markdown(f"**`{col}`**"), 719 *[escape(str(record[col])) for record in dataset], 720 ) 721 else: 722 for col in dataset.column_names: 723 table.add_column( 724 Markdown(f"**`{col}`**"), 725 overflow="fold", 726 ) 727 728 for record in dataset: 729 table.add_row( 730 *[ 731 escape(str(val)) 732 for key, val in record.items() 733 # Exclude internal Airbyte columns. 734 if key not in internal_cols 735 ] 736 ) 737 738 console.print(table) 739 740 console.print(Markdown("--------------")) 741 742 def _get_airbyte_message_iterator( 743 self, 744 *, 745 streams: Literal["*"] | list[str] | None = None, 746 state_provider: StateProviderBase | None = None, 747 progress_tracker: ProgressTracker, 748 force_full_refresh: bool = False, 749 ) -> AirbyteMessageIterator: 750 """Get an AirbyteMessageIterator for this source.""" 751 return AirbyteMessageIterator( 752 self._read_with_catalog( 753 catalog=self.get_configured_catalog( 754 streams=streams, 755 force_full_refresh=force_full_refresh, 756 ), 757 state=state_provider if not force_full_refresh else None, 758 progress_tracker=progress_tracker, 759 ) 760 ) 761 762 def _read_with_catalog( 763 self, 764 catalog: ConfiguredAirbyteCatalog, 765 progress_tracker: ProgressTracker, 766 *, 767 state: StateProviderBase | None = None, 768 stop_event: threading.Event | None = None, 769 ) -> Generator[AirbyteMessage, None, None]: 770 """Call read on the connector. 771 772 This involves the following steps: 773 * Write the config to a temporary file 774 * execute the connector with read --config <config_file> --catalog <catalog_file> 775 * Listen to the messages and return the AirbyteRecordMessages that come along. 776 * Send out telemetry on the performed sync (with information about which source was used and 777 the type of the cache) 778 """ 779 with as_temp_files( 780 [ 781 self._hydrated_config, 782 catalog.model_dump_json(exclude_none=True), 783 state.to_state_input_file_text() if state else "[]", 784 ] 785 ) as [ 786 config_file, 787 catalog_file, 788 state_file, 789 ]: 790 message_generator = self._execute( 791 [ 792 "read", 793 "--config", 794 config_file, 795 "--catalog", 796 catalog_file, 797 "--state", 798 state_file, 799 ], 800 progress_tracker=progress_tracker, 801 ) 802 for message in progress_tracker.tally_records_read(message_generator): 803 if stop_event and stop_event.is_set(): 804 progress_tracker._log_sync_cancel() # noqa: SLF001 805 time.sleep(0.1) 806 return 807 808 yield message 809 810 progress_tracker.log_read_complete() 811 812 def _peek_airbyte_message( 813 self, 814 message: AirbyteMessage, 815 *, 816 raise_on_error: bool = True, 817 ) -> None: 818 """Process an Airbyte message. 819 820 This method handles reading Airbyte messages and taking action, if needed, based on the 821 message type. For instance, log messages are logged, records are tallied, and errors are 822 raised as exceptions if `raise_on_error` is True. 823 824 Raises: 825 AirbyteConnectorFailedError: If a TRACE message of type ERROR is emitted. 826 """ 827 super()._peek_airbyte_message(message, raise_on_error=raise_on_error) 828 829 def _log_incremental_streams( 830 self, 831 *, 832 incremental_streams: set[str] | None = None, 833 ) -> None: 834 """Log the streams which are using incremental sync mode.""" 835 log_message = ( 836 "The following streams are currently using incremental sync:\n" 837 f"{incremental_streams}\n" 838 "To perform a full refresh, set 'force_full_refresh=True' in 'airbyte.read()' method." 839 ) 840 print(log_message, file=sys.stderr) 841 842 def read( 843 self, 844 cache: CacheBase | None = None, 845 *, 846 streams: str | list[str] | None = None, 847 write_strategy: str | WriteStrategy = WriteStrategy.AUTO, 848 force_full_refresh: bool = False, 849 skip_validation: bool = False, 850 ) -> ReadResult: 851 """Read from the connector and write to the cache. 852 853 Args: 854 cache: The cache to write to. If not set, a default cache will be used. 855 streams: Optional if already set. A list of stream names to select for reading. If set 856 to "*", all streams will be selected. 857 write_strategy: The strategy to use when writing to the cache. If a string, it must be 858 one of "append", "merge", "replace", or "auto". If a WriteStrategy, it must be one 859 of WriteStrategy.APPEND, WriteStrategy.MERGE, WriteStrategy.REPLACE, or 860 WriteStrategy.AUTO. 861 force_full_refresh: If True, the source will operate in full refresh mode. Otherwise, 862 streams will be read in incremental mode if supported by the connector. This option 863 must be True when using the "replace" strategy. 864 skip_validation: If True, PyAirbyte will not pre-validate the input configuration before 865 running the connector. This can be helpful in debugging, when you want to send 866 configurations to the connector that otherwise might be rejected by JSON Schema 867 validation rules. 868 """ 869 cache = cache or get_default_cache() 870 progress_tracker = ProgressTracker( 871 source=self, 872 cache=cache, 873 destination=None, 874 expected_streams=None, # Will be set later 875 ) 876 877 # Set up state provider if not in full refresh mode 878 if force_full_refresh: 879 state_provider: StateProviderBase | None = None 880 else: 881 state_provider = cache.get_state_provider( 882 source_name=self._name, 883 ) 884 state_writer = cache.get_state_writer(source_name=self._name) 885 886 if streams: 887 self.select_streams(streams) 888 889 if not self._selected_stream_names: 890 raise exc.PyAirbyteNoStreamsSelectedError( 891 connector_name=self.name, 892 available_streams=self.get_available_streams(), 893 ) 894 895 try: 896 result = self._read_to_cache( 897 cache=cache, 898 catalog_provider=CatalogProvider( 899 self.get_configured_catalog(force_full_refresh=force_full_refresh) 900 ), 901 stream_names=self._selected_stream_names, 902 state_provider=state_provider, 903 state_writer=state_writer, 904 write_strategy=write_strategy, 905 force_full_refresh=force_full_refresh, 906 skip_validation=skip_validation, 907 progress_tracker=progress_tracker, 908 ) 909 except exc.PyAirbyteInternalError as ex: 910 progress_tracker.log_failure(exception=ex) 911 raise exc.AirbyteConnectorFailedError( 912 connector_name=self.name, 913 log_text=self._last_log_messages, 914 ) from ex 915 except Exception as ex: 916 progress_tracker.log_failure(exception=ex) 917 raise 918 919 progress_tracker.log_success() 920 return result 921 922 def _read_to_cache( # noqa: PLR0913 # Too many arguments 923 self, 924 cache: CacheBase, 925 *, 926 catalog_provider: CatalogProvider, 927 stream_names: list[str], 928 state_provider: StateProviderBase | None, 929 state_writer: StateWriterBase | None, 930 write_strategy: str | WriteStrategy = WriteStrategy.AUTO, 931 force_full_refresh: bool = False, 932 skip_validation: bool = False, 933 progress_tracker: ProgressTracker, 934 ) -> ReadResult: 935 """Internal read method.""" 936 if write_strategy == WriteStrategy.REPLACE and not force_full_refresh: 937 warnings.warn( 938 message=( 939 "Using `REPLACE` strategy without also setting `force_full_refresh=True` " 940 "could result in data loss. " 941 "To silence this warning, use the following: " 942 'warnings.filterwarnings("ignore", ' 943 'category="airbyte.warnings.PyAirbyteDataLossWarning")`' 944 ), 945 category=exc.PyAirbyteDataLossWarning, 946 stacklevel=1, 947 ) 948 if isinstance(write_strategy, str): 949 try: 950 write_strategy = WriteStrategy(write_strategy) 951 except ValueError: 952 raise exc.PyAirbyteInputError( 953 message="Invalid strategy", 954 context={ 955 "write_strategy": write_strategy, 956 "available_strategies": [ 957 s.value 958 for s in WriteStrategy # pyrefly: ignore[not-iterable] 959 ], 960 }, 961 ) from None 962 963 # Run optional validation step 964 if not skip_validation: 965 self.validate_config() 966 967 # Log incremental stream if incremental streams are known 968 if state_provider and state_provider.known_stream_names: 969 # Retrieve set of the known streams support which support incremental sync 970 incremental_streams = ( 971 set(self._get_incremental_stream_names()) 972 & state_provider.known_stream_names 973 & set(self.get_selected_streams()) 974 ) 975 if incremental_streams: 976 self._log_incremental_streams(incremental_streams=incremental_streams) 977 978 airbyte_message_iterator = AirbyteMessageIterator( 979 self._read_with_catalog( 980 catalog=catalog_provider.configured_catalog, 981 state=state_provider, 982 progress_tracker=progress_tracker, 983 ) 984 ) 985 cache._write_airbyte_message_stream( # noqa: SLF001 # Non-public API 986 stdin=airbyte_message_iterator, 987 catalog_provider=catalog_provider, 988 write_strategy=write_strategy, 989 state_writer=state_writer, 990 progress_tracker=progress_tracker, 991 ) 992 993 # Flush the WAL, if applicable 994 cache.processor._do_checkpoint() # noqa: SLF001 # Non-public API 995 996 return ReadResult( 997 source_name=self.name, 998 progress_tracker=progress_tracker, 999 processed_streams=stream_names, 1000 cache=cache, 1001 ) 1002 1003 1004__all__ = [ 1005 "Source", 1006]
68class Source(ConnectorBase): # noqa: PLR0904 69 """A class representing a source that can be called.""" 70 71 connector_type = "source" 72 73 def __init__( 74 self, 75 executor: Executor, 76 name: str, 77 config: dict[str, Any] | None = None, 78 *, 79 config_change_callback: ConfigChangeCallback | None = None, 80 streams: str | list[str] | None = None, 81 validate: bool = False, 82 cursor_key_overrides: dict[str, str] | None = None, 83 primary_key_overrides: dict[str, str | list[str]] | None = None, 84 ) -> None: 85 """Initialize the source. 86 87 If config is provided, it will be validated against the spec if validate is True. 88 """ 89 self._to_be_selected_streams: list[str] | str = [] 90 """Used to hold selection criteria before catalog is known.""" 91 92 super().__init__( 93 executor=executor, 94 name=name, 95 config=config, 96 config_change_callback=config_change_callback, 97 validate=validate, 98 ) 99 self._config_dict: dict[str, Any] | None = None 100 self._last_log_messages: list[str] = [] 101 self._discovered_catalog: AirbyteCatalog | None = None 102 self._selected_stream_names: list[str] = [] 103 104 self._cursor_key_overrides: dict[str, str] = {} 105 """A mapping of lower-cased stream names to cursor key overrides.""" 106 107 self._primary_key_overrides: dict[str, list[str]] = {} 108 """A mapping of lower-cased stream names to primary key overrides.""" 109 110 if config is not None: 111 self.set_config(config, validate=validate) 112 if streams is not None: 113 self.select_streams(streams) 114 if cursor_key_overrides is not None: 115 self.set_cursor_keys(**cursor_key_overrides) 116 if primary_key_overrides is not None: 117 self.set_primary_keys(**primary_key_overrides) 118 119 def set_streams(self, streams: list[str]) -> None: 120 """Deprecated. See select_streams().""" 121 warnings.warn( 122 "The 'set_streams' method is deprecated and will be removed in a future version. " 123 "Please use the 'select_streams' method instead.", 124 DeprecationWarning, 125 stacklevel=2, 126 ) 127 self.select_streams(streams) 128 129 def set_cursor_key( 130 self, 131 stream_name: str, 132 cursor_key: str, 133 ) -> None: 134 """Set the cursor for a single stream. 135 136 Note: 137 - This does not unset previously set cursors. 138 - The cursor key must be a single field name. 139 - Not all streams support custom cursors. If a stream does not support custom cursors, 140 the override may be ignored. 141 - Stream names are case insensitive, while field names are case sensitive. 142 - Stream names are not validated by PyAirbyte. If the stream name 143 does not exist in the catalog, the override may be ignored. 144 """ 145 self._cursor_key_overrides[stream_name.lower()] = cursor_key 146 147 def set_cursor_keys( 148 self, 149 **kwargs: str, 150 ) -> None: 151 """Override the cursor key for one or more streams. 152 153 Usage: 154 ```python 155 source.set_cursor_keys( 156 stream1="cursor1", 157 stream2="cursor2", 158 ) 159 ``` 160 161 Note: 162 - This does not unset previously set cursors. 163 - The cursor key must be a single field name. 164 - Not all streams support custom cursors. If a stream does not support custom cursors, 165 the override may be ignored. 166 - Stream names are case insensitive, while field names are case sensitive. 167 - Stream names are not validated by PyAirbyte. If the stream name 168 does not exist in the catalog, the override may be ignored. 169 """ 170 self._cursor_key_overrides.update({k.lower(): v for k, v in kwargs.items()}) 171 172 def set_primary_key( 173 self, 174 stream_name: str, 175 primary_key: str | list[str], 176 ) -> None: 177 """Set the primary key for a single stream. 178 179 Note: 180 - This does not unset previously set primary keys. 181 - The primary key must be a single field name or a list of field names. 182 - Not all streams support overriding primary keys. If a stream does not support overriding 183 primary keys, the override may be ignored. 184 - Stream names are case insensitive, while field names are case sensitive. 185 - Stream names are not validated by PyAirbyte. If the stream name 186 does not exist in the catalog, the override may be ignored. 187 """ 188 self._primary_key_overrides[stream_name.lower()] = ( 189 primary_key if isinstance(primary_key, list) else [primary_key] 190 ) 191 192 def set_primary_keys( 193 self, 194 **kwargs: str | list[str], 195 ) -> None: 196 """Override the primary keys for one or more streams. 197 198 This does not unset previously set primary keys. 199 200 Usage: 201 ```python 202 source.set_primary_keys( 203 stream1="pk1", 204 stream2=["pk1", "pk2"], 205 ) 206 ``` 207 208 Note: 209 - This does not unset previously set primary keys. 210 - The primary key must be a single field name or a list of field names. 211 - Not all streams support overriding primary keys. If a stream does not support overriding 212 primary keys, the override may be ignored. 213 - Stream names are case insensitive, while field names are case sensitive. 214 - Stream names are not validated by PyAirbyte. If the stream name 215 does not exist in the catalog, the override may be ignored. 216 """ 217 self._primary_key_overrides.update( 218 {k.lower(): v if isinstance(v, list) else [v] for k, v in kwargs.items()} 219 ) 220 221 def _log_warning_preselected_stream(self, streams: str | list[str]) -> None: 222 """Logs a warning message indicating stream selection which are not selected yet.""" 223 if streams == "*": 224 print( 225 "Warning: Config is not set yet. All streams will be selected after config is set.", 226 file=sys.stderr, 227 ) 228 else: 229 print( 230 "Warning: Config is not set yet. " 231 f"Streams to be selected after config is set: {streams}", 232 file=sys.stderr, 233 ) 234 235 def select_all_streams(self) -> None: 236 """Select all streams. 237 238 This is a more streamlined equivalent to: 239 > source.select_streams(source.get_available_streams()). 240 """ 241 if self._config_dict is None: 242 self._to_be_selected_streams = "*" 243 self._log_warning_preselected_stream(self._to_be_selected_streams) 244 return 245 246 self._selected_stream_names = self.get_available_streams() 247 248 def select_streams(self, streams: str | list[str]) -> None: 249 """Select the stream names that should be read from the connector. 250 251 Args: 252 streams: A list of stream names to select. If set to "*", all streams will be selected. 253 254 Currently, if this is not set, all streams will be read. 255 """ 256 if self._config_dict is None: 257 self._to_be_selected_streams = streams 258 self._log_warning_preselected_stream(streams) 259 return 260 261 if streams == "*": 262 self.select_all_streams() 263 return 264 265 if isinstance(streams, str): 266 # If a single stream is provided, convert it to a one-item list 267 streams = [streams] 268 269 available_streams = self.get_available_streams() 270 for stream in streams: 271 if stream not in available_streams: 272 raise exc.AirbyteStreamNotFoundError( 273 stream_name=stream, 274 connector_name=self.name, 275 available_streams=available_streams, 276 ) 277 self._selected_stream_names = streams 278 279 def get_selected_streams(self) -> list[str]: 280 """Get the selected streams. 281 282 If no streams are selected, return an empty list. 283 """ 284 return self._selected_stream_names 285 286 def set_config( 287 self, 288 config: dict[str, Any], 289 *, 290 validate: bool = True, 291 ) -> None: 292 """Set the config for the connector. 293 294 If validate is True, raise an exception if the config fails validation. 295 296 If validate is False, validation will be deferred until check() or validate_config() 297 is called. 298 """ 299 if validate: 300 self.validate_config(config) 301 302 self._config_dict = config 303 304 if self._to_be_selected_streams: 305 self.select_streams(self._to_be_selected_streams) 306 self._to_be_selected_streams = [] 307 308 def _discover(self) -> AirbyteCatalog: 309 """Call discover on the connector. 310 311 This involves the following steps: 312 - Write the config to a temporary file 313 - execute the connector with discover --config <config_file> 314 - Listen to the messages and return the first AirbyteCatalog that comes along. 315 - Make sure the subprocess is killed when the function returns. 316 """ 317 with as_temp_files([self._hydrated_config]) as [config_file]: 318 for msg in self._execute(["discover", "--config", config_file]): 319 if msg.type == Type.CATALOG and msg.catalog: 320 return msg.catalog 321 raise exc.AirbyteConnectorMissingCatalogError( 322 connector_name=self.name, 323 log_text=self._last_log_messages, 324 ) 325 326 def get_available_streams(self) -> list[str]: 327 """Get the available streams from the spec.""" 328 return [s.name for s in self.discovered_catalog.streams] 329 330 def _get_incremental_stream_names(self) -> list[str]: 331 """Get the name of streams that support incremental sync.""" 332 return [ 333 stream.name 334 for stream in self.discovered_catalog.streams 335 if SyncMode.incremental in stream.supported_sync_modes 336 ] 337 338 @override 339 def _get_spec(self, *, force_refresh: bool = False) -> ConnectorSpecification: 340 """Call spec on the connector. 341 342 This involves the following steps: 343 * execute the connector with spec 344 * Listen to the messages and return the first AirbyteCatalog that comes along. 345 * Make sure the subprocess is killed when the function returns. 346 """ 347 if force_refresh or self._spec is None: 348 for msg in self._execute(["spec"]): 349 if msg.type == Type.SPEC and msg.spec: 350 self._spec = msg.spec 351 break 352 353 if self._spec: 354 return self._spec 355 356 raise exc.AirbyteConnectorMissingSpecError( 357 connector_name=self.name, 358 log_text=self._last_log_messages, 359 ) 360 361 @property 362 def config_spec(self) -> dict[str, Any]: 363 """Generate a configuration spec for this connector, as a JSON Schema definition. 364 365 This function generates a JSON Schema dictionary with configuration specs for the 366 current connector, as a dictionary. 367 368 Returns: 369 dict: The JSON Schema configuration spec as a dictionary. 370 """ 371 return self._get_spec(force_refresh=True).connectionSpecification 372 373 @property 374 def _yaml_spec(self) -> str: 375 """Get the spec as a yaml string. 376 377 For now, the primary use case is for writing and debugging a valid config for a source. 378 379 This is private for now because we probably want better polish before exposing this 380 as a stable interface. This will also get easier when we have docs links with this info 381 for each connector. 382 """ 383 spec_obj: ConnectorSpecification = self._get_spec() 384 spec_dict: dict[str, Any] = spec_obj.model_dump(exclude_unset=True) 385 # convert to a yaml string 386 return yaml.dump(spec_dict) 387 388 @property 389 def docs_url(self) -> str: 390 """Get the URL to the connector's documentation.""" 391 return "https://docs.airbyte.com/integrations/sources/" + self.name.lower().replace( 392 "source-", "" 393 ) 394 395 @property 396 def discovered_catalog(self) -> AirbyteCatalog: 397 """Get the raw catalog for the given streams. 398 399 If the catalog is not yet known, we call discover to get it. 400 """ 401 if self._discovered_catalog is None: 402 self._discovered_catalog = self._discover() 403 404 return self._discovered_catalog 405 406 @property 407 def configured_catalog(self) -> ConfiguredAirbyteCatalog: 408 """Get the configured catalog for the given streams. 409 410 If the raw catalog is not yet known, we call discover to get it. 411 412 If no specific streams are selected, we return a catalog that syncs all available streams. 413 414 TODO: We should consider disabling by default the streams that the connector would 415 disable by default. (For instance, streams that require a premium license are sometimes 416 disabled by default within the connector.) 417 """ 418 # Ensure discovered catalog is cached before we start 419 _ = self.discovered_catalog 420 421 # Filter for selected streams if set, otherwise use all available streams: 422 streams_filter: list[str] = self._selected_stream_names or self.get_available_streams() 423 return self.get_configured_catalog(streams=streams_filter) 424 425 def get_configured_catalog( 426 self, 427 streams: Literal["*"] | list[str] | None = None, 428 *, 429 force_full_refresh: bool = False, 430 ) -> ConfiguredAirbyteCatalog: 431 """Get a configured catalog for the given streams. 432 433 If no streams are provided, the selected streams will be used. If no streams are selected, 434 all available streams will be used. 435 436 If '*' is provided, all available streams will be used. 437 438 If force_full_refresh is True, streams will be configured with full_refresh sync mode 439 when supported by the stream. Otherwise, incremental sync mode is used when supported. 440 """ 441 selected_streams: list[str] = [] 442 if streams is None: 443 selected_streams = self._selected_stream_names or self.get_available_streams() 444 elif streams == "*": 445 selected_streams = self.get_available_streams() 446 elif isinstance(streams, list): 447 selected_streams = streams 448 else: 449 raise exc.PyAirbyteInputError( 450 message="Invalid streams argument.", 451 input_value=streams, 452 ) 453 454 def _get_sync_mode(stream: AirbyteStream) -> SyncMode: 455 """Determine the sync mode for a stream based on force_full_refresh and support.""" 456 # Use getattr to handle mocks or streams without supported_sync_modes attribute 457 supported_modes = getattr(stream, "supported_sync_modes", None) 458 459 if force_full_refresh: 460 # When force_full_refresh is True, prefer full_refresh if supported 461 if supported_modes and SyncMode.full_refresh in supported_modes: 462 return SyncMode.full_refresh 463 # Fall back to incremental if full_refresh is not supported 464 return SyncMode.incremental 465 466 # Default behavior: preserve previous semantics (always incremental) 467 return SyncMode.incremental 468 469 return ConfiguredAirbyteCatalog( 470 streams=[ 471 ConfiguredAirbyteStream( 472 stream=stream, 473 destination_sync_mode=DestinationSyncMode.overwrite, 474 sync_mode=_get_sync_mode(stream), 475 primary_key=( 476 [self._primary_key_overrides[stream.name.lower()]] 477 if stream.name.lower() in self._primary_key_overrides 478 else stream.source_defined_primary_key 479 ), 480 cursor_field=( 481 [self._cursor_key_overrides[stream.name.lower()]] 482 if stream.name.lower() in self._cursor_key_overrides 483 else stream.default_cursor_field 484 ), 485 # These are unused in the current implementation: 486 generation_id=None, 487 minimum_generation_id=None, 488 sync_id=None, 489 ) 490 for stream in self.discovered_catalog.streams 491 if stream.name in selected_streams 492 ], 493 ) 494 495 def get_stream_json_schema(self, stream_name: str) -> dict[str, Any]: 496 """Return the JSON Schema spec for the specified stream name.""" 497 catalog: AirbyteCatalog = self.discovered_catalog 498 found: list[AirbyteStream] = [ 499 stream for stream in catalog.streams if stream.name == stream_name 500 ] 501 502 if len(found) == 0: 503 raise exc.PyAirbyteInputError( 504 message="Stream name does not exist in catalog.", 505 input_value=stream_name, 506 ) 507 508 if len(found) > 1: 509 raise exc.PyAirbyteInternalError( 510 message="Duplicate streams found with the same name.", 511 context={ 512 "found_streams": found, 513 }, 514 ) 515 516 return found[0].json_schema 517 518 def get_records( 519 self, 520 stream: str, 521 *, 522 limit: int | None = None, 523 stop_event: threading.Event | None = None, 524 normalize_field_names: bool = False, 525 prune_undeclared_fields: bool = True, 526 ) -> LazyDataset: 527 """Read a stream from the connector. 528 529 Args: 530 stream: The name of the stream to read. 531 limit: The maximum number of records to read. If None, all records will be read. 532 stop_event: If set, the event can be triggered by the caller to stop reading records 533 and terminate the process. 534 normalize_field_names: When `True`, field names will be normalized to lower case, with 535 special characters removed. This matches the behavior of PyAirbyte caches and most 536 Airbyte destinations. 537 prune_undeclared_fields: When `True`, undeclared fields will be pruned from the records, 538 which generally matches the behavior of PyAirbyte caches and most Airbyte 539 destinations, specifically when you expect the catalog may be stale. You can disable 540 this to keep all fields in the records. 541 542 This involves the following steps: 543 * Call discover to get the catalog 544 * Generate a configured catalog that syncs the given stream in full_refresh mode 545 * Write the configured catalog and the config to a temporary file 546 * execute the connector with read --config <config_file> --catalog <catalog_file> 547 * Listen to the messages and return the first AirbyteRecordMessages that come along. 548 * Make sure the subprocess is killed when the function returns. 549 """ 550 stop_event = stop_event or threading.Event() 551 configured_catalog = self.get_configured_catalog(streams=[stream]) 552 if len(configured_catalog.streams) == 0: 553 raise exc.PyAirbyteInputError( 554 message="Requested stream does not exist.", 555 context={ 556 "stream": stream, 557 "available_streams": self.get_available_streams(), 558 "connector_name": self.name, 559 }, 560 ) from KeyError(stream) 561 562 configured_stream = configured_catalog.streams[0] 563 564 def _with_logging(records: Iterable[dict[str, Any]]) -> Iterator[dict[str, Any]]: 565 yield from records 566 567 stream_record_handler = StreamRecordHandler( 568 json_schema=self.get_stream_json_schema(stream), 569 prune_extra_fields=prune_undeclared_fields, 570 normalize_keys=normalize_field_names, 571 ) 572 573 # This method is non-blocking, so we use "PLAIN" to avoid a live progress display 574 progress_tracker = ProgressTracker( 575 ProgressStyle.PLAIN, 576 source=self, 577 cache=None, 578 destination=None, 579 expected_streams=[stream], 580 ) 581 582 iterator: Iterator[dict[str, Any]] = ( 583 StreamRecord.from_record_message( 584 record_message=record.record, 585 stream_record_handler=stream_record_handler, 586 ) 587 for record in self._read_with_catalog( 588 catalog=configured_catalog, 589 progress_tracker=progress_tracker, 590 stop_event=stop_event, 591 ) 592 if record.record 593 ) 594 if limit is not None: 595 # Stop the iterator after the limit is reached 596 iterator = islice(iterator, limit) 597 598 return LazyDataset( 599 iterator, 600 stream_metadata=configured_stream, 601 stop_event=stop_event, 602 progress_tracker=progress_tracker, 603 ) 604 605 def get_documents( 606 self, 607 stream: str, 608 title_property: str | None = None, 609 content_properties: list[str] | None = None, 610 metadata_properties: list[str] | None = None, 611 *, 612 render_metadata: bool = False, 613 ) -> Iterable[Document]: 614 """Read a stream from the connector and return the records as documents. 615 616 If metadata_properties is not set, all properties that are not content will be added to 617 the metadata. 618 619 If render_metadata is True, metadata will be rendered in the document, as well as the 620 the main content. 621 """ 622 return self.get_records(stream).to_documents( 623 title_property=title_property, 624 content_properties=content_properties, 625 metadata_properties=metadata_properties, 626 render_metadata=render_metadata, 627 ) 628 629 def get_samples( 630 self, 631 streams: list[str] | Literal["*"] | None = None, 632 *, 633 limit: int = 5, 634 on_error: Literal["raise", "ignore", "log"] = "raise", 635 ) -> dict[str, InMemoryDataset | None]: 636 """Get a sample of records from the given streams.""" 637 if streams == "*": 638 streams = self.get_available_streams() 639 elif streams is None: 640 streams = self.get_selected_streams() 641 642 results: dict[str, InMemoryDataset | None] = {} 643 for stream in streams: 644 stop_event = threading.Event() 645 try: 646 results[stream] = self.get_records( 647 stream, 648 limit=limit, 649 stop_event=stop_event, 650 ).fetch_all() 651 stop_event.set() 652 except Exception as ex: 653 results[stream] = None 654 if on_error == "ignore": 655 continue 656 657 if on_error == "raise": 658 raise ex from None 659 660 if on_error == "log": 661 print(f"Error fetching sample for stream '{stream}': {ex}") 662 663 return results 664 665 def print_samples( 666 self, 667 streams: list[str] | Literal["*"] | None = None, 668 *, 669 limit: int = 5, 670 on_error: Literal["raise", "ignore", "log"] = "log", 671 ) -> None: 672 """Print a sample of records from the given streams.""" 673 internal_cols: list[str] = [ 674 AB_EXTRACTED_AT_COLUMN, 675 AB_META_COLUMN, 676 AB_RAW_ID_COLUMN, 677 ] 678 col_limit = 10 679 if streams == "*": 680 streams = self.get_available_streams() 681 elif streams is None: 682 streams = self.get_selected_streams() 683 684 console = Console() 685 686 console.print( 687 Markdown( 688 f"# Sample Records from `{self.name}` ({len(streams)} selected streams)", 689 justify="left", 690 ) 691 ) 692 693 for stream in streams: 694 console.print(Markdown(f"## `{stream}` Stream Sample", justify="left")) 695 samples = self.get_samples( 696 streams=[stream], 697 limit=limit, 698 on_error=on_error, 699 ) 700 dataset = samples[stream] 701 702 table = Table( 703 show_header=True, 704 show_lines=True, 705 ) 706 if dataset is None: 707 console.print( 708 Markdown("**⚠️ `Error fetching sample records.` ⚠️**"), 709 ) 710 continue 711 712 if len(dataset.column_names) > col_limit: 713 # We'll pivot the columns so each column is its own row 714 table.add_column("Column Name") 715 for _ in range(len(dataset)): 716 table.add_column(overflow="fold") 717 for col in dataset.column_names: 718 table.add_row( 719 Markdown(f"**`{col}`**"), 720 *[escape(str(record[col])) for record in dataset], 721 ) 722 else: 723 for col in dataset.column_names: 724 table.add_column( 725 Markdown(f"**`{col}`**"), 726 overflow="fold", 727 ) 728 729 for record in dataset: 730 table.add_row( 731 *[ 732 escape(str(val)) 733 for key, val in record.items() 734 # Exclude internal Airbyte columns. 735 if key not in internal_cols 736 ] 737 ) 738 739 console.print(table) 740 741 console.print(Markdown("--------------")) 742 743 def _get_airbyte_message_iterator( 744 self, 745 *, 746 streams: Literal["*"] | list[str] | None = None, 747 state_provider: StateProviderBase | None = None, 748 progress_tracker: ProgressTracker, 749 force_full_refresh: bool = False, 750 ) -> AirbyteMessageIterator: 751 """Get an AirbyteMessageIterator for this source.""" 752 return AirbyteMessageIterator( 753 self._read_with_catalog( 754 catalog=self.get_configured_catalog( 755 streams=streams, 756 force_full_refresh=force_full_refresh, 757 ), 758 state=state_provider if not force_full_refresh else None, 759 progress_tracker=progress_tracker, 760 ) 761 ) 762 763 def _read_with_catalog( 764 self, 765 catalog: ConfiguredAirbyteCatalog, 766 progress_tracker: ProgressTracker, 767 *, 768 state: StateProviderBase | None = None, 769 stop_event: threading.Event | None = None, 770 ) -> Generator[AirbyteMessage, None, None]: 771 """Call read on the connector. 772 773 This involves the following steps: 774 * Write the config to a temporary file 775 * execute the connector with read --config <config_file> --catalog <catalog_file> 776 * Listen to the messages and return the AirbyteRecordMessages that come along. 777 * Send out telemetry on the performed sync (with information about which source was used and 778 the type of the cache) 779 """ 780 with as_temp_files( 781 [ 782 self._hydrated_config, 783 catalog.model_dump_json(exclude_none=True), 784 state.to_state_input_file_text() if state else "[]", 785 ] 786 ) as [ 787 config_file, 788 catalog_file, 789 state_file, 790 ]: 791 message_generator = self._execute( 792 [ 793 "read", 794 "--config", 795 config_file, 796 "--catalog", 797 catalog_file, 798 "--state", 799 state_file, 800 ], 801 progress_tracker=progress_tracker, 802 ) 803 for message in progress_tracker.tally_records_read(message_generator): 804 if stop_event and stop_event.is_set(): 805 progress_tracker._log_sync_cancel() # noqa: SLF001 806 time.sleep(0.1) 807 return 808 809 yield message 810 811 progress_tracker.log_read_complete() 812 813 def _peek_airbyte_message( 814 self, 815 message: AirbyteMessage, 816 *, 817 raise_on_error: bool = True, 818 ) -> None: 819 """Process an Airbyte message. 820 821 This method handles reading Airbyte messages and taking action, if needed, based on the 822 message type. For instance, log messages are logged, records are tallied, and errors are 823 raised as exceptions if `raise_on_error` is True. 824 825 Raises: 826 AirbyteConnectorFailedError: If a TRACE message of type ERROR is emitted. 827 """ 828 super()._peek_airbyte_message(message, raise_on_error=raise_on_error) 829 830 def _log_incremental_streams( 831 self, 832 *, 833 incremental_streams: set[str] | None = None, 834 ) -> None: 835 """Log the streams which are using incremental sync mode.""" 836 log_message = ( 837 "The following streams are currently using incremental sync:\n" 838 f"{incremental_streams}\n" 839 "To perform a full refresh, set 'force_full_refresh=True' in 'airbyte.read()' method." 840 ) 841 print(log_message, file=sys.stderr) 842 843 def read( 844 self, 845 cache: CacheBase | None = None, 846 *, 847 streams: str | list[str] | None = None, 848 write_strategy: str | WriteStrategy = WriteStrategy.AUTO, 849 force_full_refresh: bool = False, 850 skip_validation: bool = False, 851 ) -> ReadResult: 852 """Read from the connector and write to the cache. 853 854 Args: 855 cache: The cache to write to. If not set, a default cache will be used. 856 streams: Optional if already set. A list of stream names to select for reading. If set 857 to "*", all streams will be selected. 858 write_strategy: The strategy to use when writing to the cache. If a string, it must be 859 one of "append", "merge", "replace", or "auto". If a WriteStrategy, it must be one 860 of WriteStrategy.APPEND, WriteStrategy.MERGE, WriteStrategy.REPLACE, or 861 WriteStrategy.AUTO. 862 force_full_refresh: If True, the source will operate in full refresh mode. Otherwise, 863 streams will be read in incremental mode if supported by the connector. This option 864 must be True when using the "replace" strategy. 865 skip_validation: If True, PyAirbyte will not pre-validate the input configuration before 866 running the connector. This can be helpful in debugging, when you want to send 867 configurations to the connector that otherwise might be rejected by JSON Schema 868 validation rules. 869 """ 870 cache = cache or get_default_cache() 871 progress_tracker = ProgressTracker( 872 source=self, 873 cache=cache, 874 destination=None, 875 expected_streams=None, # Will be set later 876 ) 877 878 # Set up state provider if not in full refresh mode 879 if force_full_refresh: 880 state_provider: StateProviderBase | None = None 881 else: 882 state_provider = cache.get_state_provider( 883 source_name=self._name, 884 ) 885 state_writer = cache.get_state_writer(source_name=self._name) 886 887 if streams: 888 self.select_streams(streams) 889 890 if not self._selected_stream_names: 891 raise exc.PyAirbyteNoStreamsSelectedError( 892 connector_name=self.name, 893 available_streams=self.get_available_streams(), 894 ) 895 896 try: 897 result = self._read_to_cache( 898 cache=cache, 899 catalog_provider=CatalogProvider( 900 self.get_configured_catalog(force_full_refresh=force_full_refresh) 901 ), 902 stream_names=self._selected_stream_names, 903 state_provider=state_provider, 904 state_writer=state_writer, 905 write_strategy=write_strategy, 906 force_full_refresh=force_full_refresh, 907 skip_validation=skip_validation, 908 progress_tracker=progress_tracker, 909 ) 910 except exc.PyAirbyteInternalError as ex: 911 progress_tracker.log_failure(exception=ex) 912 raise exc.AirbyteConnectorFailedError( 913 connector_name=self.name, 914 log_text=self._last_log_messages, 915 ) from ex 916 except Exception as ex: 917 progress_tracker.log_failure(exception=ex) 918 raise 919 920 progress_tracker.log_success() 921 return result 922 923 def _read_to_cache( # noqa: PLR0913 # Too many arguments 924 self, 925 cache: CacheBase, 926 *, 927 catalog_provider: CatalogProvider, 928 stream_names: list[str], 929 state_provider: StateProviderBase | None, 930 state_writer: StateWriterBase | None, 931 write_strategy: str | WriteStrategy = WriteStrategy.AUTO, 932 force_full_refresh: bool = False, 933 skip_validation: bool = False, 934 progress_tracker: ProgressTracker, 935 ) -> ReadResult: 936 """Internal read method.""" 937 if write_strategy == WriteStrategy.REPLACE and not force_full_refresh: 938 warnings.warn( 939 message=( 940 "Using `REPLACE` strategy without also setting `force_full_refresh=True` " 941 "could result in data loss. " 942 "To silence this warning, use the following: " 943 'warnings.filterwarnings("ignore", ' 944 'category="airbyte.warnings.PyAirbyteDataLossWarning")`' 945 ), 946 category=exc.PyAirbyteDataLossWarning, 947 stacklevel=1, 948 ) 949 if isinstance(write_strategy, str): 950 try: 951 write_strategy = WriteStrategy(write_strategy) 952 except ValueError: 953 raise exc.PyAirbyteInputError( 954 message="Invalid strategy", 955 context={ 956 "write_strategy": write_strategy, 957 "available_strategies": [ 958 s.value 959 for s in WriteStrategy # pyrefly: ignore[not-iterable] 960 ], 961 }, 962 ) from None 963 964 # Run optional validation step 965 if not skip_validation: 966 self.validate_config() 967 968 # Log incremental stream if incremental streams are known 969 if state_provider and state_provider.known_stream_names: 970 # Retrieve set of the known streams support which support incremental sync 971 incremental_streams = ( 972 set(self._get_incremental_stream_names()) 973 & state_provider.known_stream_names 974 & set(self.get_selected_streams()) 975 ) 976 if incremental_streams: 977 self._log_incremental_streams(incremental_streams=incremental_streams) 978 979 airbyte_message_iterator = AirbyteMessageIterator( 980 self._read_with_catalog( 981 catalog=catalog_provider.configured_catalog, 982 state=state_provider, 983 progress_tracker=progress_tracker, 984 ) 985 ) 986 cache._write_airbyte_message_stream( # noqa: SLF001 # Non-public API 987 stdin=airbyte_message_iterator, 988 catalog_provider=catalog_provider, 989 write_strategy=write_strategy, 990 state_writer=state_writer, 991 progress_tracker=progress_tracker, 992 ) 993 994 # Flush the WAL, if applicable 995 cache.processor._do_checkpoint() # noqa: SLF001 # Non-public API 996 997 return ReadResult( 998 source_name=self.name, 999 progress_tracker=progress_tracker, 1000 processed_streams=stream_names, 1001 cache=cache, 1002 )
A class representing a source that can be called.
73 def __init__( 74 self, 75 executor: Executor, 76 name: str, 77 config: dict[str, Any] | None = None, 78 *, 79 config_change_callback: ConfigChangeCallback | None = None, 80 streams: str | list[str] | None = None, 81 validate: bool = False, 82 cursor_key_overrides: dict[str, str] | None = None, 83 primary_key_overrides: dict[str, str | list[str]] | None = None, 84 ) -> None: 85 """Initialize the source. 86 87 If config is provided, it will be validated against the spec if validate is True. 88 """ 89 self._to_be_selected_streams: list[str] | str = [] 90 """Used to hold selection criteria before catalog is known.""" 91 92 super().__init__( 93 executor=executor, 94 name=name, 95 config=config, 96 config_change_callback=config_change_callback, 97 validate=validate, 98 ) 99 self._config_dict: dict[str, Any] | None = None 100 self._last_log_messages: list[str] = [] 101 self._discovered_catalog: AirbyteCatalog | None = None 102 self._selected_stream_names: list[str] = [] 103 104 self._cursor_key_overrides: dict[str, str] = {} 105 """A mapping of lower-cased stream names to cursor key overrides.""" 106 107 self._primary_key_overrides: dict[str, list[str]] = {} 108 """A mapping of lower-cased stream names to primary key overrides.""" 109 110 if config is not None: 111 self.set_config(config, validate=validate) 112 if streams is not None: 113 self.select_streams(streams) 114 if cursor_key_overrides is not None: 115 self.set_cursor_keys(**cursor_key_overrides) 116 if primary_key_overrides is not None: 117 self.set_primary_keys(**primary_key_overrides)
Initialize the source.
If config is provided, it will be validated against the spec if validate is True.
119 def set_streams(self, streams: list[str]) -> None: 120 """Deprecated. See select_streams().""" 121 warnings.warn( 122 "The 'set_streams' method is deprecated and will be removed in a future version. " 123 "Please use the 'select_streams' method instead.", 124 DeprecationWarning, 125 stacklevel=2, 126 ) 127 self.select_streams(streams)
Deprecated. See select_streams().
129 def set_cursor_key( 130 self, 131 stream_name: str, 132 cursor_key: str, 133 ) -> None: 134 """Set the cursor for a single stream. 135 136 Note: 137 - This does not unset previously set cursors. 138 - The cursor key must be a single field name. 139 - Not all streams support custom cursors. If a stream does not support custom cursors, 140 the override may be ignored. 141 - Stream names are case insensitive, while field names are case sensitive. 142 - Stream names are not validated by PyAirbyte. If the stream name 143 does not exist in the catalog, the override may be ignored. 144 """ 145 self._cursor_key_overrides[stream_name.lower()] = cursor_key
Set the cursor for a single stream.
Note:
- This does not unset previously set cursors.
- The cursor key must be a single field name.
- Not all streams support custom cursors. If a stream does not support custom cursors, the override may be ignored.
- Stream names are case insensitive, while field names are case sensitive.
- Stream names are not validated by PyAirbyte. If the stream name does not exist in the catalog, the override may be ignored.
147 def set_cursor_keys( 148 self, 149 **kwargs: str, 150 ) -> None: 151 """Override the cursor key for one or more streams. 152 153 Usage: 154 ```python 155 source.set_cursor_keys( 156 stream1="cursor1", 157 stream2="cursor2", 158 ) 159 ``` 160 161 Note: 162 - This does not unset previously set cursors. 163 - The cursor key must be a single field name. 164 - Not all streams support custom cursors. If a stream does not support custom cursors, 165 the override may be ignored. 166 - Stream names are case insensitive, while field names are case sensitive. 167 - Stream names are not validated by PyAirbyte. If the stream name 168 does not exist in the catalog, the override may be ignored. 169 """ 170 self._cursor_key_overrides.update({k.lower(): v for k, v in kwargs.items()})
Override the cursor key for one or more streams.
Usage:
source.set_cursor_keys( stream1="cursor1", stream2="cursor2", )
Note:
- This does not unset previously set cursors.
- The cursor key must be a single field name.
- Not all streams support custom cursors. If a stream does not support custom cursors, the override may be ignored.
- Stream names are case insensitive, while field names are case sensitive.
- Stream names are not validated by PyAirbyte. If the stream name does not exist in the catalog, the override may be ignored.
172 def set_primary_key( 173 self, 174 stream_name: str, 175 primary_key: str | list[str], 176 ) -> None: 177 """Set the primary key for a single stream. 178 179 Note: 180 - This does not unset previously set primary keys. 181 - The primary key must be a single field name or a list of field names. 182 - Not all streams support overriding primary keys. If a stream does not support overriding 183 primary keys, the override may be ignored. 184 - Stream names are case insensitive, while field names are case sensitive. 185 - Stream names are not validated by PyAirbyte. If the stream name 186 does not exist in the catalog, the override may be ignored. 187 """ 188 self._primary_key_overrides[stream_name.lower()] = ( 189 primary_key if isinstance(primary_key, list) else [primary_key] 190 )
Set the primary key for a single stream.
Note:
- This does not unset previously set primary keys.
- The primary key must be a single field name or a list of field names.
- Not all streams support overriding primary keys. If a stream does not support overriding primary keys, the override may be ignored.
- Stream names are case insensitive, while field names are case sensitive.
- Stream names are not validated by PyAirbyte. If the stream name does not exist in the catalog, the override may be ignored.
192 def set_primary_keys( 193 self, 194 **kwargs: str | list[str], 195 ) -> None: 196 """Override the primary keys for one or more streams. 197 198 This does not unset previously set primary keys. 199 200 Usage: 201 ```python 202 source.set_primary_keys( 203 stream1="pk1", 204 stream2=["pk1", "pk2"], 205 ) 206 ``` 207 208 Note: 209 - This does not unset previously set primary keys. 210 - The primary key must be a single field name or a list of field names. 211 - Not all streams support overriding primary keys. If a stream does not support overriding 212 primary keys, the override may be ignored. 213 - Stream names are case insensitive, while field names are case sensitive. 214 - Stream names are not validated by PyAirbyte. If the stream name 215 does not exist in the catalog, the override may be ignored. 216 """ 217 self._primary_key_overrides.update( 218 {k.lower(): v if isinstance(v, list) else [v] for k, v in kwargs.items()} 219 )
Override the primary keys for one or more streams.
This does not unset previously set primary keys.
Usage:
source.set_primary_keys( stream1="pk1", stream2=["pk1", "pk2"], )
Note:
- This does not unset previously set primary keys.
- The primary key must be a single field name or a list of field names.
- Not all streams support overriding primary keys. If a stream does not support overriding primary keys, the override may be ignored.
- Stream names are case insensitive, while field names are case sensitive.
- Stream names are not validated by PyAirbyte. If the stream name does not exist in the catalog, the override may be ignored.
235 def select_all_streams(self) -> None: 236 """Select all streams. 237 238 This is a more streamlined equivalent to: 239 > source.select_streams(source.get_available_streams()). 240 """ 241 if self._config_dict is None: 242 self._to_be_selected_streams = "*" 243 self._log_warning_preselected_stream(self._to_be_selected_streams) 244 return 245 246 self._selected_stream_names = self.get_available_streams()
Select all streams.
This is a more streamlined equivalent to:
source.select_streams(source.get_available_streams()).
248 def select_streams(self, streams: str | list[str]) -> None: 249 """Select the stream names that should be read from the connector. 250 251 Args: 252 streams: A list of stream names to select. If set to "*", all streams will be selected. 253 254 Currently, if this is not set, all streams will be read. 255 """ 256 if self._config_dict is None: 257 self._to_be_selected_streams = streams 258 self._log_warning_preselected_stream(streams) 259 return 260 261 if streams == "*": 262 self.select_all_streams() 263 return 264 265 if isinstance(streams, str): 266 # If a single stream is provided, convert it to a one-item list 267 streams = [streams] 268 269 available_streams = self.get_available_streams() 270 for stream in streams: 271 if stream not in available_streams: 272 raise exc.AirbyteStreamNotFoundError( 273 stream_name=stream, 274 connector_name=self.name, 275 available_streams=available_streams, 276 ) 277 self._selected_stream_names = streams
Select the stream names that should be read from the connector.
Arguments:
- streams: A list of stream names to select. If set to "*", all streams will be selected.
Currently, if this is not set, all streams will be read.
279 def get_selected_streams(self) -> list[str]: 280 """Get the selected streams. 281 282 If no streams are selected, return an empty list. 283 """ 284 return self._selected_stream_names
Get the selected streams.
If no streams are selected, return an empty list.
286 def set_config( 287 self, 288 config: dict[str, Any], 289 *, 290 validate: bool = True, 291 ) -> None: 292 """Set the config for the connector. 293 294 If validate is True, raise an exception if the config fails validation. 295 296 If validate is False, validation will be deferred until check() or validate_config() 297 is called. 298 """ 299 if validate: 300 self.validate_config(config) 301 302 self._config_dict = config 303 304 if self._to_be_selected_streams: 305 self.select_streams(self._to_be_selected_streams) 306 self._to_be_selected_streams = []
Set the config for the connector.
If validate is True, raise an exception if the config fails validation.
If validate is False, validation will be deferred until check() or validate_config() is called.
326 def get_available_streams(self) -> list[str]: 327 """Get the available streams from the spec.""" 328 return [s.name for s in self.discovered_catalog.streams]
Get the available streams from the spec.
361 @property 362 def config_spec(self) -> dict[str, Any]: 363 """Generate a configuration spec for this connector, as a JSON Schema definition. 364 365 This function generates a JSON Schema dictionary with configuration specs for the 366 current connector, as a dictionary. 367 368 Returns: 369 dict: The JSON Schema configuration spec as a dictionary. 370 """ 371 return self._get_spec(force_refresh=True).connectionSpecification
Generate a configuration spec for this connector, as a JSON Schema definition.
This function generates a JSON Schema dictionary with configuration specs for the current connector, as a dictionary.
Returns:
dict: The JSON Schema configuration spec as a dictionary.
388 @property 389 def docs_url(self) -> str: 390 """Get the URL to the connector's documentation.""" 391 return "https://docs.airbyte.com/integrations/sources/" + self.name.lower().replace( 392 "source-", "" 393 )
Get the URL to the connector's documentation.
395 @property 396 def discovered_catalog(self) -> AirbyteCatalog: 397 """Get the raw catalog for the given streams. 398 399 If the catalog is not yet known, we call discover to get it. 400 """ 401 if self._discovered_catalog is None: 402 self._discovered_catalog = self._discover() 403 404 return self._discovered_catalog
Get the raw catalog for the given streams.
If the catalog is not yet known, we call discover to get it.
406 @property 407 def configured_catalog(self) -> ConfiguredAirbyteCatalog: 408 """Get the configured catalog for the given streams. 409 410 If the raw catalog is not yet known, we call discover to get it. 411 412 If no specific streams are selected, we return a catalog that syncs all available streams. 413 414 TODO: We should consider disabling by default the streams that the connector would 415 disable by default. (For instance, streams that require a premium license are sometimes 416 disabled by default within the connector.) 417 """ 418 # Ensure discovered catalog is cached before we start 419 _ = self.discovered_catalog 420 421 # Filter for selected streams if set, otherwise use all available streams: 422 streams_filter: list[str] = self._selected_stream_names or self.get_available_streams() 423 return self.get_configured_catalog(streams=streams_filter)
Get the configured catalog for the given streams.
If the raw catalog is not yet known, we call discover to get it.
If no specific streams are selected, we return a catalog that syncs all available streams.
TODO: We should consider disabling by default the streams that the connector would disable by default. (For instance, streams that require a premium license are sometimes disabled by default within the connector.)
425 def get_configured_catalog( 426 self, 427 streams: Literal["*"] | list[str] | None = None, 428 *, 429 force_full_refresh: bool = False, 430 ) -> ConfiguredAirbyteCatalog: 431 """Get a configured catalog for the given streams. 432 433 If no streams are provided, the selected streams will be used. If no streams are selected, 434 all available streams will be used. 435 436 If '*' is provided, all available streams will be used. 437 438 If force_full_refresh is True, streams will be configured with full_refresh sync mode 439 when supported by the stream. Otherwise, incremental sync mode is used when supported. 440 """ 441 selected_streams: list[str] = [] 442 if streams is None: 443 selected_streams = self._selected_stream_names or self.get_available_streams() 444 elif streams == "*": 445 selected_streams = self.get_available_streams() 446 elif isinstance(streams, list): 447 selected_streams = streams 448 else: 449 raise exc.PyAirbyteInputError( 450 message="Invalid streams argument.", 451 input_value=streams, 452 ) 453 454 def _get_sync_mode(stream: AirbyteStream) -> SyncMode: 455 """Determine the sync mode for a stream based on force_full_refresh and support.""" 456 # Use getattr to handle mocks or streams without supported_sync_modes attribute 457 supported_modes = getattr(stream, "supported_sync_modes", None) 458 459 if force_full_refresh: 460 # When force_full_refresh is True, prefer full_refresh if supported 461 if supported_modes and SyncMode.full_refresh in supported_modes: 462 return SyncMode.full_refresh 463 # Fall back to incremental if full_refresh is not supported 464 return SyncMode.incremental 465 466 # Default behavior: preserve previous semantics (always incremental) 467 return SyncMode.incremental 468 469 return ConfiguredAirbyteCatalog( 470 streams=[ 471 ConfiguredAirbyteStream( 472 stream=stream, 473 destination_sync_mode=DestinationSyncMode.overwrite, 474 sync_mode=_get_sync_mode(stream), 475 primary_key=( 476 [self._primary_key_overrides[stream.name.lower()]] 477 if stream.name.lower() in self._primary_key_overrides 478 else stream.source_defined_primary_key 479 ), 480 cursor_field=( 481 [self._cursor_key_overrides[stream.name.lower()]] 482 if stream.name.lower() in self._cursor_key_overrides 483 else stream.default_cursor_field 484 ), 485 # These are unused in the current implementation: 486 generation_id=None, 487 minimum_generation_id=None, 488 sync_id=None, 489 ) 490 for stream in self.discovered_catalog.streams 491 if stream.name in selected_streams 492 ], 493 )
Get a configured catalog for the given streams.
If no streams are provided, the selected streams will be used. If no streams are selected, all available streams will be used.
If '*' is provided, all available streams will be used.
If force_full_refresh is True, streams will be configured with full_refresh sync mode when supported by the stream. Otherwise, incremental sync mode is used when supported.
495 def get_stream_json_schema(self, stream_name: str) -> dict[str, Any]: 496 """Return the JSON Schema spec for the specified stream name.""" 497 catalog: AirbyteCatalog = self.discovered_catalog 498 found: list[AirbyteStream] = [ 499 stream for stream in catalog.streams if stream.name == stream_name 500 ] 501 502 if len(found) == 0: 503 raise exc.PyAirbyteInputError( 504 message="Stream name does not exist in catalog.", 505 input_value=stream_name, 506 ) 507 508 if len(found) > 1: 509 raise exc.PyAirbyteInternalError( 510 message="Duplicate streams found with the same name.", 511 context={ 512 "found_streams": found, 513 }, 514 ) 515 516 return found[0].json_schema
Return the JSON Schema spec for the specified stream name.
518 def get_records( 519 self, 520 stream: str, 521 *, 522 limit: int | None = None, 523 stop_event: threading.Event | None = None, 524 normalize_field_names: bool = False, 525 prune_undeclared_fields: bool = True, 526 ) -> LazyDataset: 527 """Read a stream from the connector. 528 529 Args: 530 stream: The name of the stream to read. 531 limit: The maximum number of records to read. If None, all records will be read. 532 stop_event: If set, the event can be triggered by the caller to stop reading records 533 and terminate the process. 534 normalize_field_names: When `True`, field names will be normalized to lower case, with 535 special characters removed. This matches the behavior of PyAirbyte caches and most 536 Airbyte destinations. 537 prune_undeclared_fields: When `True`, undeclared fields will be pruned from the records, 538 which generally matches the behavior of PyAirbyte caches and most Airbyte 539 destinations, specifically when you expect the catalog may be stale. You can disable 540 this to keep all fields in the records. 541 542 This involves the following steps: 543 * Call discover to get the catalog 544 * Generate a configured catalog that syncs the given stream in full_refresh mode 545 * Write the configured catalog and the config to a temporary file 546 * execute the connector with read --config <config_file> --catalog <catalog_file> 547 * Listen to the messages and return the first AirbyteRecordMessages that come along. 548 * Make sure the subprocess is killed when the function returns. 549 """ 550 stop_event = stop_event or threading.Event() 551 configured_catalog = self.get_configured_catalog(streams=[stream]) 552 if len(configured_catalog.streams) == 0: 553 raise exc.PyAirbyteInputError( 554 message="Requested stream does not exist.", 555 context={ 556 "stream": stream, 557 "available_streams": self.get_available_streams(), 558 "connector_name": self.name, 559 }, 560 ) from KeyError(stream) 561 562 configured_stream = configured_catalog.streams[0] 563 564 def _with_logging(records: Iterable[dict[str, Any]]) -> Iterator[dict[str, Any]]: 565 yield from records 566 567 stream_record_handler = StreamRecordHandler( 568 json_schema=self.get_stream_json_schema(stream), 569 prune_extra_fields=prune_undeclared_fields, 570 normalize_keys=normalize_field_names, 571 ) 572 573 # This method is non-blocking, so we use "PLAIN" to avoid a live progress display 574 progress_tracker = ProgressTracker( 575 ProgressStyle.PLAIN, 576 source=self, 577 cache=None, 578 destination=None, 579 expected_streams=[stream], 580 ) 581 582 iterator: Iterator[dict[str, Any]] = ( 583 StreamRecord.from_record_message( 584 record_message=record.record, 585 stream_record_handler=stream_record_handler, 586 ) 587 for record in self._read_with_catalog( 588 catalog=configured_catalog, 589 progress_tracker=progress_tracker, 590 stop_event=stop_event, 591 ) 592 if record.record 593 ) 594 if limit is not None: 595 # Stop the iterator after the limit is reached 596 iterator = islice(iterator, limit) 597 598 return LazyDataset( 599 iterator, 600 stream_metadata=configured_stream, 601 stop_event=stop_event, 602 progress_tracker=progress_tracker, 603 )
Read a stream from the connector.
Arguments:
- stream: The name of the stream to read.
- limit: The maximum number of records to read. If None, all records will be read.
- stop_event: If set, the event can be triggered by the caller to stop reading records and terminate the process.
- normalize_field_names: When
True, field names will be normalized to lower case, with special characters removed. This matches the behavior of PyAirbyte caches and most Airbyte destinations. - prune_undeclared_fields: When
True, undeclared fields will be pruned from the records, which generally matches the behavior of PyAirbyte caches and most Airbyte destinations, specifically when you expect the catalog may be stale. You can disable this to keep all fields in the records.
This involves the following steps:
- Call discover to get the catalog
- Generate a configured catalog that syncs the given stream in full_refresh mode
- Write the configured catalog and the config to a temporary file
- execute the connector with read --config
--catalog - Listen to the messages and return the first AirbyteRecordMessages that come along.
- Make sure the subprocess is killed when the function returns.
605 def get_documents( 606 self, 607 stream: str, 608 title_property: str | None = None, 609 content_properties: list[str] | None = None, 610 metadata_properties: list[str] | None = None, 611 *, 612 render_metadata: bool = False, 613 ) -> Iterable[Document]: 614 """Read a stream from the connector and return the records as documents. 615 616 If metadata_properties is not set, all properties that are not content will be added to 617 the metadata. 618 619 If render_metadata is True, metadata will be rendered in the document, as well as the 620 the main content. 621 """ 622 return self.get_records(stream).to_documents( 623 title_property=title_property, 624 content_properties=content_properties, 625 metadata_properties=metadata_properties, 626 render_metadata=render_metadata, 627 )
Read a stream from the connector and return the records as documents.
If metadata_properties is not set, all properties that are not content will be added to the metadata.
If render_metadata is True, metadata will be rendered in the document, as well as the the main content.
629 def get_samples( 630 self, 631 streams: list[str] | Literal["*"] | None = None, 632 *, 633 limit: int = 5, 634 on_error: Literal["raise", "ignore", "log"] = "raise", 635 ) -> dict[str, InMemoryDataset | None]: 636 """Get a sample of records from the given streams.""" 637 if streams == "*": 638 streams = self.get_available_streams() 639 elif streams is None: 640 streams = self.get_selected_streams() 641 642 results: dict[str, InMemoryDataset | None] = {} 643 for stream in streams: 644 stop_event = threading.Event() 645 try: 646 results[stream] = self.get_records( 647 stream, 648 limit=limit, 649 stop_event=stop_event, 650 ).fetch_all() 651 stop_event.set() 652 except Exception as ex: 653 results[stream] = None 654 if on_error == "ignore": 655 continue 656 657 if on_error == "raise": 658 raise ex from None 659 660 if on_error == "log": 661 print(f"Error fetching sample for stream '{stream}': {ex}") 662 663 return results
Get a sample of records from the given streams.
665 def print_samples( 666 self, 667 streams: list[str] | Literal["*"] | None = None, 668 *, 669 limit: int = 5, 670 on_error: Literal["raise", "ignore", "log"] = "log", 671 ) -> None: 672 """Print a sample of records from the given streams.""" 673 internal_cols: list[str] = [ 674 AB_EXTRACTED_AT_COLUMN, 675 AB_META_COLUMN, 676 AB_RAW_ID_COLUMN, 677 ] 678 col_limit = 10 679 if streams == "*": 680 streams = self.get_available_streams() 681 elif streams is None: 682 streams = self.get_selected_streams() 683 684 console = Console() 685 686 console.print( 687 Markdown( 688 f"# Sample Records from `{self.name}` ({len(streams)} selected streams)", 689 justify="left", 690 ) 691 ) 692 693 for stream in streams: 694 console.print(Markdown(f"## `{stream}` Stream Sample", justify="left")) 695 samples = self.get_samples( 696 streams=[stream], 697 limit=limit, 698 on_error=on_error, 699 ) 700 dataset = samples[stream] 701 702 table = Table( 703 show_header=True, 704 show_lines=True, 705 ) 706 if dataset is None: 707 console.print( 708 Markdown("**⚠️ `Error fetching sample records.` ⚠️**"), 709 ) 710 continue 711 712 if len(dataset.column_names) > col_limit: 713 # We'll pivot the columns so each column is its own row 714 table.add_column("Column Name") 715 for _ in range(len(dataset)): 716 table.add_column(overflow="fold") 717 for col in dataset.column_names: 718 table.add_row( 719 Markdown(f"**`{col}`**"), 720 *[escape(str(record[col])) for record in dataset], 721 ) 722 else: 723 for col in dataset.column_names: 724 table.add_column( 725 Markdown(f"**`{col}`**"), 726 overflow="fold", 727 ) 728 729 for record in dataset: 730 table.add_row( 731 *[ 732 escape(str(val)) 733 for key, val in record.items() 734 # Exclude internal Airbyte columns. 735 if key not in internal_cols 736 ] 737 ) 738 739 console.print(table) 740 741 console.print(Markdown("--------------"))
Print a sample of records from the given streams.
843 def read( 844 self, 845 cache: CacheBase | None = None, 846 *, 847 streams: str | list[str] | None = None, 848 write_strategy: str | WriteStrategy = WriteStrategy.AUTO, 849 force_full_refresh: bool = False, 850 skip_validation: bool = False, 851 ) -> ReadResult: 852 """Read from the connector and write to the cache. 853 854 Args: 855 cache: The cache to write to. If not set, a default cache will be used. 856 streams: Optional if already set. A list of stream names to select for reading. If set 857 to "*", all streams will be selected. 858 write_strategy: The strategy to use when writing to the cache. If a string, it must be 859 one of "append", "merge", "replace", or "auto". If a WriteStrategy, it must be one 860 of WriteStrategy.APPEND, WriteStrategy.MERGE, WriteStrategy.REPLACE, or 861 WriteStrategy.AUTO. 862 force_full_refresh: If True, the source will operate in full refresh mode. Otherwise, 863 streams will be read in incremental mode if supported by the connector. This option 864 must be True when using the "replace" strategy. 865 skip_validation: If True, PyAirbyte will not pre-validate the input configuration before 866 running the connector. This can be helpful in debugging, when you want to send 867 configurations to the connector that otherwise might be rejected by JSON Schema 868 validation rules. 869 """ 870 cache = cache or get_default_cache() 871 progress_tracker = ProgressTracker( 872 source=self, 873 cache=cache, 874 destination=None, 875 expected_streams=None, # Will be set later 876 ) 877 878 # Set up state provider if not in full refresh mode 879 if force_full_refresh: 880 state_provider: StateProviderBase | None = None 881 else: 882 state_provider = cache.get_state_provider( 883 source_name=self._name, 884 ) 885 state_writer = cache.get_state_writer(source_name=self._name) 886 887 if streams: 888 self.select_streams(streams) 889 890 if not self._selected_stream_names: 891 raise exc.PyAirbyteNoStreamsSelectedError( 892 connector_name=self.name, 893 available_streams=self.get_available_streams(), 894 ) 895 896 try: 897 result = self._read_to_cache( 898 cache=cache, 899 catalog_provider=CatalogProvider( 900 self.get_configured_catalog(force_full_refresh=force_full_refresh) 901 ), 902 stream_names=self._selected_stream_names, 903 state_provider=state_provider, 904 state_writer=state_writer, 905 write_strategy=write_strategy, 906 force_full_refresh=force_full_refresh, 907 skip_validation=skip_validation, 908 progress_tracker=progress_tracker, 909 ) 910 except exc.PyAirbyteInternalError as ex: 911 progress_tracker.log_failure(exception=ex) 912 raise exc.AirbyteConnectorFailedError( 913 connector_name=self.name, 914 log_text=self._last_log_messages, 915 ) from ex 916 except Exception as ex: 917 progress_tracker.log_failure(exception=ex) 918 raise 919 920 progress_tracker.log_success() 921 return result
Read from the connector and write to the cache.
Arguments:
- cache: The cache to write to. If not set, a default cache will be used.
- streams: Optional if already set. A list of stream names to select for reading. If set to "*", all streams will be selected.
- write_strategy: The strategy to use when writing to the cache. If a string, it must be one of "append", "merge", "replace", or "auto". If a WriteStrategy, it must be one of WriteStrategy.APPEND, WriteStrategy.MERGE, WriteStrategy.REPLACE, or WriteStrategy.AUTO.
- force_full_refresh: If True, the source will operate in full refresh mode. Otherwise, streams will be read in incremental mode if supported by the connector. This option must be True when using the "replace" strategy.
- skip_validation: If True, PyAirbyte will not pre-validate the input configuration before running the connector. This can be helpful in debugging, when you want to send configurations to the connector that otherwise might be rejected by JSON Schema validation rules.