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.
- {tukan_python-0.3.0/tukan_python.egg-info → tukan_python-0.5.0}/PKG-INFO +81 -1
- {tukan_python-0.3.0 → tukan_python-0.5.0}/README.md +80 -0
- {tukan_python-0.3.0 → tukan_python-0.5.0}/pyproject.toml +5 -1
- tukan_python-0.5.0/tests/test_atlas.py +354 -0
- tukan_python-0.5.0/tests/test_mcp_contract.py +141 -0
- tukan_python-0.5.0/tests/test_query.py +223 -0
- tukan_python-0.5.0/tests/test_sql_query.py +156 -0
- tukan_python-0.5.0/tests/test_transport.py +183 -0
- tukan_python-0.5.0/tukan_python/__init__.py +16 -0
- tukan_python-0.5.0/tukan_python/atlas/__init__.py +3 -0
- tukan_python-0.5.0/tukan_python/atlas/client.py +606 -0
- tukan_python-0.5.0/tukan_python/exceptions.py +33 -0
- {tukan_python-0.3.0 → tukan_python-0.5.0}/tukan_python/query.py +110 -97
- tukan_python-0.5.0/tukan_python/tukan.py +366 -0
- {tukan_python-0.3.0 → tukan_python-0.5.0/tukan_python.egg-info}/PKG-INFO +81 -1
- {tukan_python-0.3.0 → tukan_python-0.5.0}/tukan_python.egg-info/SOURCES.txt +7 -1
- tukan_python-0.3.0/tests/test_query.py +0 -210
- tukan_python-0.3.0/tests/test_sql_query.py +0 -204
- tukan_python-0.3.0/tukan_python/__init__.py +0 -5
- tukan_python-0.3.0/tukan_python/tukan.py +0 -241
- {tukan_python-0.3.0 → tukan_python-0.5.0}/LICENSE +0 -0
- {tukan_python-0.3.0 → tukan_python-0.5.0}/setup.cfg +0 -0
- {tukan_python-0.3.0 → tukan_python-0.5.0}/tukan_python.egg-info/dependency_links.txt +0 -0
- {tukan_python-0.3.0 → tukan_python-0.5.0}/tukan_python.egg-info/requires.txt +0 -0
- {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
|
+
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.
|
|
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'
|