aesops 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
aesops/client.py ADDED
@@ -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")
aesops/colors.py ADDED
@@ -0,0 +1,142 @@
1
+ """Aesops brand color tokens, for building charts/dashboards that match the
2
+ platform's own design system.
3
+
4
+ Mirrors the CSS custom properties in packages/ui/src/styles/globals.css —
5
+ keep both in sync if the brand palette changes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
14
+ h = hex_color.lstrip("#")
15
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
16
+
17
+
18
+ def _ansi_swatch(hex_color: str) -> str:
19
+ r, g, b = _hex_to_rgb(hex_color)
20
+ return f"\x1b[48;2;{r};{g};{b}m \x1b[0m"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Palette:
25
+ """A color palette matching one of Aesops' light/dark CSS themes.
26
+
27
+ Printing a palette (``print(colors.light)``, ``print(colors.dark)``) or
28
+ displaying it in a Jupyter/IPython notebook renders its 6 `aeschart`
29
+ chart-series swatches — the colors you'd actually plot with — as actual
30
+ color chips next to their name and hex value, rather than just hex text.
31
+ """
32
+
33
+ primary: str
34
+ primary_foreground: str
35
+ accent: str
36
+ accent_foreground: str
37
+ background: str
38
+ foreground: str
39
+ card: str
40
+ card_foreground: str
41
+ muted: str
42
+ muted_foreground: str
43
+ border: str
44
+ success: str
45
+ success_foreground: str
46
+ destructive: str
47
+ destructive_foreground: str
48
+ # 6-slot categorical series for charts, in fixed order: teal, peach, rust,
49
+ # sage, orange, brown. Slot 1 is always the brand primary teal. See
50
+ # DESIGN.md's chart palette section for how the other 5 slots were
51
+ # derived (packages/ui/src/components/logo.tsx's 9-stop gradient, nudged
52
+ # in OKLCH to clear categorical lightness/chroma checks).
53
+ aeschart: tuple[str, str, str, str, str, str]
54
+
55
+ def _swatches(self) -> list[tuple[str, str]]:
56
+ return [(f"aeschart[{i}]", hex_color) for i, hex_color in enumerate(self.aeschart)]
57
+
58
+ def __repr__(self) -> str:
59
+ name_w = max(len(name) for name, _ in self._swatches())
60
+ lines = [
61
+ f"{_ansi_swatch(hex_color)} {name.ljust(name_w)} {hex_color}"
62
+ for name, hex_color in self._swatches()
63
+ ]
64
+ return "\n".join(lines)
65
+
66
+ __str__ = __repr__
67
+
68
+ def _repr_html_(self) -> str:
69
+ """Rich display hook Jupyter/IPython (incl. Zed's REPL) call
70
+ automatically for the last expression in a cell."""
71
+ cell = (
72
+ "display:flex;align-items:center;gap:8px;"
73
+ "font-family:monospace;padding:2px 0"
74
+ )
75
+ swatch = "width:16px;height:16px;border-radius:3px;border:1px solid rgba(0,0,0,0.15)"
76
+ rows = "".join(
77
+ f"<div style='{cell}'>"
78
+ f"<span style='{swatch};background:{hex_color}'></span>"
79
+ f"<span>{name}</span><span style='color:#888'>{hex_color}</span>"
80
+ "</div>"
81
+ for name, hex_color in self._swatches()
82
+ )
83
+ return f"<div>{rows}</div>"
84
+
85
+
86
+ light = Palette(
87
+ primary="#155f6b",
88
+ primary_foreground="#F8F3ED",
89
+ accent="#D4956A",
90
+ accent_foreground="#0A2533",
91
+ background="#F8F3ED",
92
+ foreground="#0A2533",
93
+ card="#FFFCF8",
94
+ card_foreground="#0A2533",
95
+ muted="#F0EBE4",
96
+ muted_foreground="#5C6B6E",
97
+ border="#E5DED4",
98
+ success="#5A8A7A",
99
+ success_foreground="#F8F3ED",
100
+ destructive="#A65D4A",
101
+ destructive_foreground="#F8F3ED",
102
+ aeschart=("#155f6b", "#e89f53", "#bc5308", "#4ab59e", "#ff7d00", "#86361d"),
103
+ )
104
+
105
+ dark = Palette(
106
+ primary="#D4956A",
107
+ primary_foreground="#0A2533",
108
+ accent="#E8C9A0",
109
+ accent_foreground="#0A2533",
110
+ background="#0A2533",
111
+ foreground="#F0EBE4",
112
+ card="#0F3040",
113
+ card_foreground="#F0EBE4",
114
+ muted="#153545",
115
+ muted_foreground="#9BB3AC",
116
+ border="#1A4050",
117
+ success="#6A9A8A",
118
+ success_foreground="#0A2533",
119
+ destructive="#A65D4A",
120
+ destructive_foreground="#F0EBE4",
121
+ aeschart=("#238595", "#c68031", "#bc5308", "#3ba892", "#e76700", "#934229"),
122
+ )
123
+
124
+ # Module-level attributes mirror `light` so `from aesops import colors;
125
+ # colors.primary` works directly, without an extra `.light`. Use `colors.dark`
126
+ # for the dark-theme palette.
127
+ primary = light.primary
128
+ primary_foreground = light.primary_foreground
129
+ accent = light.accent
130
+ accent_foreground = light.accent_foreground
131
+ background = light.background
132
+ foreground = light.foreground
133
+ card = light.card
134
+ card_foreground = light.card_foreground
135
+ muted = light.muted
136
+ muted_foreground = light.muted_foreground
137
+ border = light.border
138
+ success = light.success
139
+ success_foreground = light.success_foreground
140
+ destructive = light.destructive
141
+ destructive_foreground = light.destructive_foreground
142
+ aeschart = light.aeschart
aesops/dataset.py ADDED
@@ -0,0 +1,422 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import threading
5
+ import time
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import duckdb
9
+
10
+ from .models import Discussion
11
+
12
+ if TYPE_CHECKING:
13
+ from .client import Client
14
+
15
+
16
+ _DESCRIBE_FIELDS = ["dtype", "count", "null_count", "null_%", "unique", "mean", "std", "min", "median", "max"]
17
+
18
+
19
+ def _describe_fmt(v: Any) -> str:
20
+ if v is None:
21
+ return "NaN"
22
+ if isinstance(v, float):
23
+ return f"{v:.2f}"
24
+ return str(v)
25
+
26
+
27
+ def _clip(value: Any, limit: int = 100) -> str:
28
+ if not value:
29
+ return "—"
30
+ text = " ".join(str(value).split())
31
+ return text if len(text) <= limit else text[: limit - 1] + "…"
32
+
33
+
34
+ class Summary:
35
+ """`Dataset.summary()`'s return value — a compact overview of a dataset's
36
+ identity, description, AI-generated insights, link back to its Aesops
37
+ page, and any linked community discussions. Built entirely from
38
+ metadata already fetched by `load_dataset()`, so this makes no network
39
+ call.
40
+
41
+ Renders with box-drawing borders when printed in a terminal, and as a
42
+ bordered HTML table when the last expression in a Jupyter/IPython
43
+ notebook cell (via `_repr_html_`).
44
+
45
+ A future addition will link to blog articles referencing the dataset,
46
+ once blog-to-dataset linking exists server-side (community discussions
47
+ already support an analogous `linked_blog_*` association on threads).
48
+ """
49
+
50
+ def __init__(self, dataset: "Dataset") -> None:
51
+ self._dataset = dataset
52
+
53
+ @property
54
+ def discussions(self) -> list[Discussion]:
55
+ return self._dataset.detail.discussion_links
56
+
57
+ def _rows(self) -> list[tuple[str, str]]:
58
+ d = self._dataset.detail
59
+ discussions = (
60
+ "; ".join(f"{disc.title} ({self._dataset.link(disc.path)})" for disc in self.discussions)
61
+ if self.discussions
62
+ else "—"
63
+ )
64
+ return [
65
+ ("name", d.name),
66
+ ("slug", d.slug),
67
+ ("description", _clip(d.description)),
68
+ ("insights", _clip(d.insights, limit=200)),
69
+ ("url", self._dataset.url),
70
+ ("discussions", discussions),
71
+ ]
72
+
73
+ def __repr__(self) -> str:
74
+ rows = self._rows()
75
+ label_w = max(len(r[0]) for r in rows)
76
+ value_w = max(len(r[1]) for r in rows)
77
+
78
+ def border(left: str, mid: str, right: str) -> str:
79
+ return left + mid.join("─" * (w + 2) for w in (label_w, value_w)) + right
80
+
81
+ def row_line(label: str, value: str) -> str:
82
+ return f"│ {label.ljust(label_w)} │ {value.ljust(value_w)} │"
83
+
84
+ lines = [
85
+ border("┌", "┬", "┐"),
86
+ *(row_line(*r) for r in rows),
87
+ border("└", "┴", "┘"),
88
+ ]
89
+ return "\n".join(lines)
90
+
91
+ __str__ = __repr__
92
+
93
+ def _repr_html_(self) -> str:
94
+ """Rich display hook Jupyter/IPython (incl. Zed's REPL) call
95
+ automatically for the last expression in a cell."""
96
+ rows = self._rows()
97
+ cell_style = "padding:4px 10px;border:1px solid var(--jp-border-color2,#ccc);text-align:left"
98
+ body = "".join(
99
+ f"<tr><td style='{cell_style};font-weight:600'>{label}</td>"
100
+ f"<td style='{cell_style}'>{value}</td></tr>"
101
+ for label, value in rows
102
+ )
103
+ return f"<table style='border-collapse:collapse'>{body}</table>"
104
+
105
+
106
+ class DescribeTable:
107
+ """`Dataset.describe()`'s return value — a small, dependency-free table.
108
+
109
+ Renders with box-drawing borders when printed in a terminal, and as a
110
+ bordered HTML `<table>` when the last expression in a Jupyter/IPython
111
+ notebook cell (via `_repr_html_`), so it looks right either way without
112
+ requiring pandas. Call `.to_frame()` for a real `pandas.DataFrame` (e.g.
113
+ to `.loc[...]` or chain further) or `.to_dicts()` for plain rows.
114
+ """
115
+
116
+ def __init__(self, rows: list[dict[str, Any]]) -> None:
117
+ self._rows = rows
118
+
119
+ def to_dicts(self) -> list[dict[str, Any]]:
120
+ """The underlying rows, one dict per column."""
121
+ return self._rows
122
+
123
+ def to_frame(self):
124
+ """The same data as a `pandas.DataFrame`, indexed by column name.
125
+
126
+ Requires ``pandas`` (``pip install 'aesops[pandas]'``).
127
+ """
128
+ try:
129
+ import pandas as pd
130
+ except ImportError as exc:
131
+ raise ImportError(
132
+ "pandas is not installed. Install it with: pip install 'aesops[pandas]'"
133
+ ) from exc
134
+ return pd.DataFrame(self._rows).set_index("column")
135
+
136
+ def __len__(self) -> int:
137
+ return len(self._rows)
138
+
139
+ def _cells(self) -> tuple[list[str], list[list[str]]]:
140
+ headers = ["column", *_DESCRIBE_FIELDS]
141
+ rows = [
142
+ [str(row["column"]), *(_describe_fmt(row.get(f)) for f in _DESCRIBE_FIELDS)]
143
+ for row in self._rows
144
+ ]
145
+ return headers, rows
146
+
147
+ def __repr__(self) -> str:
148
+ if not self._rows:
149
+ return "<empty>"
150
+
151
+ headers, rows = self._cells()
152
+ widths = [max(len(headers[i]), *(len(r[i]) for r in rows)) for i in range(len(headers))]
153
+
154
+ def border(left: str, mid: str, right: str) -> str:
155
+ return left + mid.join("─" * (w + 2) for w in widths) + right
156
+
157
+ def row_line(cells: list[str]) -> str:
158
+ return "│ " + " │ ".join(c.rjust(w) for c, w in zip(cells, widths)) + " │"
159
+
160
+ lines = [
161
+ border("┌", "┬", "┐"),
162
+ row_line(headers),
163
+ border("├", "┼", "┤"),
164
+ *(row_line(r) for r in rows),
165
+ border("└", "┴", "┘"),
166
+ ]
167
+ return "\n".join(lines)
168
+
169
+ __str__ = __repr__
170
+
171
+ def _repr_html_(self) -> str:
172
+ """Rich display hook Jupyter/IPython (incl. Zed's REPL) call
173
+ automatically for the last expression in a cell."""
174
+ if not self._rows:
175
+ return "<em>empty</em>"
176
+
177
+ headers, rows = self._cells()
178
+ cell_style = "padding:4px 10px;border:1px solid var(--jp-border-color2,#ccc);text-align:right"
179
+ thead = "".join(f"<th style='{cell_style}'>{h}</th>" for h in headers)
180
+ tbody = "".join(
181
+ "<tr>" + "".join(f"<td style='{cell_style}'>{c}</td>" for c in r) + "</tr>" for r in rows
182
+ )
183
+ return f"<table style='border-collapse:collapse'><thead><tr>{thead}</tr></thead><tbody>{tbody}</tbody></table>"
184
+
185
+
186
+ class Dataset:
187
+ """A handle to a single Aesops dataset — always the latest active version.
188
+
189
+ Metadata is available immediately (no network required after construction).
190
+ Data access (:meth:`to_pandas`, :meth:`to_polars`, :meth:`to_duckdb`,
191
+ :meth:`sql`) fetches a short-lived signed Parquet URL lazily on first use
192
+ and re-fetches automatically on expiry or 403, so long-running sessions
193
+ never stall waiting for a new URL.
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ client: "Client",
199
+ slug: str,
200
+ detail: DatasetDetail,
201
+ ) -> None:
202
+ self._client = client
203
+ self.slug = slug
204
+ self.detail = detail
205
+ self._url: str | None = None
206
+ self._url_expires_at: float = 0.0 # unix timestamp
207
+ self._lock = threading.Lock()
208
+ self._conn: duckdb.DuckDBPyConnection | None = None
209
+
210
+ # ── metadata shortcuts ────────────────────────────────────────────────────
211
+
212
+ @property
213
+ def name(self) -> str:
214
+ return self.detail.name
215
+
216
+ @property
217
+ def row_count(self) -> int | None:
218
+ return self.detail.row_count
219
+
220
+ @property
221
+ def column_count(self) -> int | None:
222
+ return self.detail.column_count
223
+
224
+ @property
225
+ def schema(self) -> list[dict[str, Any]]:
226
+ return self.detail.schema
227
+
228
+ def link(self, path: str) -> str:
229
+ """Join a server-relative path (e.g. a `Discussion.path`) with this
230
+ dataset's client's own `base_url` — works against whatever host the
231
+ `Client` was configured for (production, preview, or local dev),
232
+ rather than hardcoding a domain."""
233
+ return f"{self._client.base_url}{path}"
234
+
235
+ @property
236
+ def url(self) -> str:
237
+ """Link to this dataset's page on the Aesops site, built from the
238
+ client's `base_url`."""
239
+ return self.link(f"/datasets/{self.slug}")
240
+
241
+ def describe(self) -> DescribeTable:
242
+ """Per-column summary statistics — count, nulls, unique values, and
243
+ (for numeric columns) mean/std/min/median/max — built entirely from
244
+ metadata already fetched by `load_dataset()`, so this makes no network
245
+ call and never requires pandas.
246
+
247
+ Returns a `DescribeTable`: prints with box-drawing borders, renders
248
+ as a bordered HTML table in a notebook, and has `.to_frame()` if you
249
+ want a real `pandas.DataFrame`::
250
+
251
+ print(ds.describe())
252
+ ds.describe().to_frame().loc["price"]
253
+ """
254
+ rows = []
255
+ for col in self.detail.columns:
256
+ count = None if self.row_count is None else self.row_count - col.null_count
257
+ rows.append(
258
+ {
259
+ "column": col.name,
260
+ "dtype": col.dtype,
261
+ "count": count,
262
+ "null_count": col.null_count,
263
+ "null_%": col.null_percent,
264
+ "unique": col.unique_count,
265
+ "mean": col.mean,
266
+ "std": col.std,
267
+ "min": col.min,
268
+ "median": col.median,
269
+ "max": col.max,
270
+ }
271
+ )
272
+ return DescribeTable(rows)
273
+
274
+ def summary(self) -> Summary:
275
+ """A compact overview — name, slug, description, AI insights, a link
276
+ to the dataset's Aesops page, and any linked community discussions.
277
+ Built from metadata already fetched by `load_dataset()`, so this
278
+ makes no network call::
279
+
280
+ print(ds.summary())
281
+ """
282
+ return Summary(self)
283
+
284
+ # ── URL management ────────────────────────────────────────────────────────
285
+
286
+ def _get_url(self) -> str:
287
+ """Return a valid signed Parquet URL, refreshing proactively if it
288
+ expires within the next 60 seconds."""
289
+ with self._lock:
290
+ if self._url is None or time.time() > (self._url_expires_at - 60):
291
+ self._refresh_url()
292
+ return self._url # type: ignore[return-value]
293
+
294
+ def _refresh_url(self) -> None:
295
+ resp = self._client._fetch_data_url(self.slug)
296
+ self._url = resp["url"]
297
+ expires_str: str = resp.get("expires_at", "")
298
+ try:
299
+ expires_str = expires_str.replace("Z", "+00:00")
300
+ dt = datetime.datetime.fromisoformat(expires_str)
301
+ self._url_expires_at = dt.timestamp()
302
+ except (ValueError, AttributeError):
303
+ # Fallback: assume the server's 1 h TTL
304
+ self._url_expires_at = time.time() + 3600
305
+
306
+ def _invalidate_url(self) -> None:
307
+ with self._lock:
308
+ self._url = None
309
+ self._url_expires_at = 0.0
310
+
311
+ # ── DuckDB helpers ────────────────────────────────────────────────────────
312
+
313
+ def _open_conn(self) -> duckdb.DuckDBPyConnection:
314
+ """Open an in-memory DuckDB connection with httpfs loaded and a ``data``
315
+ view pointing at the signed Parquet URL.
316
+
317
+ Stored on the instance (`self._conn`) rather than only returned —
318
+ whether a relation keeps its parent connection alive on its own is
319
+ duckdb-version-dependent, so without this a relation could be handed
320
+ a connection that's already been garbage-collected out from under it
321
+ by the time `.fetchall()`/`.df()`/`.pl()` actually runs.
322
+ """
323
+ url = self._get_url()
324
+ conn = duckdb.connect()
325
+ conn.execute("INSTALL httpfs; LOAD httpfs;")
326
+ conn.execute(f"CREATE VIEW data AS SELECT * FROM read_parquet('{url}')")
327
+ self._conn = conn
328
+ return conn
329
+
330
+ def _run(self, sql_str: str) -> duckdb.DuckDBPyRelation:
331
+ """Execute *sql_str*, retrying once with a fresh URL on an R2 403."""
332
+ try:
333
+ return self._open_conn().sql(sql_str)
334
+ except duckdb.IOException as exc:
335
+ msg = str(exc)
336
+ if "403" in msg or "Access Denied" in msg or "Forbidden" in msg:
337
+ self._invalidate_url()
338
+ return self._open_conn().sql(sql_str)
339
+ raise
340
+
341
+ # ── public data methods ───────────────────────────────────────────────────
342
+
343
+ def to_duckdb(self) -> duckdb.DuckDBPyConnection:
344
+ """Return an in-memory DuckDB connection with a ``data`` view bound to
345
+ the signed Parquet URL.
346
+
347
+ DuckDB's httpfs extension performs predicate- and projection-pushdown,
348
+ so only the row-groups your query touches are fetched from R2::
349
+
350
+ con = ds.to_duckdb()
351
+ con.sql("SELECT county, avg(price) FROM data GROUP BY county").df()
352
+ """
353
+ return self._open_conn()
354
+
355
+ def sql(self, query: str) -> duckdb.DuckDBPyRelation:
356
+ """Run a SQL query against this dataset. Reference the dataset as
357
+ ``data``::
358
+
359
+ ds.sql("SELECT county, avg(price) FROM data GROUP BY 1")
360
+
361
+ Returns a :class:`duckdb.DuckDBPyRelation`; call ``.df()`` for pandas
362
+ or ``.pl()`` for polars.
363
+ """
364
+ return self._run(query)
365
+
366
+ @staticmethod
367
+ def _select(limit: int | None) -> str:
368
+ if limit is None:
369
+ return "SELECT * FROM data"
370
+ if limit < 0:
371
+ raise ValueError("limit must be >= 0")
372
+ return f"SELECT * FROM data LIMIT {limit}"
373
+
374
+ def to_pandas(self, limit: int | None = None):
375
+ """Load the dataset into a :class:`pandas.DataFrame`.
376
+
377
+ Pass ``limit`` to fetch only the first N rows — DuckDB pushes the
378
+ LIMIT down through ``read_parquet``, so it also cuts how much of the
379
+ file crosses the network, not just what ends up in the DataFrame.
380
+
381
+ Requires ``pandas`` (``pip install 'aesops[pandas]'``).
382
+ """
383
+ try:
384
+ import pandas # noqa: F401
385
+ except ImportError as exc:
386
+ raise ImportError(
387
+ "pandas is not installed. "
388
+ "Install it with: pip install 'aesops[pandas]'"
389
+ ) from exc
390
+ return self._run(self._select(limit)).df()
391
+
392
+ def to_polars(self, limit: int | None = None):
393
+ """Load the dataset into a :class:`polars.DataFrame`.
394
+
395
+ Pass ``limit`` to fetch only the first N rows (see `to_pandas`).
396
+
397
+ Requires ``polars`` (``pip install 'aesops[polars]'``).
398
+ """
399
+ try:
400
+ import polars # noqa: F401
401
+ except ImportError as exc:
402
+ raise ImportError(
403
+ "polars is not installed. "
404
+ "Install it with: pip install 'aesops[polars]'"
405
+ ) from exc
406
+ return self._run(self._select(limit)).pl()
407
+
408
+ def to_csv(self, path: str, limit: int | None = None) -> None:
409
+ """Write the dataset to a CSV file (client-side conversion).
410
+
411
+ Pass ``limit`` to write only the first N rows (see `to_pandas`).
412
+
413
+ No extra server compute — DuckDB reads the Parquet directly and
414
+ writes the CSV locally.
415
+ """
416
+ self._run(f"COPY ({self._select(limit)}) TO '{path}' (FORMAT CSV, HEADER TRUE)")
417
+
418
+ def __repr__(self) -> str:
419
+ return (
420
+ f"<Dataset slug={self.slug!r} "
421
+ f"rows={self.detail.row_count} cols={self.detail.column_count}>"
422
+ )
aesops/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ class AesopsError(Exception):
2
+ """Base class for all aesops SDK errors."""
3
+
4
+
5
+ class NotFoundError(AesopsError):
6
+ """Raised when a dataset or version doesn't exist."""
7
+
8
+
9
+ class AuthError(AesopsError):
10
+ """Raised when an API key is missing, invalid, or lacks the required scope.
11
+
12
+ A read key is only required for `Client.download` / `Dataset.to_*` — listing
13
+ and metadata lookups work without one.
14
+ """
15
+
16
+
17
+ class ApiError(AesopsError):
18
+ """Raised for any other non-2xx response from the API."""
19
+
20
+ def __init__(self, status_code: int, message: str):
21
+ super().__init__(f"[{status_code}] {message}")
22
+ self.status_code = status_code
aesops/models.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, fields
4
+ from typing import Any, TypeVar
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ def _from_json(cls: type[T], data: dict[str, Any]) -> T:
10
+ """Build a dataclass from an API response, ignoring unknown fields so a new
11
+ field the server adds later doesn't break older SDK versions."""
12
+ known = {f.name for f in fields(cls)}
13
+ return cls(**{k: v for k, v in data.items() if k in known})
14
+
15
+
16
+ @dataclass
17
+ class Column:
18
+ name: str
19
+ dtype: str
20
+ null_count: int
21
+ null_percent: float
22
+ unique_count: int
23
+ mean: float | None = None
24
+ std: float | None = None
25
+ min: float | None = None
26
+ max: float | None = None
27
+ median: float | None = None
28
+ top_values: list[dict[str, Any]] | None = None
29
+ sample_values: list[Any] | None = None
30
+
31
+
32
+ @dataclass
33
+ class DatasetSummary:
34
+ slug: str
35
+ name: str
36
+ description: Any
37
+ license: str | None
38
+ category: str | None
39
+ tags: list[str]
40
+ size: int
41
+ row_count: int | None
42
+ column_count: int | None
43
+ keyless: bool
44
+ keyless_until: str | None
45
+ created_at: str
46
+ updated_at: str
47
+
48
+
49
+ @dataclass
50
+ class Discussion:
51
+ id: str
52
+ title: str
53
+ slug: str | None
54
+ # Relative to the API's host (e.g. "/community/discussions/<id>") — build
55
+ # a full link by joining with the Client's own `base_url`.
56
+ path: str
57
+
58
+
59
+ @dataclass
60
+ class DatasetDetail:
61
+ slug: str
62
+ name: str
63
+ description: Any
64
+ license: str | None
65
+ source: str | None
66
+ category: str | None
67
+ tags: list[str]
68
+ size: int
69
+ row_count: int | None
70
+ column_count: int | None
71
+ schema: list[dict[str, Any]]
72
+ keyless: bool
73
+ keyless_until: str | None
74
+ created_at: str
75
+ updated_at: str
76
+ insights: str | None = None
77
+ discussions: list[dict[str, Any]] = field(default_factory=list)
78
+
79
+ @property
80
+ def columns(self) -> list[Column]:
81
+ return [_from_json(Column, c) for c in self.schema]
82
+
83
+ @property
84
+ def discussion_links(self) -> list[Discussion]:
85
+ return [_from_json(Discussion, d) for d in self.discussions]
@@ -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.
@@ -0,0 +1,10 @@
1
+ aesops/__init__.py,sha256=bkUOqg11LHI2BzEi9DRpw_XHx6f5ASOvrmwQIVN52t4,605
2
+ aesops/client.py,sha256=UNRmFSPuwcrv8oaYSA8VlVfPaV6tXSgHJhETAKB-DSw,4457
3
+ aesops/colors.py,sha256=5vAXlL7CE7TUqpOI4ZwgyFqLdOJ0cZ59qDJsJdPwS7E,4636
4
+ aesops/dataset.py,sha256=NI1xbTXSWoiccLeG-7Ft7PyzmBpEkV0lV6im58NVyPg,15865
5
+ aesops/errors.py,sha256=8LGBlQCI_n7DlXmtYtScKe3HX1Tnbt2yD1KJXtp-Lvo,663
6
+ aesops/models.py,sha256=kUHDEFSvbMnjIgd-EvpC1MOB-8QKgtcvm97le8BbJpo,2075
7
+ aesops-0.1.0.dist-info/METADATA,sha256=iQT5tFXN-Rp9zmV9lBNhe45G25uTIqxqoJK9JTBXzWc,8724
8
+ aesops-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ aesops-0.1.0.dist-info/licenses/LICENSE,sha256=VnKC0z_OU6qUKncNEPwGULw3Q58557DbRIw6hPf5drY,1063
10
+ aesops-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.