Metadata-Version: 2.4
Name: aesops
Version: 0.1.0
Summary: Python client for the Aesops dataset API — browse the catalog and load datasets into pandas, polars, or DuckDB.
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.9
Requires-Dist: duckdb>=1.1
Requires-Dist: httpx>=0.27
Provides-Extra: pandas
Requires-Dist: pandas>=2.0; extra == 'pandas'
Provides-Extra: polars
Requires-Dist: polars>=1.0; extra == 'polars'
Requires-Dist: pyarrow>=14; extra == 'polars'
Description-Content-Type: text/markdown

# aesops

Python client for the [Aesops](https://aesops.co.ke) dataset API.

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).

## Install

```bash
pip install aesops                  # core: httpx + duckdb
pip install 'aesops[pandas]'        # + pandas
pip install 'aesops[polars]'        # + polars
pip install 'aesops[pandas,polars]' # both
```

Requires Python 3.9+.

## Quickstart

```python
from aesops import Client

# No key needed to browse — the full catalog is public
client = Client()

datasets = client.list(query="housing", license="MIT")
for ds in datasets:
    print(ds.slug, ds.row_count, ds.keyless)

# Fetch metadata + schema (works for any dataset, keyless or not)
ds = client.load_dataset("kenya-housing-prices")
print(ds.name, ds.row_count, ds.column_count)
for col in ds.detail.columns:
    print(col.name, col.dtype)

# Summary stats per column — no network call, no API key needed; built from
# the metadata already fetched by load_dataset()
print(ds.describe())
# ┌────────┬────────┬───────┬────────────┬────────┬────────┬─────────┬──────┬────────┬────────┬────────┐
# │ column │  dtype │ count │ null_count │ null_% │ unique │    mean │  std │    min │ median │    max │
# ├────────┼────────┼───────┼────────────┼────────┼────────┼─────────┼──────┼────────┼────────┼────────┤
# │   year │ number │   178 │          0 │    0.0 │     16 │ 2018.50 │ 4.30 │ 2011.0 │ 2018.50 │ 2026.0 │
# │  month │ string │   178 │          0 │    0.0 │     12 │     NaN │  NaN │    NaN │    NaN │    NaN │
# └────────┴────────┴───────┴────────────┴────────┴────────┴─────────┴──────┴────────┴────────┴────────┘

# In a Jupyter/IPython notebook (or Zed's REPL), the same call renders as a
# bordered HTML table instead when it's the last expression in a cell.

# Want a real pandas.DataFrame for further chaining (.loc, filtering, etc.)?
ds.describe().to_frame()

# A compact overview — name, slug, description, AI insights, link to the
# dataset's Aesops page, and any linked community discussions. Also no
# network call, no API key needed.
print(ds.summary())
```

## Full catalog

`list()` always returns the full catalog and never sends the API key even if
the `Client` has one configured:

```python
client = Client()
datasets = client.list(category="finance")
for ds in datasets:
    print(ds.slug, ds.keyless)  # keyless tells you which need a key to load
```

This is deliberate: the catalog is public for discovery/marketing purposes.
What's gated is loading actual data (see below).

## Loading data

Loading a dataset's actual rows (`.to_pandas()`, `.to_polars()`, `.to_duckdb()`,
`.sql()`, `.to_csv()`) requires a **read-scoped API key** — _unless_ the
dataset is currently keyless (`ds.keyless`), in which case no key is
needed. Create a key at
[aesops.co.ke/profile/api-keys](https://aesops.co.ke/profile/api-keys).

```python
client = Client(api_key="Aes_...")

ds = client.load_dataset("kenya-housing-prices")

# pandas
df = ds.to_pandas()

# polars
lf = ds.to_polars()

# DuckDB (predicate + projection pushdown — only fetches the row-groups you touch)
con = ds.to_duckdb()
con.sql("SELECT county, avg(price) FROM data GROUP BY county").df()

# or use ds.sql() directly
ds.sql("SELECT county, avg(price) FROM data GROUP BY 1").df()

# CSV export (client-side, no extra server compute)
ds.to_csv("/tmp/housing.csv")

# Just want a peek? `limit` is pushed down through read_parquet, so it also
# cuts what crosses the network, not just what lands in the DataFrame.
sample = ds.to_pandas(limit=100)
ds.to_polars(limit=100)
ds.to_csv("/tmp/sample.csv", limit=100)

# for anything more specific (offset, filters, ordering) use ds.sql() directly
ds.sql("SELECT * FROM data ORDER BY price DESC LIMIT 20").df()
```

### New to DuckDB?

`ds.sql()` and `ds.to_duckdb().sql()` return a [`duckdb.DuckDBPyRelation`](https://duckdb.org/docs/stable/clients/python/reference/#duckdb.DuckDBPyRelation) —
a lazy query result, not a DataFrame. Call `.df()` for pandas, `.pl()` for
polars, `.arrow()` for an Arrow table, or `.fetchall()` for plain Python
tuples. If SQL or DuckDB itself is new to you: the
[DuckDB docs](https://duckdb.org/docs/stable/) are a good starting point, and
the [Python client guide](https://duckdb.org/docs/stable/clients/python/overview)
covers the API this SDK builds on.

## Brand colors

The Aesops chart palette is available as plain hex strings, so a chart built
from Aesops data can match the platform's own look — and printing a palette
renders actual color swatches, not just hex text:

```python
from aesops import colors

colors.primary          # '#155f6b' — brand teal
colors.aeschart         # 6-slot categorical series for chart colors, teal-led
colors.dark.primary     # '#D4956A' — dark mode swaps primary to terracotta
colors.dark.aeschart    # dark mode's chart series

# See the actual chart colors, not just hex codes: in a terminal, prints
# each of the 6 aeschart slots as a colored block (ANSI truecolor) next to
# its name and hex value — separately for light and dark.
print(colors.light)
print(colors.dark)

# In a Jupyter/IPython notebook, colors.light / colors.dark render as HTML
# swatches automatically when they're the last expression in a cell.

# matplotlib
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.set_prop_cycle(color=list(colors.aeschart))
```

No network call, no API key required — `colors` mirrors the same design
tokens the Aesops web app itself uses (see `DESIGN.md`). Full field list:
`primary`, `accent`, `background`, `foreground`, `card`, `muted`, `border`,
`success`, `destructive` (each with a `_foreground` counterpart where
relevant), and `aeschart` — on both the top-level (light) module and
`colors.light`/`colors.dark` explicitly.

## Context manager

```python
with Client(api_key="Aes_...") as client:
    df = client.load_dataset("kenya-housing-prices").to_pandas()
```

## Error handling

```python
from aesops import AuthError, NotFoundError, ApiError

try:
    ds = client.load_dataset("does-not-exist")
except NotFoundError:
    print("dataset not found")
except AuthError:
    print("invalid or missing API key")
except ApiError as e:
    print(f"API error {e.status_code}: {e}")
```

## Notes

- Signed URLs are fetched **lazily** at query time (not at `load_dataset`) and
  automatically re-fetched on expiry or a 403 from storage, so long-running
  sessions never stall.
- All format conversion (CSV, Arrow, etc.) happens locally — zero extra server
  compute.
- `list()` and `load_dataset()` always work without an `api_key` — the full
  catalog and every dataset's metadata are public. Loading actual data
  (`.to_pandas()`, `.to_polars()`, `.to_duckdb()`, `.sql()`, `.to_csv()`)
  requires a key unless the dataset is currently keyless (`ds.keyless`).
- `.describe()` and `.summary()` never touch the network or require a key —
  they summarize the metadata `load_dataset()` already fetched. `.describe()`
  returns a `DescribeTable` (bordered text/HTML display, no pandas required);
  call `.to_frame()` on it for a real `pandas.DataFrame`. `.summary()` returns
  a `Summary` (same bordered text/HTML display) with name, slug, description,
  AI insights, a link to the dataset's Aesops page, and any linked community
  discussions.
- Datasets are always served at their latest active version — there's no
  version pinning.
