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.
- ingestr/main.py +22 -3
- ingestr/src/adjust/__init__.py +4 -4
- ingestr/src/allium/__init__.py +128 -0
- ingestr/src/anthropic/__init__.py +277 -0
- ingestr/src/anthropic/helpers.py +525 -0
- ingestr/src/appstore/__init__.py +1 -0
- ingestr/src/asana_source/__init__.py +1 -1
- ingestr/src/buildinfo.py +1 -1
- ingestr/src/chess/__init__.py +1 -1
- ingestr/src/couchbase_source/__init__.py +118 -0
- ingestr/src/couchbase_source/helpers.py +135 -0
- ingestr/src/cursor/__init__.py +83 -0
- ingestr/src/cursor/helpers.py +188 -0
- ingestr/src/destinations.py +169 -1
- ingestr/src/docebo/__init__.py +589 -0
- ingestr/src/docebo/client.py +435 -0
- ingestr/src/docebo/helpers.py +97 -0
- ingestr/src/elasticsearch/helpers.py +138 -0
- ingestr/src/errors.py +8 -0
- ingestr/src/facebook_ads/__init__.py +26 -23
- ingestr/src/facebook_ads/helpers.py +47 -1
- ingestr/src/factory.py +48 -0
- ingestr/src/filesystem/__init__.py +8 -3
- ingestr/src/filters.py +9 -0
- ingestr/src/fluxx/__init__.py +9906 -0
- ingestr/src/fluxx/helpers.py +209 -0
- ingestr/src/frankfurter/__init__.py +157 -163
- ingestr/src/frankfurter/helpers.py +3 -3
- ingestr/src/freshdesk/__init__.py +25 -8
- ingestr/src/freshdesk/freshdesk_client.py +40 -5
- ingestr/src/fundraiseup/__init__.py +49 -0
- ingestr/src/fundraiseup/client.py +81 -0
- ingestr/src/github/__init__.py +6 -4
- ingestr/src/google_analytics/__init__.py +1 -1
- ingestr/src/hostaway/__init__.py +302 -0
- ingestr/src/hostaway/client.py +288 -0
- ingestr/src/http/__init__.py +35 -0
- ingestr/src/http/readers.py +114 -0
- ingestr/src/hubspot/__init__.py +6 -12
- ingestr/src/influxdb/__init__.py +1 -0
- ingestr/src/intercom/__init__.py +142 -0
- ingestr/src/intercom/helpers.py +674 -0
- ingestr/src/intercom/settings.py +279 -0
- ingestr/src/jira_source/__init__.py +340 -0
- ingestr/src/jira_source/helpers.py +439 -0
- ingestr/src/jira_source/settings.py +170 -0
- ingestr/src/klaviyo/__init__.py +5 -5
- ingestr/src/linear/__init__.py +553 -116
- ingestr/src/linear/helpers.py +77 -38
- ingestr/src/mailchimp/__init__.py +126 -0
- ingestr/src/mailchimp/helpers.py +226 -0
- ingestr/src/mailchimp/settings.py +164 -0
- ingestr/src/masking.py +344 -0
- ingestr/src/monday/__init__.py +246 -0
- ingestr/src/monday/helpers.py +392 -0
- ingestr/src/monday/settings.py +328 -0
- ingestr/src/mongodb/__init__.py +5 -2
- ingestr/src/mongodb/helpers.py +384 -10
- ingestr/src/plusvibeai/__init__.py +335 -0
- ingestr/src/plusvibeai/helpers.py +544 -0
- ingestr/src/plusvibeai/settings.py +252 -0
- ingestr/src/revenuecat/__init__.py +83 -0
- ingestr/src/revenuecat/helpers.py +237 -0
- ingestr/src/salesforce/__init__.py +15 -8
- ingestr/src/shopify/__init__.py +1 -1
- ingestr/src/smartsheets/__init__.py +33 -5
- ingestr/src/socrata_source/__init__.py +83 -0
- ingestr/src/socrata_source/helpers.py +85 -0
- ingestr/src/socrata_source/settings.py +8 -0
- ingestr/src/sources.py +1418 -54
- ingestr/src/stripe_analytics/__init__.py +2 -19
- ingestr/src/wise/__init__.py +68 -0
- ingestr/src/wise/client.py +63 -0
- ingestr/tests/unit/test_smartsheets.py +6 -9
- {ingestr-0.13.75.dist-info → ingestr-0.14.98.dist-info}/METADATA +24 -12
- {ingestr-0.13.75.dist-info → ingestr-0.14.98.dist-info}/RECORD +79 -37
- {ingestr-0.13.75.dist-info → ingestr-0.14.98.dist-info}/WHEEL +0 -0
- {ingestr-0.13.75.dist-info → ingestr-0.14.98.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
876
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
2494
|
-
|
|
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],
|
|
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
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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>®ion=<us|eu|au>
|
|
3826
|
+
# OR intercom://?oauth_token=<token>®ion=<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¶m2=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)
|