ingestr 0.13.75__py3-none-any.whl → 0.14.98__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ingestr might be problematic. Click here for more details.

Files changed (79) hide show
  1. ingestr/main.py +22 -3
  2. ingestr/src/adjust/__init__.py +4 -4
  3. ingestr/src/allium/__init__.py +128 -0
  4. ingestr/src/anthropic/__init__.py +277 -0
  5. ingestr/src/anthropic/helpers.py +525 -0
  6. ingestr/src/appstore/__init__.py +1 -0
  7. ingestr/src/asana_source/__init__.py +1 -1
  8. ingestr/src/buildinfo.py +1 -1
  9. ingestr/src/chess/__init__.py +1 -1
  10. ingestr/src/couchbase_source/__init__.py +118 -0
  11. ingestr/src/couchbase_source/helpers.py +135 -0
  12. ingestr/src/cursor/__init__.py +83 -0
  13. ingestr/src/cursor/helpers.py +188 -0
  14. ingestr/src/destinations.py +169 -1
  15. ingestr/src/docebo/__init__.py +589 -0
  16. ingestr/src/docebo/client.py +435 -0
  17. ingestr/src/docebo/helpers.py +97 -0
  18. ingestr/src/elasticsearch/helpers.py +138 -0
  19. ingestr/src/errors.py +8 -0
  20. ingestr/src/facebook_ads/__init__.py +26 -23
  21. ingestr/src/facebook_ads/helpers.py +47 -1
  22. ingestr/src/factory.py +48 -0
  23. ingestr/src/filesystem/__init__.py +8 -3
  24. ingestr/src/filters.py +9 -0
  25. ingestr/src/fluxx/__init__.py +9906 -0
  26. ingestr/src/fluxx/helpers.py +209 -0
  27. ingestr/src/frankfurter/__init__.py +157 -163
  28. ingestr/src/frankfurter/helpers.py +3 -3
  29. ingestr/src/freshdesk/__init__.py +25 -8
  30. ingestr/src/freshdesk/freshdesk_client.py +40 -5
  31. ingestr/src/fundraiseup/__init__.py +49 -0
  32. ingestr/src/fundraiseup/client.py +81 -0
  33. ingestr/src/github/__init__.py +6 -4
  34. ingestr/src/google_analytics/__init__.py +1 -1
  35. ingestr/src/hostaway/__init__.py +302 -0
  36. ingestr/src/hostaway/client.py +288 -0
  37. ingestr/src/http/__init__.py +35 -0
  38. ingestr/src/http/readers.py +114 -0
  39. ingestr/src/hubspot/__init__.py +6 -12
  40. ingestr/src/influxdb/__init__.py +1 -0
  41. ingestr/src/intercom/__init__.py +142 -0
  42. ingestr/src/intercom/helpers.py +674 -0
  43. ingestr/src/intercom/settings.py +279 -0
  44. ingestr/src/jira_source/__init__.py +340 -0
  45. ingestr/src/jira_source/helpers.py +439 -0
  46. ingestr/src/jira_source/settings.py +170 -0
  47. ingestr/src/klaviyo/__init__.py +5 -5
  48. ingestr/src/linear/__init__.py +553 -116
  49. ingestr/src/linear/helpers.py +77 -38
  50. ingestr/src/mailchimp/__init__.py +126 -0
  51. ingestr/src/mailchimp/helpers.py +226 -0
  52. ingestr/src/mailchimp/settings.py +164 -0
  53. ingestr/src/masking.py +344 -0
  54. ingestr/src/monday/__init__.py +246 -0
  55. ingestr/src/monday/helpers.py +392 -0
  56. ingestr/src/monday/settings.py +328 -0
  57. ingestr/src/mongodb/__init__.py +5 -2
  58. ingestr/src/mongodb/helpers.py +384 -10
  59. ingestr/src/plusvibeai/__init__.py +335 -0
  60. ingestr/src/plusvibeai/helpers.py +544 -0
  61. ingestr/src/plusvibeai/settings.py +252 -0
  62. ingestr/src/revenuecat/__init__.py +83 -0
  63. ingestr/src/revenuecat/helpers.py +237 -0
  64. ingestr/src/salesforce/__init__.py +15 -8
  65. ingestr/src/shopify/__init__.py +1 -1
  66. ingestr/src/smartsheets/__init__.py +33 -5
  67. ingestr/src/socrata_source/__init__.py +83 -0
  68. ingestr/src/socrata_source/helpers.py +85 -0
  69. ingestr/src/socrata_source/settings.py +8 -0
  70. ingestr/src/sources.py +1418 -54
  71. ingestr/src/stripe_analytics/__init__.py +2 -19
  72. ingestr/src/wise/__init__.py +68 -0
  73. ingestr/src/wise/client.py +63 -0
  74. ingestr/tests/unit/test_smartsheets.py +6 -9
  75. {ingestr-0.13.75.dist-info → ingestr-0.14.98.dist-info}/METADATA +24 -12
  76. {ingestr-0.13.75.dist-info → ingestr-0.14.98.dist-info}/RECORD +79 -37
  77. {ingestr-0.13.75.dist-info → ingestr-0.14.98.dist-info}/WHEEL +0 -0
  78. {ingestr-0.13.75.dist-info → ingestr-0.14.98.dist-info}/entry_points.txt +0 -0
  79. {ingestr-0.13.75.dist-info → ingestr-0.14.98.dist-info}/licenses/LICENSE.md +0 -0
ingestr/src/sources.py CHANGED
@@ -73,6 +73,20 @@ class SqlSource:
73
73
 
74
74
  engine_adapter_callback = None
75
75
 
76
+ if uri.startswith("md://") or uri.startswith("motherduck://"):
77
+ parsed_uri = urlparse(uri)
78
+ query_params = parse_qs(parsed_uri.query)
79
+ # Convert md:// URI to duckdb:///md: format
80
+ if parsed_uri.path:
81
+ db_path = parsed_uri.path
82
+ else:
83
+ db_path = ""
84
+
85
+ token = query_params.get("token", [""])[0]
86
+ if not token:
87
+ raise ValueError("Token is required for MotherDuck connection")
88
+ uri = f"duckdb:///md:{db_path}?motherduck_token={token}"
89
+
76
90
  if uri.startswith("mysql://"):
77
91
  uri = uri.replace("mysql://", "mysql+pymysql://")
78
92
 
@@ -223,6 +237,9 @@ class SqlSource:
223
237
  backend_kwargs: Dict[str, Any] = None, # type: ignore
224
238
  type_adapter_callback: Optional[TTypeAdapter] = None,
225
239
  included_columns: Optional[List[str]] = None,
240
+ excluded_columns: Optional[
241
+ List[str]
242
+ ] = None, # Added for dlt 1.16.0 compatibility
226
243
  query_adapter_callback: Optional[TQueryAdapter] = None,
227
244
  resolve_foreign_keys: bool = False,
228
245
  ) -> Iterator[TDataItem]:
@@ -409,31 +426,187 @@ class MongoDbSource:
409
426
  return False
410
427
 
411
428
  def dlt_source(self, uri: str, table: str, **kwargs):
412
- table_fields = table_string_to_dataclass(table)
429
+ # Check if this is a custom query format (collection:query)
430
+ if ":" in table:
431
+ collection_name, query_json = table.split(":", 1)
413
432
 
414
- incremental = None
415
- if kwargs.get("incremental_key"):
416
- start_value = kwargs.get("interval_start")
417
- end_value = kwargs.get("interval_end")
433
+ # Parse the query using MongoDB's extended JSON parser
434
+ # First, convert MongoDB shell syntax to Extended JSON format
435
+ from bson import json_util
418
436
 
419
- incremental = dlt_incremental(
420
- kwargs.get("incremental_key", ""),
421
- initial_value=start_value,
422
- end_value=end_value,
423
- range_end="closed",
424
- range_start="closed",
437
+ from ingestr.src.mongodb.helpers import convert_mongo_shell_to_extended_json
438
+
439
+ # Convert MongoDB shell constructs to Extended JSON v2 format
440
+ converted_query = convert_mongo_shell_to_extended_json(query_json)
441
+
442
+ try:
443
+ query = json_util.loads(converted_query)
444
+ except Exception as e:
445
+ raise ValueError(f"Invalid MongoDB query format: {e}")
446
+
447
+ # Validate that it's a list for aggregation pipeline
448
+ if not isinstance(query, list):
449
+ raise ValueError(
450
+ "Query must be a JSON array representing a MongoDB aggregation pipeline"
451
+ )
452
+
453
+ # Check for incremental load requirements
454
+ incremental = None
455
+ if kwargs.get("incremental_key"):
456
+ start_value = kwargs.get("interval_start")
457
+ end_value = kwargs.get("interval_end")
458
+
459
+ # Validate that incremental key is present in the pipeline
460
+ incremental_key = kwargs.get("incremental_key")
461
+ self._validate_incremental_query(query, str(incremental_key))
462
+
463
+ incremental = dlt_incremental(
464
+ str(incremental_key),
465
+ initial_value=start_value,
466
+ end_value=end_value,
467
+ )
468
+
469
+ # Substitute interval parameters in the query
470
+ query = self._substitute_interval_params(query, kwargs)
471
+
472
+ # Parse collection name to get database and collection
473
+ if "." in collection_name:
474
+ # Handle database.collection format
475
+ table_fields = table_string_to_dataclass(collection_name)
476
+ database = table_fields.dataset
477
+ collection = table_fields.table
478
+ else:
479
+ # Single collection name, use default database
480
+ database = None
481
+ collection = collection_name
482
+
483
+ table_instance = self.table_builder(
484
+ connection_url=uri,
485
+ database=database,
486
+ collection=collection,
487
+ parallel=False,
488
+ incremental=incremental,
489
+ custom_query=query,
490
+ )
491
+ table_instance.max_table_nesting = 1
492
+ return table_instance
493
+ else:
494
+ # Default behavior for simple collection names
495
+ table_fields = table_string_to_dataclass(table)
496
+
497
+ incremental = None
498
+ if kwargs.get("incremental_key"):
499
+ start_value = kwargs.get("interval_start")
500
+ end_value = kwargs.get("interval_end")
501
+
502
+ incremental = dlt_incremental(
503
+ kwargs.get("incremental_key", ""),
504
+ initial_value=start_value,
505
+ end_value=end_value,
506
+ )
507
+
508
+ table_instance = self.table_builder(
509
+ connection_url=uri,
510
+ database=table_fields.dataset,
511
+ collection=table_fields.table,
512
+ parallel=False,
513
+ incremental=incremental,
425
514
  )
515
+ table_instance.max_table_nesting = 1
516
+
517
+ return table_instance
518
+
519
+ def _validate_incremental_query(self, query: list, incremental_key: str):
520
+ """Validate that incremental key is projected in the aggregation pipeline"""
521
+ # Check if there's a $project stage and if incremental_key is included
522
+ has_project = False
523
+ incremental_key_projected = False
524
+
525
+ for stage in query:
526
+ if "$project" in stage:
527
+ has_project = True
528
+ project_stage = stage["$project"]
529
+ if isinstance(project_stage, dict):
530
+ # Check if incremental_key is explicitly included
531
+ if incremental_key in project_stage:
532
+ if project_stage[incremental_key] not in [0, False]:
533
+ incremental_key_projected = True
534
+ # If there are only inclusions (1 or True values) and incremental_key is not included
535
+ elif any(v in [1, True] for v in project_stage.values()):
536
+ # This is an inclusion projection, incremental_key must be explicitly included
537
+ incremental_key_projected = False
538
+ # If there are only exclusions (0 or False values) and incremental_key is not excluded
539
+ elif all(
540
+ v in [0, False]
541
+ for v in project_stage.values()
542
+ if v in [0, False, 1, True]
543
+ ):
544
+ # This is an exclusion projection, incremental_key is included by default
545
+ if incremental_key not in project_stage:
546
+ incremental_key_projected = True
547
+ else:
548
+ incremental_key_projected = project_stage[
549
+ incremental_key
550
+ ] not in [0, False]
551
+ else:
552
+ # Mixed or unclear projection, assume incremental_key needs to be explicit
553
+ incremental_key_projected = False
426
554
 
427
- table_instance = self.table_builder(
428
- connection_url=uri,
429
- database=table_fields.dataset,
430
- collection=table_fields.table,
431
- parallel=True,
432
- incremental=incremental,
433
- )
434
- table_instance.max_table_nesting = 1
555
+ # If there's a $project stage but incremental_key is not projected, raise error
556
+ if has_project and not incremental_key_projected:
557
+ raise ValueError(
558
+ f"Incremental key '{incremental_key}' must be included in the projected fields of the aggregation pipeline"
559
+ )
435
560
 
436
- return table_instance
561
+ def _substitute_interval_params(self, query: list, kwargs: dict):
562
+ """Substitute :interval_start and :interval_end placeholders with actual datetime values"""
563
+ from dlt.common.time import ensure_pendulum_datetime
564
+
565
+ # Get interval values and convert them to datetime objects
566
+ interval_start = kwargs.get("interval_start")
567
+ interval_end = kwargs.get("interval_end")
568
+
569
+ # Convert string dates to datetime objects if needed
570
+ if interval_start is not None:
571
+ if isinstance(interval_start, str):
572
+ pendulum_dt = ensure_pendulum_datetime(interval_start)
573
+ interval_start = (
574
+ pendulum_dt.to_datetime()
575
+ if hasattr(pendulum_dt, "to_datetime")
576
+ else pendulum_dt
577
+ )
578
+ elif hasattr(interval_start, "to_datetime"):
579
+ interval_start = interval_start.to_datetime()
580
+
581
+ if interval_end is not None:
582
+ if isinstance(interval_end, str):
583
+ pendulum_dt = ensure_pendulum_datetime(interval_end)
584
+ interval_end = (
585
+ pendulum_dt.to_datetime()
586
+ if hasattr(pendulum_dt, "to_datetime")
587
+ else pendulum_dt
588
+ )
589
+ elif hasattr(interval_end, "to_datetime"):
590
+ interval_end = interval_end.to_datetime()
591
+
592
+ # Deep copy the query and replace placeholders with actual datetime objects
593
+ def replace_placeholders(obj):
594
+ if isinstance(obj, dict):
595
+ result = {}
596
+ for key, value in obj.items():
597
+ if value == ":interval_start" and interval_start is not None:
598
+ result[key] = interval_start
599
+ elif value == ":interval_end" and interval_end is not None:
600
+ result[key] = interval_end
601
+ else:
602
+ result[key] = replace_placeholders(value)
603
+ return result
604
+ elif isinstance(obj, list):
605
+ return [replace_placeholders(item) for item in obj]
606
+ else:
607
+ return obj
608
+
609
+ return replace_placeholders(query)
437
610
 
438
611
 
439
612
  class LocalCsvSource:
@@ -538,6 +711,11 @@ class ShopifySource:
538
711
  return True
539
712
 
540
713
  def dlt_source(self, uri: str, table: str, **kwargs):
714
+ if kwargs.get("incremental_key"):
715
+ raise ValueError(
716
+ "Shopify takes care of incrementality on its own, you should not provide incremental_key"
717
+ )
718
+
541
719
  source_fields = urlparse(uri)
542
720
  source_params = parse_qs(source_fields.query)
543
721
  api_key = source_params.get("api_key")
@@ -839,6 +1017,16 @@ class FacebookAdsSource:
839
1017
  facebook_insights_source,
840
1018
  )
841
1019
 
1020
+ insights_max_wait_to_finish_seconds = source_params.get(
1021
+ "insights_max_wait_to_finish_seconds", [60 * 60 * 4]
1022
+ )
1023
+ insights_max_wait_to_start_seconds = source_params.get(
1024
+ "insights_max_wait_to_start_seconds", [60 * 30]
1025
+ )
1026
+ insights_max_async_sleep_seconds = source_params.get(
1027
+ "insights_max_async_sleep_seconds", [20]
1028
+ )
1029
+
842
1030
  endpoint = None
843
1031
  if table in ["campaigns", "ad_sets", "ad_creatives", "ads", "leads"]:
844
1032
  endpoint = table
@@ -848,6 +1036,13 @@ class FacebookAdsSource:
848
1036
  account_id=account_id[0],
849
1037
  start_date=kwargs.get("interval_start"),
850
1038
  end_date=kwargs.get("interval_end"),
1039
+ insights_max_wait_to_finish_seconds=insights_max_wait_to_finish_seconds[
1040
+ 0
1041
+ ],
1042
+ insights_max_wait_to_start_seconds=insights_max_wait_to_start_seconds[
1043
+ 0
1044
+ ],
1045
+ insights_max_async_sleep_seconds=insights_max_async_sleep_seconds[0],
851
1046
  ).with_resources("facebook_insights")
852
1047
  elif table.startswith("facebook_insights:"):
853
1048
  # Parse custom breakdowns and metrics from table name
@@ -868,35 +1063,19 @@ class FacebookAdsSource:
868
1063
  )
869
1064
 
870
1065
  # Validate breakdown type against available options from settings
871
- import typing
872
-
873
- from ingestr.src.facebook_ads.settings import TInsightsBreakdownOptions
874
1066
 
875
- # Get valid breakdown options from the type definition
876
- valid_breakdowns = list(typing.get_args(TInsightsBreakdownOptions))
877
-
878
- if breakdown_type not in valid_breakdowns:
879
- raise ValueError(
880
- f"Invalid breakdown type '{breakdown_type}'. Valid options: {', '.join(valid_breakdowns)}"
881
- )
1067
+ from ingestr.src.facebook_ads.helpers import (
1068
+ parse_insights_table_to_source_kwargs,
1069
+ )
882
1070
 
883
1071
  source_kwargs = {
884
1072
  "access_token": access_token[0],
885
1073
  "account_id": account_id[0],
886
1074
  "start_date": kwargs.get("interval_start"),
887
1075
  "end_date": kwargs.get("interval_end"),
888
- "breakdowns": breakdown_type,
889
1076
  }
890
1077
 
891
- # If custom metrics are provided, parse them
892
- if len(parts) == 3:
893
- fields = [f.strip() for f in parts[2].split(",") if f.strip()]
894
- if not fields:
895
- raise ValueError(
896
- "Custom metrics must be provided after the second colon in format: facebook_insights:breakdown_type:metric1,metric2..."
897
- )
898
- source_kwargs["fields"] = fields
899
-
1078
+ source_kwargs.update(parse_insights_table_to_source_kwargs(table))
900
1079
  return facebook_insights_source(**source_kwargs).with_resources(
901
1080
  "facebook_insights"
902
1081
  )
@@ -961,7 +1140,7 @@ class SlackSource:
961
1140
 
962
1141
  class HubspotSource:
963
1142
  def handles_incrementality(self) -> bool:
964
- return True
1143
+ return False
965
1144
 
966
1145
  # hubspot://?api_key=<api_key>
967
1146
  def dlt_source(self, uri: str, table: str, **kwargs):
@@ -1488,6 +1667,11 @@ class TikTokSource:
1488
1667
  return True
1489
1668
 
1490
1669
  def dlt_source(self, uri: str, table: str, **kwargs):
1670
+ if kwargs.get("incremental_key"):
1671
+ raise ValueError(
1672
+ "TikTok takes care of incrementality on its own, you should not provide incremental_key"
1673
+ )
1674
+
1491
1675
  endpoint = "custom_reports"
1492
1676
 
1493
1677
  parsed_uri = urlparse(uri)
@@ -1634,6 +1818,64 @@ class AsanaSource:
1634
1818
  return src.with_resources(table)
1635
1819
 
1636
1820
 
1821
+ class JiraSource:
1822
+ resources = [
1823
+ "projects",
1824
+ "issues",
1825
+ "users",
1826
+ "issue_types",
1827
+ "statuses",
1828
+ "priorities",
1829
+ "resolutions",
1830
+ "project_versions",
1831
+ "project_components",
1832
+ "events",
1833
+ ]
1834
+
1835
+ def handles_incrementality(self) -> bool:
1836
+ return True
1837
+
1838
+ def dlt_source(self, uri: str, table: str, **kwargs):
1839
+ parsed_uri = urlparse(uri)
1840
+ params = parse_qs(parsed_uri.query)
1841
+
1842
+ base_url = f"https://{parsed_uri.netloc}"
1843
+ email = params.get("email")
1844
+ api_token = params.get("api_token")
1845
+
1846
+ if not email:
1847
+ raise ValueError("email must be specified in the URI query parameters")
1848
+
1849
+ if not api_token:
1850
+ raise ValueError("api_token is required for connecting to Jira")
1851
+
1852
+ flags = {
1853
+ "skip_archived": False,
1854
+ }
1855
+ if ":" in table:
1856
+ table, rest = table.split(":", 1) # type: ignore
1857
+ for k in rest.split(":"):
1858
+ flags[k] = True
1859
+
1860
+ if table not in self.resources:
1861
+ raise ValueError(
1862
+ f"Resource '{table}' is not supported for Jira source yet, if you are interested in it please create a GitHub issue at https://github.com/bruin-data/ingestr"
1863
+ )
1864
+
1865
+ import dlt
1866
+
1867
+ from ingestr.src.jira_source import jira_source
1868
+
1869
+ dlt.secrets["sources.jira_source.base_url"] = base_url
1870
+ dlt.secrets["sources.jira_source.email"] = email[0]
1871
+ dlt.secrets["sources.jira_source.api_token"] = api_token[0]
1872
+
1873
+ src = jira_source()
1874
+ if flags["skip_archived"]:
1875
+ src.projects.add_filter(lambda p: not p.get("archived", False))
1876
+ return src.with_resources(table)
1877
+
1878
+
1637
1879
  class DynamoDBSource:
1638
1880
  AWS_ENDPOINT_PATTERN = re.compile(".*\.(.+)\.amazonaws\.com")
1639
1881
 
@@ -1703,6 +1945,72 @@ class DynamoDBSource:
1703
1945
  return dynamodb(table, creds, incremental)
1704
1946
 
1705
1947
 
1948
+ class DoceboSource:
1949
+ def handles_incrementality(self) -> bool:
1950
+ return False
1951
+
1952
+ def dlt_source(self, uri: str, table: str, **kwargs):
1953
+ # docebo://?base_url=https://yourcompany.docebosaas.com&client_id=xxx&client_secret=xxx
1954
+ # Optional: &username=xxx&password=xxx for password grant type
1955
+
1956
+ if kwargs.get("incremental_key"):
1957
+ raise ValueError("Incremental loads are not yet supported for Docebo")
1958
+
1959
+ parsed_uri = urlparse(uri)
1960
+ source_params = parse_qs(parsed_uri.query)
1961
+
1962
+ base_url = source_params.get("base_url")
1963
+ if not base_url:
1964
+ raise ValueError("base_url is required to connect to Docebo")
1965
+
1966
+ client_id = source_params.get("client_id")
1967
+ if not client_id:
1968
+ raise ValueError("client_id is required to connect to Docebo")
1969
+
1970
+ client_secret = source_params.get("client_secret")
1971
+ if not client_secret:
1972
+ raise ValueError("client_secret is required to connect to Docebo")
1973
+
1974
+ # Username and password are optional (uses client_credentials grant if not provided)
1975
+ username = source_params.get("username", [None])[0]
1976
+ password = source_params.get("password", [None])[0]
1977
+
1978
+ # Supported tables
1979
+ supported_tables = [
1980
+ "users",
1981
+ "courses",
1982
+ "user_fields",
1983
+ "branches",
1984
+ "groups",
1985
+ "group_members",
1986
+ "course_fields",
1987
+ "learning_objects",
1988
+ "learning_plans",
1989
+ "learning_plan_enrollments",
1990
+ "learning_plan_course_enrollments",
1991
+ "course_enrollments",
1992
+ "sessions",
1993
+ "categories",
1994
+ "certifications",
1995
+ "external_training",
1996
+ "survey_answers",
1997
+ ]
1998
+ if table not in supported_tables:
1999
+ raise ValueError(
2000
+ f"Resource '{table}' is not supported for Docebo source. Supported tables: {', '.join(supported_tables)}"
2001
+ )
2002
+
2003
+ from ingestr.src.docebo import docebo_source
2004
+
2005
+ return docebo_source(
2006
+ base_url=base_url[0],
2007
+ client_id=client_id[0],
2008
+ client_secret=client_secret[0],
2009
+ username=username,
2010
+ password=password,
2011
+ ).with_resources(table)
2012
+
2013
+
1706
2014
  class GoogleAnalyticsSource:
1707
2015
  def handles_incrementality(self) -> bool:
1708
2016
  return True
@@ -1710,6 +2018,11 @@ class GoogleAnalyticsSource:
1710
2018
  def dlt_source(self, uri: str, table: str, **kwargs):
1711
2019
  import ingestr.src.google_analytics.helpers as helpers
1712
2020
 
2021
+ if kwargs.get("incremental_key"):
2022
+ raise ValueError(
2023
+ "Google Analytics takes care of incrementality on its own, you should not provide incremental_key"
2024
+ )
2025
+
1713
2026
  result = helpers.parse_google_analytics_uri(uri)
1714
2027
  credentials = result["credentials"]
1715
2028
  property_id = result["property_id"]
@@ -1817,7 +2130,7 @@ class GitHubSource:
1817
2130
  start_date = kwargs.get("interval_start") or pendulum.now().subtract(
1818
2131
  days=30
1819
2132
  )
1820
- end_date = kwargs.get("interval_end") or pendulum.now()
2133
+ end_date = kwargs.get("interval_end") or None
1821
2134
 
1822
2135
  if isinstance(start_date, str):
1823
2136
  start_date = pendulum.parse(start_date)
@@ -2082,6 +2395,11 @@ class LinkedInAdsSource:
2082
2395
  return True
2083
2396
 
2084
2397
  def dlt_source(self, uri: str, table: str, **kwargs):
2398
+ if kwargs.get("incremental_key"):
2399
+ raise ValueError(
2400
+ "LinkedIn Ads takes care of incrementality on its own, you should not provide incremental_key"
2401
+ )
2402
+
2085
2403
  parsed_uri = urlparse(uri)
2086
2404
  source_fields = parse_qs(parsed_uri.query)
2087
2405
 
@@ -2165,6 +2483,11 @@ class ClickupSource:
2165
2483
  return True
2166
2484
 
2167
2485
  def dlt_source(self, uri: str, table: str, **kwargs):
2486
+ if kwargs.get("incremental_key"):
2487
+ raise ValueError(
2488
+ "ClickUp takes care of incrementality on its own, you should not provide incremental_key"
2489
+ )
2490
+
2168
2491
  parsed_uri = urlparse(uri)
2169
2492
  params = parse_qs(parsed_uri.query)
2170
2493
  api_token = params.get("api_token")
@@ -2249,6 +2572,11 @@ class ApplovinMaxSource:
2249
2572
  return True
2250
2573
 
2251
2574
  def dlt_source(self, uri: str, table: str, **kwargs):
2575
+ if kwargs.get("incremental_key"):
2576
+ raise ValueError(
2577
+ "AppLovin Max takes care of incrementality on its own, you should not provide incremental_key"
2578
+ )
2579
+
2252
2580
  parsed_uri = urlparse(uri)
2253
2581
  params = parse_qs(parsed_uri.query)
2254
2582
 
@@ -2320,6 +2648,7 @@ class SalesforceSource:
2320
2648
  "username": params.get("username", [None])[0],
2321
2649
  "password": params.get("password", [None])[0],
2322
2650
  "token": params.get("token", [None])[0],
2651
+ "domain": params.get("domain", [None])[0],
2323
2652
  }
2324
2653
  for k, v in creds.items():
2325
2654
  if v is None:
@@ -2329,6 +2658,11 @@ class SalesforceSource:
2329
2658
 
2330
2659
  src = salesforce_source(**creds) # type: ignore
2331
2660
 
2661
+ if table.startswith("custom:"):
2662
+ custom_object = table.split(":")[1]
2663
+ src = salesforce_source(**creds, custom_object=custom_object)
2664
+ return src.with_resources("custom")
2665
+
2332
2666
  if table not in src.resources:
2333
2667
  raise UnsupportedResourceError(table, "Salesforce")
2334
2668
 
@@ -2341,6 +2675,11 @@ class PersonioSource:
2341
2675
 
2342
2676
  # applovin://?client_id=123&client_secret=123
2343
2677
  def dlt_source(self, uri: str, table: str, **kwargs):
2678
+ if kwargs.get("incremental_key"):
2679
+ raise ValueError(
2680
+ "Personio takes care of incrementality on its own, you should not provide incremental_key"
2681
+ )
2682
+
2344
2683
  parsed_uri = urlparse(uri)
2345
2684
  params = parse_qs(parsed_uri.query)
2346
2685
 
@@ -2431,6 +2770,11 @@ class PipedriveSource:
2431
2770
  return True
2432
2771
 
2433
2772
  def dlt_source(self, uri: str, table: str, **kwargs):
2773
+ if kwargs.get("incremental_key"):
2774
+ raise ValueError(
2775
+ "Pipedrive takes care of incrementality on its own, you should not provide incremental_key"
2776
+ )
2777
+
2434
2778
  parsed_uri = urlparse(uri)
2435
2779
  params = parse_qs(parsed_uri.query)
2436
2780
  api_key = params.get("api_token")
@@ -2485,13 +2829,13 @@ class FrankfurterSource:
2485
2829
 
2486
2830
  if kwargs.get("interval_start"):
2487
2831
  start_date = ensure_pendulum_datetime(str(kwargs.get("interval_start")))
2488
- if kwargs.get("interval_end"):
2489
- end_date = ensure_pendulum_datetime(str(kwargs.get("interval_end")))
2490
- else:
2491
- end_date = pendulum.now()
2492
2832
  else:
2493
- start_date = pendulum.now()
2494
- end_date = pendulum.now()
2833
+ start_date = pendulum.yesterday()
2834
+
2835
+ if kwargs.get("interval_end"):
2836
+ end_date = ensure_pendulum_datetime(str(kwargs.get("interval_end")))
2837
+ else:
2838
+ end_date = None
2495
2839
 
2496
2840
  validate_dates(start_date=start_date, end_date=end_date)
2497
2841
 
@@ -2513,6 +2857,11 @@ class FreshdeskSource:
2513
2857
  return True
2514
2858
 
2515
2859
  def dlt_source(self, uri: str, table: str, **kwargs):
2860
+ if kwargs.get("incremental_key"):
2861
+ raise ValueError(
2862
+ "Freshdesk takes care of incrementality on its own, you should not provide incremental_key"
2863
+ )
2864
+
2516
2865
  parsed_uri = urlparse(uri)
2517
2866
  domain = parsed_uri.netloc
2518
2867
  query = parsed_uri.query
@@ -2528,6 +2877,22 @@ class FreshdeskSource:
2528
2877
  if api_key is None:
2529
2878
  raise MissingValueError("api_key", "Freshdesk")
2530
2879
 
2880
+ start_date = kwargs.get("interval_start")
2881
+ if start_date is not None:
2882
+ start_date = ensure_pendulum_datetime(start_date).in_tz("UTC")
2883
+ else:
2884
+ start_date = ensure_pendulum_datetime("2022-01-01T00:00:00Z")
2885
+
2886
+ end_date = kwargs.get("interval_end")
2887
+ if end_date is not None:
2888
+ end_date = ensure_pendulum_datetime(end_date).in_tz("UTC")
2889
+ else:
2890
+ end_date = None
2891
+
2892
+ custom_query: Optional[str] = None
2893
+ if ":" in table:
2894
+ table, custom_query = table.split(":", 1)
2895
+
2531
2896
  if table not in [
2532
2897
  "agents",
2533
2898
  "companies",
@@ -2538,10 +2903,17 @@ class FreshdeskSource:
2538
2903
  ]:
2539
2904
  raise UnsupportedResourceError(table, "Freshdesk")
2540
2905
 
2906
+ if custom_query and table != "tickets":
2907
+ raise ValueError(f"Custom query is not supported for {table}")
2908
+
2541
2909
  from ingestr.src.freshdesk import freshdesk_source
2542
2910
 
2543
2911
  return freshdesk_source(
2544
- api_secret_key=api_key[0], domain=domain
2912
+ api_secret_key=api_key[0],
2913
+ domain=domain,
2914
+ start_date=start_date,
2915
+ end_date=end_date,
2916
+ query=custom_query,
2545
2917
  ).with_resources(table)
2546
2918
 
2547
2919
 
@@ -2551,6 +2923,11 @@ class TrustpilotSource:
2551
2923
  return True
2552
2924
 
2553
2925
  def dlt_source(self, uri: str, table: str, **kwargs):
2926
+ if kwargs.get("incremental_key"):
2927
+ raise ValueError(
2928
+ "Trustpilot takes care of incrementality on its own, you should not provide incremental_key"
2929
+ )
2930
+
2554
2931
  parsed_uri = urlparse(uri)
2555
2932
  business_unit_id = parsed_uri.netloc
2556
2933
  params = parse_qs(parsed_uri.query)
@@ -2591,6 +2968,11 @@ class PhantombusterSource:
2591
2968
  return True
2592
2969
 
2593
2970
  def dlt_source(self, uri: str, table: str, **kwargs):
2971
+ if kwargs.get("incremental_key"):
2972
+ raise ValueError(
2973
+ "Phantombuster takes care of incrementality on its own, you should not provide incremental_key"
2974
+ )
2975
+
2594
2976
  # phantombuster://?api_key=<api_key>
2595
2977
  # source table = phantom_results:agent_id
2596
2978
  parsed_uri = urlparse(uri)
@@ -2684,7 +3066,7 @@ class ElasticsearchSource:
2684
3066
 
2685
3067
  class AttioSource:
2686
3068
  def handles_incrementality(self) -> bool:
2687
- return True
3069
+ return False
2688
3070
 
2689
3071
  def dlt_source(self, uri: str, table: str, **kwargs):
2690
3072
  parsed_uri = urlparse(uri)
@@ -2744,6 +3126,11 @@ class SolidgateSource:
2744
3126
  return True
2745
3127
 
2746
3128
  def dlt_source(self, uri: str, table: str, **kwargs):
3129
+ if kwargs.get("incremental_key"):
3130
+ raise ValueError(
3131
+ "Solidgate takes care of incrementality on its own, you should not provide incremental_key"
3132
+ )
3133
+
2747
3134
  parsed_uri = urlparse(uri)
2748
3135
  query_params = parse_qs(parsed_uri.query)
2749
3136
  public_key = query_params.get("public_key")
@@ -2837,6 +3224,11 @@ class QuickBooksSource:
2837
3224
 
2838
3225
  # quickbooks://?company_id=<company_id>&client_id=<client_id>&client_secret=<client_secret>&refresh_token=<refresh>&access_token=<access_token>&environment=<env>&minor_version=<version>
2839
3226
  def dlt_source(self, uri: str, table: str, **kwargs):
3227
+ if kwargs.get("incremental_key"):
3228
+ raise ValueError(
3229
+ "QuickBooks takes care of incrementality on its own, you should not provide incremental_key"
3230
+ )
3231
+
2840
3232
  parsed_uri = urlparse(uri)
2841
3233
 
2842
3234
  params = parse_qs(parsed_uri.query)
@@ -2906,6 +3298,11 @@ class IsocPulseSource:
2906
3298
  return True
2907
3299
 
2908
3300
  def dlt_source(self, uri: str, table: str, **kwargs):
3301
+ if kwargs.get("incremental_key"):
3302
+ raise ValueError(
3303
+ "Internet Society Pulse takes care of incrementality on its own, you should not provide incremental_key"
3304
+ )
3305
+
2909
3306
  parsed_uri = urlparse(uri)
2910
3307
  params = parse_qs(parsed_uri.query)
2911
3308
  token = params.get("token")
@@ -2941,6 +3338,11 @@ class PinterestSource:
2941
3338
  return True
2942
3339
 
2943
3340
  def dlt_source(self, uri: str, table: str, **kwargs):
3341
+ if kwargs.get("incremental_key"):
3342
+ raise ValueError(
3343
+ "Pinterest takes care of incrementality on its own, you should not provide incremental_key"
3344
+ )
3345
+
2944
3346
  parsed = urlparse(uri)
2945
3347
  params = parse_qs(parsed.query)
2946
3348
  access_token = params.get("access_token")
@@ -2970,18 +3372,113 @@ class PinterestSource:
2970
3372
  ).with_resources(table)
2971
3373
 
2972
3374
 
3375
+ class FluxxSource:
3376
+ def handles_incrementality(self) -> bool:
3377
+ return True
3378
+
3379
+ def dlt_source(self, uri: str, table: str, **kwargs):
3380
+ if kwargs.get("incremental_key"):
3381
+ raise ValueError(
3382
+ "Fluxx takes care of incrementality on its own, you should not provide incremental_key"
3383
+ )
3384
+
3385
+ # Parse URI: fluxx://instance?client_id=xxx&client_secret=xxx
3386
+ parsed_uri = urlparse(uri)
3387
+ source_params = parse_qs(parsed_uri.query)
3388
+
3389
+ instance = parsed_uri.hostname
3390
+ if not instance:
3391
+ raise ValueError(
3392
+ "Instance is required in the URI (e.g., fluxx://mycompany.preprod)"
3393
+ )
3394
+
3395
+ client_id = source_params.get("client_id")
3396
+ if not client_id:
3397
+ raise ValueError("client_id in the URI is required to connect to Fluxx")
3398
+
3399
+ client_secret = source_params.get("client_secret")
3400
+ if not client_secret:
3401
+ raise ValueError("client_secret in the URI is required to connect to Fluxx")
3402
+
3403
+ # Parse date parameters
3404
+ start_date = kwargs.get("interval_start")
3405
+ if start_date:
3406
+ start_date = ensure_pendulum_datetime(start_date)
3407
+
3408
+ end_date = kwargs.get("interval_end")
3409
+ if end_date:
3410
+ end_date = ensure_pendulum_datetime(end_date)
3411
+
3412
+ # Import Fluxx source
3413
+ from ingestr.src.fluxx import fluxx_source
3414
+
3415
+ # Parse table specification for custom column selection
3416
+ # Format: "resource_name:field1,field2,field3" or "resource_name"
3417
+ resources = None
3418
+ custom_fields = {}
3419
+
3420
+ if table:
3421
+ # Handle single resource with custom fields or multiple resources
3422
+ if ":" in table and table.count(":") == 1:
3423
+ # Single resource with custom fields: "grant_request:id,name,amount"
3424
+ resource_name, field_list = table.split(":", 1)
3425
+ resource_name = resource_name.strip()
3426
+ fields = [f.strip() for f in field_list.split(",")]
3427
+ resources = [resource_name]
3428
+ custom_fields[resource_name] = fields
3429
+ else:
3430
+ # Multiple resources or single resource without custom fields
3431
+ # Support comma-separated list: "grant_request,user"
3432
+ resources = [r.strip() for r in table.split(",")]
3433
+
3434
+ return fluxx_source(
3435
+ instance=instance,
3436
+ client_id=client_id[0],
3437
+ client_secret=client_secret[0],
3438
+ start_date=start_date,
3439
+ end_date=end_date,
3440
+ resources=resources,
3441
+ custom_fields=custom_fields,
3442
+ )
3443
+
3444
+
2973
3445
  class LinearSource:
2974
3446
  def handles_incrementality(self) -> bool:
2975
3447
  return True
2976
3448
 
2977
3449
  def dlt_source(self, uri: str, table: str, **kwargs):
3450
+ if kwargs.get("incremental_key"):
3451
+ raise ValueError(
3452
+ "Linear takes care of incrementality on its own, you should not provide incremental_key"
3453
+ )
3454
+
2978
3455
  parsed_uri = urlparse(uri)
2979
3456
  params = parse_qs(parsed_uri.query)
2980
3457
  api_key = params.get("api_key")
2981
3458
  if api_key is None:
2982
3459
  raise MissingValueError("api_key", "Linear")
2983
3460
 
2984
- if table not in ["issues", "projects", "teams", "users"]:
3461
+ if table not in [
3462
+ "issues",
3463
+ "projects",
3464
+ "teams",
3465
+ "users",
3466
+ "workflow_states",
3467
+ "cycles",
3468
+ "attachments",
3469
+ "comments",
3470
+ "documents",
3471
+ "external_users",
3472
+ "initiative",
3473
+ "integrations",
3474
+ "labels",
3475
+ "organization",
3476
+ "project_updates",
3477
+ "team_memberships",
3478
+ "initiative_to_project",
3479
+ "project_milestone",
3480
+ "project_status",
3481
+ ]:
2985
3482
  raise UnsupportedResourceError(table, "Linear")
2986
3483
 
2987
3484
  start_date = kwargs.get("interval_start")
@@ -3003,12 +3500,67 @@ class LinearSource:
3003
3500
  ).with_resources(table)
3004
3501
 
3005
3502
 
3006
- class ZoomSource:
3503
+ class RevenueCatSource:
3007
3504
  def handles_incrementality(self) -> bool:
3008
3505
  return True
3009
3506
 
3010
3507
  def dlt_source(self, uri: str, table: str, **kwargs):
3011
- parsed = urlparse(uri)
3508
+ if kwargs.get("incremental_key"):
3509
+ raise ValueError(
3510
+ "RevenueCat takes care of incrementality on its own, you should not provide incremental_key"
3511
+ )
3512
+
3513
+ parsed_uri = urlparse(uri)
3514
+ params = parse_qs(parsed_uri.query)
3515
+
3516
+ api_key = params.get("api_key")
3517
+ if api_key is None:
3518
+ raise MissingValueError("api_key", "RevenueCat")
3519
+
3520
+ project_id = params.get("project_id")
3521
+ if project_id is None and table != "projects":
3522
+ raise MissingValueError("project_id", "RevenueCat")
3523
+
3524
+ if table not in [
3525
+ "customers",
3526
+ "products",
3527
+ "entitlements",
3528
+ "offerings",
3529
+ "subscriptions",
3530
+ "purchases",
3531
+ "projects",
3532
+ ]:
3533
+ raise UnsupportedResourceError(table, "RevenueCat")
3534
+
3535
+ start_date = kwargs.get("interval_start")
3536
+ if start_date is not None:
3537
+ start_date = ensure_pendulum_datetime(start_date)
3538
+ else:
3539
+ start_date = pendulum.datetime(2020, 1, 1).in_tz("UTC")
3540
+
3541
+ end_date = kwargs.get("interval_end")
3542
+ if end_date is not None:
3543
+ end_date = ensure_pendulum_datetime(end_date).in_tz("UTC")
3544
+
3545
+ from ingestr.src.revenuecat import revenuecat_source
3546
+
3547
+ return revenuecat_source(
3548
+ api_key=api_key[0],
3549
+ project_id=project_id[0] if project_id is not None else None,
3550
+ ).with_resources(table)
3551
+
3552
+
3553
+ class ZoomSource:
3554
+ def handles_incrementality(self) -> bool:
3555
+ return True
3556
+
3557
+ def dlt_source(self, uri: str, table: str, **kwargs):
3558
+ if kwargs.get("incremental_key"):
3559
+ raise ValueError(
3560
+ "Zoom takes care of incrementality on its own, you should not provide incremental_key"
3561
+ )
3562
+
3563
+ parsed = urlparse(uri)
3012
3564
  params = parse_qs(parsed.query)
3013
3565
  client_id = params.get("client_id")
3014
3566
  client_secret = params.get("client_secret")
@@ -3049,6 +3601,11 @@ class InfluxDBSource:
3049
3601
  return True
3050
3602
 
3051
3603
  def dlt_source(self, uri: str, table: str, **kwargs):
3604
+ if kwargs.get("incremental_key"):
3605
+ raise ValueError(
3606
+ "InfluxDB takes care of incrementality on its own, you should not provide incremental_key"
3607
+ )
3608
+
3052
3609
  parsed_uri = urlparse(uri)
3053
3610
  params = parse_qs(parsed_uri.query)
3054
3611
  host = parsed_uri.hostname
@@ -3056,7 +3613,7 @@ class InfluxDBSource:
3056
3613
 
3057
3614
  secure = params.get("secure", ["true"])[0].lower() != "false"
3058
3615
  scheme = "https" if secure else "http"
3059
-
3616
+
3060
3617
  if port:
3061
3618
  host_url = f"{scheme}://{host}:{port}"
3062
3619
  else:
@@ -3097,3 +3654,810 @@ class InfluxDBSource:
3097
3654
  start_date=start_date,
3098
3655
  end_date=end_date,
3099
3656
  ).with_resources(table)
3657
+
3658
+
3659
+ class WiseSource:
3660
+ def handles_incrementality(self) -> bool:
3661
+ return True
3662
+
3663
+ def dlt_source(self, uri: str, table: str, **kwargs):
3664
+ parsed = urlparse(uri)
3665
+ params = parse_qs(parsed.query)
3666
+ api_key = params.get("api_key")
3667
+
3668
+ if not api_key:
3669
+ raise MissingValueError("api_key", "Wise")
3670
+
3671
+ if table not in ["profiles", "transfers", "balances"]:
3672
+ raise ValueError(
3673
+ f"Resource '{table}' is not supported for Wise source yet, if you are interested in it please create a GitHub issue at https://github.com/bruin-data/ingestr"
3674
+ )
3675
+
3676
+ start_date = kwargs.get("interval_start")
3677
+ if start_date:
3678
+ start_date = ensure_pendulum_datetime(start_date).in_timezone("UTC")
3679
+ else:
3680
+ start_date = pendulum.datetime(2020, 1, 1).in_timezone("UTC")
3681
+
3682
+ end_date = kwargs.get("interval_end")
3683
+ if end_date:
3684
+ end_date = ensure_pendulum_datetime(end_date).in_timezone("UTC")
3685
+ else:
3686
+ end_date = None
3687
+
3688
+ from ingestr.src.wise import wise_source
3689
+
3690
+ return wise_source(
3691
+ api_key=api_key[0],
3692
+ start_date=start_date,
3693
+ end_date=end_date,
3694
+ ).with_resources(table)
3695
+
3696
+
3697
+ class FundraiseupSource:
3698
+ def handles_incrementality(self) -> bool:
3699
+ return False
3700
+
3701
+ def dlt_source(self, uri: str, table: str, **kwargs):
3702
+ parsed_uri = urlparse(uri)
3703
+ params = parse_qs(parsed_uri.query)
3704
+
3705
+ api_key = params.get("api_key")
3706
+ if api_key is None:
3707
+ raise MissingValueError("api_key", "Fundraiseup")
3708
+
3709
+ if table not in [
3710
+ "donations",
3711
+ "events",
3712
+ "fundraisers",
3713
+ "recurring_plans",
3714
+ "supporters",
3715
+ ]:
3716
+ raise UnsupportedResourceError(table, "Fundraiseup")
3717
+
3718
+ from ingestr.src.fundraiseup import fundraiseup_source
3719
+
3720
+ return fundraiseup_source(
3721
+ api_key=api_key[0],
3722
+ ).with_resources(table)
3723
+
3724
+
3725
+ class AnthropicSource:
3726
+ def handles_incrementality(self) -> bool:
3727
+ return True
3728
+
3729
+ def dlt_source(self, uri: str, table: str, **kwargs):
3730
+ # anthropic://?api_key=<admin_api_key>
3731
+ parsed_uri = urlparse(uri)
3732
+ params = parse_qs(parsed_uri.query)
3733
+
3734
+ api_key = params.get("api_key")
3735
+ if api_key is None:
3736
+ raise MissingValueError("api_key", "Anthropic")
3737
+
3738
+ if table not in [
3739
+ "claude_code_usage",
3740
+ "usage_report",
3741
+ "cost_report",
3742
+ "organization",
3743
+ "workspaces",
3744
+ "api_keys",
3745
+ "invites",
3746
+ "users",
3747
+ "workspace_members",
3748
+ ]:
3749
+ raise UnsupportedResourceError(table, "Anthropic")
3750
+
3751
+ # Get start and end dates from kwargs
3752
+ start_date = kwargs.get("interval_start")
3753
+ if start_date:
3754
+ start_date = ensure_pendulum_datetime(start_date)
3755
+ else:
3756
+ # Default to 2023-01-01
3757
+ start_date = pendulum.datetime(2023, 1, 1)
3758
+
3759
+ end_date = kwargs.get("interval_end")
3760
+ if end_date:
3761
+ end_date = ensure_pendulum_datetime(end_date)
3762
+ else:
3763
+ end_date = None
3764
+
3765
+ from ingestr.src.anthropic import anthropic_source
3766
+
3767
+ return anthropic_source(
3768
+ api_key=api_key[0],
3769
+ initial_start_date=start_date,
3770
+ end_date=end_date,
3771
+ ).with_resources(table)
3772
+
3773
+
3774
+ class PlusVibeAISource:
3775
+ resources = [
3776
+ "campaigns",
3777
+ "leads",
3778
+ "email_accounts",
3779
+ "emails",
3780
+ "blocklist",
3781
+ "webhooks",
3782
+ "tags",
3783
+ ]
3784
+
3785
+ def handles_incrementality(self) -> bool:
3786
+ return True
3787
+
3788
+ def dlt_source(self, uri: str, table: str, **kwargs):
3789
+ # plusvibeai://?api_key=<key>&workspace_id=<id>
3790
+ parsed_uri = urlparse(uri)
3791
+ params = parse_qs(parsed_uri.query)
3792
+
3793
+ api_key = params.get("api_key")
3794
+ workspace_id = params.get("workspace_id")
3795
+
3796
+ if not api_key:
3797
+ raise MissingValueError("api_key", "PlusVibeAI")
3798
+
3799
+ if not workspace_id:
3800
+ raise MissingValueError("workspace_id", "PlusVibeAI")
3801
+
3802
+ if table not in self.resources:
3803
+ raise UnsupportedResourceError(table, "PlusVibeAI")
3804
+
3805
+ import dlt
3806
+
3807
+ from ingestr.src.plusvibeai import plusvibeai_source
3808
+
3809
+ dlt.secrets["sources.plusvibeai.api_key"] = api_key[0]
3810
+ dlt.secrets["sources.plusvibeai.workspace_id"] = workspace_id[0]
3811
+
3812
+ # Handle custom base URL if provided
3813
+ base_url = params.get("base_url", ["https://api.plusvibe.ai"])[0]
3814
+ dlt.secrets["sources.plusvibeai.base_url"] = base_url
3815
+
3816
+ src = plusvibeai_source()
3817
+ return src.with_resources(table)
3818
+
3819
+
3820
+ class IntercomSource:
3821
+ def handles_incrementality(self) -> bool:
3822
+ return True
3823
+
3824
+ def dlt_source(self, uri: str, table: str, **kwargs):
3825
+ # intercom://?access_token=<token>&region=<us|eu|au>
3826
+ # OR intercom://?oauth_token=<token>&region=<us|eu|au>
3827
+ parsed_uri = urlparse(uri)
3828
+ params = parse_qs(parsed_uri.query)
3829
+
3830
+ # Check for authentication
3831
+ access_token = params.get("access_token")
3832
+ oauth_token = params.get("oauth_token")
3833
+ region = params.get("region", ["us"])[0]
3834
+
3835
+ if not access_token and not oauth_token:
3836
+ raise MissingValueError("access_token or oauth_token", "Intercom")
3837
+
3838
+ # Validate table/resource
3839
+ supported_tables = [
3840
+ "contacts",
3841
+ "companies",
3842
+ "conversations",
3843
+ "tickets",
3844
+ "tags",
3845
+ "segments",
3846
+ "teams",
3847
+ "admins",
3848
+ "articles",
3849
+ "data_attributes",
3850
+ ]
3851
+
3852
+ if table not in supported_tables:
3853
+ raise UnsupportedResourceError(table, "Intercom")
3854
+
3855
+ # Get date parameters
3856
+ start_date = kwargs.get("interval_start")
3857
+ if start_date:
3858
+ start_date = ensure_pendulum_datetime(start_date)
3859
+ else:
3860
+ start_date = pendulum.datetime(2020, 1, 1)
3861
+
3862
+ end_date = kwargs.get("interval_end")
3863
+ if end_date:
3864
+ end_date = ensure_pendulum_datetime(end_date)
3865
+
3866
+ # Import and initialize the source
3867
+ from ingestr.src.intercom import (
3868
+ IntercomCredentialsAccessToken,
3869
+ IntercomCredentialsOAuth,
3870
+ TIntercomCredentials,
3871
+ intercom_source,
3872
+ )
3873
+
3874
+ credentials: TIntercomCredentials
3875
+ if access_token:
3876
+ credentials = IntercomCredentialsAccessToken(
3877
+ access_token=access_token[0], region=region
3878
+ )
3879
+ else:
3880
+ if not oauth_token:
3881
+ raise MissingValueError("oauth_token", "Intercom")
3882
+ credentials = IntercomCredentialsOAuth(
3883
+ oauth_token=oauth_token[0], region=region
3884
+ )
3885
+
3886
+ return intercom_source(
3887
+ credentials=credentials,
3888
+ start_date=start_date,
3889
+ end_date=end_date,
3890
+ ).with_resources(table)
3891
+
3892
+
3893
+ class HttpSource:
3894
+ """Source for reading CSV, JSON, and Parquet files from HTTP URLs"""
3895
+
3896
+ def handles_incrementality(self) -> bool:
3897
+ return False
3898
+
3899
+ def dlt_source(self, uri: str, table: str, **kwargs):
3900
+ """
3901
+ Create a dlt source for reading files from HTTP URLs.
3902
+
3903
+ URI format: http://example.com/file.csv or https://example.com/file.json
3904
+
3905
+ Args:
3906
+ uri: HTTP(S) URL to the file
3907
+ table: Not used for HTTP source (files are read directly)
3908
+ **kwargs: Additional arguments:
3909
+ - file_format: Optional file format override ('csv', 'json', 'parquet')
3910
+ - chunksize: Number of records to process at once (default varies by format)
3911
+ - merge_key: Merge key for the resource
3912
+
3913
+ Returns:
3914
+ DltResource for the HTTP file
3915
+ """
3916
+ from ingestr.src.http import http_source
3917
+
3918
+ # Extract the actual URL (remove the http:// or https:// scheme if duplicated)
3919
+ url = uri
3920
+ if uri.startswith("http://http://") or uri.startswith("https://https://"):
3921
+ url = uri.split("://", 1)[1]
3922
+
3923
+ file_format = kwargs.get("file_format")
3924
+ chunksize = kwargs.get("chunksize")
3925
+ merge_key = kwargs.get("merge_key")
3926
+
3927
+ reader_kwargs = {}
3928
+ if chunksize is not None:
3929
+ reader_kwargs["chunksize"] = chunksize
3930
+
3931
+ source = http_source(url=url, file_format=file_format, **reader_kwargs)
3932
+
3933
+ if merge_key:
3934
+ source.apply_hints(merge_key=merge_key)
3935
+
3936
+ return source
3937
+
3938
+
3939
+ class MondaySource:
3940
+ def handles_incrementality(self) -> bool:
3941
+ return False
3942
+
3943
+ def dlt_source(self, uri: str, table: str, **kwargs):
3944
+ parsed_uri = urlparse(uri)
3945
+ query_params = parse_qs(parsed_uri.query)
3946
+ api_token = query_params.get("api_token")
3947
+
3948
+ if api_token is None:
3949
+ raise MissingValueError("api_token", "Monday")
3950
+
3951
+ parts = table.replace(" ", "").split(":")
3952
+ table_name = parts[0]
3953
+ params = parts[1:]
3954
+
3955
+ # Get interval_start and interval_end from kwargs (command line args)
3956
+ interval_start = kwargs.get("interval_start")
3957
+ interval_end = kwargs.get("interval_end")
3958
+
3959
+ # Convert datetime to string format YYYY-MM-DD
3960
+ start_date = interval_start.strftime("%Y-%m-%d") if interval_start else None
3961
+ end_date = interval_end.strftime("%Y-%m-%d") if interval_end else None
3962
+
3963
+ from ingestr.src.monday import monday_source
3964
+
3965
+ try:
3966
+ return monday_source(
3967
+ api_token=api_token[0],
3968
+ params=params,
3969
+ start_date=start_date,
3970
+ end_date=end_date,
3971
+ ).with_resources(table_name)
3972
+ except ResourcesNotFoundError:
3973
+ raise UnsupportedResourceError(table_name, "Monday")
3974
+
3975
+
3976
+ class MailchimpSource:
3977
+ def handles_incrementality(self) -> bool:
3978
+ return False
3979
+
3980
+ def dlt_source(self, uri: str, table: str, **kwargs):
3981
+ parsed_uri = urlparse(uri)
3982
+ query_params = parse_qs(parsed_uri.query)
3983
+ api_key = query_params.get("api_key")
3984
+ server = query_params.get("server")
3985
+
3986
+ if api_key is None:
3987
+ raise MissingValueError("api_key", "Mailchimp")
3988
+ if server is None:
3989
+ raise MissingValueError("server", "Mailchimp")
3990
+
3991
+ from ingestr.src.mailchimp import mailchimp_source
3992
+
3993
+ try:
3994
+ return mailchimp_source(
3995
+ api_key=api_key[0],
3996
+ server=server[0],
3997
+ ).with_resources(table)
3998
+ except ResourcesNotFoundError:
3999
+ raise UnsupportedResourceError(table, "Mailchimp")
4000
+
4001
+
4002
+ class AlliumSource:
4003
+ def handles_incrementality(self) -> bool:
4004
+ return False
4005
+
4006
+ def dlt_source(self, uri: str, table: str, **kwargs):
4007
+ parsed_uri = urlparse(uri)
4008
+ query_params = parse_qs(parsed_uri.query)
4009
+ api_key = query_params.get("api_key")
4010
+
4011
+ if api_key is None:
4012
+ raise MissingValueError("api_key", "Allium")
4013
+
4014
+ # Extract query_id and custom parameters from table parameter
4015
+ # Format: query_id or query:query_id or query:query_id:param1=value1&param2=value2
4016
+ query_id = table
4017
+ custom_params = {}
4018
+ limit = None
4019
+ compute_profile = None
4020
+
4021
+ if ":" in table:
4022
+ parts = table.split(":", 2) # Split into max 3 parts
4023
+ if len(parts) >= 2:
4024
+ query_id = parts[1]
4025
+ if len(parts) == 3:
4026
+ # Parse custom parameters from query string format
4027
+ param_string = parts[2]
4028
+ for param in param_string.split("&"):
4029
+ if "=" in param:
4030
+ key, value = param.split("=", 1)
4031
+ # Extract run_config parameters
4032
+ if key == "limit":
4033
+ limit = int(value)
4034
+ elif key == "compute_profile":
4035
+ compute_profile = value
4036
+ else:
4037
+ custom_params[key] = value
4038
+
4039
+ # Extract parameters from interval_start and interval_end
4040
+ # Default: 2 days ago 00:00 to yesterday 00:00
4041
+ now = pendulum.now()
4042
+ default_start = now.subtract(days=2).start_of("day")
4043
+ default_end = now.subtract(days=1).start_of("day")
4044
+
4045
+ parameters = {}
4046
+ interval_start = kwargs.get("interval_start")
4047
+ interval_end = kwargs.get("interval_end")
4048
+
4049
+ start_date = interval_start if interval_start is not None else default_start
4050
+ end_date = interval_end if interval_end is not None else default_end
4051
+
4052
+ parameters["start_date"] = start_date.strftime("%Y-%m-%d")
4053
+ parameters["end_date"] = end_date.strftime("%Y-%m-%d")
4054
+ parameters["start_timestamp"] = str(int(start_date.timestamp()))
4055
+ parameters["end_timestamp"] = str(int(end_date.timestamp()))
4056
+
4057
+ # Merge custom parameters (they override default parameters)
4058
+ parameters.update(custom_params)
4059
+
4060
+ from ingestr.src.allium import allium_source
4061
+
4062
+ return allium_source(
4063
+ api_key=api_key[0],
4064
+ query_id=query_id,
4065
+ parameters=parameters if parameters else None,
4066
+ limit=limit,
4067
+ compute_profile=compute_profile,
4068
+ )
4069
+
4070
+
4071
+ class CouchbaseSource:
4072
+ table_builder: Callable
4073
+
4074
+ def __init__(self, table_builder=None) -> None:
4075
+ if table_builder is None:
4076
+ from ingestr.src.couchbase_source import couchbase_collection
4077
+
4078
+ table_builder = couchbase_collection
4079
+
4080
+ self.table_builder = table_builder
4081
+
4082
+ def handles_incrementality(self) -> bool:
4083
+ return False
4084
+
4085
+ def dlt_source(self, uri: str, table: str, **kwargs):
4086
+ """
4087
+ Create a dlt source for reading data from Couchbase.
4088
+
4089
+ URI formats:
4090
+ - couchbase://username:password@host
4091
+ - couchbase://username:password@host/bucket
4092
+ - couchbase://username:password@host?ssl=true
4093
+ - couchbases://username:password@host (SSL enabled)
4094
+
4095
+ Table formats:
4096
+ - bucket.scope.collection (when bucket not in URI)
4097
+ - scope.collection (when bucket specified in URI path)
4098
+
4099
+ Note: If password contains special characters (@, :, /, etc.), they must be URL-encoded.
4100
+
4101
+ Examples:
4102
+ Local/Self-hosted:
4103
+ - couchbase://admin:password123@localhost with table "mybucket.myscope.mycollection"
4104
+ - couchbase://admin:password123@localhost/mybucket with table "myscope.mycollection"
4105
+ - couchbase://admin:password123@localhost?ssl=true with table "mybucket._default._default"
4106
+
4107
+ Capella (Cloud):
4108
+ - couchbases://user:pass@cb.xxx.cloud.couchbase.com with table "travel-sample.inventory.airport"
4109
+ - couchbase://user:pass@cb.xxx.cloud.couchbase.com/travel-sample?ssl=true with table "inventory.airport"
4110
+
4111
+ To encode password in Python:
4112
+ from urllib.parse import quote
4113
+ encoded_pwd = quote("MyPass@123!", safe='')
4114
+ uri = f"couchbase://admin:{encoded_pwd}@localhost?ssl=true"
4115
+
4116
+ Args:
4117
+ uri: Couchbase connection URI (can include /bucket path and ?ssl=true query parameter)
4118
+ table: Format depends on URI:
4119
+ - bucket.scope.collection (if bucket not in URI)
4120
+ - scope.collection (if bucket in URI path)
4121
+ **kwargs: Additional arguments:
4122
+ - limit: Maximum number of documents to fetch
4123
+ - incremental_key: Field to use for incremental loading
4124
+ - interval_start: Start value for incremental loading
4125
+ - interval_end: End value for incremental loading
4126
+
4127
+ Returns:
4128
+ DltResource for the Couchbase collection
4129
+ """
4130
+ # Parse the URI to extract connection details
4131
+ # urlparse automatically decodes URL-encoded credentials
4132
+
4133
+ parsed = urlparse(uri)
4134
+
4135
+ # Extract username and password from URI
4136
+ # Note: urlparse automatically decodes URL-encoded characters in username/password
4137
+ from urllib.parse import unquote
4138
+
4139
+ username = parsed.username
4140
+ password = unquote(parsed.password) if parsed.password else None
4141
+
4142
+ if not username or not password:
4143
+ raise ValueError(
4144
+ "Username and password must be provided in the URI.\n"
4145
+ "Format: couchbase://username:password@host\n"
4146
+ "If password has special characters (@, :, /), URL-encode them.\n"
4147
+ "Example: couchbase://admin:MyPass%40123@localhost for password 'MyPass@123'"
4148
+ )
4149
+
4150
+ # Reconstruct connection string without credentials
4151
+ scheme = parsed.scheme
4152
+ netloc = parsed.netloc
4153
+
4154
+ # Remove username:password@ from netloc if present
4155
+ if "@" in netloc:
4156
+ netloc = netloc.split("@", 1)[1]
4157
+
4158
+ # Parse query parameters from URI
4159
+ from urllib.parse import parse_qs
4160
+
4161
+ query_params = parse_qs(parsed.query)
4162
+
4163
+ # Check if SSL is requested via URI query parameter (?ssl=true)
4164
+ if "ssl" in query_params:
4165
+ ssl_value = query_params["ssl"][0].lower()
4166
+ use_ssl = ssl_value in ("true", "1", "yes")
4167
+
4168
+ # Apply SSL scheme based on parameter
4169
+ if use_ssl and scheme == "couchbase":
4170
+ scheme = "couchbases"
4171
+
4172
+ connection_string = f"{scheme}://{netloc}"
4173
+
4174
+ # Extract bucket from URI path if present (e.g., couchbase://host/bucket)
4175
+ bucket_from_uri = None
4176
+ if parsed.path and parsed.path.strip("/"):
4177
+ bucket_from_uri = parsed.path.strip("/").split("/")[0]
4178
+
4179
+ # Parse table format: can be "scope.collection" or "bucket.scope.collection"
4180
+ table_parts = table.split(".")
4181
+
4182
+ if len(table_parts) == 3:
4183
+ # Format: bucket.scope.collection
4184
+ bucket, scope, collection = table_parts
4185
+ elif len(table_parts) == 2:
4186
+ # Format: scope.collection (bucket from URI)
4187
+ if bucket_from_uri:
4188
+ bucket = bucket_from_uri
4189
+ scope, collection = table_parts
4190
+ else:
4191
+ raise ValueError(
4192
+ "Table format is 'scope.collection' but no bucket specified in URI.\n"
4193
+ f"Either use URI format: couchbase://user:pass@host/bucket\n"
4194
+ f"Or use table format: bucket.scope.collection\n"
4195
+ f"Got table: {table}"
4196
+ )
4197
+ else:
4198
+ raise ValueError(
4199
+ "Table format must be 'bucket.scope.collection' or 'scope.collection' (with bucket in URI). "
4200
+ f"Got: {table}\n"
4201
+ "Examples:\n"
4202
+ " - URI: couchbase://user:pass@host, Table: travel-sample.inventory.airport\n"
4203
+ " - URI: couchbase://user:pass@host/travel-sample, Table: inventory.airport"
4204
+ )
4205
+
4206
+ # Handle incremental loading
4207
+ incremental = None
4208
+ if kwargs.get("incremental_key"):
4209
+ start_value = kwargs.get("interval_start")
4210
+ end_value = kwargs.get("interval_end")
4211
+
4212
+ incremental = dlt_incremental(
4213
+ kwargs.get("incremental_key", ""),
4214
+ initial_value=start_value,
4215
+ end_value=end_value,
4216
+ range_end="closed",
4217
+ range_start="closed",
4218
+ )
4219
+
4220
+ # Get optional parameters
4221
+ limit = kwargs.get("limit")
4222
+
4223
+ table_instance = self.table_builder(
4224
+ connection_string=connection_string,
4225
+ username=username,
4226
+ password=password,
4227
+ bucket=bucket,
4228
+ scope=scope,
4229
+ collection=collection,
4230
+ incremental=incremental,
4231
+ limit=limit,
4232
+ )
4233
+ table_instance.max_table_nesting = 1
4234
+
4235
+ return table_instance
4236
+
4237
+
4238
+ class CursorSource:
4239
+ resources = [
4240
+ "team_members",
4241
+ "daily_usage_data",
4242
+ "team_spend",
4243
+ "filtered_usage_events",
4244
+ ]
4245
+
4246
+ def handles_incrementality(self) -> bool:
4247
+ return True
4248
+
4249
+ def dlt_source(self, uri: str, table: str, **kwargs):
4250
+ # cursor://?api_key=<api_key>
4251
+ parsed_uri = urlparse(uri)
4252
+ params = parse_qs(parsed_uri.query)
4253
+
4254
+ api_key = params.get("api_key")
4255
+
4256
+ if not api_key:
4257
+ raise MissingValueError("api_key", "Cursor")
4258
+
4259
+ if table not in self.resources:
4260
+ raise UnsupportedResourceError(table, "Cursor")
4261
+
4262
+ import dlt
4263
+
4264
+ from ingestr.src.cursor import cursor_source
4265
+
4266
+ dlt.secrets["sources.cursor.api_key"] = api_key[0]
4267
+
4268
+ # Handle interval_start and interval_end for daily_usage_data and filtered_usage_events (optional)
4269
+ if table in ["daily_usage_data", "filtered_usage_events"]:
4270
+ interval_start = kwargs.get("interval_start")
4271
+ interval_end = kwargs.get("interval_end")
4272
+
4273
+ # Both are optional, but if one is provided, both should be provided
4274
+ if interval_start is not None and interval_end is not None:
4275
+ # Convert datetime to epoch milliseconds
4276
+ start_ms = int(interval_start.timestamp() * 1000)
4277
+ end_ms = int(interval_end.timestamp() * 1000)
4278
+
4279
+ dlt.config["sources.cursor.start_date"] = start_ms
4280
+ dlt.config["sources.cursor.end_date"] = end_ms
4281
+
4282
+ src = cursor_source()
4283
+ return src.with_resources(table)
4284
+
4285
+
4286
+ class SocrataSource:
4287
+ def handles_incrementality(self) -> bool:
4288
+ return False
4289
+
4290
+ def dlt_source(self, uri: str, table: str, **kwargs):
4291
+ """
4292
+ Creates a DLT source for Socrata open data platform.
4293
+
4294
+ URI format: socrata://domain?app_token=TOKEN
4295
+ Table: dataset_id (e.g., "6udu-fhnu")
4296
+
4297
+ Args:
4298
+ uri: Socrata connection URI with domain and optional auth params
4299
+ table: Dataset ID (e.g., "6udu-fhnu")
4300
+ **kwargs: Additional arguments:
4301
+ - incremental_key: Field to use for incremental loading (e.g., ":updated_at")
4302
+ - interval_start: Start date for initial load
4303
+ - interval_end: End date for load
4304
+ - primary_key: Primary key field for merge operations
4305
+
4306
+ Returns:
4307
+ DltResource for the Socrata dataset
4308
+ """
4309
+ from urllib.parse import parse_qs, urlparse
4310
+
4311
+ parsed = urlparse(uri)
4312
+
4313
+ domain = parsed.netloc
4314
+ if not domain:
4315
+ raise ValueError(
4316
+ "Domain must be provided in the URI.\n"
4317
+ "Format: socrata://domain?app_token=TOKEN\n"
4318
+ "Example: socrata://evergreen.data.socrata.com?app_token=mytoken"
4319
+ )
4320
+
4321
+ query_params = parse_qs(parsed.query)
4322
+
4323
+ dataset_id = table
4324
+ if not dataset_id:
4325
+ raise ValueError(
4326
+ "Dataset ID must be provided as the table parameter.\n"
4327
+ "Example: --source-table 6udu-fhnu"
4328
+ )
4329
+
4330
+ app_token = query_params.get("app_token", [None])[0]
4331
+ username = query_params.get("username", [None])[0]
4332
+ password = query_params.get("password", [None])[0]
4333
+
4334
+ incremental = None
4335
+ if kwargs.get("incremental_key"):
4336
+ start_value = kwargs.get("interval_start")
4337
+ end_value = kwargs.get("interval_end")
4338
+
4339
+ if start_value:
4340
+ start_value = (
4341
+ start_value.isoformat()
4342
+ if hasattr(start_value, "isoformat")
4343
+ else str(start_value)
4344
+ )
4345
+
4346
+ if end_value:
4347
+ end_value = (
4348
+ end_value.isoformat()
4349
+ if hasattr(end_value, "isoformat")
4350
+ else str(end_value)
4351
+ )
4352
+
4353
+ incremental = dlt_incremental(
4354
+ kwargs.get("incremental_key", ""),
4355
+ initial_value=start_value,
4356
+ end_value=end_value,
4357
+ range_end="open",
4358
+ range_start="closed",
4359
+ )
4360
+
4361
+ primary_key = kwargs.get("primary_key")
4362
+
4363
+ from ingestr.src.socrata_source import source
4364
+
4365
+ return source(
4366
+ domain=domain,
4367
+ dataset_id=dataset_id,
4368
+ app_token=app_token,
4369
+ username=username,
4370
+ password=password,
4371
+ incremental=incremental,
4372
+ primary_key=primary_key,
4373
+ ).with_resources("dataset")
4374
+
4375
+
4376
+ class HostawaySource:
4377
+ def handles_incrementality(self) -> bool:
4378
+ return True
4379
+
4380
+ def dlt_source(self, uri: str, table: str, **kwargs):
4381
+ if kwargs.get("incremental_key"):
4382
+ raise ValueError(
4383
+ "Hostaway takes care of incrementality on its own, you should not provide incremental_key"
4384
+ )
4385
+
4386
+ source_parts = urlparse(uri)
4387
+ source_params = parse_qs(source_parts.query)
4388
+ api_key = source_params.get("api_key")
4389
+
4390
+ if not api_key:
4391
+ raise ValueError("api_key in the URI is required to connect to Hostaway")
4392
+
4393
+ match table:
4394
+ case "listings":
4395
+ resource_name = "listings"
4396
+ case "listing_fee_settings":
4397
+ resource_name = "listing_fee_settings"
4398
+ case "listing_agreements":
4399
+ resource_name = "listing_agreements"
4400
+ case "listing_pricing_settings":
4401
+ resource_name = "listing_pricing_settings"
4402
+ case "cancellation_policies":
4403
+ resource_name = "cancellation_policies"
4404
+ case "cancellation_policies_airbnb":
4405
+ resource_name = "cancellation_policies_airbnb"
4406
+ case "cancellation_policies_marriott":
4407
+ resource_name = "cancellation_policies_marriott"
4408
+ case "cancellation_policies_vrbo":
4409
+ resource_name = "cancellation_policies_vrbo"
4410
+ case "reservations":
4411
+ resource_name = "reservations"
4412
+ case "finance_fields":
4413
+ resource_name = "finance_fields"
4414
+ case "reservation_payment_methods":
4415
+ resource_name = "reservation_payment_methods"
4416
+ case "reservation_rental_agreements":
4417
+ resource_name = "reservation_rental_agreements"
4418
+ case "listing_calendars":
4419
+ resource_name = "listing_calendars"
4420
+ case "conversations":
4421
+ resource_name = "conversations"
4422
+ case "message_templates":
4423
+ resource_name = "message_templates"
4424
+ case "bed_types":
4425
+ resource_name = "bed_types"
4426
+ case "property_types":
4427
+ resource_name = "property_types"
4428
+ case "countries":
4429
+ resource_name = "countries"
4430
+ case "account_tax_settings":
4431
+ resource_name = "account_tax_settings"
4432
+ case "user_groups":
4433
+ resource_name = "user_groups"
4434
+ case "guest_payment_charges":
4435
+ resource_name = "guest_payment_charges"
4436
+ case "coupons":
4437
+ resource_name = "coupons"
4438
+ case "webhook_reservations":
4439
+ resource_name = "webhook_reservations"
4440
+ case "tasks":
4441
+ resource_name = "tasks"
4442
+ case _:
4443
+ raise ValueError(
4444
+ f"Resource '{table}' is not supported for Hostaway source yet, if you are interested in it please create a GitHub issue at https://github.com/bruin-data/ingestr"
4445
+ )
4446
+
4447
+ start_date = kwargs.get("interval_start")
4448
+ if start_date:
4449
+ start_date = ensure_pendulum_datetime(start_date).in_timezone("UTC")
4450
+ else:
4451
+ start_date = pendulum.datetime(1970, 1, 1).in_timezone("UTC")
4452
+
4453
+ end_date = kwargs.get("interval_end")
4454
+ if end_date:
4455
+ end_date = ensure_pendulum_datetime(end_date).in_timezone("UTC")
4456
+
4457
+ from ingestr.src.hostaway import hostaway_source
4458
+
4459
+ return hostaway_source(
4460
+ api_key=api_key[0],
4461
+ start_date=start_date,
4462
+ end_date=end_date,
4463
+ ).with_resources(resource_name)