Skip to main content

pyskylight

An async Python client for the Skylight API — calendars, chores, lists, rewards, and frames.

Unofficial. Not affiliated with or endorsed by Skylight. The API is reverse-engineered from observed traffic and may change without notice. Use it only with accounts you own.

Install

uv add pyskylight
pip install pyskylight

Quick start

import asyncio

from pyskylight import PasswordAuth, Skylight


async def main() -> None:
    async with Skylight(PasswordAuth("me@example.com", "hunter2")) as skylight:
        frame = (await skylight.get_frames())[0]

        for chore in await skylight.get_chores(frame.id, after="2025-08-25", before="2025-08-31"):
            print(chore.summary, chore.start, "done" if chore.completed else "todo")

        for family_list in await skylight.get_lists(frame.id):
            print(family_list.label, family_list.kind)


asyncio.run(main())

Skylight creates and owns an aiohttp.ClientSession unless you pass one in:

async with aiohttp.ClientSession() as session:
    skylight = Skylight(PasswordAuth(email, password, session=session), session=session)

Authentication

Skylight uses OAuth 2.0 authorization code + PKCE, with credentials entered into a server-rendered Rails login form. PasswordAuth drives that whole flow headlessly and refreshes the access token before it expires:

  1. GET /oauth/authorize → redirect to /auth/session/new, which carries a Rails CSRF token in <meta name="csrf-token"> and sets a skylightcloud_session cookie.
  2. POST /auth/session with authenticity_token, email, password.
  3. GET /oauth/authorize again (now authenticated) → redirect to https://ourskylight.com/welcome?code=...&state=....
  4. POST /oauth/token exchanges the code plus the PKCE code_verifier for an access token and a refresh token.

pyskylight never follows the final redirect — it reads the authorization code out of the Location header — and the Rails session cookie is confined to a private cookie jar.

If you already captured a token, skip the flow:

from pyskylight import Skylight, TokenAuth

skylight = Skylight(TokenAuth("<access token>"))

TokenAuth cannot refresh; a rejected token raises AuthenticationError.

Sign out with await auth.revoke().

Common operations

# Family profiles ("categories" in the API)
categories = await skylight.get_categories(frame_id)

# Chores
chore = await skylight.create_chore(
    frame_id,
    "Take out recycling",
    categories[0].id,  # a chore must belong to a profile
    start="2025-09-01",
    start_time="10:00",
    recurring=True,
    recurrence_set="RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;WKST=SU",
)
await skylight.complete_chore(frame_id, chore.chore_id, instance_date="2025-09-01")
await skylight.delete_chore(frame_id, chore.chore_id, apply_to=ApplyTo.ALL)  # recurring only

# Lists
grocery = await skylight.get_list(frame_id, list_id)  # items + sections resolved
await skylight.create_list_item(frame_id, grocery.id, "Milk")

# Calendar
events = await skylight.get_calendar_events(
    frame_id, date_min="2025-09-01", date_max="2025-09-30", timezone="America/Los_Angeles"
)

Recurring chores are returned one resource per occurrence. Chore.id is the occurrence id ("<chore_id>-<date>"); pass Chore.chore_id — the group attribute — when updating, deleting, or completing.

A few endpoints don't follow the usual shapes, and pyskylight normalizes them:

groups = await skylight.get_all_chores(frame_id)  # ChoreGroups, bucketed
groups.chores["late"], groups.chores["today"], groups.routines["today_timed"]
groups.all  # flattened

balances = await skylight.get_reward_points(frame_id)  # plain array upstream
frames = await skylight.get_calendar_frames()  # a list, despite the path

Some endpoints reject requests that omit an optional-looking parameter, so pyskylight makes those required: get_countdowns(frame_id, timezone), get_nudges(frame_id, after, before), get_meal_sittings(frame_id, date_min, date_max).

Display settings belong to the device, not the frame: update_frame() accepts them and silently applies nothing, while update_device() works. Write calls send flat bodies, not JSON:API documents, and several have sharp edges the published spec does not mention — "complete" rather than "completed", apply_to being forbidden on one-time chores, move_chore taking a neighbour instead of an index. All of it is verified against a live test frame and written up in docs/api-notes.md.

Models and unmodeled fields

The upstream schema is observed, not specified, so every model keeps its raw resource:

chore.attributes["a_field_pyskylight_does_not_know_about"]

Fully typed models, all verified against live responses: Frame, Category, Chore, TaskBoxItem, SkylightList, ListItem, Device, CalendarEvent, SourceCalendar, Reward, RewardPoint, Nudge, User. Thin models (id plus .attributes) where the account used for verification had no data to capture: Alarm. Endpoints whose shape is entirely unknown (meals, photos, Plus, activities) return the decoded JSON untouched.

Anything not wrapped is still reachable:

data = await skylight.request("GET", f"/api/frames/{frame_id}/month_in_review")

Errors

Exception When
AuthenticationError Login failed, or the token was rejected and could not be refreshed
NotAuthorizedError HTTP 401/403 after one refresh attempt
NotFoundError HTTP 404
RateLimitError HTTP 429
ApiError Any other unsuccessful status

All derive from SkylightError. 304 Not Modified and 204 No Content return None (empty lists for list endpoints).

Development

This project uses uv. One command sets up a virtualenv with the locked dependency versions:

uv sync
uv run pytest
uv run ruff check . && uv run ruff format --check . && uv run mypy pyskylight

Dev tools live in the dev dependency group, which uv sync installs by default and which stays out of the published wheel. uv.lock is committed and CI runs --frozen, so a new upstream release can't turn a green branch red on its own; run uv lock --upgrade to pick up newer versions deliberately.

Enable the git hooks (ruff, mypy, pytest, lockfile freshness, and a guard against committing credentials) once:

uv run pre-commit install

Test against another interpreter with uv run --python 3.10 pytest, and build with uv build.

CI runs the suite on 3.10–3.13 with branch coverage (floor: 97%), the linters, the pre-commit hooks, a build with twine check, and a job that installs the declared dependency floor (aiohttp==3.9.0) to check that claim is true.

Releasing

Publishing runs from CI via PyPI Trusted Publishing, so no API token is stored in the repository. One-time setup on PyPI (Account → Publishing): owner dknowles2, repository pyskylight, workflow release.yml, environment pypi.

To cut a release, bump version in pyproject.toml, then publish a GitHub release tagged vX.Y.Z. The workflow refuses to publish if the tag and the packaged version disagree. workflow_dispatch publishes to TestPyPI for a dry run.

Sources

The endpoint surface comes from two reverse-engineering efforts; see docs/api-notes.md for how they differ and which one pyskylight follows where they disagree.

License

Apache-2.0

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyskylight-0.2.0.tar.gz (163.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyskylight-0.2.0-py3-none-any.whl (32.5 kB view details)

Uploaded Python 3

File details

Details for the file pyskylight-0.2.0.tar.gz.

File metadata

  • Download URL: pyskylight-0.2.0.tar.gz
  • Upload date:
  • Size: 163.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyskylight-0.2.0.tar.gz
Algorithm Hash digest
SHA256 34b84d3f877ea32b193513fa051d42a0990daab3d5a7298e4e9dd675a3baafb5
MD5 0cd46097056730bd10fffa7fe81984ec
BLAKE2b-256 6881738f3b871b6d0e72c74410fb1ed282de1a693c5cefeca41777aaf14da1ea

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyskylight-0.2.0.tar.gz:

Publisher: release.yml on dknowles2/pyskylight

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyskylight-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: pyskylight-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 32.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyskylight-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 44b270d25692f0326c9970c01dd936f4e2e82aeb98a3d6dbd4cc87fc29bc071b
MD5 4de9b2caab041d5716871e2cb61e1209
BLAKE2b-256 cf0cc0c4babc372df036d3b85547b0f53f5f69b7a0a1d820d10a31b26c5fd4b9

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyskylight-0.2.0-py3-none-any.whl:

Publisher: release.yml on dknowles2/pyskylight

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.6.0

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

This release

0.2.0 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page