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.
@@ -0,0 +1,6 @@
1
+ .venv
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ scratch.py
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,5 @@
1
+ {
2
+ "name": "@aesops/sdk-python",
3
+ "version": "0.1.0",
4
+ "private": true
5
+ }
@@ -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")