tukan-python 0.3.0__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.0/tukan_python.egg-info → tukan_python-0.5.0}/PKG-INFO +81 -1
  2. {tukan_python-0.3.0 → tukan_python-0.5.0}/README.md +80 -0
  3. {tukan_python-0.3.0 → 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.0 → tukan_python-0.5.0}/tukan_python/query.py +110 -97
  14. tukan_python-0.5.0/tukan_python/tukan.py +366 -0
  15. {tukan_python-0.3.0 → tukan_python-0.5.0/tukan_python.egg-info}/PKG-INFO +81 -1
  16. {tukan_python-0.3.0 → tukan_python-0.5.0}/tukan_python.egg-info/SOURCES.txt +7 -1
  17. tukan_python-0.3.0/tests/test_query.py +0 -210
  18. tukan_python-0.3.0/tests/test_sql_query.py +0 -204
  19. tukan_python-0.3.0/tukan_python/__init__.py +0 -5
  20. tukan_python-0.3.0/tukan_python/tukan.py +0 -241
  21. {tukan_python-0.3.0 → tukan_python-0.5.0}/LICENSE +0 -0
  22. {tukan_python-0.3.0 → tukan_python-0.5.0}/setup.cfg +0 -0
  23. {tukan_python-0.3.0 → tukan_python-0.5.0}/tukan_python.egg-info/dependency_links.txt +0 -0
  24. {tukan_python-0.3.0 → tukan_python-0.5.0}/tukan_python.egg-info/requires.txt +0 -0
  25. {tukan_python-0.3.0 → 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.0
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
@@ -300,6 +300,86 @@ q.set_table_name("mex_cnbv_cb_orig_by_gender_monthly")
300
300
  resultado = q.execute_query()
301
301
  ```
302
302
 
303
+ ### 7. Consultas SQL con `SQLQuery`
304
+
305
+ Si prefieres escribir SQL directamente, puedes usar la clase `SQLQuery`. Las consultas se ejecutan con el motor Blizzard y la paginación se maneja automáticamente:
306
+
307
+ ```python
308
+ from tukan_python import SQLQuery
309
+
310
+ sq = SQLQuery(sql="""
311
+ SELECT
312
+ END_DATE AS end_date,
313
+ INSTITUTIONS_REF AS institutions,
314
+ INSTITUTIONS_NAME AS institutions__name,
315
+ INDICATOR_REF AS indicator,
316
+ INDICATOR_NAME AS indicator__name,
317
+ VALUE as value
318
+ FROM tukan_db.source_of_truth_full.mex_tukan_retail_sales_by_company
319
+ WHERE END_DATE = '2022-12-31'
320
+ LIMIT 100000 OFFSET 0
321
+ """)
322
+
323
+ resultado = sq.execute()
324
+ print(resultado["df"])
325
+ ```
326
+
327
+ Las tablas disponibles en SQL se encuentran en el esquema `tukan_db.source_of_truth_full`.
328
+
329
+ #### Guardar una consulta SQL en tu perfil
330
+
331
+ Puedes guardar tus consultas SQL para acceder a ellas desde la [aplicación web](https://app.tukanmx.com):
332
+
333
+ ```python
334
+ sq = SQLQuery(sql="SELECT * FROM tukan_db.source_of_truth_full.mex_banxico_cf102 LIMIT 100")
335
+ sq.save_sql_query(name="Tipo de cambio FIX", language="es")
336
+ ```
337
+
338
+ Parámetros opcionales: `description`, `tags` (lista de strings).
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
+
303
383
  ## Conceptos clave
304
384
 
305
385
  ### Tablas
@@ -272,6 +272,86 @@ q.set_table_name("mex_cnbv_cb_orig_by_gender_monthly")
272
272
  resultado = q.execute_query()
273
273
  ```
274
274
 
275
+ ### 7. Consultas SQL con `SQLQuery`
276
+
277
+ Si prefieres escribir SQL directamente, puedes usar la clase `SQLQuery`. Las consultas se ejecutan con el motor Blizzard y la paginación se maneja automáticamente:
278
+
279
+ ```python
280
+ from tukan_python import SQLQuery
281
+
282
+ sq = SQLQuery(sql="""
283
+ SELECT
284
+ END_DATE AS end_date,
285
+ INSTITUTIONS_REF AS institutions,
286
+ INSTITUTIONS_NAME AS institutions__name,
287
+ INDICATOR_REF AS indicator,
288
+ INDICATOR_NAME AS indicator__name,
289
+ VALUE as value
290
+ FROM tukan_db.source_of_truth_full.mex_tukan_retail_sales_by_company
291
+ WHERE END_DATE = '2022-12-31'
292
+ LIMIT 100000 OFFSET 0
293
+ """)
294
+
295
+ resultado = sq.execute()
296
+ print(resultado["df"])
297
+ ```
298
+
299
+ Las tablas disponibles en SQL se encuentran en el esquema `tukan_db.source_of_truth_full`.
300
+
301
+ #### Guardar una consulta SQL en tu perfil
302
+
303
+ Puedes guardar tus consultas SQL para acceder a ellas desde la [aplicación web](https://app.tukanmx.com):
304
+
305
+ ```python
306
+ sq = SQLQuery(sql="SELECT * FROM tukan_db.source_of_truth_full.mex_banxico_cf102 LIMIT 100")
307
+ sq.save_sql_query(name="Tipo de cambio FIX", language="es")
308
+ ```
309
+
310
+ Parámetros opcionales: `description`, `tags` (lista de strings).
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
+
275
355
  ## Conceptos clave
276
356
 
277
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.0"
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'