ingestr 0.13.85__py3-none-any.whl → 0.13.86__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/src/buildinfo.py +1 -1
- ingestr/src/factory.py +4 -0
- ingestr/src/fluxx/__init__.py +5725 -0
- ingestr/src/fluxx/helpers.py +216 -0
- ingestr/src/frankfurter/__init__.py +121 -123
- ingestr/src/frankfurter/helpers.py +4 -4
- ingestr/src/linear/__init__.py +10 -9
- ingestr/src/linear/helpers.py +20 -10
- ingestr/src/revenuecat/__init__.py +103 -0
- ingestr/src/revenuecat/helpers.py +262 -0
- ingestr/src/sources.py +132 -13
- ingestr/src/stripe_analytics/__init__.py +1 -18
- {ingestr-0.13.85.dist-info → ingestr-0.13.86.dist-info}/METADATA +1 -1
- {ingestr-0.13.85.dist-info → ingestr-0.13.86.dist-info}/RECORD +17 -13
- {ingestr-0.13.85.dist-info → ingestr-0.13.86.dist-info}/WHEEL +0 -0
- {ingestr-0.13.85.dist-info → ingestr-0.13.86.dist-info}/entry_points.txt +0 -0
- {ingestr-0.13.85.dist-info → ingestr-0.13.86.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
3
|
+
|
|
4
|
+
import dlt
|
|
5
|
+
import pendulum
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
FLUXX_API_BASE = "https://{instance}.fluxxlabs.com"
|
|
9
|
+
FLUXX_OAUTH_TOKEN_PATH = "/oauth/token"
|
|
10
|
+
FLUXX_API_V2_PATH = "/api/rest/v2"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_access_token(instance: str, client_id: str, client_secret: str) -> str:
|
|
14
|
+
"""Obtain OAuth access token using client credentials flow."""
|
|
15
|
+
token_url = f"{FLUXX_API_BASE.format(instance=instance)}{FLUXX_OAUTH_TOKEN_PATH}"
|
|
16
|
+
|
|
17
|
+
response = requests.post(
|
|
18
|
+
token_url,
|
|
19
|
+
data={
|
|
20
|
+
"grant_type": "client_credentials",
|
|
21
|
+
"client_id": client_id,
|
|
22
|
+
"client_secret": client_secret,
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
response.raise_for_status()
|
|
26
|
+
|
|
27
|
+
token_data = response.json()
|
|
28
|
+
return token_data["access_token"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fluxx_api_request(
|
|
32
|
+
instance: str,
|
|
33
|
+
access_token: str,
|
|
34
|
+
endpoint: str,
|
|
35
|
+
method: str = "GET",
|
|
36
|
+
params: Optional[Dict[str, Any]] = None,
|
|
37
|
+
data: Optional[Dict[str, Any]] = None,
|
|
38
|
+
) -> Dict[str, Any]:
|
|
39
|
+
"""Make an authenticated request to the Fluxx API."""
|
|
40
|
+
url = f"{FLUXX_API_BASE.format(instance=instance)}{FLUXX_API_V2_PATH}/{endpoint}"
|
|
41
|
+
|
|
42
|
+
headers = {
|
|
43
|
+
"Authorization": f"Bearer {access_token}",
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
}
|
|
46
|
+
# print(f"Making request to Fluxx API:")
|
|
47
|
+
# print(f" Method: {method}")
|
|
48
|
+
# print(f" URL: {url}")
|
|
49
|
+
# print(f" Headers: {headers}")
|
|
50
|
+
# print(f" Params: {params}")
|
|
51
|
+
# print(f" Data: {data}")
|
|
52
|
+
|
|
53
|
+
response = requests.request(
|
|
54
|
+
method=method,
|
|
55
|
+
url=url,
|
|
56
|
+
headers=headers,
|
|
57
|
+
params=params,
|
|
58
|
+
json=data,
|
|
59
|
+
)
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
|
|
62
|
+
if response.text:
|
|
63
|
+
return response.json()
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def paginate_fluxx_resource(
|
|
68
|
+
instance: str,
|
|
69
|
+
access_token: str,
|
|
70
|
+
endpoint: str,
|
|
71
|
+
params: Optional[Dict[str, Any]] = None,
|
|
72
|
+
page_size: int = 100,
|
|
73
|
+
) -> Iterator[List[Dict[str, Any]]]:
|
|
74
|
+
"""Paginate through a Fluxx API resource."""
|
|
75
|
+
if params is None:
|
|
76
|
+
params = {}
|
|
77
|
+
|
|
78
|
+
page = 1
|
|
79
|
+
params["per_page"] = page_size
|
|
80
|
+
|
|
81
|
+
while True:
|
|
82
|
+
params["page"] = page
|
|
83
|
+
|
|
84
|
+
response = fluxx_api_request(
|
|
85
|
+
instance=instance,
|
|
86
|
+
access_token=access_token,
|
|
87
|
+
endpoint=endpoint,
|
|
88
|
+
params=params,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
print("resssponse", response)
|
|
92
|
+
if not response:
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
# Get the first available key from records instead of assuming endpoint name
|
|
96
|
+
records = response["records"]
|
|
97
|
+
if records:
|
|
98
|
+
# Pick the first key available in records
|
|
99
|
+
first_key = next(iter(records))
|
|
100
|
+
items = records[first_key]
|
|
101
|
+
else:
|
|
102
|
+
items = []
|
|
103
|
+
|
|
104
|
+
yield items
|
|
105
|
+
|
|
106
|
+
if response["per_page"] is None or len(items) < response["per_page"]:
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
page += 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_date_range(updated_at, start_date):
|
|
113
|
+
"""Extract current start and end dates from incremental state."""
|
|
114
|
+
if updated_at.last_value:
|
|
115
|
+
current_start_date = pendulum.parse(updated_at.last_value)
|
|
116
|
+
else:
|
|
117
|
+
current_start_date = (
|
|
118
|
+
pendulum.parse(start_date)
|
|
119
|
+
if start_date
|
|
120
|
+
else pendulum.now().subtract(days=30)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if updated_at.end_value:
|
|
124
|
+
current_end_date = pendulum.parse(updated_at.end_value)
|
|
125
|
+
else:
|
|
126
|
+
current_end_date = pendulum.now(tz="UTC")
|
|
127
|
+
|
|
128
|
+
return current_start_date, current_end_date
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def create_dynamic_resource(
|
|
132
|
+
resource_name: str,
|
|
133
|
+
endpoint: str,
|
|
134
|
+
instance: str,
|
|
135
|
+
access_token: str,
|
|
136
|
+
start_date: Optional[pendulum.DateTime] = None,
|
|
137
|
+
end_date: Optional[pendulum.DateTime] = None,
|
|
138
|
+
fields_to_extract: Optional[Dict[str, Any]] = None,
|
|
139
|
+
):
|
|
140
|
+
"""Factory function to create dynamic Fluxx resources."""
|
|
141
|
+
|
|
142
|
+
# Extract column definitions for DLT resource
|
|
143
|
+
columns = {}
|
|
144
|
+
if fields_to_extract:
|
|
145
|
+
for field_name, field_config in fields_to_extract.items():
|
|
146
|
+
data_type = field_config.get("data_type")
|
|
147
|
+
if data_type:
|
|
148
|
+
columns[field_name] = {"data_type": data_type}
|
|
149
|
+
|
|
150
|
+
@dlt.resource(name=resource_name, write_disposition="replace", columns=columns) # type: ignore
|
|
151
|
+
def fluxx_resource() -> Iterator[Dict[str, Any]]:
|
|
152
|
+
params = {}
|
|
153
|
+
if fields_to_extract:
|
|
154
|
+
field_names = list(fields_to_extract.keys())
|
|
155
|
+
params["cols"] = json.dumps(field_names)
|
|
156
|
+
|
|
157
|
+
for page in paginate_fluxx_resource(
|
|
158
|
+
instance=instance,
|
|
159
|
+
access_token=access_token,
|
|
160
|
+
endpoint=endpoint,
|
|
161
|
+
params=params,
|
|
162
|
+
page_size=100,
|
|
163
|
+
):
|
|
164
|
+
yield [normalize_fluxx_item(item, fields_to_extract) for item in page] # type: ignore
|
|
165
|
+
|
|
166
|
+
return fluxx_resource
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def normalize_fluxx_item(
|
|
170
|
+
item: Dict[str, Any], fields_to_extract: Optional[Dict[str, Any]] = None
|
|
171
|
+
) -> Dict[str, Any]:
|
|
172
|
+
"""
|
|
173
|
+
Normalize a Fluxx API response item.
|
|
174
|
+
Handles nested structures and field extraction based on field types.
|
|
175
|
+
Rounds all decimal/float values to 4 decimal places regardless of field type.
|
|
176
|
+
"""
|
|
177
|
+
normalized: Dict[str, Any] = {}
|
|
178
|
+
|
|
179
|
+
# If no field mapping provided, just return the item as-is
|
|
180
|
+
if not fields_to_extract:
|
|
181
|
+
return item
|
|
182
|
+
|
|
183
|
+
for field_name, field_config in fields_to_extract.items():
|
|
184
|
+
if field_name in item:
|
|
185
|
+
value = item[field_name]
|
|
186
|
+
field_type = field_config.get("data_type")
|
|
187
|
+
|
|
188
|
+
if isinstance(value, float):
|
|
189
|
+
# Round any numeric value with decimal places
|
|
190
|
+
normalized[field_name] = round(value, 4)
|
|
191
|
+
elif field_type == "json":
|
|
192
|
+
# Handle json fields (arrays/relations)
|
|
193
|
+
if value is None:
|
|
194
|
+
normalized[field_name] = None
|
|
195
|
+
elif value == "":
|
|
196
|
+
normalized[field_name] = None
|
|
197
|
+
elif isinstance(value, (list, dict)):
|
|
198
|
+
normalized[field_name] = value
|
|
199
|
+
else:
|
|
200
|
+
# Single value - wrap in array for json fields
|
|
201
|
+
normalized[field_name] = [value]
|
|
202
|
+
elif field_type in ("date", "timestamp", "datetime", "text"):
|
|
203
|
+
# Handle text/date fields - convert empty strings to None
|
|
204
|
+
if value == "":
|
|
205
|
+
normalized[field_name] = None
|
|
206
|
+
else:
|
|
207
|
+
normalized[field_name] = value
|
|
208
|
+
else:
|
|
209
|
+
# All other field types - pass through as-is
|
|
210
|
+
normalized[field_name] = value
|
|
211
|
+
|
|
212
|
+
# Always include id if present
|
|
213
|
+
if "id" in item:
|
|
214
|
+
normalized["id"] = item["id"]
|
|
215
|
+
|
|
216
|
+
return normalized
|
|
@@ -14,150 +14,148 @@ from ingestr.src.frankfurter.helpers import get_path_with_retry
|
|
|
14
14
|
)
|
|
15
15
|
def frankfurter_source(
|
|
16
16
|
start_date: TAnyDateTime,
|
|
17
|
-
end_date: TAnyDateTime,
|
|
17
|
+
end_date: TAnyDateTime|None,
|
|
18
18
|
base_currency: str,
|
|
19
19
|
) -> Any:
|
|
20
20
|
"""
|
|
21
21
|
A dlt source for the frankfurter.dev API. It groups several resources (in this case frankfurter.dev API endpoints) containing
|
|
22
22
|
various types of data: currencies, latest rates, historical rates.
|
|
23
23
|
"""
|
|
24
|
-
|
|
25
|
-
"date",
|
|
26
|
-
initial_value=start_date,
|
|
27
|
-
end_value=end_date,
|
|
28
|
-
range_start="closed",
|
|
29
|
-
range_end="closed",
|
|
30
|
-
)
|
|
24
|
+
|
|
31
25
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
latest(base_currency=base_currency),
|
|
35
|
-
exchange_rates(
|
|
36
|
-
start_date=date_time, end_date=end_date, base_currency=base_currency
|
|
37
|
-
),
|
|
26
|
+
@dlt.resource(
|
|
27
|
+
write_disposition="replace",
|
|
38
28
|
)
|
|
29
|
+
def currencies() -> Iterator[dict]:
|
|
30
|
+
"""
|
|
31
|
+
Yields each currency as a separate row with two columns: currency_code and currency_name.
|
|
32
|
+
"""
|
|
33
|
+
# Retrieve the list of currencies from the API
|
|
34
|
+
currencies_data = get_path_with_retry("currencies")
|
|
35
|
+
|
|
36
|
+
for currency_code, currency_name in currencies_data.items():
|
|
37
|
+
yield {"currency_code": currency_code, "currency_name": currency_name}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dlt.resource(
|
|
41
|
+
write_disposition="merge",
|
|
42
|
+
columns={
|
|
43
|
+
"date": {"data_type": "text"},
|
|
44
|
+
"currency_code": {"data_type": "text"},
|
|
45
|
+
"rate": {"data_type": "double"},
|
|
46
|
+
"base_currency": {"data_type": "text"},
|
|
47
|
+
},
|
|
48
|
+
primary_key=["date", "currency_code", "base_currency"],
|
|
49
|
+
)
|
|
50
|
+
def latest(base_currency: Optional[str] = "") -> Iterator[dict]:
|
|
51
|
+
"""
|
|
52
|
+
Fetches the latest exchange rates and yields them as rows.
|
|
53
|
+
"""
|
|
54
|
+
# Base URL
|
|
55
|
+
url = "latest?"
|
|
39
56
|
|
|
57
|
+
if base_currency:
|
|
58
|
+
url += f"base={base_currency}"
|
|
40
59
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
columns={
|
|
44
|
-
"currency_code": {"data_type": "text"},
|
|
45
|
-
"currency_name": {"data_type": "text"},
|
|
46
|
-
},
|
|
47
|
-
)
|
|
48
|
-
def currencies() -> Iterator[dict]:
|
|
49
|
-
"""
|
|
50
|
-
Yields each currency as a separate row with two columns: currency_code and currency_name.
|
|
51
|
-
"""
|
|
52
|
-
# Retrieve the list of currencies from the API
|
|
53
|
-
currencies_data = get_path_with_retry("currencies")
|
|
54
|
-
|
|
55
|
-
for currency_code, currency_name in currencies_data.items():
|
|
56
|
-
yield {"currency_code": currency_code, "currency_name": currency_name}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@dlt.resource(
|
|
60
|
-
write_disposition="merge",
|
|
61
|
-
columns={
|
|
62
|
-
"date": {"data_type": "text"},
|
|
63
|
-
"currency_code": {"data_type": "text"},
|
|
64
|
-
"rate": {"data_type": "double"},
|
|
65
|
-
"base_currency": {"data_type": "text"},
|
|
66
|
-
},
|
|
67
|
-
primary_key=["date", "currency_code", "base_currency"],
|
|
68
|
-
)
|
|
69
|
-
def latest(base_currency: Optional[str] = "") -> Iterator[dict]:
|
|
70
|
-
"""
|
|
71
|
-
Fetches the latest exchange rates and yields them as rows.
|
|
72
|
-
"""
|
|
73
|
-
# Base URL
|
|
74
|
-
url = "latest?"
|
|
75
|
-
|
|
76
|
-
if base_currency:
|
|
77
|
-
url += f"base={base_currency}"
|
|
78
|
-
|
|
79
|
-
# Fetch data
|
|
80
|
-
data = get_path_with_retry(url)
|
|
81
|
-
|
|
82
|
-
# Extract rates and base currency
|
|
83
|
-
rates = data["rates"]
|
|
84
|
-
date = pendulum.parse(data["date"])
|
|
85
|
-
|
|
86
|
-
# Add the base currency with a rate of 1.0
|
|
87
|
-
yield {
|
|
88
|
-
"date": date,
|
|
89
|
-
"currency_code": base_currency,
|
|
90
|
-
"rate": 1.0,
|
|
91
|
-
"base_currency": base_currency,
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
# Add all currencies and their rates
|
|
95
|
-
for currency_code, rate in rates.items():
|
|
96
|
-
yield {
|
|
97
|
-
"date": date,
|
|
98
|
-
"currency_code": currency_code,
|
|
99
|
-
"rate": rate,
|
|
100
|
-
"base_currency": base_currency,
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
@dlt.resource(
|
|
105
|
-
write_disposition="merge",
|
|
106
|
-
columns={
|
|
107
|
-
"date": {"data_type": "text"},
|
|
108
|
-
"currency_code": {"data_type": "text"},
|
|
109
|
-
"rate": {"data_type": "double"},
|
|
110
|
-
"base_currency": {"data_type": "text"},
|
|
111
|
-
},
|
|
112
|
-
primary_key=("date", "currency_code", "base_currency"),
|
|
113
|
-
)
|
|
114
|
-
def exchange_rates(
|
|
115
|
-
end_date: TAnyDateTime,
|
|
116
|
-
start_date: dlt.sources.incremental[TAnyDateTime] = dlt.sources.incremental("date"),
|
|
117
|
-
base_currency: Optional[str] = "",
|
|
118
|
-
) -> Iterator[dict]:
|
|
119
|
-
"""
|
|
120
|
-
Fetches exchange rates for a specified date range.
|
|
121
|
-
If only start_date is provided, fetches data until now.
|
|
122
|
-
If both start_date and end_date are provided, fetches data for each day in the range.
|
|
123
|
-
"""
|
|
124
|
-
# Ensure start_date.last_value is a pendulum.DateTime object
|
|
125
|
-
start_date_obj = ensure_pendulum_datetime(start_date.last_value) # type: ignore
|
|
126
|
-
start_date_str = start_date_obj.format("YYYY-MM-DD")
|
|
127
|
-
|
|
128
|
-
# Ensure end_date is a pendulum.DateTime object
|
|
129
|
-
end_date_obj = ensure_pendulum_datetime(end_date)
|
|
130
|
-
end_date_str = end_date_obj.format("YYYY-MM-DD")
|
|
131
|
-
|
|
132
|
-
# Compose the URL
|
|
133
|
-
url = f"{start_date_str}..{end_date_str}?"
|
|
134
|
-
|
|
135
|
-
if base_currency:
|
|
136
|
-
url += f"base={base_currency}"
|
|
137
|
-
|
|
138
|
-
# Fetch data from the API
|
|
139
|
-
data = get_path_with_retry(url)
|
|
140
|
-
|
|
141
|
-
# Extract base currency and rates from the API response
|
|
142
|
-
rates = data["rates"]
|
|
60
|
+
# Fetch data
|
|
61
|
+
data = get_path_with_retry(url)
|
|
143
62
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
63
|
+
# Extract rates and base currency
|
|
64
|
+
rates = data["rates"]
|
|
65
|
+
date = pendulum.parse(data["date"])
|
|
147
66
|
|
|
148
67
|
# Add the base currency with a rate of 1.0
|
|
149
68
|
yield {
|
|
150
|
-
"date":
|
|
69
|
+
"date": date,
|
|
151
70
|
"currency_code": base_currency,
|
|
152
71
|
"rate": 1.0,
|
|
153
72
|
"base_currency": base_currency,
|
|
154
73
|
}
|
|
155
74
|
|
|
156
|
-
# Add all
|
|
157
|
-
for currency_code, rate in
|
|
75
|
+
# Add all currencies and their rates
|
|
76
|
+
for currency_code, rate in rates.items():
|
|
158
77
|
yield {
|
|
159
|
-
"date":
|
|
78
|
+
"date": date,
|
|
160
79
|
"currency_code": currency_code,
|
|
161
80
|
"rate": rate,
|
|
162
81
|
"base_currency": base_currency,
|
|
163
82
|
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dlt.resource(
|
|
86
|
+
write_disposition="merge",
|
|
87
|
+
columns={
|
|
88
|
+
"date": {"data_type": "text"},
|
|
89
|
+
"currency_code": {"data_type": "text"},
|
|
90
|
+
"rate": {"data_type": "double"},
|
|
91
|
+
"base_currency": {"data_type": "text"},
|
|
92
|
+
},
|
|
93
|
+
primary_key=("date", "currency_code", "base_currency"),
|
|
94
|
+
)
|
|
95
|
+
def exchange_rates(
|
|
96
|
+
date_time = dlt.sources.incremental(
|
|
97
|
+
"date",
|
|
98
|
+
initial_value=start_date,
|
|
99
|
+
end_value=end_date,
|
|
100
|
+
range_start="closed",
|
|
101
|
+
range_end="closed",
|
|
102
|
+
)
|
|
103
|
+
) -> Iterator[dict]:
|
|
104
|
+
"""
|
|
105
|
+
Fetches exchange rates for a specified date range.
|
|
106
|
+
If only start_date is provided, fetches data until now.
|
|
107
|
+
If both start_date and end_date are provided, fetches data for each day in the range.
|
|
108
|
+
"""
|
|
109
|
+
if date_time.last_value is not None:
|
|
110
|
+
start_date = date_time.last_value
|
|
111
|
+
else:
|
|
112
|
+
start_date = start_date
|
|
113
|
+
|
|
114
|
+
if date_time.end_value is not None:
|
|
115
|
+
end_date = date_time.end_value
|
|
116
|
+
else:
|
|
117
|
+
end_date = pendulum.now()
|
|
118
|
+
|
|
119
|
+
# Ensure start_date.last_value is a pendulum.DateTime object
|
|
120
|
+
start_date_obj = ensure_pendulum_datetime(start_date) # type: ignore
|
|
121
|
+
start_date_str = start_date_obj.format("YYYY-MM-DD")
|
|
122
|
+
|
|
123
|
+
# Ensure end_date is a pendulum.DateTime object
|
|
124
|
+
end_date_obj = ensure_pendulum_datetime(end_date)
|
|
125
|
+
end_date_str = end_date_obj.format("YYYY-MM-DD")
|
|
126
|
+
|
|
127
|
+
# Compose the URL
|
|
128
|
+
url = f"{start_date_str}..{end_date_str}?"
|
|
129
|
+
|
|
130
|
+
if base_currency:
|
|
131
|
+
url += f"base={base_currency}"
|
|
132
|
+
|
|
133
|
+
# Fetch data from the API
|
|
134
|
+
data = get_path_with_retry(url)
|
|
135
|
+
|
|
136
|
+
# Extract base currency and rates from the API response
|
|
137
|
+
rates = data["rates"]
|
|
138
|
+
|
|
139
|
+
# Iterate over the rates dictionary (one entry per date)
|
|
140
|
+
for date, daily_rates in rates.items():
|
|
141
|
+
formatted_date = pendulum.parse(date)
|
|
142
|
+
|
|
143
|
+
# Add the base currency with a rate of 1.0
|
|
144
|
+
yield {
|
|
145
|
+
"date": formatted_date,
|
|
146
|
+
"currency_code": base_currency,
|
|
147
|
+
"rate": 1.0,
|
|
148
|
+
"base_currency": base_currency,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# Add all other currencies and their rates
|
|
152
|
+
for currency_code, rate in daily_rates.items():
|
|
153
|
+
yield {
|
|
154
|
+
"date": formatted_date,
|
|
155
|
+
"currency_code": currency_code,
|
|
156
|
+
"rate": rate,
|
|
157
|
+
"base_currency": base_currency,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return currencies, latest, exchange_rates
|
|
161
|
+
|
|
@@ -16,19 +16,19 @@ def get_path_with_retry(path: str) -> StrAny:
|
|
|
16
16
|
return get_url_with_retry(f"{FRANKFURTER_API_URL}{path}")
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def validate_dates(start_date: datetime, end_date: datetime) -> None:
|
|
19
|
+
def validate_dates(start_date: datetime, end_date: datetime|None) -> None:
|
|
20
20
|
current_date = pendulum.now()
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
# Check if start_date is in the futurep
|
|
23
23
|
if start_date > current_date:
|
|
24
24
|
raise ValueError("Interval-start cannot be in the future.")
|
|
25
25
|
|
|
26
26
|
# Check if end_date is in the future
|
|
27
|
-
if end_date > current_date:
|
|
27
|
+
if end_date is not None and end_date > current_date:
|
|
28
28
|
raise ValueError("Interval-end cannot be in the future.")
|
|
29
29
|
|
|
30
30
|
# Check if start_date is before end_date
|
|
31
|
-
if start_date > end_date:
|
|
31
|
+
if end_date is not None and start_date > end_date:
|
|
32
32
|
raise ValueError("Interval-end cannot be before interval-start.")
|
|
33
33
|
|
|
34
34
|
|
ingestr/src/linear/__init__.py
CHANGED
|
@@ -2,10 +2,13 @@ from typing import Any, Dict, Iterable, Iterator
|
|
|
2
2
|
|
|
3
3
|
import dlt
|
|
4
4
|
import pendulum
|
|
5
|
-
import requests
|
|
6
|
-
|
|
7
|
-
from .helpers import _graphql, normalize_dictionaries, _get_date_range, _create_paginated_resource
|
|
8
5
|
|
|
6
|
+
from .helpers import (
|
|
7
|
+
_create_paginated_resource,
|
|
8
|
+
_get_date_range,
|
|
9
|
+
_graphql,
|
|
10
|
+
normalize_dictionaries,
|
|
11
|
+
)
|
|
9
12
|
|
|
10
13
|
ISSUES_QUERY = """
|
|
11
14
|
query Issues($cursor: String) {
|
|
@@ -249,7 +252,6 @@ query Initiatives($cursor: String) {
|
|
|
249
252
|
"""
|
|
250
253
|
|
|
251
254
|
|
|
252
|
-
|
|
253
255
|
INITIATIVE_TO_PROJECTS_QUERY = """
|
|
254
256
|
query InitiativeToProjects($cursor: String) {
|
|
255
257
|
initiativeToProjects(first: 50, after: $cursor) {
|
|
@@ -406,7 +408,6 @@ query ProjectUpdates($cursor: String) {
|
|
|
406
408
|
"""
|
|
407
409
|
|
|
408
410
|
|
|
409
|
-
|
|
410
411
|
TEAM_MEMBERSHIPS_QUERY = """
|
|
411
412
|
query TeamMemberships($cursor: String) {
|
|
412
413
|
teamMemberships(first: 50, after: $cursor) {
|
|
@@ -594,14 +595,12 @@ PAGINATED_RESOURCES = [
|
|
|
594
595
|
]
|
|
595
596
|
|
|
596
597
|
|
|
597
|
-
|
|
598
598
|
@dlt.source(name="linear", max_table_nesting=0)
|
|
599
599
|
def linear_source(
|
|
600
600
|
api_key: str,
|
|
601
601
|
start_date: pendulum.DateTime,
|
|
602
602
|
end_date: pendulum.DateTime | None = None,
|
|
603
603
|
) -> Iterable[dlt.sources.DltResource]:
|
|
604
|
-
|
|
605
604
|
@dlt.resource(name="organization", primary_key="id", write_disposition="merge")
|
|
606
605
|
def organization(
|
|
607
606
|
updated_at: dlt.sources.incremental[str] = dlt.sources.incremental(
|
|
@@ -623,10 +622,12 @@ def linear_source(
|
|
|
623
622
|
|
|
624
623
|
# Create paginated resources dynamically
|
|
625
624
|
paginated_resources = [
|
|
626
|
-
_create_paginated_resource(
|
|
625
|
+
_create_paginated_resource(
|
|
626
|
+
resource_name, query, query_field, api_key, start_date, end_date
|
|
627
|
+
)
|
|
627
628
|
for resource_name, query, query_field in PAGINATED_RESOURCES
|
|
628
629
|
]
|
|
629
|
-
|
|
630
|
+
|
|
630
631
|
return [
|
|
631
632
|
*paginated_resources,
|
|
632
633
|
organization,
|
ingestr/src/linear/helpers.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from typing import Any, Dict, Iterator, Optional
|
|
2
2
|
|
|
3
|
-
import requests
|
|
4
|
-
import pendulum
|
|
5
3
|
import dlt
|
|
4
|
+
import pendulum
|
|
5
|
+
import requests
|
|
6
6
|
|
|
7
7
|
LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql"
|
|
8
8
|
|
|
@@ -34,8 +34,6 @@ def _paginate(api_key: str, query: str, root: str) -> Iterator[Dict[str, Any]]:
|
|
|
34
34
|
cursor = data["pageInfo"]["endCursor"]
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
37
|
def _get_date_range(updated_at, start_date):
|
|
40
38
|
"""Extract current start and end dates from incremental state."""
|
|
41
39
|
if updated_at.last_value:
|
|
@@ -47,11 +45,13 @@ def _get_date_range(updated_at, start_date):
|
|
|
47
45
|
current_end_date = pendulum.parse(updated_at.end_value)
|
|
48
46
|
else:
|
|
49
47
|
current_end_date = pendulum.now(tz="UTC")
|
|
50
|
-
|
|
48
|
+
|
|
51
49
|
return current_start_date, current_end_date
|
|
52
50
|
|
|
53
51
|
|
|
54
|
-
def _paginated_resource(
|
|
52
|
+
def _paginated_resource(
|
|
53
|
+
api_key: str, query: str, query_field: str, updated_at, start_date
|
|
54
|
+
) -> Iterator[Dict[str, Any]]:
|
|
55
55
|
"""Helper function for paginated resources with date filtering."""
|
|
56
56
|
current_start_date, current_end_date = _get_date_range(updated_at, start_date)
|
|
57
57
|
|
|
@@ -61,8 +61,16 @@ def _paginated_resource(api_key: str, query: str, query_field: str, updated_at,
|
|
|
61
61
|
yield normalize_dictionaries(item)
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
def _create_paginated_resource(
|
|
64
|
+
def _create_paginated_resource(
|
|
65
|
+
resource_name: str,
|
|
66
|
+
query: str,
|
|
67
|
+
query_field: str,
|
|
68
|
+
api_key: str,
|
|
69
|
+
start_date,
|
|
70
|
+
end_date=None,
|
|
71
|
+
):
|
|
65
72
|
"""Factory function to create paginated resources dynamically."""
|
|
73
|
+
|
|
66
74
|
@dlt.resource(name=resource_name, primary_key="id", write_disposition="merge")
|
|
67
75
|
def paginated_resource(
|
|
68
76
|
updated_at: dlt.sources.incremental[str] = dlt.sources.incremental(
|
|
@@ -73,9 +81,11 @@ def _create_paginated_resource(resource_name: str, query: str, query_field: str,
|
|
|
73
81
|
range_end="closed",
|
|
74
82
|
),
|
|
75
83
|
) -> Iterator[Dict[str, Any]]:
|
|
76
|
-
for item in _paginated_resource(
|
|
84
|
+
for item in _paginated_resource(
|
|
85
|
+
api_key, query, query_field, updated_at, start_date
|
|
86
|
+
):
|
|
77
87
|
yield normalize_dictionaries(item)
|
|
78
|
-
|
|
88
|
+
|
|
79
89
|
return paginated_resource
|
|
80
90
|
|
|
81
91
|
|
|
@@ -84,7 +94,7 @@ def normalize_dictionaries(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
84
94
|
Automatically normalize dictionary fields by detecting their structure:
|
|
85
95
|
- Convert nested objects with 'id' field to {field_name}_id
|
|
86
96
|
- Convert objects with 'nodes' field to arrays
|
|
87
|
-
|
|
97
|
+
|
|
88
98
|
"""
|
|
89
99
|
normalized_item = item.copy()
|
|
90
100
|
|