cyclecalcs
A thin Python client for the CycleCalcs astronomy API: moon phase, sunrise and sunset, planet visibility, eclipses, twilight and the genuinely dark moonless window of a night. The API answers the question rather than the coordinates, so you get "Waning Gibbous, 95 percent lit, sets at 06:14", not a longitude to decode.
Free, no account, no API key. No dependencies: it is urllib underneath, so
installing it adds nothing to your lockfile. Positions are good to well under a
degree for the Sun, Moon and planets, roughly 1700 to 2200. Astronomy only: no
astrology, and no claim that anything in the sky affects events on Earth.
Install
pip install cyclecalcs
Use
from cyclecalcs import CycleCalcs
cc = CycleCalcs()
tonight = cc.today(lat=51.5074, lon=-0.1278, tz="Europe/London")
print(tonight.data["moon"]["phase"]["name"]) # 'Waning Gibbous'
print(tonight.data["night"]["verdict"]) # 'Good. No moonlight ...'
There is one method per endpoint, named for its route, and every parameter is a keyword argument that is left out of the request when you leave it out of the call:
cc.moon(at="2026-08-27T22:00Z") # /v2/moon
cc.rise_set(body="mars", lat=39.74, lon=-104.99) # /v2/rise-set
cc.dark_window(lat=38.72, lon=-9.14, nights=14) # /v2/dark-window
cc.eclipses(lat=39.74, lon=-104.99, count=2) # /v2/eclipses
Dates and times can be strings or datetime objects. An aware datetime
names an instant and is converted to UTC. A naive one names a wall clock and
is sent unmarked, which the API reads as UTC on its own but as local time in
the zone when you also pass tz= or place=. If you mean an instant, pass
an aware datetime. Lists are joined the way the API expects:
from datetime import datetime
cc.positions(bodies=["Venus", "Mars"], at=datetime(2026, 8, 20, 22, 0))
The whole envelope, not just the answer
Every response carries nine keys and means all of them. data is the answer;
the rest is how to use it honestly.
r = cc.sun(lat=78.22, lon=15.63) # Svalbard, in summer
r.data # the answer
r.warnings # [{'code': 'polar_day', ...}]
r.links # self, spec, docs, page, explain
r.meta # api_version, engine, accuracy, cache class
r.attribution # one credit line; keep it where required
r.etag # for a conditional request later
r.rate_limit.remaining # what is left in the current window
warnings is where the API tells you things that did not change the status
code, such as polar_day when the Sun never set. Some are only reported when
you ask for them: an unrecognised parameter shows up as unknown_parameter
under verbosity="full" and is silent otherwise. A client that discards
warnings throws away the part that explains the answer.
r["day_length"] is shorthand for r.data["day_length"].
Ranges and paging
Twenty-one of the twenty-nine routes take start and end, and the fourteen
that walk a grid take step as well. When a series is longer than one
response, links.next carries the rest:
for page in cc.moon(start="2026-01-01", end="2026-03-31", step="1d", limit=30).pages():
for row in page.data["series"]:
...
The key holding the rows is named for what it holds and so varies by endpoint:
series on moon, days on sun and twilight, rows on sidereal-time, phases
on phases, nights on dark-window. page.has_next says whether another
follows.
Ask for more than a request can carry and it refuses with a 400 naming the
cap, rather than truncating the answer silently. Two bounds can apply: your
tier's row cap, and the hard response-size ceiling, which on a wide endpoint
binds first. When the size ceiling is the one that bound, the refusal usually
says so in bound_by.
except BadRequest as err:
err.extensions["cap"] # {'requested': 2193, 'maximum': 262,
# 'unit': 'rows', 'bound_by': 'response_size'}
Conditional requests
Pass an ETag back and an unchanged answer costs you a 304 and no body:
first = cc.moon(at="2026-08-27T22:00Z")
later = cc.moon(at="2026-08-27T22:00Z", if_none_match=first.etag)
later.not_modified # True
later.data # None: you already have it
When it fails
Failures raise, and every field of the API's RFC 9457 problem document survives on the exception:
from cyclecalcs import BadRequest, RateLimited
try:
cc.sun(lat=999, lon=0)
except BadRequest as err:
err.code # 'BAD_LATITUDE'
err.parameter # 'lat'
err.hint # what to send instead
err.extensions # {} here. Carries 'cap' on a refused range,
# or 'candidates' on an ambiguous local time.
try:
...
except RateLimited as err:
err.retry_after # seconds, from the API
TransportError covers anything that never became an HTTP response.
Limits, and the paid tiers
The direct API is keyless and free: 300 requests a minute, 2,000 an hour and 5,000 a day, per caller. Those are a published floor and may only ever rise.
A RapidAPI subscription raises the rate and range ceilings and changes no answer. Pass the key and the client talks to the gateway instead:
cc = CycleCalcs(rapidapi_key="...")
The key travels in a header, never in a URL, on either host.
What it sends
One GET per call, to www.cyclecalcs.com (or the RapidAPI gateway if you gave
a key). No cookies, no identifiers, no analytics. Requests carry a
client=python tag so the API can see how its own traffic splits; it is never
echoed, never part of a cache key, and CycleCalcs(client_tag=None) removes it.
Rights
The numbers are computed astronomical facts: store them, publish them, redistribute them, build commercial products on them, no licence and no attribution required. The terms say so in one page.
One exception rides in the response rather than in a licence file: a place
lookup returns GeoNames data under CC BY 4.0, and the credit arrives in
response.attribution. Keep it wherever you show the result.
Also available
- The API itself, plain HTTP with a live playground
- An MCP server for Claude and other AI clients
- Embeddable widgets for a web page, and
cyclecalcs-widgeton npm for a framework - Bulk datasets as CC0 CSV, for offline work
Licence
MIT, scoped to this package directory. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cyclecalcs-1.0.0.tar.gz.
File metadata
- Download URL: cyclecalcs-1.0.0.tar.gz
- Upload date:
- Size: 26.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.11.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
74afff7c14ee313ee83611cb58d05cfa63414382f25abb2ee002ff8f0be6b54d
|
|
| MD5 |
5463b15d51627535b8fdd449d3bae49c
|
|
| BLAKE2b-256 |
273b5d017e3a51d7dee582b9fd76370f6c2a205a6f74822faf4ef02a1e3972e9
|
File details
Details for the file cyclecalcs-1.0.0-py3-none-any.whl.
File metadata
- Download URL: cyclecalcs-1.0.0-py3-none-any.whl
- Upload date:
- Size: 22.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.11.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
acc3399a01e00437013e68e73d23b786ec57e3befcdf338f00c38a3ac1ea9aad
|
|
| MD5 |
de1e58a659f6315aff19a967ceb964a1
|
|
| BLAKE2b-256 |
ef27d2ac275c9f11a89a92b3a0d5fe0e6247df7ea892e5f6b857f3d0619f3401
|