tukan-python 0.2.1__tar.gz → 0.3.0__tar.gz
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.
- {tukan_python-0.2.1/tukan_python.egg-info → tukan_python-0.3.0}/PKG-INFO +1 -1
- {tukan_python-0.2.1 → tukan_python-0.3.0}/pyproject.toml +1 -1
- tukan_python-0.3.0/tests/test_sql_query.py +204 -0
- tukan_python-0.3.0/tukan_python/__init__.py +5 -0
- {tukan_python-0.2.1 → tukan_python-0.3.0}/tukan_python/query.py +85 -0
- {tukan_python-0.2.1 → tukan_python-0.3.0}/tukan_python/tukan.py +23 -0
- {tukan_python-0.2.1 → tukan_python-0.3.0/tukan_python.egg-info}/PKG-INFO +1 -1
- {tukan_python-0.2.1 → tukan_python-0.3.0}/tukan_python.egg-info/SOURCES.txt +1 -0
- tukan_python-0.2.1/tukan_python/__init__.py +0 -5
- {tukan_python-0.2.1 → tukan_python-0.3.0}/LICENSE +0 -0
- {tukan_python-0.2.1 → tukan_python-0.3.0}/README.md +0 -0
- {tukan_python-0.2.1 → tukan_python-0.3.0}/setup.cfg +0 -0
- {tukan_python-0.2.1 → tukan_python-0.3.0}/tests/test_query.py +0 -0
- {tukan_python-0.2.1 → tukan_python-0.3.0}/tukan_python.egg-info/dependency_links.txt +0 -0
- {tukan_python-0.2.1 → tukan_python-0.3.0}/tukan_python.egg-info/requires.txt +0 -0
- {tukan_python-0.2.1 → tukan_python-0.3.0}/tukan_python.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tukan_python"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "SDK de Python para acceder a datos oficiales de México a través de la API de Tukan."
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "TukanMx", email = "contacto@tukanmx.com" }
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import pytest
|
|
5
|
+
from unittest.mock import patch, MagicMock
|
|
6
|
+
|
|
7
|
+
from tukan_python.query import SQLQuery
|
|
8
|
+
from tukan_python.tukan import Tukan
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# --- Initialization ---
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
15
|
+
def test_sql_query_init_with_sql(mock_init):
|
|
16
|
+
sq = SQLQuery(sql="SELECT 1")
|
|
17
|
+
assert sq.sql == "SELECT 1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
21
|
+
def test_sql_query_init_without_sql(mock_init):
|
|
22
|
+
sq = SQLQuery()
|
|
23
|
+
assert sq.sql is None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
27
|
+
def test_set_sql(mock_init):
|
|
28
|
+
sq = SQLQuery()
|
|
29
|
+
sq.set_sql("SELECT * FROM table")
|
|
30
|
+
assert sq.sql == "SELECT * FROM table"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --- Base64 encoding ---
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
37
|
+
def test_encode_sql(mock_init):
|
|
38
|
+
sq = SQLQuery(sql="SELECT 1")
|
|
39
|
+
encoded = sq._encode_sql()
|
|
40
|
+
decoded = base64.b64decode(encoded).decode("utf-8")
|
|
41
|
+
assert decoded == "SELECT 1"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
45
|
+
def test_encode_sql_unicode(mock_init):
|
|
46
|
+
sq = SQLQuery(sql="SELECT * FROM tabla WHERE nombre = 'México'")
|
|
47
|
+
encoded = sq._encode_sql()
|
|
48
|
+
decoded = base64.b64decode(encoded).decode("utf-8")
|
|
49
|
+
assert "México" in decoded
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
53
|
+
def test_encode_sql_raises_when_no_sql(mock_init):
|
|
54
|
+
sq = SQLQuery()
|
|
55
|
+
with pytest.raises(ValueError, match="SQL query not set"):
|
|
56
|
+
sq._encode_sql()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- Payload ---
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
63
|
+
def test_build_payload_no_offset(mock_init):
|
|
64
|
+
sq = SQLQuery(sql="SELECT 1")
|
|
65
|
+
payload = sq._build_payload()
|
|
66
|
+
assert "raw_sql" in payload
|
|
67
|
+
assert "offset" not in payload
|
|
68
|
+
assert base64.b64decode(payload["raw_sql"]).decode("utf-8") == "SELECT 1"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
72
|
+
def test_build_payload_with_offset(mock_init):
|
|
73
|
+
sq = SQLQuery(sql="SELECT 1")
|
|
74
|
+
payload = sq._build_payload(offset=100)
|
|
75
|
+
assert payload["offset"] == 100
|
|
76
|
+
assert "raw_sql" in payload
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- Execution (mocked) ---
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
83
|
+
@patch.object(Tukan, "execute_post_operation")
|
|
84
|
+
def test_execute_single_page(mock_post, mock_init):
|
|
85
|
+
mock_post.return_value = {
|
|
86
|
+
"data": [{"col1": "a", "col2": 1}, {"col1": "b", "col2": 2}],
|
|
87
|
+
"has_more_data": False,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
sq = SQLQuery(sql="SELECT col1, col2 FROM table")
|
|
91
|
+
result = sq.execute()
|
|
92
|
+
|
|
93
|
+
assert isinstance(result["df"], pd.DataFrame)
|
|
94
|
+
assert len(result["df"]) == 2
|
|
95
|
+
assert len(result["data"]) == 2
|
|
96
|
+
assert result["data"][0]["col1"] == "a"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
100
|
+
@patch.object(Tukan, "execute_post_operation")
|
|
101
|
+
def test_execute_with_pagination(mock_post, mock_init):
|
|
102
|
+
mock_post.side_effect = [
|
|
103
|
+
{"data": [{"id": 1}, {"id": 2}], "has_more_data": True},
|
|
104
|
+
{"data": [{"id": 3}], "has_more_data": False},
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
sq = SQLQuery(sql="SELECT id FROM table")
|
|
108
|
+
result = sq.execute()
|
|
109
|
+
|
|
110
|
+
assert len(result["df"]) == 3
|
|
111
|
+
assert len(result["data"]) == 3
|
|
112
|
+
assert mock_post.call_count == 2
|
|
113
|
+
# Verify offset was passed in the second call
|
|
114
|
+
second_call_payload = mock_post.call_args_list[1][0][0]
|
|
115
|
+
assert second_call_payload["offset"] == 2
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
119
|
+
@patch.object(Tukan, "execute_post_operation")
|
|
120
|
+
def test_execute_empty_result(mock_post, mock_init):
|
|
121
|
+
mock_post.return_value = {"data": [], "has_more_data": False}
|
|
122
|
+
|
|
123
|
+
sq = SQLQuery(sql="SELECT 1 WHERE 1=0")
|
|
124
|
+
result = sq.execute()
|
|
125
|
+
|
|
126
|
+
assert len(result["df"]) == 0
|
|
127
|
+
assert result["data"] == []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- API validation errors ---
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
134
|
+
@patch.object(Tukan, "execute_post_operation")
|
|
135
|
+
def test_execute_raises_on_validation_error(mock_post, mock_init):
|
|
136
|
+
mock_post.return_value = {
|
|
137
|
+
"detail": "Raw SQL validation failed: Query must start with SELECT or WITH (for CTEs)"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
sq = SQLQuery(sql="DROP TABLE users")
|
|
141
|
+
with pytest.raises(ValueError, match="Raw SQL validation failed"):
|
|
142
|
+
sq.execute()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
146
|
+
@patch.object(Tukan, "execute_post_operation")
|
|
147
|
+
def test_execute_raises_on_pagination_error(mock_post, mock_init):
|
|
148
|
+
mock_post.side_effect = [
|
|
149
|
+
{"data": [{"id": 1}], "has_more_data": True},
|
|
150
|
+
{"detail": "Some server error"},
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
sq = SQLQuery(sql="SELECT id FROM table")
|
|
154
|
+
with pytest.raises(ValueError, match="Some server error"):
|
|
155
|
+
sq.execute()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --- String representation ---
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@patch.object(Tukan, "__init__", return_value=None)
|
|
162
|
+
def test_str(mock_init):
|
|
163
|
+
sq = SQLQuery(sql="SELECT 1")
|
|
164
|
+
assert "SELECT 1" in str(sq)
|
|
165
|
+
assert "SELECT 1" in repr(sq)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- Tukan.sql() convenience ---
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@patch.object(Tukan, "execute_post_operation")
|
|
172
|
+
def test_tukan_sql_convenience(mock_post):
|
|
173
|
+
mock_post.return_value = {"data": [{"x": 1}], "has_more_data": False}
|
|
174
|
+
|
|
175
|
+
t = Tukan.__new__(Tukan)
|
|
176
|
+
t.token = "test_token"
|
|
177
|
+
t.env = "https://client.tukanmx.com/"
|
|
178
|
+
result = t.sql("SELECT x FROM table")
|
|
179
|
+
|
|
180
|
+
assert isinstance(result["df"], pd.DataFrame)
|
|
181
|
+
assert len(result["data"]) == 1
|
|
182
|
+
# Verify the endpoint used
|
|
183
|
+
call_args = mock_post.call_args
|
|
184
|
+
assert call_args[0][1] == "data/retrieve/?engine=blizzard"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --- Endpoint ---
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_endpoint_includes_engine_param():
|
|
191
|
+
assert SQLQuery.ENDPOINT == "data/retrieve/?engine=blizzard"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --- _from_tukan classmethod ---
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_from_tukan():
|
|
198
|
+
t = Tukan.__new__(Tukan)
|
|
199
|
+
t.token = "test"
|
|
200
|
+
t.env = "https://client.tukanmx.com/"
|
|
201
|
+
|
|
202
|
+
sq = SQLQuery._from_tukan(t, "SELECT 1")
|
|
203
|
+
assert sq.tukan is t
|
|
204
|
+
assert sq.sql == "SELECT 1"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
from typing import Literal, Optional
|
|
2
3
|
|
|
3
4
|
import pandas as pd
|
|
@@ -207,3 +208,87 @@ class Query:
|
|
|
207
208
|
if "indicators" in response:
|
|
208
209
|
result["indicators"] = response["indicators"]
|
|
209
210
|
return result
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class SQLQuery:
|
|
214
|
+
"""Execute raw SQL queries against the Tukan API (blizzard engine).
|
|
215
|
+
|
|
216
|
+
The SQL string is base64-encoded and sent to the blizzard engine endpoint.
|
|
217
|
+
Pagination is handled automatically when the result set exceeds a single page.
|
|
218
|
+
|
|
219
|
+
Usage:
|
|
220
|
+
sq = SQLQuery(sql="SELECT * FROM tukan_db.source_of_truth_full.table_name LIMIT 1000")
|
|
221
|
+
result = sq.execute()
|
|
222
|
+
df = result["df"]
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
ENDPOINT = "data/retrieve/?engine=blizzard"
|
|
226
|
+
|
|
227
|
+
def __init__(self, token: Optional[str] = None, sql: Optional[str] = None):
|
|
228
|
+
self.tukan = Tukan(token)
|
|
229
|
+
self._sql = sql
|
|
230
|
+
|
|
231
|
+
def set_sql(self, sql: str) -> None:
|
|
232
|
+
self._sql = sql
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def sql(self) -> Optional[str]:
|
|
236
|
+
return self._sql
|
|
237
|
+
|
|
238
|
+
def _encode_sql(self) -> str:
|
|
239
|
+
if self._sql is None:
|
|
240
|
+
raise ValueError(
|
|
241
|
+
"SQL query not set. Call set_sql() or pass sql= to the constructor."
|
|
242
|
+
)
|
|
243
|
+
return base64.b64encode(self._sql.encode("utf-8")).decode("utf-8")
|
|
244
|
+
|
|
245
|
+
def _build_payload(self, offset: int = 0) -> dict:
|
|
246
|
+
payload = {"raw_sql": self._encode_sql()}
|
|
247
|
+
if offset > 0:
|
|
248
|
+
payload["offset"] = offset
|
|
249
|
+
return payload
|
|
250
|
+
|
|
251
|
+
def execute(self) -> dict:
|
|
252
|
+
"""Execute the SQL query and return results with pagination.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
dict with keys:
|
|
256
|
+
- "df": pandas DataFrame with all result rows
|
|
257
|
+
- "data": raw list of dicts
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ValueError: If the API rejects the SQL (e.g. validation failure).
|
|
261
|
+
"""
|
|
262
|
+
payload = self._build_payload()
|
|
263
|
+
response = self.tukan.execute_post_operation(payload, self.ENDPOINT)
|
|
264
|
+
|
|
265
|
+
if isinstance(response, dict) and "detail" in response:
|
|
266
|
+
raise ValueError(response["detail"])
|
|
267
|
+
|
|
268
|
+
all_data = response.get("data", [])
|
|
269
|
+
offset = len(all_data)
|
|
270
|
+
|
|
271
|
+
while response.get("has_more_data", False):
|
|
272
|
+
payload = self._build_payload(offset=offset)
|
|
273
|
+
response = self.tukan.execute_post_operation(payload, self.ENDPOINT)
|
|
274
|
+
if isinstance(response, dict) and "detail" in response:
|
|
275
|
+
raise ValueError(response["detail"])
|
|
276
|
+
page_data = response.get("data", [])
|
|
277
|
+
all_data.extend(page_data)
|
|
278
|
+
offset += len(page_data)
|
|
279
|
+
|
|
280
|
+
df = pd.DataFrame(all_data)
|
|
281
|
+
return {"df": df, "data": all_data}
|
|
282
|
+
|
|
283
|
+
@classmethod
|
|
284
|
+
def _from_tukan(cls, tukan_instance: "Tukan", sql: str) -> "SQLQuery":
|
|
285
|
+
instance = cls.__new__(cls)
|
|
286
|
+
instance.tukan = tukan_instance
|
|
287
|
+
instance._sql = sql
|
|
288
|
+
return instance
|
|
289
|
+
|
|
290
|
+
def __str__(self) -> str:
|
|
291
|
+
return f"SQLQuery(sql={self._sql!r})"
|
|
292
|
+
|
|
293
|
+
def __repr__(self) -> str:
|
|
294
|
+
return self.__str__()
|
|
@@ -198,6 +198,29 @@ class Tukan:
|
|
|
198
198
|
return df
|
|
199
199
|
|
|
200
200
|
|
|
201
|
+
def sql(self, query: str) -> dict:
|
|
202
|
+
"""Execute a raw SQL query against the Tukan API.
|
|
203
|
+
|
|
204
|
+
The SQL string is base64-encoded and sent to the blizzard engine.
|
|
205
|
+
Pagination is handled automatically for large result sets.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
query: SQL query string
|
|
209
|
+
(e.g. "SELECT * FROM tukan_db.source_of_truth_full.table_name LIMIT 1000")
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
dict with keys:
|
|
213
|
+
- "df": pandas DataFrame with query results
|
|
214
|
+
- "data": raw list of dicts
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
ValueError: If the API rejects the SQL (e.g. validation failure).
|
|
218
|
+
"""
|
|
219
|
+
from tukan_python.query import SQLQuery
|
|
220
|
+
|
|
221
|
+
return SQLQuery._from_tukan(self, query).execute()
|
|
222
|
+
|
|
223
|
+
|
|
201
224
|
def wrapped_partial(func, *args, **kwargs) -> Callable:
|
|
202
225
|
partial_func = partial(func, *args, **kwargs)
|
|
203
226
|
update_wrapper(partial_func, func)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|