tukan-python 0.3.1__tar.gz → 0.5.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.
Files changed (25) hide show
  1. {tukan_python-0.3.1/tukan_python.egg-info → tukan_python-0.5.0}/PKG-INFO +44 -1
  2. {tukan_python-0.3.1 → tukan_python-0.5.0}/README.md +43 -0
  3. {tukan_python-0.3.1 → tukan_python-0.5.0}/pyproject.toml +5 -1
  4. tukan_python-0.5.0/tests/test_atlas.py +354 -0
  5. tukan_python-0.5.0/tests/test_mcp_contract.py +141 -0
  6. tukan_python-0.5.0/tests/test_query.py +223 -0
  7. tukan_python-0.5.0/tests/test_sql_query.py +156 -0
  8. tukan_python-0.5.0/tests/test_transport.py +183 -0
  9. tukan_python-0.5.0/tukan_python/__init__.py +16 -0
  10. tukan_python-0.5.0/tukan_python/atlas/__init__.py +3 -0
  11. tukan_python-0.5.0/tukan_python/atlas/client.py +606 -0
  12. tukan_python-0.5.0/tukan_python/exceptions.py +33 -0
  13. {tukan_python-0.3.1 → tukan_python-0.5.0}/tukan_python/query.py +92 -112
  14. tukan_python-0.5.0/tukan_python/tukan.py +366 -0
  15. {tukan_python-0.3.1 → tukan_python-0.5.0/tukan_python.egg-info}/PKG-INFO +44 -1
  16. {tukan_python-0.3.1 → tukan_python-0.5.0}/tukan_python.egg-info/SOURCES.txt +7 -1
  17. tukan_python-0.3.1/tests/test_query.py +0 -210
  18. tukan_python-0.3.1/tests/test_sql_query.py +0 -204
  19. tukan_python-0.3.1/tukan_python/__init__.py +0 -5
  20. tukan_python-0.3.1/tukan_python/tukan.py +0 -288
  21. {tukan_python-0.3.1 → tukan_python-0.5.0}/LICENSE +0 -0
  22. {tukan_python-0.3.1 → tukan_python-0.5.0}/setup.cfg +0 -0
  23. {tukan_python-0.3.1 → tukan_python-0.5.0}/tukan_python.egg-info/dependency_links.txt +0 -0
  24. {tukan_python-0.3.1 → tukan_python-0.5.0}/tukan_python.egg-info/requires.txt +0 -0
  25. {tukan_python-0.3.1 → tukan_python-0.5.0}/tukan_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tukan_python
3
- Version: 0.3.1
3
+ Version: 0.5.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
@@ -337,6 +337,49 @@ sq.save_sql_query(name="Tipo de cambio FIX", language="es")
337
337
 
338
338
  Parámetros opcionales: `description`, `tags` (lista de strings).
339
339
 
340
+ ## Tukan Atlas (beta)
341
+
342
+ El módulo Atlas da acceso a la [API de Tukan Atlas](https://docs.tukanmx.com/es/atlas): más de 420 indicadores geoespaciales, lugares comerciales, polígonos e isócronas para México. Requiere acceso al beta (contacto@tukanmx.com).
343
+
344
+ ```python
345
+ from tukan_python import Tukan
346
+
347
+ atlas = Tukan().atlas
348
+
349
+ # ¿Qué marcas hay a 1 km de un punto?
350
+ df = atlas.places_count(group_by="brand", latitude=19.4326, longitude=-99.1332, radius_km=1)
351
+
352
+ # Población menor de 5 años por municipio de la CDMX
353
+ df = atlas.latest(indicator_ids=[18], entity_type="MUNICIPALITY", within=("STATE", "09"))
354
+
355
+ # Coordenada → jerarquía geográfica completa
356
+ info = atlas.pinpoint(19.4326, -99.1332)
357
+ ```
358
+
359
+ Los filtros de ubicación aceptan `latitude`/`longitude`/`radius_km`, `address`, `zipcode`/`municipality`/`state`, `entity_type`/`entity_codes`, o un dict `geo_filter=` crudo. Los polígonos aceptan GeoJSON o cualquier objeto con `__geo_interface__` (shapely, geopandas) — sin dependencias geoespaciales adicionales.
360
+
361
+ ## Manejo de errores
362
+
363
+ Desde la versión 0.4.0, los errores de la API **lanzan excepciones tipadas** (antes podían regresar como diccionarios):
364
+
365
+ ```python
366
+ from tukan_python import Tukan, TukanAPIError, TukanValidationError
367
+
368
+ client = Tukan()
369
+ try:
370
+ result = client.sql("SELECT * FROM tukan_db.source_of_truth_full.mex_banxico_cf102 LIMIT 10")
371
+ except TukanValidationError as e:
372
+ print(f"Consulta rechazada: {e.detail}") # ej. SQL inválido (HTTP 400)
373
+ except TukanAPIError as e:
374
+ print(f"Error de la API {e.status_code}: {e}")
375
+ ```
376
+
377
+ - `TukanAuthError` — token inválido o sin permisos (HTTP 401/403)
378
+ - `TukanValidationError` — consulta rechazada por la API (HTTP 400/422)
379
+ - `TukanAPIError` — cualquier otro error, o API inaccesible tras reintentos
380
+
381
+ Las tres heredan de `ValueError`, por lo que el código existente que hace `except ValueError` sigue funcionando. Los reintentos son automáticos (3 intentos con backoff) solo para fallas de red y errores 5xx.
382
+
340
383
  ## Conceptos clave
341
384
 
342
385
  ### Tablas
@@ -309,6 +309,49 @@ sq.save_sql_query(name="Tipo de cambio FIX", language="es")
309
309
 
310
310
  Parámetros opcionales: `description`, `tags` (lista de strings).
311
311
 
312
+ ## Tukan Atlas (beta)
313
+
314
+ El módulo Atlas da acceso a la [API de Tukan Atlas](https://docs.tukanmx.com/es/atlas): más de 420 indicadores geoespaciales, lugares comerciales, polígonos e isócronas para México. Requiere acceso al beta (contacto@tukanmx.com).
315
+
316
+ ```python
317
+ from tukan_python import Tukan
318
+
319
+ atlas = Tukan().atlas
320
+
321
+ # ¿Qué marcas hay a 1 km de un punto?
322
+ df = atlas.places_count(group_by="brand", latitude=19.4326, longitude=-99.1332, radius_km=1)
323
+
324
+ # Población menor de 5 años por municipio de la CDMX
325
+ df = atlas.latest(indicator_ids=[18], entity_type="MUNICIPALITY", within=("STATE", "09"))
326
+
327
+ # Coordenada → jerarquía geográfica completa
328
+ info = atlas.pinpoint(19.4326, -99.1332)
329
+ ```
330
+
331
+ Los filtros de ubicación aceptan `latitude`/`longitude`/`radius_km`, `address`, `zipcode`/`municipality`/`state`, `entity_type`/`entity_codes`, o un dict `geo_filter=` crudo. Los polígonos aceptan GeoJSON o cualquier objeto con `__geo_interface__` (shapely, geopandas) — sin dependencias geoespaciales adicionales.
332
+
333
+ ## Manejo de errores
334
+
335
+ Desde la versión 0.4.0, los errores de la API **lanzan excepciones tipadas** (antes podían regresar como diccionarios):
336
+
337
+ ```python
338
+ from tukan_python import Tukan, TukanAPIError, TukanValidationError
339
+
340
+ client = Tukan()
341
+ try:
342
+ result = client.sql("SELECT * FROM tukan_db.source_of_truth_full.mex_banxico_cf102 LIMIT 10")
343
+ except TukanValidationError as e:
344
+ print(f"Consulta rechazada: {e.detail}") # ej. SQL inválido (HTTP 400)
345
+ except TukanAPIError as e:
346
+ print(f"Error de la API {e.status_code}: {e}")
347
+ ```
348
+
349
+ - `TukanAuthError` — token inválido o sin permisos (HTTP 401/403)
350
+ - `TukanValidationError` — consulta rechazada por la API (HTTP 400/422)
351
+ - `TukanAPIError` — cualquier otro error, o API inaccesible tras reintentos
352
+
353
+ Las tres heredan de `ValueError`, por lo que el código existente que hace `except ValueError` sigue funcionando. Los reintentos son automáticos (3 intentos con backoff) solo para fallas de red y errores 5xx.
354
+
312
355
  ## Conceptos clave
313
356
 
314
357
  ### Tablas
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tukan_python"
7
- version = "0.3.1"
7
+ version = "0.5.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" }
@@ -40,6 +40,10 @@ Documentation = "https://github.com/TukanMx/tukan_python#readme"
40
40
  [tool.setuptools.packages.find]
41
41
  include = ["tukan_python*"]
42
42
 
43
+ [tool.black]
44
+ # Strings should be single quoted.
45
+ skip-string-normalization = true
46
+
43
47
  [dependency-groups]
44
48
  dev = [
45
49
  "build>=1.4.0",
@@ -0,0 +1,354 @@
1
+ '''Tests for the Atlas module through the transport seam — no network.'''
2
+
3
+ import pandas as pd
4
+ import pytest
5
+
6
+ from conftest import FakeTransport
7
+ from tukan_python import Tukan
8
+ from tukan_python.atlas import Atlas
9
+ from tukan_python.atlas.client import _as_geojson, _build_geo_filter
10
+
11
+
12
+ def make_atlas(responses=None):
13
+ transport = FakeTransport(responses)
14
+ return Atlas(Tukan(token='test-token', transport=transport)), transport
15
+
16
+
17
+ # --- geo filter builder -------------------------------------------------
18
+
19
+
20
+ def test_geo_filter_point():
21
+ gf = _build_geo_filter(latitude=19.43, longitude=-99.13, radius_km=2)
22
+ assert gf == {'latitude': 19.43, 'longitude': -99.13, 'radius_km': 2}
23
+
24
+
25
+ def test_geo_filter_address():
26
+ assert _build_geo_filter(address='Reforma 222') == {'address': 'Reforma 222'}
27
+
28
+
29
+ def test_geo_filter_zipcode_shortcut():
30
+ gf = _build_geo_filter(zipcode='06600')
31
+ assert gf == {'entity_type': 'ZIPCODE', 'entity_codes': ['06600']}
32
+
33
+
34
+ def test_geo_filter_municipality_list():
35
+ gf = _build_geo_filter(municipality=['09015', '09016'])
36
+ assert gf == {'entity_type': 'MUNICIPALITY', 'entity_codes': ['09015', '09016']}
37
+
38
+
39
+ def test_geo_filter_entity_type():
40
+ gf = _build_geo_filter(entity_type='STATE', entity_codes='09')
41
+ assert gf == {'entity_type': 'STATE', 'entity_codes': ['09']}
42
+
43
+
44
+ def test_geo_filter_passthrough():
45
+ raw = {'latitude': 1, 'longitude': 2, 'future_field': 3}
46
+ assert _build_geo_filter(geo_filter=raw) is raw
47
+
48
+
49
+ def test_geo_filter_rejects_mixed_modes():
50
+ with pytest.raises(ValueError, match='only one location mode'):
51
+ _build_geo_filter(latitude=19.4, longitude=-99.1, zipcode='06600')
52
+
53
+
54
+ def test_geo_filter_rejects_lat_without_lng():
55
+ with pytest.raises(ValueError, match='together'):
56
+ _build_geo_filter(latitude=19.4)
57
+
58
+
59
+ def test_geo_filter_rejects_bad_entity_type():
60
+ with pytest.raises(ValueError, match='entity_type must be one of'):
61
+ _build_geo_filter(entity_type='POSTAL_CODE', entity_codes=['06600'])
62
+
63
+
64
+ def test_geo_filter_rejects_passthrough_plus_keywords():
65
+ with pytest.raises(ValueError, match='cannot be combined'):
66
+ _build_geo_filter(geo_filter={'address': 'x'}, zipcode='06600')
67
+
68
+
69
+ def test_geo_filter_requires_a_mode():
70
+ with pytest.raises(ValueError, match='exactly one location'):
71
+ _build_geo_filter()
72
+ assert _build_geo_filter(required=False) is None
73
+
74
+
75
+ # --- polygon normalization ----------------------------------------------
76
+
77
+
78
+ def test_as_geojson_accepts_dict():
79
+ poly = {'type': 'Polygon', 'coordinates': [[[0, 0], [1, 0], [1, 1], [0, 0]]]}
80
+ assert _as_geojson(poly) == poly
81
+
82
+
83
+ def test_as_geojson_accepts_geo_interface():
84
+ class FakeShape:
85
+ __geo_interface__ = {
86
+ 'type': 'Polygon',
87
+ 'coordinates': [[[0, 0], [1, 1], [0, 1], [0, 0]]],
88
+ }
89
+
90
+ assert _as_geojson(FakeShape())['type'] == 'Polygon'
91
+
92
+
93
+ def test_as_geojson_rejects_non_polygon():
94
+ with pytest.raises(ValueError, match='GeoJSON'):
95
+ _as_geojson({'type': 'Point', 'coordinates': [0, 0]})
96
+
97
+
98
+ # --- catalogs and pagination --------------------------------------------
99
+
100
+
101
+ def test_indicators_returns_dataframe():
102
+ atlas, transport = make_atlas(
103
+ {
104
+ 'tukan-atlas/indicators/': {
105
+ 'count': 2,
106
+ 'results': [
107
+ {'indicator_id': 1, 'mnemonic': 'a'},
108
+ {'indicator_id': 2, 'mnemonic': 'b'},
109
+ ],
110
+ }
111
+ }
112
+ )
113
+ df = atlas.indicators(search='crime')
114
+ assert isinstance(df, pd.DataFrame)
115
+ assert len(df) == 2
116
+ assert df.attrs['count'] == 2
117
+ method, endpoint, params = transport.calls[0]
118
+ assert method == 'GET'
119
+ assert params['search'] == 'crime'
120
+ assert params['page'] == 1
121
+
122
+
123
+ def test_catalog_paginates_until_count():
124
+ page1 = {'count': 3, 'results': [{'id': 1}, {'id': 2}]}
125
+ page2 = {'count': 3, 'results': [{'id': 3}]}
126
+ atlas, transport = make_atlas({'tukan-atlas/brands/': [page1, page2]})
127
+ df = atlas.brands()
128
+ assert len(df) == 3
129
+ assert transport.calls[1][2]['page'] == 2
130
+
131
+
132
+ def test_borders():
133
+ atlas, _ = make_atlas(
134
+ {
135
+ 'tukan-atlas/geo-entities/42/borders/': {
136
+ 'count': 1,
137
+ 'results': [{'entity_code': '09014'}],
138
+ }
139
+ }
140
+ )
141
+ df = atlas.borders(42)
142
+ assert df.iloc[0]['entity_code'] == '09014'
143
+
144
+
145
+ # --- statistics ---------------------------------------------------------
146
+
147
+
148
+ def test_latest_builds_geo_filter_with_within():
149
+ atlas, transport = make_atlas(
150
+ {
151
+ 'tukan-atlas/latest/': {
152
+ 'count': 1,
153
+ 'results': [{'entity_code': '06600', 'value': 5}],
154
+ }
155
+ }
156
+ )
157
+ df = atlas.latest(
158
+ indicator_ids=[2], entity_type='ZIPCODE', within=('MUNICIPALITY', '09015')
159
+ )
160
+ assert len(df) == 1
161
+ payload = transport.calls[0][2]
162
+ assert payload['indicator_ids'] == [2]
163
+ assert payload['geo_filter'] == {
164
+ 'entity_type_id': 'ZIPCODE',
165
+ 'within': {'entity_type_id': 'MUNICIPALITY', 'entity_code': '09015'},
166
+ }
167
+
168
+
169
+ def test_latest_requires_indicators_or_collections():
170
+ atlas, _ = make_atlas()
171
+ with pytest.raises(ValueError, match='indicator_ids and/or collection_ids'):
172
+ atlas.latest()
173
+
174
+
175
+ def test_latest_entity_type_requires_within():
176
+ atlas, _ = make_atlas()
177
+ with pytest.raises(ValueError, match='within is required'):
178
+ atlas.latest(indicator_ids=[1], entity_type='ZIPCODE')
179
+
180
+
181
+ def test_polygon_context():
182
+ atlas, transport = make_atlas(
183
+ {'tukan-atlas/polygon-context/': {'data': {'101': 5000}, 'context': {}}}
184
+ )
185
+ poly = {'type': 'Polygon', 'coordinates': [[[0, 0], [1, 0], [1, 1], [0, 0]]]}
186
+ result = atlas.polygon_context(poly, indicator_ids=[101])
187
+ assert result['data'] == {'101': 5000}
188
+ assert transport.calls[0][2] == {'polygon': poly, 'indicators': [101]}
189
+
190
+
191
+ # --- places -------------------------------------------------------------
192
+
193
+
194
+ def test_places_count():
195
+ atlas, transport = make_atlas(
196
+ {
197
+ 'tukan-atlas/places/grouped/': {
198
+ 'count': 2,
199
+ 'geo_context': {'zipcode': '06600'},
200
+ 'results': [
201
+ {'brand': 'OXXO', 'total': 12},
202
+ {'brand': 'Soriana', 'total': 1},
203
+ ],
204
+ }
205
+ }
206
+ )
207
+ df = atlas.places_count(group_by='brand', zipcode='06600')
208
+ assert len(df) == 2
209
+ assert df.attrs['geo_context'] == {'zipcode': '06600'}
210
+ payload = transport.calls[0][2]
211
+ assert payload['geo_filter'] == {
212
+ 'entity_type': 'ZIPCODE',
213
+ 'entity_codes': ['06600'],
214
+ }
215
+
216
+
217
+ def test_places_count_rejects_bad_group_by():
218
+ atlas, _ = make_atlas()
219
+ with pytest.raises(ValueError, match='group_by'):
220
+ atlas.places_count(group_by='color', zipcode='06600')
221
+
222
+
223
+ def test_places_paginates_and_filters():
224
+ page1 = {'count': 3, 'results': [{'id': 1}, {'id': 2}]}
225
+ page2 = {'count': 3, 'results': [{'id': 3}]}
226
+ atlas, transport = make_atlas({'tukan-atlas/places/detail/': [page1, page2]})
227
+ df = atlas.places(zipcode='06600', brand_ids=[7], page_size=2)
228
+ assert len(df) == 3
229
+ first_payload = transport.calls[0][2]
230
+ assert first_payload['filters'] == {'brand_ids': [7]}
231
+ assert transport.calls[1][2]['page'] == 2
232
+
233
+
234
+ def test_places_isochrone_flattens_nested_places():
235
+ # The API returns one result per ORIGIN with its places nested inside.
236
+ atlas, transport = make_atlas(
237
+ {
238
+ 'tukan-atlas/places/isochrone/': {
239
+ 'results': [
240
+ {
241
+ 'index': 0,
242
+ 'latitude': 19.43,
243
+ 'longitude': -99.13,
244
+ 'area_km2': 0.42,
245
+ 'places_count': 2,
246
+ 'places': [{'brand_name': 'OXXO'}, {'brand_name': 'Soriana'}],
247
+ }
248
+ ],
249
+ 'isochrone_errors': [],
250
+ }
251
+ }
252
+ )
253
+ df = atlas.places_isochrone(
254
+ coordinates=(19.43, -99.13), travel_mode='WALK', duration_minutes=10
255
+ )
256
+ assert len(df) == 2
257
+ assert df.iloc[0]['brand_name'] == 'OXXO'
258
+ assert df.iloc[0]['origin_index'] == 0
259
+ assert df.iloc[1]['origin_latitude'] == 19.43
260
+ assert df.attrs['origins'][0]['area_km2'] == 0.42
261
+ payload = transport.calls[0][2]
262
+ assert payload['coordinates'] == [[19.43, -99.13]]
263
+ assert payload['travel_mode'] == 'WALK'
264
+
265
+
266
+ def test_places_isochrone_validates_travel_mode():
267
+ atlas, _ = make_atlas()
268
+ with pytest.raises(ValueError, match='travel_mode'):
269
+ atlas.places_isochrone(coordinates=(1, 2), travel_mode='TELEPORT')
270
+
271
+
272
+ # --- pinpoint -----------------------------------------------------------
273
+
274
+
275
+ def test_pinpoint_single_returns_dict():
276
+ atlas, transport = make_atlas(
277
+ {
278
+ 'tukan-atlas/pinpoint/': {
279
+ 'count': 1,
280
+ 'results': [{'state': 'Ciudad de México'}],
281
+ }
282
+ }
283
+ )
284
+ result = atlas.pinpoint(19.43, -99.13)
285
+ assert result == {'state': 'Ciudad de México'}
286
+ assert transport.calls[0][2]['latitude'] == 19.43
287
+
288
+
289
+ def test_pinpoint_batch_paginates():
290
+ page1 = {
291
+ 'count': 3,
292
+ 'results': [
293
+ {'latitude': 1, 'longitude': 2, 'state': 'A'},
294
+ {'latitude': 3, 'longitude': 4, 'state': 'B'},
295
+ ],
296
+ }
297
+ page2 = {'count': 3, 'results': [{'latitude': 5, 'longitude': 6, 'state': 'C'}]}
298
+ atlas, transport = make_atlas(
299
+ {
300
+ 'tukan-atlas/pinpoint/?page=1&page_size=2': page1,
301
+ 'tukan-atlas/pinpoint/?page=2&page_size=2': page2,
302
+ }
303
+ )
304
+ df = atlas.pinpoint_batch(coordinates=[(1, 2), (3, 4), (5, 6)], page_size=2)
305
+ assert len(df) == 3
306
+ assert df['state'].tolist() == ['A', 'B', 'C']
307
+
308
+
309
+ def test_pinpoint_batch_reexpands_deduplicated_rows():
310
+ # The API dedupes repeated coordinates (the-tukan #1444); the SDK must
311
+ # return one row per input, in input order, safe for positional joins.
312
+ atlas, _ = make_atlas(
313
+ {
314
+ 'tukan-atlas/pinpoint/?page=1&page_size=1000': {
315
+ 'count': 2,
316
+ 'results': [
317
+ {'latitude': 19.43, 'longitude': -99.13, 'state': 'CDMX'},
318
+ {'latitude': 20.66, 'longitude': -103.35, 'state': 'Jalisco'},
319
+ ],
320
+ }
321
+ }
322
+ )
323
+ df = atlas.pinpoint_batch(
324
+ coordinates=[(19.43, -99.13), (20.66, -103.35), (19.43, -99.13)]
325
+ )
326
+ assert len(df) == 3
327
+ assert df['state'].tolist() == ['CDMX', 'Jalisco', 'CDMX']
328
+ assert df['input_index'].tolist() == [0, 1, 2]
329
+
330
+
331
+ def test_pinpoint_batch_marks_missing_coordinates():
332
+ atlas, _ = make_atlas(
333
+ {
334
+ 'tukan-atlas/pinpoint/?page=1&page_size=1000': {
335
+ 'count': 1,
336
+ 'results': [{'latitude': 19.43, 'longitude': -99.13, 'state': 'CDMX'}],
337
+ }
338
+ }
339
+ )
340
+ df = atlas.pinpoint_batch(coordinates=[(19.43, -99.13), (0.0, 0.0)])
341
+ assert len(df) == 2
342
+ assert df.iloc[1]['error'] == 'No result returned by the API for this coordinate'
343
+
344
+
345
+ # --- access pattern -----------------------------------------------------
346
+
347
+
348
+ def test_tukan_atlas_property_is_lazy_and_cached():
349
+ t = Tukan(token='test-token', transport=FakeTransport())
350
+ assert t._atlas is None
351
+ atlas = t.atlas
352
+ assert isinstance(atlas, Atlas)
353
+ assert t.atlas is atlas
354
+ assert atlas._transport is t._transport
@@ -0,0 +1,141 @@
1
+ '''Contract tests: the interface the customer-facing MCP server consumes.
2
+
3
+ The Tukan MCP (the-tukan/tukan_mcp) crosses the SDK seam at exactly the
4
+ members tested here. If any of these tests break, the MCP breaks — change
5
+ the MCP first, then the test, never the other way around.
6
+
7
+ All tests run against FakeTransport: fast, no token, no network.
8
+ '''
9
+
10
+ import pandas as pd
11
+ import pytest
12
+
13
+ from tukan_python import (
14
+ SQLQuery,
15
+ Tukan,
16
+ TukanAPIError,
17
+ TukanValidationError,
18
+ __version__,
19
+ )
20
+ from conftest import FakeTransport
21
+
22
+
23
+ def make_sql_query(sql, responses):
24
+ sq = SQLQuery.__new__(SQLQuery)
25
+ sq.tukan = Tukan(token='test-token', transport=FakeTransport(responses))
26
+ sq._sql = sql
27
+ return sq
28
+
29
+
30
+ def test_tukan_constructor_accepts_token():
31
+ t = Tukan(token='abc')
32
+ assert t.token == 'abc'
33
+
34
+
35
+ def test_tukan_constructor_requires_some_token(monkeypatch):
36
+ monkeypatch.delenv('API_TUKAN', raising=False)
37
+ with pytest.raises(ValueError):
38
+ Tukan()
39
+
40
+
41
+ def test_sqlquery_execute_returns_df_and_data():
42
+ sq = make_sql_query(
43
+ 'SELECT 1',
44
+ {SQLQuery.ENDPOINT: {'data': [{'x': 1}], 'has_more_data': False}},
45
+ )
46
+ result = sq.execute()
47
+ # The MCP reads result['df'] and result['data'].
48
+ assert set(result.keys()) == {'df', 'data'}
49
+ assert isinstance(result['df'], pd.DataFrame)
50
+ assert isinstance(result['data'], list)
51
+
52
+
53
+ def test_sqlquery_execute_raises_valueerror_on_api_rejection():
54
+ # The MCP catches ValueError around execute(); TukanValidationError must
55
+ # remain a ValueError subclass.
56
+ sq = make_sql_query(
57
+ 'DROP TABLE x',
58
+ {
59
+ SQLQuery.ENDPOINT: TukanValidationError(
60
+ 'Raw SQL validation failed', status_code=400
61
+ )
62
+ },
63
+ )
64
+ with pytest.raises(ValueError):
65
+ sq.execute()
66
+
67
+
68
+ def test_sqlquery_endpoint_is_blizzard():
69
+ assert SQLQuery.ENDPOINT == 'data/retrieve/?engine=blizzard'
70
+
71
+
72
+ def test_save_sql_query_signature_and_response():
73
+ sq = make_sql_query('SELECT 1', {'visualizations/query/': {'saved': True}})
74
+ response = sq.save_sql_query(name='n', language='es', description='d')
75
+ assert response == {'saved': True}
76
+
77
+
78
+ def test_tukan_sql_convenience():
79
+ t = Tukan(
80
+ token='test-token',
81
+ transport=FakeTransport(
82
+ {SQLQuery.ENDPOINT: {'data': [{'x': 1}], 'has_more_data': False}}
83
+ ),
84
+ )
85
+ result = t.sql('SELECT x FROM table')
86
+ assert isinstance(result['df'], pd.DataFrame)
87
+ assert len(result['data']) == 1
88
+
89
+
90
+ def test_get_table_metadata_returns_response_dict():
91
+ t = Tukan(
92
+ token='test-token',
93
+ transport=FakeTransport({'data/metadata/': {'data_table': {'id': 't'}}}),
94
+ )
95
+ assert t.get_table_metadata('t')['data_table']['id'] == 't'
96
+
97
+
98
+ def test_all_indicators_for_table_returns_list():
99
+ t = Tukan(
100
+ token='test-token',
101
+ transport=FakeTransport({'data/': {'data': [{'ref': 'a'}, {'ref': 'b'}]}}),
102
+ )
103
+ indicators = t.all_indicators_for_table('t')
104
+ assert indicators == [{'ref': 'a'}, {'ref': 'b'}]
105
+
106
+
107
+ def test_get_reference_flat_tree_returns_dataframe():
108
+ csv = 'raw|ref|name|in_table\n1|abc|Foo|True\n2|def|Bar|False'
109
+ t = Tukan(
110
+ token='test-token',
111
+ transport=FakeTransport({'data/visualizations/flat-tree/t/geography/': csv}),
112
+ )
113
+ df = t.get_reference_flat_tree('t', 'geography')
114
+ assert list(df.columns) == ['raw', 'ref', 'name', 'in_table']
115
+ only = t.get_reference_flat_tree('t', 'geography', only_in_table=True)
116
+ assert len(only) == 1
117
+
118
+
119
+ def test_get_catalog_tables_returns_json():
120
+ t = Tukan(
121
+ token='test-token',
122
+ # Outer list is FakeTransport's response queue; the response is a list.
123
+ transport=FakeTransport({'data/catalogue_table/': [[{'id': 'cat1'}]]}),
124
+ )
125
+ assert t.get_catalog_tables() == [{'id': 'cat1'}]
126
+
127
+
128
+ def test_execute_post_operation_still_available():
129
+ # Deprecated but documented; kept as a thin forwarder.
130
+ t = Tukan(token='test-token', transport=FakeTransport({'data/': {'ok': 1}}))
131
+ assert t.execute_post_operation({'p': 1}, 'data/') == {'ok': 1}
132
+
133
+
134
+ def test_exceptions_are_exported():
135
+ from tukan_python import TukanAuthError # noqa: F401
136
+
137
+ assert issubclass(TukanAPIError, ValueError)
138
+
139
+
140
+ def test_version():
141
+ assert __version__ == '0.5.0'