aesops 0.1.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.
- aesops-0.1.0/.gitignore +6 -0
- aesops-0.1.0/LICENSE +21 -0
- aesops-0.1.0/PKG-INFO +215 -0
- aesops-0.1.0/README.md +199 -0
- aesops-0.1.0/package.json +5 -0
- aesops-0.1.0/pyproject.toml +33 -0
- aesops-0.1.0/src/aesops/__init__.py +25 -0
- aesops-0.1.0/src/aesops/client.py +126 -0
- aesops-0.1.0/src/aesops/colors.py +142 -0
- aesops-0.1.0/src/aesops/dataset.py +422 -0
- aesops-0.1.0/src/aesops/errors.py +22 -0
- aesops-0.1.0/src/aesops/models.py +85 -0
- aesops-0.1.0/tests/conftest.py +16 -0
- aesops-0.1.0/tests/test_client.py +304 -0
- aesops-0.1.0/tests/test_colors.py +43 -0
- aesops-0.1.0/uv.lock +3156 -0
aesops-0.1.0/.gitignore
ADDED
aesops-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aesops
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
aesops-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aesops
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Aesops dataset API — browse the catalog and load datasets into pandas, polars, or DuckDB.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: duckdb>=1.1
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Provides-Extra: pandas
|
|
11
|
+
Requires-Dist: pandas>=2.0; extra == 'pandas'
|
|
12
|
+
Provides-Extra: polars
|
|
13
|
+
Requires-Dist: polars>=1.0; extra == 'polars'
|
|
14
|
+
Requires-Dist: pyarrow>=14; extra == 'polars'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# aesops
|
|
18
|
+
|
|
19
|
+
Python client for the [Aesops](https://aesops.co.ke) dataset API.
|
|
20
|
+
|
|
21
|
+
Browse the full catalog without any credentials. Load datasets directly into pandas, polars, or a DuckDB connection (with range-request pushdown, so only the row-groups you query are fetched).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install aesops # core: httpx + duckdb
|
|
27
|
+
pip install 'aesops[pandas]' # + pandas
|
|
28
|
+
pip install 'aesops[polars]' # + polars
|
|
29
|
+
pip install 'aesops[pandas,polars]' # both
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Python 3.9+.
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from aesops import Client
|
|
38
|
+
|
|
39
|
+
# No key needed to browse — the full catalog is public
|
|
40
|
+
client = Client()
|
|
41
|
+
|
|
42
|
+
datasets = client.list(query="housing", license="MIT")
|
|
43
|
+
for ds in datasets:
|
|
44
|
+
print(ds.slug, ds.row_count, ds.keyless)
|
|
45
|
+
|
|
46
|
+
# Fetch metadata + schema (works for any dataset, keyless or not)
|
|
47
|
+
ds = client.load_dataset("kenya-housing-prices")
|
|
48
|
+
print(ds.name, ds.row_count, ds.column_count)
|
|
49
|
+
for col in ds.detail.columns:
|
|
50
|
+
print(col.name, col.dtype)
|
|
51
|
+
|
|
52
|
+
# Summary stats per column — no network call, no API key needed; built from
|
|
53
|
+
# the metadata already fetched by load_dataset()
|
|
54
|
+
print(ds.describe())
|
|
55
|
+
# ┌────────┬────────┬───────┬────────────┬────────┬────────┬─────────┬──────┬────────┬────────┬────────┐
|
|
56
|
+
# │ column │ dtype │ count │ null_count │ null_% │ unique │ mean │ std │ min │ median │ max │
|
|
57
|
+
# ├────────┼────────┼───────┼────────────┼────────┼────────┼─────────┼──────┼────────┼────────┼────────┤
|
|
58
|
+
# │ year │ number │ 178 │ 0 │ 0.0 │ 16 │ 2018.50 │ 4.30 │ 2011.0 │ 2018.50 │ 2026.0 │
|
|
59
|
+
# │ month │ string │ 178 │ 0 │ 0.0 │ 12 │ NaN │ NaN │ NaN │ NaN │ NaN │
|
|
60
|
+
# └────────┴────────┴───────┴────────────┴────────┴────────┴─────────┴──────┴────────┴────────┴────────┘
|
|
61
|
+
|
|
62
|
+
# In a Jupyter/IPython notebook (or Zed's REPL), the same call renders as a
|
|
63
|
+
# bordered HTML table instead when it's the last expression in a cell.
|
|
64
|
+
|
|
65
|
+
# Want a real pandas.DataFrame for further chaining (.loc, filtering, etc.)?
|
|
66
|
+
ds.describe().to_frame()
|
|
67
|
+
|
|
68
|
+
# A compact overview — name, slug, description, AI insights, link to the
|
|
69
|
+
# dataset's Aesops page, and any linked community discussions. Also no
|
|
70
|
+
# network call, no API key needed.
|
|
71
|
+
print(ds.summary())
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Full catalog
|
|
75
|
+
|
|
76
|
+
`list()` always returns the full catalog and never sends the API key even if
|
|
77
|
+
the `Client` has one configured:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
client = Client()
|
|
81
|
+
datasets = client.list(category="finance")
|
|
82
|
+
for ds in datasets:
|
|
83
|
+
print(ds.slug, ds.keyless) # keyless tells you which need a key to load
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This is deliberate: the catalog is public for discovery/marketing purposes.
|
|
87
|
+
What's gated is loading actual data (see below).
|
|
88
|
+
|
|
89
|
+
## Loading data
|
|
90
|
+
|
|
91
|
+
Loading a dataset's actual rows (`.to_pandas()`, `.to_polars()`, `.to_duckdb()`,
|
|
92
|
+
`.sql()`, `.to_csv()`) requires a **read-scoped API key** — _unless_ the
|
|
93
|
+
dataset is currently keyless (`ds.keyless`), in which case no key is
|
|
94
|
+
needed. Create a key at
|
|
95
|
+
[aesops.co.ke/profile/api-keys](https://aesops.co.ke/profile/api-keys).
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
client = Client(api_key="Aes_...")
|
|
99
|
+
|
|
100
|
+
ds = client.load_dataset("kenya-housing-prices")
|
|
101
|
+
|
|
102
|
+
# pandas
|
|
103
|
+
df = ds.to_pandas()
|
|
104
|
+
|
|
105
|
+
# polars
|
|
106
|
+
lf = ds.to_polars()
|
|
107
|
+
|
|
108
|
+
# DuckDB (predicate + projection pushdown — only fetches the row-groups you touch)
|
|
109
|
+
con = ds.to_duckdb()
|
|
110
|
+
con.sql("SELECT county, avg(price) FROM data GROUP BY county").df()
|
|
111
|
+
|
|
112
|
+
# or use ds.sql() directly
|
|
113
|
+
ds.sql("SELECT county, avg(price) FROM data GROUP BY 1").df()
|
|
114
|
+
|
|
115
|
+
# CSV export (client-side, no extra server compute)
|
|
116
|
+
ds.to_csv("/tmp/housing.csv")
|
|
117
|
+
|
|
118
|
+
# Just want a peek? `limit` is pushed down through read_parquet, so it also
|
|
119
|
+
# cuts what crosses the network, not just what lands in the DataFrame.
|
|
120
|
+
sample = ds.to_pandas(limit=100)
|
|
121
|
+
ds.to_polars(limit=100)
|
|
122
|
+
ds.to_csv("/tmp/sample.csv", limit=100)
|
|
123
|
+
|
|
124
|
+
# for anything more specific (offset, filters, ordering) use ds.sql() directly
|
|
125
|
+
ds.sql("SELECT * FROM data ORDER BY price DESC LIMIT 20").df()
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### New to DuckDB?
|
|
129
|
+
|
|
130
|
+
`ds.sql()` and `ds.to_duckdb().sql()` return a [`duckdb.DuckDBPyRelation`](https://duckdb.org/docs/stable/clients/python/reference/#duckdb.DuckDBPyRelation) —
|
|
131
|
+
a lazy query result, not a DataFrame. Call `.df()` for pandas, `.pl()` for
|
|
132
|
+
polars, `.arrow()` for an Arrow table, or `.fetchall()` for plain Python
|
|
133
|
+
tuples. If SQL or DuckDB itself is new to you: the
|
|
134
|
+
[DuckDB docs](https://duckdb.org/docs/stable/) are a good starting point, and
|
|
135
|
+
the [Python client guide](https://duckdb.org/docs/stable/clients/python/overview)
|
|
136
|
+
covers the API this SDK builds on.
|
|
137
|
+
|
|
138
|
+
## Brand colors
|
|
139
|
+
|
|
140
|
+
The Aesops chart palette is available as plain hex strings, so a chart built
|
|
141
|
+
from Aesops data can match the platform's own look — and printing a palette
|
|
142
|
+
renders actual color swatches, not just hex text:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from aesops import colors
|
|
146
|
+
|
|
147
|
+
colors.primary # '#155f6b' — brand teal
|
|
148
|
+
colors.aeschart # 6-slot categorical series for chart colors, teal-led
|
|
149
|
+
colors.dark.primary # '#D4956A' — dark mode swaps primary to terracotta
|
|
150
|
+
colors.dark.aeschart # dark mode's chart series
|
|
151
|
+
|
|
152
|
+
# See the actual chart colors, not just hex codes: in a terminal, prints
|
|
153
|
+
# each of the 6 aeschart slots as a colored block (ANSI truecolor) next to
|
|
154
|
+
# its name and hex value — separately for light and dark.
|
|
155
|
+
print(colors.light)
|
|
156
|
+
print(colors.dark)
|
|
157
|
+
|
|
158
|
+
# In a Jupyter/IPython notebook, colors.light / colors.dark render as HTML
|
|
159
|
+
# swatches automatically when they're the last expression in a cell.
|
|
160
|
+
|
|
161
|
+
# matplotlib
|
|
162
|
+
import matplotlib.pyplot as plt
|
|
163
|
+
fig, ax = plt.subplots()
|
|
164
|
+
ax.set_prop_cycle(color=list(colors.aeschart))
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
No network call, no API key required — `colors` mirrors the same design
|
|
168
|
+
tokens the Aesops web app itself uses (see `DESIGN.md`). Full field list:
|
|
169
|
+
`primary`, `accent`, `background`, `foreground`, `card`, `muted`, `border`,
|
|
170
|
+
`success`, `destructive` (each with a `_foreground` counterpart where
|
|
171
|
+
relevant), and `aeschart` — on both the top-level (light) module and
|
|
172
|
+
`colors.light`/`colors.dark` explicitly.
|
|
173
|
+
|
|
174
|
+
## Context manager
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
with Client(api_key="Aes_...") as client:
|
|
178
|
+
df = client.load_dataset("kenya-housing-prices").to_pandas()
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Error handling
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from aesops import AuthError, NotFoundError, ApiError
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
ds = client.load_dataset("does-not-exist")
|
|
188
|
+
except NotFoundError:
|
|
189
|
+
print("dataset not found")
|
|
190
|
+
except AuthError:
|
|
191
|
+
print("invalid or missing API key")
|
|
192
|
+
except ApiError as e:
|
|
193
|
+
print(f"API error {e.status_code}: {e}")
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Notes
|
|
197
|
+
|
|
198
|
+
- Signed URLs are fetched **lazily** at query time (not at `load_dataset`) and
|
|
199
|
+
automatically re-fetched on expiry or a 403 from storage, so long-running
|
|
200
|
+
sessions never stall.
|
|
201
|
+
- All format conversion (CSV, Arrow, etc.) happens locally — zero extra server
|
|
202
|
+
compute.
|
|
203
|
+
- `list()` and `load_dataset()` always work without an `api_key` — the full
|
|
204
|
+
catalog and every dataset's metadata are public. Loading actual data
|
|
205
|
+
(`.to_pandas()`, `.to_polars()`, `.to_duckdb()`, `.sql()`, `.to_csv()`)
|
|
206
|
+
requires a key unless the dataset is currently keyless (`ds.keyless`).
|
|
207
|
+
- `.describe()` and `.summary()` never touch the network or require a key —
|
|
208
|
+
they summarize the metadata `load_dataset()` already fetched. `.describe()`
|
|
209
|
+
returns a `DescribeTable` (bordered text/HTML display, no pandas required);
|
|
210
|
+
call `.to_frame()` on it for a real `pandas.DataFrame`. `.summary()` returns
|
|
211
|
+
a `Summary` (same bordered text/HTML display) with name, slug, description,
|
|
212
|
+
AI insights, a link to the dataset's Aesops page, and any linked community
|
|
213
|
+
discussions.
|
|
214
|
+
- Datasets are always served at their latest active version — there's no
|
|
215
|
+
version pinning.
|
aesops-0.1.0/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# aesops
|
|
2
|
+
|
|
3
|
+
Python client for the [Aesops](https://aesops.co.ke) dataset API.
|
|
4
|
+
|
|
5
|
+
Browse the full catalog without any credentials. Load datasets directly into pandas, polars, or a DuckDB connection (with range-request pushdown, so only the row-groups you query are fetched).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install aesops # core: httpx + duckdb
|
|
11
|
+
pip install 'aesops[pandas]' # + pandas
|
|
12
|
+
pip install 'aesops[polars]' # + polars
|
|
13
|
+
pip install 'aesops[pandas,polars]' # both
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Python 3.9+.
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from aesops import Client
|
|
22
|
+
|
|
23
|
+
# No key needed to browse — the full catalog is public
|
|
24
|
+
client = Client()
|
|
25
|
+
|
|
26
|
+
datasets = client.list(query="housing", license="MIT")
|
|
27
|
+
for ds in datasets:
|
|
28
|
+
print(ds.slug, ds.row_count, ds.keyless)
|
|
29
|
+
|
|
30
|
+
# Fetch metadata + schema (works for any dataset, keyless or not)
|
|
31
|
+
ds = client.load_dataset("kenya-housing-prices")
|
|
32
|
+
print(ds.name, ds.row_count, ds.column_count)
|
|
33
|
+
for col in ds.detail.columns:
|
|
34
|
+
print(col.name, col.dtype)
|
|
35
|
+
|
|
36
|
+
# Summary stats per column — no network call, no API key needed; built from
|
|
37
|
+
# the metadata already fetched by load_dataset()
|
|
38
|
+
print(ds.describe())
|
|
39
|
+
# ┌────────┬────────┬───────┬────────────┬────────┬────────┬─────────┬──────┬────────┬────────┬────────┐
|
|
40
|
+
# │ column │ dtype │ count │ null_count │ null_% │ unique │ mean │ std │ min │ median │ max │
|
|
41
|
+
# ├────────┼────────┼───────┼────────────┼────────┼────────┼─────────┼──────┼────────┼────────┼────────┤
|
|
42
|
+
# │ year │ number │ 178 │ 0 │ 0.0 │ 16 │ 2018.50 │ 4.30 │ 2011.0 │ 2018.50 │ 2026.0 │
|
|
43
|
+
# │ month │ string │ 178 │ 0 │ 0.0 │ 12 │ NaN │ NaN │ NaN │ NaN │ NaN │
|
|
44
|
+
# └────────┴────────┴───────┴────────────┴────────┴────────┴─────────┴──────┴────────┴────────┴────────┘
|
|
45
|
+
|
|
46
|
+
# In a Jupyter/IPython notebook (or Zed's REPL), the same call renders as a
|
|
47
|
+
# bordered HTML table instead when it's the last expression in a cell.
|
|
48
|
+
|
|
49
|
+
# Want a real pandas.DataFrame for further chaining (.loc, filtering, etc.)?
|
|
50
|
+
ds.describe().to_frame()
|
|
51
|
+
|
|
52
|
+
# A compact overview — name, slug, description, AI insights, link to the
|
|
53
|
+
# dataset's Aesops page, and any linked community discussions. Also no
|
|
54
|
+
# network call, no API key needed.
|
|
55
|
+
print(ds.summary())
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Full catalog
|
|
59
|
+
|
|
60
|
+
`list()` always returns the full catalog and never sends the API key even if
|
|
61
|
+
the `Client` has one configured:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
client = Client()
|
|
65
|
+
datasets = client.list(category="finance")
|
|
66
|
+
for ds in datasets:
|
|
67
|
+
print(ds.slug, ds.keyless) # keyless tells you which need a key to load
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This is deliberate: the catalog is public for discovery/marketing purposes.
|
|
71
|
+
What's gated is loading actual data (see below).
|
|
72
|
+
|
|
73
|
+
## Loading data
|
|
74
|
+
|
|
75
|
+
Loading a dataset's actual rows (`.to_pandas()`, `.to_polars()`, `.to_duckdb()`,
|
|
76
|
+
`.sql()`, `.to_csv()`) requires a **read-scoped API key** — _unless_ the
|
|
77
|
+
dataset is currently keyless (`ds.keyless`), in which case no key is
|
|
78
|
+
needed. Create a key at
|
|
79
|
+
[aesops.co.ke/profile/api-keys](https://aesops.co.ke/profile/api-keys).
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
client = Client(api_key="Aes_...")
|
|
83
|
+
|
|
84
|
+
ds = client.load_dataset("kenya-housing-prices")
|
|
85
|
+
|
|
86
|
+
# pandas
|
|
87
|
+
df = ds.to_pandas()
|
|
88
|
+
|
|
89
|
+
# polars
|
|
90
|
+
lf = ds.to_polars()
|
|
91
|
+
|
|
92
|
+
# DuckDB (predicate + projection pushdown — only fetches the row-groups you touch)
|
|
93
|
+
con = ds.to_duckdb()
|
|
94
|
+
con.sql("SELECT county, avg(price) FROM data GROUP BY county").df()
|
|
95
|
+
|
|
96
|
+
# or use ds.sql() directly
|
|
97
|
+
ds.sql("SELECT county, avg(price) FROM data GROUP BY 1").df()
|
|
98
|
+
|
|
99
|
+
# CSV export (client-side, no extra server compute)
|
|
100
|
+
ds.to_csv("/tmp/housing.csv")
|
|
101
|
+
|
|
102
|
+
# Just want a peek? `limit` is pushed down through read_parquet, so it also
|
|
103
|
+
# cuts what crosses the network, not just what lands in the DataFrame.
|
|
104
|
+
sample = ds.to_pandas(limit=100)
|
|
105
|
+
ds.to_polars(limit=100)
|
|
106
|
+
ds.to_csv("/tmp/sample.csv", limit=100)
|
|
107
|
+
|
|
108
|
+
# for anything more specific (offset, filters, ordering) use ds.sql() directly
|
|
109
|
+
ds.sql("SELECT * FROM data ORDER BY price DESC LIMIT 20").df()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### New to DuckDB?
|
|
113
|
+
|
|
114
|
+
`ds.sql()` and `ds.to_duckdb().sql()` return a [`duckdb.DuckDBPyRelation`](https://duckdb.org/docs/stable/clients/python/reference/#duckdb.DuckDBPyRelation) —
|
|
115
|
+
a lazy query result, not a DataFrame. Call `.df()` for pandas, `.pl()` for
|
|
116
|
+
polars, `.arrow()` for an Arrow table, or `.fetchall()` for plain Python
|
|
117
|
+
tuples. If SQL or DuckDB itself is new to you: the
|
|
118
|
+
[DuckDB docs](https://duckdb.org/docs/stable/) are a good starting point, and
|
|
119
|
+
the [Python client guide](https://duckdb.org/docs/stable/clients/python/overview)
|
|
120
|
+
covers the API this SDK builds on.
|
|
121
|
+
|
|
122
|
+
## Brand colors
|
|
123
|
+
|
|
124
|
+
The Aesops chart palette is available as plain hex strings, so a chart built
|
|
125
|
+
from Aesops data can match the platform's own look — and printing a palette
|
|
126
|
+
renders actual color swatches, not just hex text:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from aesops import colors
|
|
130
|
+
|
|
131
|
+
colors.primary # '#155f6b' — brand teal
|
|
132
|
+
colors.aeschart # 6-slot categorical series for chart colors, teal-led
|
|
133
|
+
colors.dark.primary # '#D4956A' — dark mode swaps primary to terracotta
|
|
134
|
+
colors.dark.aeschart # dark mode's chart series
|
|
135
|
+
|
|
136
|
+
# See the actual chart colors, not just hex codes: in a terminal, prints
|
|
137
|
+
# each of the 6 aeschart slots as a colored block (ANSI truecolor) next to
|
|
138
|
+
# its name and hex value — separately for light and dark.
|
|
139
|
+
print(colors.light)
|
|
140
|
+
print(colors.dark)
|
|
141
|
+
|
|
142
|
+
# In a Jupyter/IPython notebook, colors.light / colors.dark render as HTML
|
|
143
|
+
# swatches automatically when they're the last expression in a cell.
|
|
144
|
+
|
|
145
|
+
# matplotlib
|
|
146
|
+
import matplotlib.pyplot as plt
|
|
147
|
+
fig, ax = plt.subplots()
|
|
148
|
+
ax.set_prop_cycle(color=list(colors.aeschart))
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
No network call, no API key required — `colors` mirrors the same design
|
|
152
|
+
tokens the Aesops web app itself uses (see `DESIGN.md`). Full field list:
|
|
153
|
+
`primary`, `accent`, `background`, `foreground`, `card`, `muted`, `border`,
|
|
154
|
+
`success`, `destructive` (each with a `_foreground` counterpart where
|
|
155
|
+
relevant), and `aeschart` — on both the top-level (light) module and
|
|
156
|
+
`colors.light`/`colors.dark` explicitly.
|
|
157
|
+
|
|
158
|
+
## Context manager
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
with Client(api_key="Aes_...") as client:
|
|
162
|
+
df = client.load_dataset("kenya-housing-prices").to_pandas()
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Error handling
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from aesops import AuthError, NotFoundError, ApiError
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
ds = client.load_dataset("does-not-exist")
|
|
172
|
+
except NotFoundError:
|
|
173
|
+
print("dataset not found")
|
|
174
|
+
except AuthError:
|
|
175
|
+
print("invalid or missing API key")
|
|
176
|
+
except ApiError as e:
|
|
177
|
+
print(f"API error {e.status_code}: {e}")
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Notes
|
|
181
|
+
|
|
182
|
+
- Signed URLs are fetched **lazily** at query time (not at `load_dataset`) and
|
|
183
|
+
automatically re-fetched on expiry or a 403 from storage, so long-running
|
|
184
|
+
sessions never stall.
|
|
185
|
+
- All format conversion (CSV, Arrow, etc.) happens locally — zero extra server
|
|
186
|
+
compute.
|
|
187
|
+
- `list()` and `load_dataset()` always work without an `api_key` — the full
|
|
188
|
+
catalog and every dataset's metadata are public. Loading actual data
|
|
189
|
+
(`.to_pandas()`, `.to_polars()`, `.to_duckdb()`, `.sql()`, `.to_csv()`)
|
|
190
|
+
requires a key unless the dataset is currently keyless (`ds.keyless`).
|
|
191
|
+
- `.describe()` and `.summary()` never touch the network or require a key —
|
|
192
|
+
they summarize the metadata `load_dataset()` already fetched. `.describe()`
|
|
193
|
+
returns a `DescribeTable` (bordered text/HTML display, no pandas required);
|
|
194
|
+
call `.to_frame()` on it for a real `pandas.DataFrame`. `.summary()` returns
|
|
195
|
+
a `Summary` (same bordered text/HTML display) with name, slug, description,
|
|
196
|
+
AI insights, a link to the dataset's Aesops page, and any linked community
|
|
197
|
+
discussions.
|
|
198
|
+
- Datasets are always served at their latest active version — there's no
|
|
199
|
+
version pinning.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aesops"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python client for the Aesops dataset API — browse the catalog and load datasets into pandas, polars, or DuckDB."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
"duckdb>=1.1",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
pandas = ["pandas>=2.0"]
|
|
15
|
+
# duckdb's relation.pl() converts via Arrow under the hood, so pyarrow is a
|
|
16
|
+
# real (if non-obvious) requirement for polars output, not just polars itself.
|
|
17
|
+
polars = ["polars>=1.0", "pyarrow>=14"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/aesops"]
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"ipykernel>=6.31.0",
|
|
29
|
+
"matplotlib>=3.9.4",
|
|
30
|
+
"pandas>=2.3.3",
|
|
31
|
+
"pytest>=8.0",
|
|
32
|
+
"respx>=0.21",
|
|
33
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Aesops Python SDK — browse the catalog and load datasets into pandas, polars, or DuckDB."""
|
|
2
|
+
|
|
3
|
+
from . import colors
|
|
4
|
+
from .client import Client
|
|
5
|
+
from .dataset import Dataset, DescribeTable, Summary
|
|
6
|
+
from .errors import AesopsError, ApiError, AuthError, NotFoundError
|
|
7
|
+
from .models import Column, DatasetDetail, DatasetSummary, Discussion
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Client",
|
|
11
|
+
"Dataset",
|
|
12
|
+
"DescribeTable",
|
|
13
|
+
"Summary",
|
|
14
|
+
"colors",
|
|
15
|
+
# models
|
|
16
|
+
"Column",
|
|
17
|
+
"DatasetDetail",
|
|
18
|
+
"DatasetSummary",
|
|
19
|
+
"Discussion",
|
|
20
|
+
# errors
|
|
21
|
+
"AesopsError",
|
|
22
|
+
"ApiError",
|
|
23
|
+
"AuthError",
|
|
24
|
+
"NotFoundError",
|
|
25
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .dataset import Dataset
|
|
8
|
+
from .errors import ApiError, AuthError, NotFoundError
|
|
9
|
+
from .models import DatasetDetail, DatasetSummary, _from_json
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE_URL = "https://aesops.co.ke"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _error_message(resp: httpx.Response) -> str:
|
|
15
|
+
try:
|
|
16
|
+
return resp.json().get("error", resp.text)
|
|
17
|
+
except ValueError:
|
|
18
|
+
return resp.text
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Client:
|
|
22
|
+
"""Client for the Aesops dataset API.
|
|
23
|
+
|
|
24
|
+
`list()` and `load_dataset()` work without an `api_key` and always show
|
|
25
|
+
the full catalog. Loading actual data (`Dataset.to_pandas()` etc.) only
|
|
26
|
+
requires a read-scoped key for datasets that aren't currently keyless —
|
|
27
|
+
create one at https://aesops.co.ke/profile/api-keys.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
api_key: str | None = None,
|
|
33
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
34
|
+
timeout: float = 30.0,
|
|
35
|
+
):
|
|
36
|
+
self.api_key = api_key
|
|
37
|
+
self.base_url = base_url.rstrip("/")
|
|
38
|
+
self._http = httpx.Client(base_url=self.base_url, timeout=timeout)
|
|
39
|
+
|
|
40
|
+
def __enter__(self) -> "Client":
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def __exit__(self, *exc: object) -> None:
|
|
44
|
+
self.close()
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
self._http.close()
|
|
48
|
+
|
|
49
|
+
def _request(self, method: str, path: str, *, auth: bool = True, **kwargs: Any) -> dict[str, Any]:
|
|
50
|
+
headers = kwargs.pop("headers", {})
|
|
51
|
+
if auth and self.api_key:
|
|
52
|
+
headers.setdefault("Authorization", f"Bearer {self.api_key}")
|
|
53
|
+
resp = self._http.request(method, path, headers=headers, **kwargs)
|
|
54
|
+
if resp.status_code == 401:
|
|
55
|
+
raise AuthError(_error_message(resp))
|
|
56
|
+
if resp.status_code == 404:
|
|
57
|
+
raise NotFoundError(_error_message(resp))
|
|
58
|
+
if resp.status_code >= 400:
|
|
59
|
+
raise ApiError(resp.status_code, _error_message(resp))
|
|
60
|
+
return resp.json()
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _csv(v: str | list[str] | None) -> str | None:
|
|
64
|
+
if v is None:
|
|
65
|
+
return None
|
|
66
|
+
return v if isinstance(v, str) else ",".join(v)
|
|
67
|
+
|
|
68
|
+
def _list_params(
|
|
69
|
+
self,
|
|
70
|
+
query: str | None,
|
|
71
|
+
license: str | list[str] | None,
|
|
72
|
+
category: str | list[str] | None,
|
|
73
|
+
tags: str | list[str] | None,
|
|
74
|
+
min_size: int | None,
|
|
75
|
+
max_size: int | None,
|
|
76
|
+
min_rows: int | None,
|
|
77
|
+
max_rows: int | None,
|
|
78
|
+
page: int,
|
|
79
|
+
page_size: int,
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
params = {
|
|
82
|
+
"query": query,
|
|
83
|
+
"license": self._csv(license),
|
|
84
|
+
"category": self._csv(category),
|
|
85
|
+
"tags": self._csv(tags),
|
|
86
|
+
"min_size": min_size,
|
|
87
|
+
"max_size": max_size,
|
|
88
|
+
"min_rows": min_rows,
|
|
89
|
+
"max_rows": max_rows,
|
|
90
|
+
"page": page,
|
|
91
|
+
"page_size": page_size,
|
|
92
|
+
}
|
|
93
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
94
|
+
|
|
95
|
+
def list(
|
|
96
|
+
self,
|
|
97
|
+
query: str | None = None,
|
|
98
|
+
license: str | list[str] | None = None,
|
|
99
|
+
category: str | list[str] | None = None,
|
|
100
|
+
tags: str | list[str] | None = None,
|
|
101
|
+
min_size: int | None = None,
|
|
102
|
+
max_size: int | None = None,
|
|
103
|
+
min_rows: int | None = None,
|
|
104
|
+
max_rows: int | None = None,
|
|
105
|
+
page: int = 1,
|
|
106
|
+
page_size: int = 20,
|
|
107
|
+
) -> list[DatasetSummary]:
|
|
108
|
+
"""List the full dataset catalog. No API key required or sent; every
|
|
109
|
+
dataset's metadata is public. Check `.keyless` on each result, or see
|
|
110
|
+
`load_dataset()`, to know which need a key to load actual data."""
|
|
111
|
+
params = self._list_params(
|
|
112
|
+
query, license, category, tags, min_size, max_size, min_rows, max_rows, page, page_size
|
|
113
|
+
)
|
|
114
|
+
data = self._request("GET", "/api/v1/datasets", params=params, auth=False)
|
|
115
|
+
return [_from_json(DatasetSummary, item) for item in data["items"]]
|
|
116
|
+
|
|
117
|
+
def load_dataset(self, slug: str) -> Dataset:
|
|
118
|
+
"""Fetch a dataset's metadata + schema (always the latest active
|
|
119
|
+
version). No API key required — a key is only needed once you call
|
|
120
|
+
`.to_pandas()` / `.to_polars()` / `.sql()`, and only if the dataset
|
|
121
|
+
isn't currently keyless."""
|
|
122
|
+
data = self._request("GET", f"/api/v1/datasets/{slug}")
|
|
123
|
+
return Dataset(self, slug=data["slug"], detail=_from_json(DatasetDetail, data))
|
|
124
|
+
|
|
125
|
+
def _fetch_data_url(self, slug: str) -> dict[str, Any]:
|
|
126
|
+
return self._request("GET", f"/api/v1/datasets/{slug}/data")
|