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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tukan_python
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: SDK de Python para acceder a datos oficiales de México a través de la API de Tukan.
5
5
  Author-email: TukanMx <contacto@tukanmx.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tukan_python"
7
- version = "0.2.1"
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"
@@ -0,0 +1,5 @@
1
+ from .tukan import Tukan
2
+ from .query import Query, SQLQuery
3
+
4
+ __version__ = "0.3.0"
5
+ __all__ = ["Tukan", "Query", "SQLQuery", "__version__"]
@@ -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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tukan_python
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: SDK de Python para acceder a datos oficiales de México a través de la API de Tukan.
5
5
  Author-email: TukanMx <contacto@tukanmx.com>
6
6
  License-Expression: MIT
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  tests/test_query.py
5
+ tests/test_sql_query.py
5
6
  tukan_python/__init__.py
6
7
  tukan_python/query.py
7
8
  tukan_python/tukan.py
@@ -1,5 +0,0 @@
1
- from .tukan import Tukan
2
- from .query import Query
3
-
4
- __version__ = "0.2.0"
5
- __all__ = ["Tukan", "Query", "__version__"]
File without changes
File without changes
File without changes