Skip to main content

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

Licence

MIT, scoped to this package directory. See LICENSE.

Metadata

Release files for cyclecalcs 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cyclecalcs 1.0.0
File Size Uploaded
cyclecalcs-1.0.0.tar.gz 26.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for cyclecalcs 1.0.0
File Interpreter ABI Platform
cyclecalcs-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 48.7 kB

Release files / cyclecalcs-1.0.0.tar.gz

Download URL cyclecalcs-1.0.0.tar.gz
Size 26.1 kB
Tags Source
SHA-256 checksum
How to use checksums
74afff7c14ee313ee83611cb58d05cfa63414382f25abb2ee002ff8f0be6b54d
BLAKE2b-256 checksum
How to use checksums
273b5d017e3a51d7dee582b9fd76370f6c2a205a6f74822faf4ef02a1e3972e9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release files / cyclecalcs-1.0.0-py3-none-any.whl

Download URL cyclecalcs-1.0.0-py3-none-any.whl
Size 22.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
acc3399a01e00437013e68e73d23b786ec57e3befcdf338f00c38a3ac1ea9aad
BLAKE2b-256 checksum
How to use checksums
ef27d2ac275c9f11a89a92b3a0d5fe0e6247df7ea892e5f6b857f3d0619f3401
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release 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