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 +25 -0
- aesops/client.py +126 -0
- aesops/colors.py +142 -0
- aesops/dataset.py +422 -0
- aesops/errors.py +22 -0
- aesops/models.py +85 -0
- aesops-0.1.0.dist-info/METADATA +215 -0
- aesops-0.1.0.dist-info/RECORD +10 -0
- aesops-0.1.0.dist-info/WHEEL +4 -0
- aesops-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|