omniload 0.0.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. omniload/conftest.py +72 -0
  2. omniload/main.py +810 -0
  3. omniload/src/.gitignore +10 -0
  4. omniload/src/adjust/__init__.py +108 -0
  5. omniload/src/adjust/adjust_helpers.py +122 -0
  6. omniload/src/airtable/__init__.py +84 -0
  7. omniload/src/allium/__init__.py +128 -0
  8. omniload/src/anthropic/__init__.py +277 -0
  9. omniload/src/anthropic/helpers.py +525 -0
  10. omniload/src/applovin/__init__.py +316 -0
  11. omniload/src/applovin_max/__init__.py +117 -0
  12. omniload/src/appsflyer/__init__.py +325 -0
  13. omniload/src/appsflyer/client.py +110 -0
  14. omniload/src/appstore/__init__.py +142 -0
  15. omniload/src/appstore/client.py +126 -0
  16. omniload/src/appstore/errors.py +15 -0
  17. omniload/src/appstore/models.py +117 -0
  18. omniload/src/appstore/resources.py +179 -0
  19. omniload/src/arrow/__init__.py +81 -0
  20. omniload/src/asana_source/__init__.py +281 -0
  21. omniload/src/asana_source/helpers.py +30 -0
  22. omniload/src/asana_source/settings.py +158 -0
  23. omniload/src/attio/__init__.py +102 -0
  24. omniload/src/attio/helpers.py +65 -0
  25. omniload/src/blob.py +95 -0
  26. omniload/src/bruin/__init__.py +76 -0
  27. omniload/src/chess/__init__.py +180 -0
  28. omniload/src/chess/helpers.py +35 -0
  29. omniload/src/chess/settings.py +18 -0
  30. omniload/src/clickup/__init__.py +85 -0
  31. omniload/src/clickup/helpers.py +47 -0
  32. omniload/src/collector/spinner.py +43 -0
  33. omniload/src/couchbase_source/__init__.py +118 -0
  34. omniload/src/couchbase_source/helpers.py +135 -0
  35. omniload/src/cursor/__init__.py +83 -0
  36. omniload/src/cursor/helpers.py +188 -0
  37. omniload/src/customer_io/__init__.py +486 -0
  38. omniload/src/customer_io/helpers.py +530 -0
  39. omniload/src/destinations.py +982 -0
  40. omniload/src/docebo/__init__.py +589 -0
  41. omniload/src/docebo/client.py +435 -0
  42. omniload/src/docebo/helpers.py +97 -0
  43. omniload/src/dune/__init__.py +104 -0
  44. omniload/src/dune/helpers.py +108 -0
  45. omniload/src/dynamodb/__init__.py +86 -0
  46. omniload/src/elasticsearch/__init__.py +80 -0
  47. omniload/src/elasticsearch/helpers.py +141 -0
  48. omniload/src/errors.py +26 -0
  49. omniload/src/facebook_ads/__init__.py +403 -0
  50. omniload/src/facebook_ads/exceptions.py +19 -0
  51. omniload/src/facebook_ads/helpers.py +296 -0
  52. omniload/src/facebook_ads/settings.py +224 -0
  53. omniload/src/facebook_ads/utils.py +53 -0
  54. omniload/src/factory.py +305 -0
  55. omniload/src/filesystem/__init__.py +133 -0
  56. omniload/src/filesystem/helpers.py +114 -0
  57. omniload/src/filesystem/readers.py +187 -0
  58. omniload/src/filters.py +62 -0
  59. omniload/src/fireflies/__init__.py +151 -0
  60. omniload/src/fireflies/helpers.py +753 -0
  61. omniload/src/fluxx/__init__.py +10013 -0
  62. omniload/src/fluxx/helpers.py +233 -0
  63. omniload/src/frankfurter/__init__.py +157 -0
  64. omniload/src/frankfurter/helpers.py +48 -0
  65. omniload/src/freshdesk/__init__.py +103 -0
  66. omniload/src/freshdesk/freshdesk_client.py +151 -0
  67. omniload/src/freshdesk/settings.py +23 -0
  68. omniload/src/fundraiseup/__init__.py +95 -0
  69. omniload/src/fundraiseup/client.py +81 -0
  70. omniload/src/github/__init__.py +202 -0
  71. omniload/src/github/helpers.py +207 -0
  72. omniload/src/github/queries.py +129 -0
  73. omniload/src/github/settings.py +24 -0
  74. omniload/src/google_ads/__init__.py +198 -0
  75. omniload/src/google_ads/field.py +17 -0
  76. omniload/src/google_ads/metrics.py +254 -0
  77. omniload/src/google_ads/predicates.py +37 -0
  78. omniload/src/google_ads/reports.py +411 -0
  79. omniload/src/google_ads/test_google_ads.py +184 -0
  80. omniload/src/google_analytics/__init__.py +144 -0
  81. omniload/src/google_analytics/helpers.py +312 -0
  82. omniload/src/google_sheets/README.md +95 -0
  83. omniload/src/google_sheets/__init__.py +166 -0
  84. omniload/src/google_sheets/helpers/__init__.py +15 -0
  85. omniload/src/google_sheets/helpers/api_calls.py +160 -0
  86. omniload/src/google_sheets/helpers/data_processing.py +316 -0
  87. omniload/src/gorgias/__init__.py +595 -0
  88. omniload/src/gorgias/helpers.py +166 -0
  89. omniload/src/hostaway/__init__.py +302 -0
  90. omniload/src/hostaway/client.py +288 -0
  91. omniload/src/http/__init__.py +38 -0
  92. omniload/src/http/readers.py +146 -0
  93. omniload/src/http_client.py +24 -0
  94. omniload/src/hubspot/__init__.py +800 -0
  95. omniload/src/hubspot/helpers.py +417 -0
  96. omniload/src/hubspot/settings.py +329 -0
  97. omniload/src/indeed/__init__.py +153 -0
  98. omniload/src/indeed/helpers.py +228 -0
  99. omniload/src/influxdb/__init__.py +46 -0
  100. omniload/src/influxdb/client.py +34 -0
  101. omniload/src/intercom/__init__.py +142 -0
  102. omniload/src/intercom/helpers.py +674 -0
  103. omniload/src/intercom/settings.py +279 -0
  104. omniload/src/isoc_pulse/__init__.py +159 -0
  105. omniload/src/jira_source/__init__.py +377 -0
  106. omniload/src/jira_source/helpers.py +510 -0
  107. omniload/src/jira_source/settings.py +184 -0
  108. omniload/src/kafka/__init__.py +120 -0
  109. omniload/src/kafka/helpers.py +241 -0
  110. omniload/src/kinesis/__init__.py +153 -0
  111. omniload/src/kinesis/helpers.py +96 -0
  112. omniload/src/klaviyo/__init__.py +237 -0
  113. omniload/src/klaviyo/client.py +212 -0
  114. omniload/src/klaviyo/helpers.py +19 -0
  115. omniload/src/linear/__init__.py +634 -0
  116. omniload/src/linear/helpers.py +111 -0
  117. omniload/src/linkedin_ads/__init__.py +266 -0
  118. omniload/src/linkedin_ads/dimension_time_enum.py +17 -0
  119. omniload/src/linkedin_ads/helpers.py +246 -0
  120. omniload/src/loader.py +69 -0
  121. omniload/src/mailchimp/__init__.py +126 -0
  122. omniload/src/mailchimp/helpers.py +226 -0
  123. omniload/src/mailchimp/settings.py +164 -0
  124. omniload/src/masking.py +344 -0
  125. omniload/src/mixpanel/__init__.py +62 -0
  126. omniload/src/mixpanel/client.py +104 -0
  127. omniload/src/monday/__init__.py +246 -0
  128. omniload/src/monday/helpers.py +392 -0
  129. omniload/src/monday/settings.py +325 -0
  130. omniload/src/mongodb/__init__.py +281 -0
  131. omniload/src/mongodb/helpers.py +975 -0
  132. omniload/src/notion/__init__.py +69 -0
  133. omniload/src/notion/helpers/__init__.py +14 -0
  134. omniload/src/notion/helpers/client.py +178 -0
  135. omniload/src/notion/helpers/database.py +92 -0
  136. omniload/src/notion/settings.py +17 -0
  137. omniload/src/partition.py +32 -0
  138. omniload/src/personio/__init__.py +345 -0
  139. omniload/src/personio/helpers.py +100 -0
  140. omniload/src/phantombuster/__init__.py +65 -0
  141. omniload/src/phantombuster/client.py +87 -0
  142. omniload/src/pinterest/__init__.py +82 -0
  143. omniload/src/pipedrive/__init__.py +212 -0
  144. omniload/src/pipedrive/helpers/__init__.py +37 -0
  145. omniload/src/pipedrive/helpers/custom_fields_munger.py +116 -0
  146. omniload/src/pipedrive/helpers/pages.py +129 -0
  147. omniload/src/pipedrive/settings.py +41 -0
  148. omniload/src/pipedrive/typing.py +17 -0
  149. omniload/src/plusvibeai/__init__.py +335 -0
  150. omniload/src/plusvibeai/helpers.py +544 -0
  151. omniload/src/plusvibeai/settings.py +252 -0
  152. omniload/src/primer/__init__.py +45 -0
  153. omniload/src/primer/helpers.py +79 -0
  154. omniload/src/quickbooks/__init__.py +117 -0
  155. omniload/src/reddit_ads/__init__.py +183 -0
  156. omniload/src/reddit_ads/helpers.py +232 -0
  157. omniload/src/resource.py +40 -0
  158. omniload/src/revenuecat/__init__.py +83 -0
  159. omniload/src/revenuecat/helpers.py +237 -0
  160. omniload/src/salesforce/__init__.py +170 -0
  161. omniload/src/salesforce/helpers.py +78 -0
  162. omniload/src/shopify/__init__.py +1953 -0
  163. omniload/src/shopify/exceptions.py +17 -0
  164. omniload/src/shopify/helpers.py +202 -0
  165. omniload/src/shopify/settings.py +19 -0
  166. omniload/src/slack/__init__.py +290 -0
  167. omniload/src/slack/helpers.py +218 -0
  168. omniload/src/slack/settings.py +36 -0
  169. omniload/src/smartsheets/__init__.py +82 -0
  170. omniload/src/snapchat_ads/__init__.py +455 -0
  171. omniload/src/snapchat_ads/client.py +72 -0
  172. omniload/src/snapchat_ads/helpers.py +630 -0
  173. omniload/src/snapchat_ads/settings.py +130 -0
  174. omniload/src/socrata_source/__init__.py +83 -0
  175. omniload/src/socrata_source/helpers.py +85 -0
  176. omniload/src/socrata_source/settings.py +8 -0
  177. omniload/src/solidgate/__init__.py +219 -0
  178. omniload/src/solidgate/helpers.py +154 -0
  179. omniload/src/sources.py +5408 -0
  180. omniload/src/sql_database/__init__.py +0 -0
  181. omniload/src/sql_database/callbacks.py +66 -0
  182. omniload/src/stripe_analytics/__init__.py +183 -0
  183. omniload/src/stripe_analytics/helpers.py +386 -0
  184. omniload/src/stripe_analytics/settings.py +80 -0
  185. omniload/src/table_definition.py +15 -0
  186. omniload/src/testdata/fakebqcredentials.json +14 -0
  187. omniload/src/tiktok_ads/__init__.py +150 -0
  188. omniload/src/tiktok_ads/tiktok_helpers.py +130 -0
  189. omniload/src/time.py +11 -0
  190. omniload/src/trustpilot/__init__.py +48 -0
  191. omniload/src/trustpilot/client.py +48 -0
  192. omniload/src/version.py +6 -0
  193. omniload/src/wise/__init__.py +68 -0
  194. omniload/src/wise/client.py +63 -0
  195. omniload/src/zendesk/__init__.py +480 -0
  196. omniload/src/zendesk/helpers/__init__.py +39 -0
  197. omniload/src/zendesk/helpers/api_helpers.py +119 -0
  198. omniload/src/zendesk/helpers/credentials.py +68 -0
  199. omniload/src/zendesk/helpers/talk_api.py +132 -0
  200. omniload/src/zendesk/settings.py +71 -0
  201. omniload/src/zoom/__init__.py +99 -0
  202. omniload/src/zoom/helpers.py +102 -0
  203. omniload/testdata/.gitignore +2 -0
  204. omniload/testdata/create_replace.csv +21 -0
  205. omniload/testdata/delete_insert_expected.csv +6 -0
  206. omniload/testdata/delete_insert_part1.csv +5 -0
  207. omniload/testdata/delete_insert_part2.csv +6 -0
  208. omniload/testdata/merge_expected.csv +5 -0
  209. omniload/testdata/merge_part1.csv +4 -0
  210. omniload/testdata/merge_part2.csv +5 -0
  211. omniload/tests/unit/test_smartsheets.py +133 -0
  212. omniload-0.0.0.dev0.dist-info/METADATA +439 -0
  213. omniload-0.0.0.dev0.dist-info/RECORD +218 -0
  214. omniload-0.0.0.dev0.dist-info/WHEEL +4 -0
  215. omniload-0.0.0.dev0.dist-info/entry_points.txt +2 -0
  216. omniload-0.0.0.dev0.dist-info/licenses/LICENSE.Apache-2.0 +201 -0
  217. omniload-0.0.0.dev0.dist-info/licenses/LICENSE.md +21 -0
  218. omniload-0.0.0.dev0.dist-info/licenses/NOTICE +35 -0
@@ -0,0 +1,233 @@
1
+ import json
2
+ from typing import Any, Dict, Iterator, List, Optional
3
+ from urllib.parse import urlparse
4
+
5
+ import dlt
6
+ import pendulum
7
+ import requests
8
+ import tldextract
9
+
10
+ FLUXX_OAUTH_TOKEN_PATH = "/oauth/token"
11
+ FLUXX_API_V2_PATH = "/api/rest/v2"
12
+
13
+
14
+ def _get_base_url(instance: str) -> str:
15
+ """Get the base URL for Fluxx API.
16
+
17
+ If instance has a valid TLD (e.g., 'acme.fluxx.io'), use it as full domain.
18
+ Otherwise, append '.fluxxlabs.com' for backward compatibility.
19
+ Preserves the original scheme (http/https) if provided, defaults to https.
20
+
21
+ Examples:
22
+ - "mycompany" -> "https://mycompany.fluxxlabs.com"
23
+ - "mycompany.preprod" -> "https://mycompany.preprod.fluxxlabs.com"
24
+ - "acme.fluxx.io" -> "https://acme.fluxx.io"
25
+ - "http://acme.fluxx.io" -> "http://acme.fluxx.io"
26
+ """
27
+ parsed = urlparse(instance)
28
+ scheme = parsed.scheme or "https"
29
+ host = parsed.netloc or instance
30
+
31
+ extracted = tldextract.extract(host)
32
+ if extracted.suffix:
33
+ return f"{scheme}://{extracted.fqdn}"
34
+ return f"{scheme}://{host}.fluxxlabs.com"
35
+
36
+
37
+ def get_access_token(instance: str, client_id: str, client_secret: str) -> str:
38
+ """Obtain OAuth access token using client credentials flow."""
39
+ token_url = f"{_get_base_url(instance)}{FLUXX_OAUTH_TOKEN_PATH}"
40
+
41
+ response = requests.post(
42
+ token_url,
43
+ data={
44
+ "grant_type": "client_credentials",
45
+ "client_id": client_id,
46
+ "client_secret": client_secret,
47
+ },
48
+ )
49
+ response.raise_for_status()
50
+
51
+ token_data = response.json()
52
+ return token_data["access_token"]
53
+
54
+
55
+ def fluxx_api_request(
56
+ instance: str,
57
+ access_token: str,
58
+ endpoint: str,
59
+ method: str = "GET",
60
+ params: Optional[Dict[str, Any]] = None,
61
+ data: Optional[Dict[str, Any]] = None,
62
+ ) -> Dict[str, Any]:
63
+ """Make an authenticated request to the Fluxx API."""
64
+ url = f"{_get_base_url(instance)}{FLUXX_API_V2_PATH}/{endpoint}"
65
+
66
+ headers = {
67
+ "Authorization": f"Bearer {access_token}",
68
+ "Content-Type": "application/json",
69
+ }
70
+
71
+ response = requests.request(
72
+ method=method,
73
+ url=url,
74
+ headers=headers,
75
+ params=params,
76
+ json=data,
77
+ )
78
+ response.raise_for_status()
79
+
80
+ if response.text:
81
+ return response.json()
82
+ return {}
83
+
84
+
85
+ def paginate_fluxx_resource(
86
+ instance: str,
87
+ access_token: str,
88
+ endpoint: str,
89
+ params: Optional[Dict[str, Any]] = None,
90
+ page_size: int = 100,
91
+ ) -> Iterator[List[Dict[str, Any]]]:
92
+ """Paginate through a Fluxx API resource."""
93
+ if params is None:
94
+ params = {}
95
+
96
+ page = 1
97
+ params["per_page"] = page_size
98
+
99
+ while True:
100
+ params["page"] = page
101
+
102
+ response = fluxx_api_request(
103
+ instance=instance,
104
+ access_token=access_token,
105
+ endpoint=endpoint,
106
+ params=params,
107
+ )
108
+
109
+ if not response:
110
+ break
111
+
112
+ # Get the first available key from records instead of assuming endpoint name
113
+ records = response["records"]
114
+ if records:
115
+ # Pick the first key available in records
116
+ first_key = next(iter(records))
117
+ items = records[first_key]
118
+ else:
119
+ items = []
120
+
121
+ yield items
122
+
123
+ if response["per_page"] is None or len(items) < response["per_page"]:
124
+ break
125
+
126
+ page += 1
127
+
128
+
129
+ def get_date_range(updated_at, start_date):
130
+ """Extract current start and end dates from incremental state."""
131
+ if updated_at.last_value:
132
+ current_start_date = pendulum.parse(updated_at.last_value)
133
+ else:
134
+ current_start_date = (
135
+ pendulum.parse(start_date)
136
+ if start_date
137
+ else pendulum.now().subtract(days=30)
138
+ )
139
+
140
+ if updated_at.end_value:
141
+ current_end_date = pendulum.parse(updated_at.end_value)
142
+ else:
143
+ current_end_date = pendulum.now(tz="UTC")
144
+
145
+ return current_start_date, current_end_date
146
+
147
+
148
+ def create_dynamic_resource(
149
+ resource_name: str,
150
+ endpoint: str,
151
+ instance: str,
152
+ access_token: str,
153
+ start_date: Optional[pendulum.DateTime] = None,
154
+ end_date: Optional[pendulum.DateTime] = None,
155
+ fields_to_extract: Optional[Dict[str, Any]] = None,
156
+ ):
157
+ """Factory function to create dynamic Fluxx resources."""
158
+
159
+ # Extract column definitions for DLT resource
160
+ columns = {}
161
+ if fields_to_extract:
162
+ for field_name, field_config in fields_to_extract.items():
163
+ data_type = field_config.get("data_type")
164
+ if data_type:
165
+ columns[field_name] = {"data_type": data_type}
166
+
167
+ @dlt.resource(name=resource_name, write_disposition="replace", columns=columns) # type: ignore
168
+ def fluxx_resource() -> Iterator[Dict[str, Any]]:
169
+ params = {}
170
+ if fields_to_extract:
171
+ field_names = list(fields_to_extract.keys())
172
+ params["cols"] = json.dumps(field_names)
173
+
174
+ for page in paginate_fluxx_resource(
175
+ instance=instance,
176
+ access_token=access_token,
177
+ endpoint=endpoint,
178
+ params=params,
179
+ page_size=100,
180
+ ):
181
+ yield [normalize_fluxx_item(item, fields_to_extract) for item in page] # type: ignore
182
+
183
+ return fluxx_resource
184
+
185
+
186
+ def normalize_fluxx_item(
187
+ item: Dict[str, Any], fields_to_extract: Optional[Dict[str, Any]] = None
188
+ ) -> Dict[str, Any]:
189
+ """
190
+ Normalize a Fluxx API response item.
191
+ Handles nested structures and field extraction based on field types.
192
+ Rounds all decimal/float values to 4 decimal places regardless of field type.
193
+ """
194
+ normalized: Dict[str, Any] = {}
195
+
196
+ # If no field mapping provided, just return the item as-is
197
+ if not fields_to_extract:
198
+ return item
199
+
200
+ for field_name, field_config in fields_to_extract.items():
201
+ if field_name in item:
202
+ value = item[field_name]
203
+ field_type = field_config.get("data_type")
204
+
205
+ if isinstance(value, float):
206
+ # Round any numeric value with decimal places
207
+ normalized[field_name] = round(value, 4)
208
+ elif field_type == "json":
209
+ # Handle json fields (arrays/relations)
210
+ if value is None:
211
+ normalized[field_name] = None
212
+ elif value == "":
213
+ normalized[field_name] = None
214
+ elif isinstance(value, (list, dict)):
215
+ normalized[field_name] = value
216
+ else:
217
+ # Single value - wrap in array for json fields
218
+ normalized[field_name] = [value]
219
+ elif field_type in ("date", "timestamp", "datetime", "text"):
220
+ # Handle text/date fields - convert empty strings to None
221
+ if value == "":
222
+ normalized[field_name] = None
223
+ else:
224
+ normalized[field_name] = value
225
+ else:
226
+ # All other field types - pass through as-is
227
+ normalized[field_name] = value
228
+
229
+ # Always include id if present
230
+ if "id" in item:
231
+ normalized["id"] = item["id"]
232
+
233
+ return normalized
@@ -0,0 +1,157 @@
1
+ from typing import Any, Iterator, Optional
2
+
3
+ import dlt
4
+ from dlt.common.pendulum import pendulum
5
+ from dlt.common.time import ensure_pendulum_datetime
6
+ from dlt.common.typing import TAnyDateTime
7
+
8
+ from omniload.src.frankfurter.helpers import get_path_with_retry
9
+
10
+
11
+ @dlt.source(
12
+ name="frankfurter",
13
+ max_table_nesting=0,
14
+ )
15
+ def frankfurter_source(
16
+ start_date: TAnyDateTime,
17
+ end_date: TAnyDateTime | None,
18
+ base_currency: str,
19
+ ) -> Any:
20
+ """
21
+ A dlt source for the frankfurter.dev API. It groups several resources (in this case frankfurter.dev API endpoints) containing
22
+ various types of data: currencies, latest rates, historical rates.
23
+ """
24
+
25
+ @dlt.resource(
26
+ write_disposition="replace",
27
+ )
28
+ def currencies() -> Iterator[dict]:
29
+ """
30
+ Yields each currency as a separate row with two columns: currency_code and currency_name.
31
+ """
32
+ # Retrieve the list of currencies from the API
33
+ currencies_data = get_path_with_retry("currencies")
34
+
35
+ for currency_code, currency_name in currencies_data.items():
36
+ yield {"currency_code": currency_code, "currency_name": currency_name}
37
+
38
+ @dlt.resource(
39
+ write_disposition="merge",
40
+ columns={
41
+ "date": {"data_type": "text"},
42
+ "currency_code": {"data_type": "text"},
43
+ "rate": {"data_type": "double"},
44
+ "base_currency": {"data_type": "text"},
45
+ },
46
+ primary_key=["date", "currency_code", "base_currency"],
47
+ )
48
+ def latest(base_currency: Optional[str] = "") -> Iterator[dict]:
49
+ """
50
+ Fetches the latest exchange rates and yields them as rows.
51
+ """
52
+ # Base URL
53
+ url = "latest?"
54
+
55
+ if base_currency:
56
+ url += f"base={base_currency}"
57
+
58
+ # Fetch data
59
+ data = get_path_with_retry(url)
60
+
61
+ # Extract rates and base currency
62
+ rates = data["rates"]
63
+ date = pendulum.parse(data["date"])
64
+
65
+ # Add the base currency with a rate of 1.0
66
+ yield {
67
+ "date": date,
68
+ "currency_code": base_currency,
69
+ "rate": 1.0,
70
+ "base_currency": base_currency,
71
+ }
72
+
73
+ # Add all currencies and their rates
74
+ for currency_code, rate in rates.items():
75
+ yield {
76
+ "date": date,
77
+ "currency_code": currency_code,
78
+ "rate": rate,
79
+ "base_currency": base_currency,
80
+ }
81
+
82
+ @dlt.resource(
83
+ write_disposition="merge",
84
+ columns={
85
+ "date": {"data_type": "text"},
86
+ "currency_code": {"data_type": "text"},
87
+ "rate": {"data_type": "double"},
88
+ "base_currency": {"data_type": "text"},
89
+ },
90
+ primary_key=("date", "currency_code", "base_currency"),
91
+ )
92
+ def exchange_rates(
93
+ date_time=dlt.sources.incremental(
94
+ "date",
95
+ initial_value=start_date,
96
+ end_value=end_date,
97
+ range_start="closed",
98
+ range_end="closed",
99
+ ),
100
+ ) -> Iterator[dict]:
101
+ """
102
+ Fetches exchange rates for a specified date range.
103
+ If only start_date is provided, fetches data until now.
104
+ If both start_date and end_date are provided, fetches data for each day in the range.
105
+ """
106
+ if date_time.last_value is not None:
107
+ start_date = date_time.last_value
108
+ else:
109
+ start_date = start_date
110
+
111
+ if date_time.end_value is not None:
112
+ end_date = date_time.end_value
113
+ else:
114
+ end_date = pendulum.now()
115
+
116
+ # Ensure start_date.last_value is a pendulum.DateTime object
117
+ start_date_obj = ensure_pendulum_datetime(start_date) # type: ignore
118
+ start_date_str = start_date_obj.format("YYYY-MM-DD")
119
+
120
+ # Ensure end_date is a pendulum.DateTime object
121
+ end_date_obj = ensure_pendulum_datetime(end_date)
122
+ end_date_str = end_date_obj.format("YYYY-MM-DD")
123
+
124
+ # Compose the URL
125
+ url = f"{start_date_str}..{end_date_str}?"
126
+
127
+ if base_currency:
128
+ url += f"base={base_currency}"
129
+
130
+ # Fetch data from the API
131
+ data = get_path_with_retry(url)
132
+
133
+ # Extract base currency and rates from the API response
134
+ rates = data["rates"]
135
+
136
+ # Iterate over the rates dictionary (one entry per date)
137
+ for date, daily_rates in rates.items():
138
+ formatted_date = pendulum.parse(date)
139
+
140
+ # Add the base currency with a rate of 1.0
141
+ yield {
142
+ "date": formatted_date,
143
+ "currency_code": base_currency,
144
+ "rate": 1.0,
145
+ "base_currency": base_currency,
146
+ }
147
+
148
+ # Add all other currencies and their rates
149
+ for currency_code, rate in daily_rates.items():
150
+ yield {
151
+ "date": formatted_date,
152
+ "currency_code": currency_code,
153
+ "rate": rate,
154
+ "base_currency": base_currency,
155
+ }
156
+
157
+ return currencies, latest, exchange_rates
@@ -0,0 +1,48 @@
1
+ from datetime import datetime
2
+
3
+ from dlt.common.pendulum import pendulum
4
+ from dlt.common.typing import StrAny
5
+ from dlt.sources.helpers import requests
6
+
7
+ FRANKFURTER_API_URL = "https://api.frankfurter.dev/v1/"
8
+
9
+
10
+ def get_url_with_retry(url: str) -> StrAny:
11
+ r = requests.get(url, timeout=5)
12
+ return r.json() # type: ignore
13
+
14
+
15
+ def get_path_with_retry(path: str) -> StrAny:
16
+ return get_url_with_retry(f"{FRANKFURTER_API_URL}{path}")
17
+
18
+
19
+ def validate_dates(start_date: datetime, end_date: datetime | None) -> None:
20
+ current_date = pendulum.now()
21
+
22
+ # Check if start_date is in the futurep
23
+ if start_date > current_date:
24
+ raise ValueError("Interval-start cannot be in the future.")
25
+
26
+ # Check if end_date is in the future
27
+ if end_date is not None and end_date > current_date:
28
+ raise ValueError("Interval-end cannot be in the future.")
29
+
30
+ # Check if start_date is before end_date
31
+ if end_date is not None and start_date > end_date:
32
+ raise ValueError("Interval-end cannot be before interval-start.")
33
+
34
+
35
+ def validate_currency(currency_code: str) -> bool:
36
+ url = "https://api.frankfurter.dev/v1/currencies"
37
+
38
+ response = requests.get(url, timeout=5)
39
+ currencies = response.json()
40
+
41
+ if currency_code.upper() in currencies:
42
+ return True
43
+ else:
44
+ supported_currencies = list(currencies.keys())
45
+ print(
46
+ f"Invalid base currency '{currency_code}'. Supported currencies are: {supported_currencies}"
47
+ )
48
+ return False
@@ -0,0 +1,103 @@
1
+ # Copyright 2022-2025 ScaleVector
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """This source uses Freshdesk API and dlt to load data such as Agents, Companies, Tickets
16
+ etc. to the database"""
17
+
18
+ from typing import Any, Dict, Generator, Iterable, List, Optional
19
+
20
+ import dlt
21
+ import pendulum
22
+ from dlt.common.time import ensure_pendulum_datetime
23
+ from dlt.sources import DltResource
24
+
25
+ from .freshdesk_client import FreshdeskClient
26
+ from .settings import DEFAULT_ENDPOINTS
27
+
28
+
29
+ @dlt.source()
30
+ def freshdesk_source(
31
+ domain: str,
32
+ api_secret_key: str,
33
+ start_date: pendulum.DateTime,
34
+ end_date: Optional[pendulum.DateTime] = None,
35
+ per_page: int = 100,
36
+ endpoints: Optional[List[str]] = None,
37
+ query: Optional[str] = None,
38
+ ) -> Iterable[DltResource]:
39
+ """
40
+ Retrieves data from specified Freshdesk API endpoints.
41
+
42
+ This source supports pagination and incremental data loading. It fetches data from a list of
43
+ specified endpoints, or defaults to predefined endpoints in 'settings.py'.
44
+
45
+ Args:
46
+ endpoints: A list of Freshdesk API endpoints to fetch. Deafults to 'settings.py'.
47
+ per_page: The number of items to fetch per page, with a maximum of 100.
48
+ domain: The Freshdesk domain from which to fetch the data. Defaults to 'config.toml'.
49
+ api_secret_key: Freshdesk API key. Defaults to 'secrets.toml'.
50
+
51
+ Yields:
52
+ Iterable[DltResource]: Resources with data updated after the last 'updated_at'
53
+ timestamp for each endpoint.
54
+ """
55
+ # Instantiate FreshdeskClient with the provided domain and API key
56
+ freshdesk = FreshdeskClient(api_key=api_secret_key, domain=domain)
57
+
58
+ def incremental_resource(
59
+ endpoint: str,
60
+ updated_at: Optional[Any] = dlt.sources.incremental(
61
+ "updated_at",
62
+ initial_value=start_date.isoformat(),
63
+ end_value=end_date.isoformat() if end_date else None,
64
+ range_start="closed",
65
+ range_end="closed",
66
+ ),
67
+ ) -> Generator[Dict[Any, Any], Any, None]:
68
+ """
69
+ Fetches and yields paginated data from a specified API endpoint.
70
+ Each page of data is fetched based on the `updated_at` timestamp
71
+ to ensure incremental loading.
72
+ """
73
+
74
+ if updated_at.last_value is not None:
75
+ start_date = ensure_pendulum_datetime(updated_at.last_value)
76
+ else:
77
+ start_date = start_date
78
+
79
+ if updated_at.end_value is not None:
80
+ end_date = ensure_pendulum_datetime(updated_at.end_value)
81
+ else:
82
+ end_date = pendulum.now(tz="UTC")
83
+
84
+ # Use the FreshdeskClient instance to fetch paginated responses
85
+ yield from freshdesk.paginated_response(
86
+ endpoint=endpoint,
87
+ per_page=per_page,
88
+ start_date=start_date,
89
+ end_date=end_date,
90
+ query=query,
91
+ )
92
+
93
+ # Set default endpoints if not provided
94
+ endpoints = endpoints or DEFAULT_ENDPOINTS
95
+
96
+ # For each endpoint, create and yield a DLT resource
97
+ for endpoint in endpoints:
98
+ yield dlt.resource(
99
+ incremental_resource,
100
+ name=endpoint,
101
+ write_disposition="merge",
102
+ primary_key="id",
103
+ )(endpoint=endpoint)
@@ -0,0 +1,151 @@
1
+ # Copyright 2022-2025 ScaleVector
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Freshdesk Client for making authenticated requests"""
16
+
17
+ import logging
18
+ import time
19
+ from typing import Any, Dict, Iterable, Optional
20
+
21
+ import pendulum
22
+ from dlt.common.typing import TDataItem
23
+ from dlt.sources.helpers import requests
24
+
25
+ from omniload.src.errors import HTTPError
26
+
27
+ TICKETS_QUERY_MAX_PAGE = 10
28
+
29
+
30
+ class FreshdeskClient:
31
+ """
32
+ Client for making authenticated requests to the Freshdesk API. It incorporates API requests with
33
+ rate limit and pagination.
34
+
35
+ Attributes:
36
+ api_key (str): The API key used for authenticating requests to the Freshdesk API.
37
+ domain (str): The Freshdesk domain specific to the user, used in constructing the base URL.
38
+ base_url (str): The base URL constructed from the domain, targeting the Freshdesk API v2.
39
+ """
40
+
41
+ def __init__(self, api_key: str, domain: str):
42
+ # Initialize the FreshdeskClient instance with API key and domain.
43
+ # The API key is used for authentication with the Freshdesk API.
44
+ # The domain specifies the unique Freshdesk domain of the user.
45
+
46
+ # Store the API key provided during initialization.
47
+ self.api_key = api_key
48
+ # Store the Freshdesk domain provided during initialization.
49
+ self.domain = domain
50
+
51
+ # Construct the base URL for the API requests.
52
+ # This URL is formed by appending the domain to the standard Freshdesk API base URL format.
53
+ # All API requests will use this base URL as their starting point.
54
+ self.base_url = f"https://{domain}.freshdesk.com/api/v2"
55
+
56
+ def _request_with_rate_limit(self, url: str, **kwargs: Any) -> requests.Response:
57
+ """
58
+ Handles rate limits in HTTP requests and ensures
59
+ that the client doesn't exceed the limit set by the server.
60
+ """
61
+
62
+ while True:
63
+ try:
64
+ response = requests.get(url, **kwargs, auth=(self.api_key, "X"))
65
+ response.raise_for_status()
66
+
67
+ return response
68
+ except requests.HTTPError as e:
69
+ if e.response.status_code == 429:
70
+ # Get the 'Retry-After' header to know how long to wait
71
+ # Fallback to 60 seconds if header is missing
72
+ seconds_to_wait = int(e.response.headers.get("Retry-After", 60))
73
+ # Log a warning message
74
+ logging.warning(
75
+ "Rate limited. Waiting to retry after: %s secs", seconds_to_wait
76
+ )
77
+
78
+ # Wait for the specified number of seconds before retrying
79
+ time.sleep(seconds_to_wait)
80
+ else:
81
+ # If the error is not a rate limit (429), raise the exception to be
82
+ # handled elsewhere or stop execution
83
+ raise HTTPError(e) from e
84
+
85
+ def paginated_response(
86
+ self,
87
+ endpoint: str,
88
+ per_page: int,
89
+ start_date: pendulum.DateTime,
90
+ end_date: pendulum.DateTime,
91
+ query: Optional[str] = None,
92
+ ) -> Iterable[TDataItem]:
93
+ """
94
+ Fetches a paginated response from a specified endpoint.
95
+
96
+ This method will continuously fetch data from the given endpoint,
97
+ page by page, until no more data is available or until it reaches data
98
+ updated at the specified timestamp.
99
+ """
100
+ page = 1
101
+ if query is not None:
102
+ query = query.replace('"', "").strip()
103
+
104
+ is_tickets_query = query and endpoint == "tickets"
105
+
106
+ while True:
107
+ # Construct the URL for the specific endpoint
108
+ url = f"{self.base_url}/{endpoint}"
109
+
110
+ params: Dict[str, Any] = {"per_page": per_page, "page": page}
111
+
112
+ # Implement date range splitting logic here, if applicable
113
+ if endpoint in ["tickets", "contacts"]:
114
+ param_key = (
115
+ "updated_since" if endpoint == "tickets" else "_updated_since"
116
+ )
117
+
118
+ params[param_key] = start_date.to_iso8601_string()
119
+
120
+ if is_tickets_query:
121
+ url = f"{self.base_url}/search/tickets"
122
+ params = {
123
+ "query": f'"{query}"',
124
+ "page": page,
125
+ }
126
+
127
+ # Handle requests with rate-limiting
128
+ # A maximum of 300 pages (30000 tickets) will be returned.
129
+ response = self._request_with_rate_limit(url, params=params)
130
+ data = response.json()
131
+
132
+ if query and endpoint == "tickets":
133
+ data = data["results"]
134
+
135
+ if not data:
136
+ break # Stop if no data or max page limit reached
137
+
138
+ filtered_data = [
139
+ item
140
+ for item in data
141
+ if "updated_at" in item
142
+ and pendulum.parse(item["updated_at"]) <= end_date
143
+ ]
144
+ if not filtered_data:
145
+ break
146
+ yield filtered_data
147
+ page += 1
148
+
149
+ # https://developers.freshdesk.com/api/#filter_tickets
150
+ if is_tickets_query and page > TICKETS_QUERY_MAX_PAGE:
151
+ break