Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: CI

on:
push:
branches: [main, master]
pull_request:
workflow_call:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v7
- run: uv sync --locked
- run: uv run ruff check
- run: uv run ruff format --check
- run: uv run mypy src

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
- run: uv sync --locked
- run: uv run pytest --cov=moonpy --cov-report=term-missing --cov-fail-under=100
51 changes: 51 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Release

# Publishes to PyPI when a GitHub release is published. The release tag must
# match the version in pyproject.toml, e.g. tag v0.1.0 for version 0.1.0.
on:
release:
types: [published]

permissions:
contents: read

jobs:
ci:
uses: ./.github/workflows/ci.yml

build:
needs: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v7
- name: Check tag matches package version
run: |
version=$(uv version --short)
if [ "${GITHUB_REF_NAME}" != "v${version}" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match pyproject version v${version}" >&2
exit 1
fi
- run: uv build
- name: Smoke-test the wheel
run: uv run --isolated --no-project --with dist/*.whl -- python -c "from moonpy import MoonPhase; print(MoonPhase().name)"
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/moonpy/
permissions:
id-token: write # trusted publishing; no API token needed
steps:
- uses: actions/download-artifact@v5
with:
name: dist
path: dist/
- uses: astral-sh/setup-uv@v7
- run: uv publish --trusted-publishing always
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ wheels/
# Virtual environments
.venv

# Tools
.claude/

# Coverage
.coverage
htmlcov/
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

A library for calculating the phases of the moon.

MoonPy has no runtime dependencies and requires Python 3.14 or later.
MoonPy has no runtime dependencies and requires Python 3.11 or later.

```sh
pip install moonpy
```

## Usage

Expand Down Expand Up @@ -43,7 +47,7 @@ Full Moon, 🌖 Waning Gibbous, 🌗 Third Quarter, 🌘 Waning Crescent.

MoonPy uses the mean length of a lunar month (29.530588853 days) counted from a known new moon on 2000-01-06 18:14 UTC.
It is not an astronomical ephemeris: the real moon's orbit speeds up and slows down, so actual new and full moons can
fall up to about 14 hours from the times predicted here. That is accurate enough for phase names and illumination, but
fall up to about 18 hours from the times predicted here. That is accurate enough for phase names and illumination, but
not for exact event times. In the example above, the real full moon was at 17:54 UTC, while MoonPy places it about 9
hours later.

Expand Down
23 changes: 21 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,29 @@ readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
authors = [
{ name = "Tom Camp", email = "tom.camp@civicactions.com" }
{ name = "Tom Camp", email = "tomcamp@protonmail.ch.com" }
]
requires-python = ">=3.14"
requires-python = ">=3.11"
dependencies = []
keywords = ["moon", "lunar", "moon phase", "astronomy"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering :: Astronomy",
"Typing :: Typed",
]

[project.urls]
Homepage = "https://github.com/Tom-Camp/MoonPy"
Repository = "https://github.com/Tom-Camp/MoonPy"
Issues = "https://github.com/Tom-Camp/MoonPy/issues"

[build-system]
requires = ["uv_build>=0.10.4,<0.11.0"]
Expand Down
53 changes: 53 additions & 0 deletions tests/test_phases.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from dataclasses import FrozenInstanceError
from datetime import UTC, datetime, timedelta, timezone
from pathlib import Path

import pytest

import moonpy
from moonpy import MoonPhase
from moonpy.phases import PHASE_NAMES, REFERENCE_NEW_MOON, SYNODIC_MONTH

Expand Down Expand Up @@ -122,6 +124,57 @@ def test_known_dates(self, when, name):
assert MoonPhase(when).name == name


class TestAccuracy:
"""Predictions stay within the README's stated error of real events.

Event times are from eclipses, which pin down the exact instant of new and
full moon.
"""

MAX_ERROR_DAYS = 18 / 24

@pytest.mark.parametrize(
"when",
[
datetime(2017, 8, 21, 18, 30, tzinfo=UTC),
datetime(2019, 7, 2, 19, 16, tzinfo=UTC),
datetime(2024, 4, 8, 18, 21, tzinfo=UTC),
datetime(2026, 2, 17, 12, 1, tzinfo=UTC),
],
)
def test_real_new_moons(self, when):
moon = MoonPhase(when)
assert min(moon.age, moon.days_to_new) < self.MAX_ERROR_DAYS

@pytest.mark.parametrize(
"when",
[
datetime(2022, 11, 8, 11, 2, tzinfo=UTC),
datetime(2024, 1, 25, 17, 54, tzinfo=UTC),
datetime(2025, 3, 14, 6, 55, tzinfo=UTC),
datetime(2025, 9, 7, 18, 9, tzinfo=UTC),
],
)
def test_real_full_moons(self, when):
moon = MoonPhase(when)
assert abs(moon.age - SYNODIC_MONTH / 2) < self.MAX_ERROR_DAYS


def test_readme_example():
moon = MoonPhase(datetime(2024, 1, 25, 17, 54, tzinfo=UTC))
assert moon.name == "Full Moon"
assert moon.illumination == pytest.approx(0.999, abs=0.001)
assert moon.age == pytest.approx(14.40, abs=0.01)
assert moon.cycle_fraction == pytest.approx(0.488, abs=0.001)
assert moon.days_to_full == pytest.approx(0.36, abs=0.01)
assert moon.days_to_new == pytest.approx(15.13, abs=0.01)


def test_public_api():
assert moonpy.__all__ == ["MoonPhase"]
assert (Path(moonpy.__file__).parent / "py.typed").is_file()


class TestConstruction:
def test_default_is_now_in_utc(self):
before = datetime.now(UTC)
Expand Down
Loading
Loading