Documentation

See it in action — browse live examples on the @skua profile page.

Installation

pip install getskua

That's it. Skua uses your installed libraries to serialize your objects so there will never be any version conflicts or compatibility issues. Works with matplotlib, plotly, pandas, polars, numpy, PIL, PyTorch, TensorFlow, any object with a rich HTML repr (Altair, Bokeh, folium, styled DataFrames, great_tables), and any object with a string representation.

Quick Start

import skua
import matplotlib.pyplot as plt

q3 = skua.collection("Q3 Review")

fig, ax = plt.subplots(figsize=(10, 5))
ax.bar(x, revenue, label="Revenue", ...)
ax.bar(x, costs, label="Costs", ...)
ax.plot(x, profit, "o-", label="Profit")
# ...

q3.record(fig, title="Revenue vs Costs", description="...", tags=[...])

Share the URL with anyone. Re-run q3.record() with the same title to update in place — same URL, fresh results. This is also the easiest way to change a record's visibility: just call it again with a different visibility=.

Skip the named handle entirely if you don't need scoping — bare skua.record(fig, title="...") lands in your per-user Default collection.

Collections

A collection is the named bucket that records live in. Open one with skua.collection("name") and call .record(...) on the handle.

Collections do four things:

  • Grouping — every collection has its own page at /c/{id}, listing the records inside it. Share the collection URL to share a whole notebook's worth of output as a single link.
  • Visibility — collections have their own access gate (public / unlisted / private), independent of the records inside. See Visibility for the full model.
  • Update-in-place — records dedupe by (collection, title). Re-running q3.record(chart, title="Revenue") updates that record and keeps its URL stable, so shared links stay live while you iterate.
  • Per-user Defaultskua.record() (no handle) writes to a per-user "Default" collection. Useful for scratch records you want to share via link but not organize.

skua.collection(name) resolves once per process — calling it again with the same name returns the same handle without a network roundtrip. Pass visibility= on the first call to set the collection's access gate (see Visibility). The visibility kwarg is a creation hint: passing it on a subsequent call with a value that conflicts with the persisted visibility raises ConfigurationError — either drop the kwarg, or pick a different name to make a new collection.

Visibility

Records and collections have three visibility levels. The default for both is unlisted.

| Level | /r/{id} (record) | /c/{id} (collection) | Listed on /u/{handle} profile? | |---|---|---|---| | public | Anyone | Anyone | Yes | | unlisted (default) | Anyone with the URL | Anyone with the URL | No | | private | Owner only | Owner only | No |

Record IDs are 12 base62 chars (~71 bits of entropy), so an unlisted URL is functionally unguessable — sharing the link is what makes it readable, not the visibility level. Use unlisted when you want "anyone with this link can see it" without showing up in profile listings. Use public only when you actively want the record discoverable via your profile.

Per-record visibility wins for /r/{id} access. Collection visibility just gates who sees the listing at /c/{id} — a public record inside a private collection is still readable at its own URL by anyone (the link is the capability). To make a record inaccessible to non-owners, set the *record's* visibility to private, not just the collection's.

private requires a verified account. Anonymous users can create public and unlisted records and collections; passing visibility="private" raises UploadError asking you to verify your email first. Anonymous "privacy" would be bound to a single ~/.skua/client token file with no recovery — lose the file, lose access forever. Verifying an email turns that into a recoverable account.

Changing visibility after the fact uses the API — there's no SDK kwarg for in-place edits. Re-running skua.record() with the same (collection, title) and a different visibility= works (it's an update-in-place), but the PATCH boundary is stricter: anonymous accounts can only PATCH to public. Moving a record back to unlisted or private after publication requires a verified account.

Missing-token viewers see 404, not 403, on a private record. The existence of a private record ID isn't leaked to probers — non-owners get the same response as for an ID that never existed. Same rule for private collections.

API Reference

skua.collection()

skua.collection(name, *, visibility=None) -> Collection

Return a handle to a named collection. First call within a process performs a synchronous backend roundtrip to create-or-get the collection row; subsequent calls with the same name in the same process return the cached handle for free. See Collections above for what collections do and why.

namestr, 1–100 chars

Collection name. Pairs with each record's title as the upsert key on (collection, title), so titles within one collection are unique while different collections can have the same titles. The name is a label only — it does not appear in any URL.

visibility"public" | "unlisted" | "private", default "unlisted"

The collection's access gate. See Visibility. Persisted server-side on first creation; re-passing a different value on a later call raises ConfigurationError.

Returns
class Collection:
    id: str                      # 12-char base62
    name: str
    visibility: str              # "public" | "unlisted" | "private"
    url: str                     # canonical /c/{id} URL — shareable

    def record(self, obj, title, **kwargs) -> Record: ...

skua.record()

skua.record(obj, title, description=None, visibility=None, tags=None) -> Record

Capture and share a Python object. Without a Collection handle, records land in the per-user Default collection.

obj

Object to record (matplotlib/plotly figure, pandas/polars DataFrame, PIL image, PyTorch/TF tensor, list of dicts, any object with a rich HTML repr like Altair/Bokeh/folium/styled DataFrames, or any object with a string representation).

titlestr, max 500 chars

Record title. Recording again with the same title in the same collection updates the existing record in place — same URL, replaced content. Vary the collection if you want history (e.g. one collection per period).

descriptionstr, max 1000 chars, default None

Short description providing context (1-3 sentences recommended).

visibility"public" | "unlisted" | "private", default "unlisted"

Per-record access gate, authoritative for /r/{id}. See Visibility.

tagslist[str], max 20 tags, 30 chars each, default None

Tags for categorization. Empty strings are ignored, whitespace is stripped.

Returns
class Record:
    url: str                      # shareable URL
    metadata: dict[str, Any]      # id, creator_username, raw_url, ...

In Jupyter, the original object is displayed inline.

skua.login()

skua.login() -> None

Open the browser to verify your email. Verified accounts get 365-day retention (vs 30 days for anonymous), no per-account record cap, and a public profile page at skua.dev/u/username.

skua.auth()

skua.auth(token) -> None

Save a token received by email after calling skua.login().

tokenstr, starts with sk_

The verification token from the email.

skua.status()

skua.status() -> Status

Check current authentication status.

Returns
class Status(TypedDict):
    verified: bool
    email: str | None
    username: str
    retention_days: int

skua.open_profile()

skua.open_profile(open_browser=True) -> str

Open your profile page in a browser. Verified users get a short-lived one-click login URL so the browser lands already signed in (unlisted and private records visible); anonymous users get the bare public profile URL. Returns the URL.

open_browserbool, default True

If False, returns the URL without calling webbrowser.open(). Useful in headless/CI contexts.

CLI

skua record chart.png --title "Q3 Revenue"
skua record data.csv --title "Sales Data" --public --tags "finance,q3"
skua record plot.json --title "Interactive Chart" --collection "Q3 Review"
cat report.txt | skua record - --type text --title "Analysis"

Use --json for machine-readable output:

Returns
skua record chart.png --title "Chart" --json
# {"url": "https://skua.dev/r/abc123", "id": "abc123", "visibility": "public"}

Other commands:

Returns
skua login           # open browser to verify email
skua verify sk_...   # paste the token from the verification email
skua status          # show current auth state (verified / anonymous, username)
skua list            # list your records (use --json for machine-readable)
skua open <id>       # open a record URL in the browser

Supported Types

Matplotlib figures

Saved as high-DPI PNG images. Pass any plt.Figure object.

import skua
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(8, 5))
ax.scatter(*setosa.T, label="Setosa", alpha=0.7)
ax.scatter(*versicolor.T, label="Versicolor", alpha=0.7)
ax.scatter(*virginica.T, label="Virginica", alpha=0.7)
ax.set_xlabel("Sepal Length (cm)")
ax.set_ylabel("Sepal Width (cm)")
ax.set_title("Iris Dataset — Sepal Dimensions")
ax.legend()

skua.record(fig, title="Iris Scatter Plot", description="...", tags=[...])
# > https://skua.dev/r/ZKvb4nbrY5tJ

Plotly figures

Rendered as fully interactive charts — zoom, pan, hover tooltips, and PNG export.

import skua
import plotly.graph_objects as go

fig = go.Figure()
fig.add_trace(go.Scatter(x=years, y=anomaly, ...))

skua.record(fig, title="Global Temperature Anomaly", description="...", tags=[...])
# > https://skua.dev/r/Vb3gk1Io8Clk

Pandas DataFrames

Rendered as interactive, sortable, filterable tables.

import skua
import pandas as pd

df = df.set_index("ticker")
# df = pd.DataFrame({...})  # 30 stocks
skua.record(df, title="S&P 500 Snapshot", description="...", tags=[...])
# > https://skua.dev/r/POpsKf8aR58x

Polars DataFrames

Same interactive table rendering. LazyFrames are collected automatically.

import skua
import polars as pl

# df = pl.DataFrame({...})  # 8 chips
skua.record(df, title="CPU Benchmarks", description="...", tags=[...])
# > https://skua.dev/r/zVNnwm0hPIBJ

List of dicts

Common output from HuggingFace pipelines and eval loops — rendered as a table.

import skua

# results = pipeline("sentiment-analysis")(texts)
skua.record(results, title="Sentiment Analysis", description="...", tags=[...])
# > https://skua.dev/r/9yMKCJOEtEzU

PIL Images

Any PIL.Image object, saved as PNG.

import skua
from PIL import Image
import numpy as np

w, h, max_iter = 960, 640, 80
x = np.linspace(-2.5, 1.0, w)
y = np.linspace(-1.2, 1.2, h)
C = x[np.newaxis, :] + 1j * y[:, np.newaxis]

Z = np.zeros_like(C)
M = np.zeros(C.shape, dtype=int)
for i in range(max_iter):
    mask = np.abs(Z) <= 2
    Z[mask] = Z[mask] ** 2 + C[mask]
    M[mask] = i

t = M / max_iter
r = (np.sin(3.0 * t * np.pi) * 127 + 128).astype(np.uint8)
g = (np.sin(3.0 * t * np.pi + 0.8) * 127 + 128).astype(np.uint8)
b = (np.sin(3.0 * t * np.pi + 1.6) * 127 + 128).astype(np.uint8)
rgb = np.stack([r, g, b], axis=-1)
img = Image.fromarray(rgb)

skua.record(img, title="Mandelbrot Set", description="...", tags=[...])
# > https://skua.dev/r/REtywyKymskG

PyTorch & TensorFlow tensors

Image tensors (CHW or BCHW) are rendered as PNG. Other tensors are displayed as tables.

Rich HTML outputs

When an object doesn't match a dedicated handler, Skua falls back to whatever it renders in Jupyter — any object exposing _repr_html_ or _repr_mimebundle_ is captured as HTML and replayed on the record page, the same way a Plotly figure is captured and re-rendered. That makes it the catch-all for rich outputs: Altair, Bokeh, folium, styled DataFrames (df.style), great_tables, IPython.display.HTML, and anything else that knows how to draw itself as HTML. Static HTML renders in a sandboxed iframe exactly as it looks in the notebook; script-driven interactive outputs render behind a one-click "Render output" gesture.

A styled DataFrame keeps its gradients, highlights, and conditional formatting:

import skua
import pandas as pd

# df = pd.DataFrame({...})  # 4 teams
styled = (
    df.style
    .background_gradient(subset=["wins"], cmap="Greens")
    .highlight_max(subset=["points_for"], color="#2a9d8f")
    .set_table_attributes('style="width:100%"')
)
skua.record(styled, title="League Standings", description="...", tags=[...])
# > https://skua.dev/r/c4tgKAbW1qNr

Interactive charts from libraries like Altair render behind a one-click gesture, then become fully interactive:

import skua
import altair as alt
import pandas as pd

# cars = pd.DataFrame({...})  # 12 cars
chart = alt.Chart(cars).mark_circle(size=120).encode(
    x="horsepower",
    y="mpg",
    color="origin",
    tooltip=["horsepower", "mpg", "origin"],
).properties(title="Horsepower vs Fuel Economy", width="container", height=420)

skua.record(chart, title="Altair Scatter", description="...", tags=[...])
# > https://skua.dev/r/RBsy46gY7euc

Text & strings

Any object with a string representation. Full Unicode support — em-dashes, accented characters, CJK, and emoji all render correctly.

import skua

skua.record("¥127.4M", title="Q4 Revenue", description="...", tags=[...])
# > https://skua.dev/r/AbjLdSp9TU8V

Anonymous Usage

No account required. Anonymous usage has the following limits:

  • Records expire after 30 days
  • 10 records per device
  • 20 uploads per hour
  • 10 MB max per record

Validated Accounts

Verify your email to unlock longer retention and higher limits:

  • 365-day retention per record — each record's clock resets when you update it
  • No per-account record cap
  • 10 MB max per record

Run skua.login() in your notebook to start the verification flow. Enter your email to receive a verification link.

Support

Questions or feedback? Email [email protected] or open an issue on GitHub.