diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 89d11f3..897f9ec 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -7,56 +7,6 @@ on:
branches: [main, features]
jobs:
- legacy-tests:
- runs-on: ${{ matrix.os }}
- strategy:
- fail-fast: false
- matrix:
- os:
- - ubuntu-18.04
- - ubuntu-20.04
- - macos-10.15
- - macos-11.0
- - windows-2019
- python_version:
- - '3.7'
- - '3.8'
- - '3.9'
-
- name: Legacy tests (Python ${{ matrix.python_version }} on ${{ matrix.os }})
- steps:
- - uses: actions/checkout@v1
- - name: Set up Python
- uses: actions/setup-python@v1
- with:
- python-version: ${{ matrix.python_version }}
-
- - name: Prepare environment (non-Windows systems)
- if: ${{ matrix.os != 'windows-2019' }}
- run: |
- pip install --upgrade pip pipenv
-
- - name: Prepare environment (Windows)
- if: ${{ matrix.os == 'windows-2019' }}
- run: |
- python -mpip install --upgrade pip pipenv
-
- - name: Install dependencies (all systems)
- run: |
- pipenv lock --pre
- pipenv sync --dev
- pipenv install --dev
-
- - name: Install dependencies (Windows)
- if: ${{ matrix.os == 'windows-2019' }}
- run: |
- pipenv lock --dev -r > requirements.txt
- python -mpip install -r requirements.txt
-
- - name: Run legacy tests
- run: |
- make legacy-tests
-
doc-tests:
runs-on: ${{ matrix.os }}
strategy:
diff --git a/Makefile b/Makefile
index f377b2c..945ace9 100644
--- a/Makefile
+++ b/Makefile
@@ -1,21 +1,18 @@
black:
- pipenv run black kosmorrolib tests setup.py
+ pipenv run black kosmorrolib setup.py
.PHONY: tests
-tests: legacy-tests doctests
+tests: doctests
coverage-doctests:
pipenv run python3 -m coverage run tests.py
+coverage-report:
+ pipenv run python3 -m coverage report
+
doctests:
pipenv run python3 tests.py
-legacy-tests:
- unset KOSMORRO_LATITUDE; \
- unset KOSMORRO_LONGITUDE; \
- unset KOSMORRO_TIMEZONE; \
- pipenv run python3 -m unittest tests
-
.PHONY: build
build:
python3 setup.py sdist bdist_wheel
diff --git a/kosmorrolib/dateutil.py b/kosmorrolib/dateutil.py
index f812323..20bc3e5 100644
--- a/kosmorrolib/dateutil.py
+++ b/kosmorrolib/dateutil.py
@@ -19,11 +19,21 @@
from datetime import datetime, timezone, timedelta
-def translate_to_timezone(date: datetime, to_tz: int, from_tz: int = None):
- if from_tz is not None:
- source_tz = timezone(timedelta(hours=from_tz))
- else:
- source_tz = timezone.utc
+def translate_to_timezone(date: datetime, to_tz: int):
+ """Convert a datetime from a timezone to another.
+
+ >>> translate_to_timezone(datetime(2021, 6, 9, 5, 0, 0, tzinfo=timezone.utc), 2)
+ datetime.datetime(2021, 6, 9, 7, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200)))
+
+ >>> translate_to_timezone(datetime(2021, 6, 9, 5, 0, 0, tzinfo=timezone(timedelta(hours=1))), 2)
+ datetime.datetime(2021, 6, 9, 6, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200)))
+
+ If the datetime has no timezone information, then it is interpreted as UTC:
+
+ >>> translate_to_timezone(datetime(2021, 6, 9, 5, 0, 0), 2)
+ datetime.datetime(2021, 6, 9, 7, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200)))
+ """
+ source_tz = date.tzinfo if date.tzinfo is not None else timezone.utc
return date.replace(tzinfo=source_tz).astimezone(
tz=timezone(timedelta(hours=to_tz))
diff --git a/kosmorrolib/enum.py b/kosmorrolib/enum.py
index 9695a07..f9debde 100644
--- a/kosmorrolib/enum.py
+++ b/kosmorrolib/enum.py
@@ -22,14 +22,14 @@ from enum import Enum
class MoonPhaseType(Enum):
"""An enumeration of moon phases."""
- NEW_MOON = 1
- WAXING_CRESCENT = 2
- FIRST_QUARTER = 3
- WAXING_GIBBOUS = 4
- FULL_MOON = 5
- WANING_GIBBOUS = 6
- LAST_QUARTER = 7
- WANING_CRESCENT = 8
+ NEW_MOON = 0
+ WAXING_CRESCENT = 1
+ FIRST_QUARTER = 2
+ WAXING_GIBBOUS = 3
+ FULL_MOON = 4
+ WANING_GIBBOUS = 5
+ LAST_QUARTER = 6
+ WANING_CRESCENT = 7
class SeasonType(Enum):
diff --git a/kosmorrolib/ephemerides.py b/kosmorrolib/ephemerides.py
index 466263a..1bf702b 100644
--- a/kosmorrolib/ephemerides.py
+++ b/kosmorrolib/ephemerides.py
@@ -16,7 +16,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-import datetime
+from datetime import date, datetime, timedelta
from typing import Union
from skyfield.searchlib import find_discrete, find_maxima
@@ -40,60 +40,63 @@ def _get_skyfield_to_moon_phase(
now.utc_datetime().year, now.utc_datetime().month, now.utc_datetime().day + 1
)
- phases = list(MoonPhaseType)
- current_phase = None
- current_phase_time = None
next_phase_time = None
i = 0
- if len(times) == 0:
- return None
-
+ # Find the current moon phase:
for i, time in enumerate(times):
- if now.utc_iso() <= time.utc_iso():
- if vals[i] in [0, 2, 4, 6]:
- if time.utc_datetime() < tomorrow.utc_datetime():
- current_phase_time = time
- current_phase = phases[vals[i]]
- else:
- i -= 1
- current_phase_time = None
- current_phase = phases[vals[i]]
- else:
- current_phase = phases[vals[i]]
-
+ if now.utc_datetime() <= time.utc_datetime():
+ if time.utc_datetime() >= tomorrow.utc_datetime():
+ i -= 1
break
+ current_phase = MoonPhaseType(vals[i])
+
+ if current_phase in [
+ MoonPhaseType.NEW_MOON,
+ MoonPhaseType.FIRST_QUARTER,
+ MoonPhaseType.FULL_MOON,
+ MoonPhaseType.LAST_QUARTER,
+ ]:
+ current_phase_time = translate_to_timezone(times[i].utc_datetime(), timezone)
+ else:
+ current_phase_time = None
+
+ # Find the next moon phase
for j in range(i + 1, len(times)):
if vals[j] in [0, 2, 4, 6]:
- next_phase_time = times[j]
+ next_phase_time = translate_to_timezone(times[j].utc_datetime(), timezone)
break
- return MoonPhase(
- current_phase,
- translate_to_timezone(current_phase_time.utc_datetime(), timezone)
- if current_phase_time is not None
- else None,
- translate_to_timezone(next_phase_time.utc_datetime(), timezone)
- if next_phase_time is not None
- else None,
- )
+ return MoonPhase(current_phase, current_phase_time, next_phase_time)
-def get_moon_phase(
- for_date: datetime.date = datetime.date.today(), timezone: int = 0
-) -> MoonPhase:
+def get_moon_phase(for_date: date = date.today(), timezone: int = 0) -> MoonPhase:
"""Calculate and return the moon phase for the given date, adjusted to the given timezone if any.
Get the moon phase for the 27 March, 2021:
- >>> get_moon_phase(datetime.date.fromisoformat("2021-03-27"))
+ >>> get_moon_phase(date(2021, 3, 27))
+ When the moon phase is a new moon, a first quarter, a full moon or a last quarter, you get the exact time
+ of its happening too:
+
+ >>> get_moon_phase(datetime(2021, 3, 28))
+
+
Get the moon phase for the 27 March, 2021, in the UTC+2 timezone:
- >>> get_moon_phase(datetime.date.fromisoformat("2021-03-27"), timezone=2)
+ >>> get_moon_phase(date(2021, 3, 27), timezone=2)
+
+ Note that the moon phase can only be computed for a date range.
+ Asking for the moon phase with an out of range date will result in an exception:
+
+ >>> get_moon_phase(date(1000, 1, 1))
+ Traceback (most recent call last):
+ ...
+ kosmorrolib.exceptions.OutOfRangeDateError: The date must be between 1899-08-09 and 2053-09-26
"""
earth = get_skf_objects()["earth"]
moon = get_skf_objects()["moon"]
@@ -109,56 +112,69 @@ def get_moon_phase(
moon_phase_at.rough_period = 7.0 # one lunar phase per week
today = get_timescale().utc(for_date.year, for_date.month, for_date.day)
- time1 = get_timescale().utc(for_date.year, for_date.month, for_date.day - 10)
- time2 = get_timescale().utc(for_date.year, for_date.month, for_date.day + 10)
+ start_time = get_timescale().utc(for_date.year, for_date.month, for_date.day - 10)
+ end_time = get_timescale().utc(for_date.year, for_date.month, for_date.day + 10)
try:
- times, phase = find_discrete(time1, time2, moon_phase_at)
+ times, phases = find_discrete(start_time, end_time, moon_phase_at)
+ return _get_skyfield_to_moon_phase(times, phases, today, timezone)
+
except EphemerisRangeError as error:
start = translate_to_timezone(error.start_time.utc_datetime(), timezone)
end = translate_to_timezone(error.end_time.utc_datetime(), timezone)
- start = datetime.date(start.year, start.month, start.day) + datetime.timedelta(
- days=12
- )
- end = datetime.date(end.year, end.month, end.day) - datetime.timedelta(days=12)
+ start = date(start.year, start.month, start.day) + timedelta(days=12)
+ end = date(end.year, end.month, end.day) - timedelta(days=12)
raise OutOfRangeDateError(start, end) from error
- return _get_skyfield_to_moon_phase(times, phase, today, timezone)
-
def get_ephemerides(
- position: Position, date: datetime.date = datetime.date.today(), timezone: int = 0
+ position: Position, for_date: date = date.today(), timezone: int = 0
) -> [AsterEphemerides]:
"""Compute and return the ephemerides for the given position and date, adjusted to the given timezone if any.
Compute the ephemerides for June 9th, 2021:
>>> pos = Position(50.5824, 3.0624)
- >>> get_ephemerides(pos, datetime.date(2021, 6, 9))
+ >>> get_ephemerides(pos, date(2021, 6, 9))
[>, >, >, >, >, >, >, >, >, >]
Compute the ephemerides for June 9th, 2021:
- >>> get_ephemerides(pos, datetime.date(2021, 6, 9), timezone=2)
+ >>> get_ephemerides(pos, date(2021, 6, 9), timezone=2)
[>, >, >, >, >, >, >, >, >, >]
+ Objects may not rise or set on the given date (i.e. they rise the previous day or set the next day).
+ When this happens, you will get `None` values on the rise or set time.
+ Note that this is timezone-dependent:
+
+ >>> get_ephemerides(Position(50.5876, 3.0624), date(2021, 9, 14), timezone=2)[1]
+ >
+
If an objet does not rise nor set due to your latitude, then both rise and set will be `None`:
>>> north_pole = Position(70, 20)
>>> south_pole = Position(-70, 20)
- >>> get_ephemerides(north_pole, datetime.date(2021, 6, 20))
+ >>> get_ephemerides(north_pole, date(2021, 6, 20))
[>, >, >, >, >, >, >, >, >, >]
- >>> get_ephemerides(north_pole, datetime.date(2021, 12, 21))
+ >>> get_ephemerides(north_pole, date(2021, 12, 21))
[>, >, >, >, >, >, >, >, >, >]
- >>> get_ephemerides(south_pole, datetime.date(2021, 6, 20))
+ >>> get_ephemerides(south_pole, date(2021, 6, 20))
[>, >, >, >, >, >, >, >, >, >]
- >>> get_ephemerides(south_pole, datetime.date(2021, 12, 22))
+ >>> get_ephemerides(south_pole, date(2021, 12, 22))
[>, >, >, >, >, >, >, >, >, >]
+
+ Note that the ephemerides can only be computed for a date range.
+ Asking for the ephemerides with an out of range date will result in an exception:
+
+ >>> get_ephemerides(pos, date(1000, 1, 1))
+ Traceback (most recent call last):
+ ...
+ kosmorrolib.exceptions.OutOfRangeDateError: The date must be between 1899-07-29 and 2053-10-07
"""
ephemerides = []
@@ -167,7 +183,7 @@ def get_ephemerides(
return (
position.get_planet_topos()
.at(time)
- .observe(for_aster.get_skyfield_object())
+ .observe(for_aster.skyfield_object)
.apparent()
.altaz()[0]
.degrees
@@ -183,26 +199,28 @@ def get_ephemerides(
fun.rough_period = 0.5
return fun
- start_time = get_timescale().utc(date.year, date.month, date.day, -timezone)
+ start_time = get_timescale().utc(
+ for_date.year, for_date.month, for_date.day, -timezone
+ )
end_time = get_timescale().utc(
- date.year, date.month, date.day, 23 - timezone, 59, 59
+ for_date.year, for_date.month, for_date.day, 23 - timezone, 59, 59
)
try:
for aster in ASTERS:
rise_times, arr = find_discrete(start_time, end_time, is_risen(aster))
- try:
- culmination_time, _ = find_maxima(
- start_time,
- end_time,
- f=get_angle(aster),
- epsilon=1.0 / 3600 / 24,
- num=12,
- )
- culmination_time = (
- culmination_time[0] if len(culmination_time) > 0 else None
- )
- except ValueError:
+
+ culmination_time, _ = find_maxima(
+ start_time,
+ end_time,
+ f=get_angle(aster),
+ epsilon=1.0 / 3600 / 24,
+ num=12,
+ )
+
+ if len(culmination_time) == 1:
+ culmination_time = culmination_time[0]
+ else:
culmination_time = None
rise_time, set_time = None, None
@@ -244,8 +262,8 @@ def get_ephemerides(
start = translate_to_timezone(error.start_time.utc_datetime(), timezone)
end = translate_to_timezone(error.end_time.utc_datetime(), timezone)
- start = datetime.date(start.year, start.month, start.day + 1)
- end = datetime.date(end.year, end.month, end.day - 1)
+ start = date(start.year, start.month, start.day + 1)
+ end = date(end.year, end.month, end.day - 1)
raise OutOfRangeDateError(start, end) from error
diff --git a/kosmorrolib/events.py b/kosmorrolib/events.py
index 6b4f9c8..46f5e35 100644
--- a/kosmorrolib/events.py
+++ b/kosmorrolib/events.py
@@ -25,21 +25,32 @@ from skyfield.units import Angle
from skyfield import almanac, eclipselib
from numpy import pi
-from kosmorrolib.model import Object, Event, Star, Planet, ASTERS, EARTH
+from kosmorrolib.model import (
+ Object,
+ Event,
+ Object,
+ Star,
+ Planet,
+ get_aster,
+ ASTERS,
+ EARTH,
+)
from kosmorrolib.dateutil import translate_to_timezone
from kosmorrolib.enum import EventType, ObjectIdentifier, SeasonType, LunarEclipseType
from kosmorrolib.exceptions import OutOfRangeDateError
from kosmorrolib.core import get_timescale, get_skf_objects, flatten_list
-def _search_conjunction(start_time: Time, end_time: Time, timezone: int) -> [Event]:
+def _search_conjunctions_occultations(
+ start_time: Time, end_time: Time, timezone: int
+) -> [Event]:
"""Function to search conjunction.
**Warning:** this is an internal function, not intended for use by end-developers.
Will return MOON and VENUS opposition on 2021-06-12:
- >>> conjunction = _search_conjunction(get_timescale().utc(2021,6,12),get_timescale().utc(2021,6,13),0)
+ >>> conjunction = _search_conjunctions_occultations(get_timescale().utc(2021, 6, 12), get_timescale().utc(2021, 6, 13), 0)
>>> len(conjunction)
1
>>> conjunction[0].objects
@@ -47,8 +58,13 @@ def _search_conjunction(start_time: Time, end_time: Time, timezone: int) -> [Eve
Will return nothing if no conjunction happens:
- >>> _search_conjunction(get_timescale().utc(2021,6,17),get_timescale().utc(2021,6,18),0)
+ >>> _search_conjunctions_occultations(get_timescale().utc(2021, 6, 17),get_timescale().utc(2021, 6, 18), 0)
[]
+
+ This function detects occultations too:
+
+ >>> _search_conjunctions_occultations(get_timescale().utc(2021, 4, 17),get_timescale().utc(2021, 4, 18), 0)
+ [, ] start=2021-04-17 12:08:16.115650+00:00 end=None details=None />]
"""
earth = get_skf_objects()["earth"]
aster1 = None
@@ -57,10 +73,10 @@ def _search_conjunction(start_time: Time, end_time: Time, timezone: int) -> [Eve
def is_in_conjunction(time: Time):
earth_pos = earth.at(time)
_, aster1_lon, _ = (
- earth_pos.observe(aster1.get_skyfield_object()).apparent().ecliptic_latlon()
+ earth_pos.observe(aster1.skyfield_object).apparent().ecliptic_latlon()
)
_, aster2_lon, _ = (
- earth_pos.observe(aster2.get_skyfield_object()).apparent().ecliptic_latlon()
+ earth_pos.observe(aster2.skyfield_object).apparent().ecliptic_latlon()
)
return ((aster1_lon.radians - aster2_lon.radians) / pi % 2.0).astype(
@@ -70,7 +86,7 @@ def _search_conjunction(start_time: Time, end_time: Time, timezone: int) -> [Eve
is_in_conjunction.rough_period = 60.0
computed = []
- conjunctions = []
+ events = []
for aster1 in ASTERS:
# Ignore the Sun
@@ -85,20 +101,21 @@ def _search_conjunction(start_time: Time, end_time: Time, timezone: int) -> [Eve
for i, time in enumerate(times):
if is_conjs[i]:
- aster1_pos = (aster1.get_skyfield_object() - earth).at(time)
- aster2_pos = (aster2.get_skyfield_object() - earth).at(time)
+ aster1_pos = (aster1.skyfield_object - earth).at(time)
+ aster2_pos = (aster2.skyfield_object - earth).at(time)
distance = aster1_pos.separation_from(aster2_pos).degrees
- if distance - aster2.get_apparent_radius(
- time, earth
- ) < aster1.get_apparent_radius(time, earth):
+ if (
+ distance - aster2.get_apparent_radius(time).degrees
+ < aster1.get_apparent_radius(time).degrees
+ ):
occulting_aster = (
[aster1, aster2]
if aster1_pos.distance().km < aster2_pos.distance().km
else [aster2, aster1]
)
- conjunctions.append(
+ events.append(
Event(
EventType.OCCULTATION,
occulting_aster,
@@ -106,7 +123,7 @@ def _search_conjunction(start_time: Time, end_time: Time, timezone: int) -> [Eve
)
)
else:
- conjunctions.append(
+ events.append(
Event(
EventType.CONJUNCTION,
[aster1, aster2],
@@ -116,7 +133,7 @@ def _search_conjunction(start_time: Time, end_time: Time, timezone: int) -> [Eve
computed.append(aster1)
- return conjunctions
+ return events
def _search_oppositions(start_time: Time, end_time: Time, timezone: int) -> [Event]:
@@ -151,7 +168,7 @@ def _search_oppositions(start_time: Time, end_time: Time, timezone: int) -> [Eve
sun_pos = earth_pos.observe(
sun
).apparent() # Never do this without eyes protection!
- aster_pos = earth_pos.observe(get_skf_objects()[aster.skyfield_name]).apparent()
+ aster_pos = earth_pos.observe(aster.skyfield_object).apparent()
_, lon1, _ = sun_pos.ecliptic_latlon()
_, lon2, _ = aster_pos.ecliptic_latlon()
@@ -189,26 +206,39 @@ def _search_oppositions(start_time: Time, end_time: Time, timezone: int) -> [Eve
def _search_maximal_elongations(
start_time: Time, end_time: Time, timezone: int
) -> [Event]:
+ """Function to search oppositions.
+
+ **Warning:** this is an internal function, not intended for use by end-developers.
+
+ Will return Mercury maimum elogation for September 14, 2021:
+
+ >>> get_events(date(2021, 9, 14))
+ [] start=2021-09-14 04:13:46.664879+00:00 end=None details={'deg': 26.8} />]
+ """
earth = get_skf_objects()["earth"]
sun = get_skf_objects()["sun"]
- aster = None
- def get_elongation(time: Time):
- sun_pos = (sun - earth).at(time)
- aster_pos = (aster.get_skyfield_object() - earth).at(time)
- separation = sun_pos.separation_from(aster_pos)
- return separation.degrees
+ def get_elongation(planet: Object):
+ def f(time: Time):
+ sun_pos = (sun - earth).at(time)
+ aster_pos = (planet.skyfield_object - earth).at(time)
+ separation = sun_pos.separation_from(aster_pos)
+ return separation.degrees
- get_elongation.rough_period = 1.0
+ f.rough_period = 1.0
- events = []
+ return f
- for aster in ASTERS:
- if aster.skyfield_name not in ["MERCURY", "VENUS"]:
- continue
+ events = []
+ for identifier in [ObjectIdentifier.MERCURY, ObjectIdentifier.VENUS]:
+ planet = get_aster(identifier)
times, elongations = find_maxima(
- start_time, end_time, f=get_elongation, epsilon=1.0 / 24 / 3600, num=12
+ start_time,
+ end_time,
+ f=get_elongation(planet),
+ epsilon=1.0 / 24 / 3600,
+ num=12,
)
for i, time in enumerate(times):
@@ -216,7 +246,7 @@ def _search_maximal_elongations(
events.append(
Event(
EventType.MAXIMAL_ELONGATION,
- [aster],
+ [planet],
translate_to_timezone(time.utc_datetime(), timezone),
details={"deg": elongation},
)
@@ -227,8 +257,8 @@ def _search_maximal_elongations(
def _get_distance(to_aster: Object, from_aster: Object):
def get_distance(time: Time):
- from_pos = from_aster.get_skyfield_object().at(time)
- to_pos = from_pos.observe(to_aster.get_skyfield_object())
+ from_pos = from_aster.skyfield_object.at(time)
+ to_pos = from_pos.observe(to_aster.skyfield_object)
return to_pos.distance().km
@@ -370,25 +400,23 @@ def _search_lunar_eclipse(start_time: Time, end_time: Time, timezone: int) -> [E
>>> _search_lunar_eclipse(get_timescale().utc(2017, 2, 11), get_timescale().utc(2017, 2, 12), 0)
[] start=2017-02-10 22:02:59.016572+00:00 end=2017-02-11 03:25:07.627886+00:00 details={'type': , 'maximum': datetime.datetime(2017, 2, 11, 0, 43, 51, 793786, tzinfo=datetime.timezone.utc)} />]
"""
- moon = ASTERS[1]
+ moon = get_aster(ObjectIdentifier.MOON)
events = []
t, y, details = eclipselib.lunar_eclipses(start_time, end_time, get_skf_objects())
for ti, yi in zip(t, y):
penumbra_radius = Angle(radians=details["penumbra_radius_radians"][0])
_, max_lon, _ = (
- EARTH.get_skyfield_object()
- .at(ti)
- .observe(moon.get_skyfield_object())
+ EARTH.skyfield_object.at(ti)
+ .observe(moon.skyfield_object)
.apparent()
.ecliptic_latlon()
)
def is_in_penumbra(time: Time):
_, lon, _ = (
- EARTH.get_skyfield_object()
- .at(time)
- .observe(moon.get_skyfield_object())
+ EARTH.skyfield_object.at(time)
+ .observe(moon.skyfield_object)
.apparent()
.ecliptic_latlon()
)
@@ -453,6 +481,14 @@ def get_events(for_date: date = date.today(), timezone: int = 0) -> [Event]:
>>> get_events(date(2021, 4, 20))
[]
+ Note that the events can only be found for a date range.
+ Asking for the events with an out of range date will result in an exception:
+
+ >>> get_events(date(1000, 1, 1))
+ Traceback (most recent call last):
+ ...
+ kosmorrolib.exceptions.OutOfRangeDateError: The date must be between 1899-07-28 and 2053-10-08
+
:param for_date: the date for which the events must be calculated
:param timezone: the timezone to adapt the results to. If not given, defaults to 0.
:return: a list of events found for the given date.
@@ -470,7 +506,7 @@ def get_events(for_date: date = date.today(), timezone: int = 0) -> [Event]:
for fun in [
_search_oppositions,
- _search_conjunction,
+ _search_conjunctions_occultations,
_search_maximal_elongations,
_search_apogee(ASTERS[1]),
_search_perigee(ASTERS[1]),
diff --git a/kosmorrolib/exceptions.py b/kosmorrolib/exceptions.py
index 28159d8..bc67d18 100644
--- a/kosmorrolib/exceptions.py
+++ b/kosmorrolib/exceptions.py
@@ -19,24 +19,14 @@
from datetime import date
-class UnavailableFeatureError(RuntimeError):
- def __init__(self, msg: str):
- super().__init__()
- self.msg = msg
-
-
-class OutOfRangeDateError(RuntimeError):
+class OutOfRangeDateError(ValueError):
def __init__(self, min_date: date, max_date: date):
- super().__init__()
+ super().__init__(
+ "The date must be between %s and %s"
+ % (
+ min_date.strftime("%Y-%m-%d"),
+ max_date.strftime("%Y-%m-%d"),
+ )
+ )
self.min_date = min_date
self.max_date = max_date
- self.msg = "The date must be between %s and %s" % (
- min_date.strftime("%Y-%m-%d"),
- max_date.strftime("%Y-%m-%d"),
- )
-
-
-class CompileError(RuntimeError):
- def __init__(self, msg):
- super().__init__()
- self.msg = msg
diff --git a/kosmorrolib/model.py b/kosmorrolib/model.py
index e9bf915..6f83fe1 100644
--- a/kosmorrolib/model.py
+++ b/kosmorrolib/model.py
@@ -18,14 +18,14 @@
from abc import ABC, abstractmethod
from typing import Union
-from datetime import datetime
+from datetime import datetime, timezone
-from numpy import pi, arcsin
+import numpy
-from skyfield.api import Topos, Time
+from skyfield.api import Topos, Time, Angle
from skyfield.vectorlib import VectorSum as SkfPlanet
-from .core import get_skf_objects
+from .core import get_skf_objects, get_timescale
from .enum import MoonPhaseType, EventType, ObjectIdentifier, ObjectType
@@ -54,6 +54,48 @@ class MoonPhase(Serializable):
)
def get_next_phase(self):
+ """Helper to get the Moon phase that follows the one described by the object.
+
+ If the current Moon phase is New Moon or Waxing crescent, the next one will be First Quarter:
+
+ >>> moon_phase = MoonPhase(MoonPhaseType.NEW_MOON)
+ >>> moon_phase.get_next_phase()
+
+
+ >>> moon_phase = MoonPhase(MoonPhaseType.NEW_MOON)
+ >>> moon_phase.get_next_phase()
+
+
+ If the current Moon phase is First Quarter or Waxing gibbous, the next one will be Full Moon:
+
+ >>> moon_phase = MoonPhase(MoonPhaseType.FIRST_QUARTER)
+ >>> moon_phase.get_next_phase()
+
+
+ >>> moon_phase = MoonPhase(MoonPhaseType.WAXING_GIBBOUS)
+ >>> moon_phase.get_next_phase()
+
+
+ If the current Moon phase is Full Moon or Waning gibbous, the next one will be Last Quarter:
+
+ >>> moon_phase = MoonPhase(MoonPhaseType.FULL_MOON)
+ >>> moon_phase.get_next_phase()
+
+
+ >>> moon_phase = MoonPhase(MoonPhaseType.WANING_GIBBOUS)
+ >>> moon_phase.get_next_phase()
+
+
+ If the current Moon phase is Last Quarter Moon or Waning crescent, the next one will be New Moon:
+
+ >>> moon_phase = MoonPhase(MoonPhaseType.LAST_QUARTER)
+ >>> moon_phase.get_next_phase()
+
+
+ >>> moon_phase = MoonPhase(MoonPhaseType.WANING_CRESCENT)
+ >>> moon_phase.get_next_phase()
+
+ """
if self.phase_type in [MoonPhaseType.NEW_MOON, MoonPhaseType.WAXING_CRESCENT]:
return MoonPhaseType.FIRST_QUARTER
if self.phase_type in [
@@ -83,18 +125,20 @@ class Object(Serializable):
"""
def __init__(
- self, identifier: ObjectIdentifier, skyfield_name: str, radius: float = None
+ self,
+ identifier: ObjectIdentifier,
+ skyfield_object: SkfPlanet,
+ radius: float = None,
):
"""
Initialize an astronomical object
:param ObjectIdentifier identifier: the official name of the object (may be internationalized)
- :param str skyfield_name: the internal name of the object in Skyfield library
+ :param str skyfield_object: the object from Skyfield library
:param float radius: the radius (in km) of the object
- :param AsterEphemerides ephemerides: the ephemerides associated to the object
"""
self.identifier = identifier
- self.skyfield_name = skyfield_name
+ self.skyfield_object = skyfield_object
self.radius = radius
def __repr__(self):
@@ -103,32 +147,41 @@ class Object(Serializable):
self.identifier.name,
)
- def get_skyfield_object(self) -> SkfPlanet:
- return get_skf_objects()[self.skyfield_name]
-
@abstractmethod
def get_type(self) -> ObjectType:
pass
- def get_apparent_radius(self, time: Time, from_place) -> float:
- """
- Calculate the apparent radius, in degrees, of the object from the given place at a given time.
- :param time:
- :param from_place:
- :return:
- """
- if self.radius is None:
- raise ValueError("Missing radius for %s" % self.identifier.name)
+ def get_apparent_radius(self, for_date: Union[Time, datetime]) -> Angle:
+ """Calculate the apparent radius, in degrees, of the object from the given place at a given time.
- return (
- 360
- / pi
- * arcsin(
- self.radius
- / from_place.at(time).observe(self.get_skyfield_object()).distance().km
- )
+ **Warning:** this is an internal function, not intended for use by end-developers.
+
+ For an easier usage, this method accepts datetime and Skyfield's Time objects:
+
+ >>> sun = ASTERS[0]
+ >>> sun.get_apparent_radius(datetime(2021, 6, 9, tzinfo=timezone.utc))
+
+
+ >>> sun.get_apparent_radius(get_timescale().utc(2021, 6, 9))
+
+
+ Source of the algorithm: https://rhodesmill.org/skyfield/examples.html#what-is-the-angular-diameter-of-a-planet-given-its-radius
+
+ :param for_date: the date for which the apparent radius has to be returned
+ :return: an object representing a Skyfield angle
+ """
+ if isinstance(for_date, datetime):
+ for_date = get_timescale().from_datetime(for_date)
+
+ ra, dec, distance = (
+ EARTH.skyfield_object.at(for_date)
+ .observe(self.skyfield_object)
+ .apparent()
+ .radec()
)
+ return Angle(radians=numpy.arcsin(self.radius / distance.km) * 2.0)
+
def serialize(self) -> dict:
"""Serialize the given object
@@ -243,22 +296,47 @@ class AsterEphemerides(Serializable):
}
-EARTH = Planet(ObjectIdentifier.EARTH, "EARTH")
+EARTH = Planet(ObjectIdentifier.EARTH, get_skf_objects()["EARTH"])
ASTERS = [
- Star(ObjectIdentifier.SUN, "SUN", radius=696342),
- Satellite(ObjectIdentifier.MOON, "MOON", radius=1737.4),
- Planet(ObjectIdentifier.MERCURY, "MERCURY", radius=2439.7),
- Planet(ObjectIdentifier.VENUS, "VENUS", radius=6051.8),
- Planet(ObjectIdentifier.MARS, "MARS", radius=3396.2),
- Planet(ObjectIdentifier.JUPITER, "JUPITER BARYCENTER", radius=71492),
- Planet(ObjectIdentifier.SATURN, "SATURN BARYCENTER", radius=60268),
- Planet(ObjectIdentifier.URANUS, "URANUS BARYCENTER", radius=25559),
- Planet(ObjectIdentifier.NEPTUNE, "NEPTUNE BARYCENTER", radius=24764),
- Planet(ObjectIdentifier.PLUTO, "PLUTO BARYCENTER", radius=1185),
+ Star(ObjectIdentifier.SUN, get_skf_objects()["SUN"], radius=696342),
+ Satellite(ObjectIdentifier.MOON, get_skf_objects()["MOON"], radius=1737.4),
+ Planet(ObjectIdentifier.MERCURY, get_skf_objects()["MERCURY"], radius=2439.7),
+ Planet(ObjectIdentifier.VENUS, get_skf_objects()["VENUS"], radius=6051.8),
+ Planet(ObjectIdentifier.MARS, get_skf_objects()["MARS"], radius=3396.2),
+ Planet(
+ ObjectIdentifier.JUPITER, get_skf_objects()["JUPITER BARYCENTER"], radius=71492
+ ),
+ Planet(
+ ObjectIdentifier.SATURN, get_skf_objects()["SATURN BARYCENTER"], radius=60268
+ ),
+ Planet(
+ ObjectIdentifier.URANUS, get_skf_objects()["URANUS BARYCENTER"], radius=25559
+ ),
+ Planet(
+ ObjectIdentifier.NEPTUNE, get_skf_objects()["NEPTUNE BARYCENTER"], radius=24764
+ ),
+ Planet(ObjectIdentifier.PLUTO, get_skf_objects()["PLUTO BARYCENTER"], radius=1185),
]
+def get_aster(identifier: ObjectIdentifier) -> Object:
+ """Return the aster with the given identifier
+
+ >>> get_aster(ObjectIdentifier.SATURN)
+
+
+ You can also use it to get the `EARTH` object, even though it has its own constant:
+
+ """
+ if identifier == ObjectIdentifier.EARTH:
+ return EARTH
+
+ for aster in ASTERS:
+ if aster.identifier == identifier:
+ return aster
+
+
class Position:
def __init__(self, latitude: float, longitude: float):
self.latitude = latitude
@@ -267,7 +345,7 @@ class Position:
def get_planet_topos(self) -> Topos:
if self._topos is None:
- self._topos = EARTH.get_skyfield_object() + Topos(
+ self._topos = EARTH.skyfield_object + Topos(
latitude_degrees=self.latitude, longitude_degrees=self.longitude
)
diff --git a/tests.py b/tests.py
index 170e021..8e691c8 100644
--- a/tests.py
+++ b/tests.py
@@ -1,21 +1,5 @@
#!/usr/bin/env python3
-# Kosmorrolib - The Library To Compute Your Ephemerides
-# Copyright (C) 2021 Jérôme Deuchnord
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of the
-# License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
import doctest
from kosmorrolib import *
diff --git a/tests/__init__.py b/tests/__init__.py
deleted file mode 100644
index 098ddc8..0000000
--- a/tests/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/usr/bin/env python3
-
-# Kosmorrolib - The Library To Compute Your Ephemerides
-# Copyright (C) 2021 Jérôme Deuchnord
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of the
-# License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
-from .core import *
-from .data import *
-from .ephemerides import *
-from .testutils import *
-from .dateutil import *
diff --git a/tests/core.py b/tests/core.py
deleted file mode 100644
index 20a46e7..0000000
--- a/tests/core.py
+++ /dev/null
@@ -1,33 +0,0 @@
-#!/usr/bin/env python3
-
-# Kosmorrolib - The Library To Compute Your Ephemerides
-# Copyright (C) 2021 Jérôme Deuchnord
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of the
-# License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
-import unittest
-
-import kosmorrolib.core as core
-
-
-class CoreTestCase(unittest.TestCase):
- def test_flatten_list(self):
- self.assertEqual(
- [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
- core.flatten_list([0, 1, 2, [3, 4, [5, 6], 7], 8, [9]]),
- )
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/data.py b/tests/data.py
deleted file mode 100644
index 8ab8ad3..0000000
--- a/tests/data.py
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/usr/bin/env python3
-
-# Kosmorrolib - The Library To Compute Your Ephemerides
-# Copyright (C) 2021 Jérôme Deuchnord
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of the
-# License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
-import unittest
-
-from kosmorrolib import model, core
-from kosmorrolib.enum import ObjectIdentifier
-
-
-class DataTestCase(unittest.TestCase):
- def test_object_radius_must_be_set_to_get_apparent_radius(self):
- o = model.Planet(ObjectIdentifier.SATURN, "SATURN")
-
- with self.assertRaises(ValueError) as context:
- o.get_apparent_radius(
- core.get_timescale().now(), core.get_skf_objects()["earth"]
- )
-
- self.assertEqual(("Missing radius for SATURN",), context.exception.args)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/dateutil.py b/tests/dateutil.py
deleted file mode 100644
index 70f3db3..0000000
--- a/tests/dateutil.py
+++ /dev/null
@@ -1,46 +0,0 @@
-#!/usr/bin/env python3
-
-# Kosmorrolib - The Library To Compute Your Ephemerides
-# Copyright (C) 2021 Jérôme Deuchnord
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of the
-# License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
-import unittest
-
-from kosmorrolib import dateutil
-from datetime import datetime
-
-
-class DateUtilTestCase(unittest.TestCase):
- def test_translate_to_timezone(self):
- date = dateutil.translate_to_timezone(datetime(2020, 6, 9, 4), to_tz=-2)
- self.assertEqual(2, date.hour)
-
- date = dateutil.translate_to_timezone(datetime(2020, 6, 9, 0), to_tz=2)
- self.assertEqual(2, date.hour)
-
- date = dateutil.translate_to_timezone(
- datetime(2020, 6, 9, 8), to_tz=2, from_tz=6
- )
- self.assertEqual(4, date.hour)
-
- date = dateutil.translate_to_timezone(
- datetime(2020, 6, 9, 1), to_tz=0, from_tz=2
- )
- self.assertEqual(8, date.day)
- self.assertEqual(23, date.hour)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/ephemerides.py b/tests/ephemerides.py
deleted file mode 100644
index 56d3f28..0000000
--- a/tests/ephemerides.py
+++ /dev/null
@@ -1,143 +0,0 @@
-#!/usr/bin/env python3
-
-# Kosmorrolib - The Library To Compute Your Ephemerides
-# Copyright (C) 2021 Jérôme Deuchnord
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of the
-# License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
-import unittest
-
-from kosmorrolib.enum import MoonPhaseType
-
-from .testutils import expect_assertions
-from kosmorrolib import ephemerides
-from kosmorrolib.model import Position, MoonPhase
-from datetime import date
-from kosmorrolib.exceptions import OutOfRangeDateError
-
-
-class EphemeridesTestCase(unittest.TestCase):
- def test_get_ephemerides_for_aster_returns_correct_hours(self):
- position = Position(0, 0)
- eph = ephemerides.get_ephemerides(date=date(2019, 11, 18), position=position)
-
- @expect_assertions(self.assertRegex, num=3)
- def do_assertions(assert_regex):
- for ephemeris in eph:
- if ephemeris.object.skyfield_name == "SUN":
- assert_regex(ephemeris.rise_time.isoformat(), "^2019-11-18T05:42:")
- assert_regex(
- ephemeris.culmination_time.isoformat(), "^2019-11-18T11:45:"
- )
- assert_regex(ephemeris.set_time.isoformat(), "^2019-11-18T17:49:")
- break
-
- do_assertions()
-
- ###################################################################################################################
- ### MOON PHASE TESTS ###
- ###################################################################################################################
-
- def test_moon_phase_new_moon(self):
- phase = ephemerides.get_moon_phase(date(2019, 11, 25))
- self.assertEqual(MoonPhaseType.WANING_CRESCENT, phase.phase_type)
- self.assertIsNone(phase.time)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-11-26T")
-
- phase = ephemerides.get_moon_phase(date(2019, 11, 26))
- self.assertEqual(MoonPhaseType.NEW_MOON, phase.phase_type)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-12-04T")
-
- phase = ephemerides.get_moon_phase(date(2019, 11, 27))
- self.assertEqual(MoonPhaseType.WAXING_CRESCENT, phase.phase_type)
- self.assertIsNone(phase.time)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-12-04T")
-
- def test_moon_phase_first_crescent(self):
- phase = ephemerides.get_moon_phase(date(2019, 11, 3))
- self.assertEqual(MoonPhaseType.WAXING_CRESCENT, phase.phase_type)
- self.assertIsNone(phase.time)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-11-04T")
-
- phase = ephemerides.get_moon_phase(date(2019, 11, 4))
- self.assertEqual(MoonPhaseType.FIRST_QUARTER, phase.phase_type)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-11-12T")
-
- phase = ephemerides.get_moon_phase(date(2019, 11, 5))
- self.assertEqual(MoonPhaseType.WAXING_GIBBOUS, phase.phase_type)
- self.assertIsNone(phase.time)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-11-12T")
-
- def test_moon_phase_full_moon(self):
- phase = ephemerides.get_moon_phase(date(2019, 11, 11))
- self.assertEqual(MoonPhaseType.WAXING_GIBBOUS, phase.phase_type)
- self.assertIsNone(phase.time)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-11-12T")
-
- phase = ephemerides.get_moon_phase(date(2019, 11, 12))
- self.assertEqual(MoonPhaseType.FULL_MOON, phase.phase_type)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-11-19T")
-
- phase = ephemerides.get_moon_phase(date(2019, 11, 13))
- self.assertEqual(MoonPhaseType.WANING_GIBBOUS, phase.phase_type)
- self.assertIsNone(phase.time)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-11-19T")
-
- def test_moon_phase_last_quarter(self):
- phase = ephemerides.get_moon_phase(date(2019, 11, 18))
- self.assertEqual(MoonPhaseType.WANING_GIBBOUS, phase.phase_type)
- self.assertIsNone(phase.time)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-11-19T")
-
- phase = ephemerides.get_moon_phase(date(2019, 11, 19))
- self.assertEqual(MoonPhaseType.LAST_QUARTER, phase.phase_type)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-11-26T")
-
- phase = ephemerides.get_moon_phase(date(2019, 11, 20))
- self.assertEqual(MoonPhaseType.WANING_CRESCENT, phase.phase_type)
- self.assertIsNone(phase.time)
- self.assertRegexpMatches(phase.next_phase_date.isoformat(), "^2019-11-26T")
-
- def test_moon_phase_prediction(self):
- phase = MoonPhase(MoonPhaseType.NEW_MOON, None, None)
- self.assertEqual(MoonPhaseType.FIRST_QUARTER, phase.get_next_phase())
- phase = MoonPhase(MoonPhaseType.WAXING_CRESCENT, None, None)
- self.assertEqual(MoonPhaseType.FIRST_QUARTER, phase.get_next_phase())
-
- phase = MoonPhase(MoonPhaseType.FIRST_QUARTER, None, None)
- self.assertEqual(MoonPhaseType.FULL_MOON, phase.get_next_phase())
- phase = MoonPhase(MoonPhaseType.WAXING_GIBBOUS, None, None)
- self.assertEqual(MoonPhaseType.FULL_MOON, phase.get_next_phase())
-
- phase = MoonPhase(MoonPhaseType.FULL_MOON, None, None)
- self.assertEqual(MoonPhaseType.LAST_QUARTER, phase.get_next_phase())
- phase = MoonPhase(MoonPhaseType.WANING_GIBBOUS, None, None)
- self.assertEqual(MoonPhaseType.LAST_QUARTER, phase.get_next_phase())
-
- phase = MoonPhase(MoonPhaseType.LAST_QUARTER, None, None)
- self.assertEqual(MoonPhaseType.NEW_MOON, phase.get_next_phase())
- phase = MoonPhase(MoonPhaseType.WANING_CRESCENT, None, None)
- self.assertEqual(MoonPhaseType.NEW_MOON, phase.get_next_phase())
-
- def test_get_ephemerides_raises_exception_on_out_of_date_range(self):
- with self.assertRaises(OutOfRangeDateError):
- ephemerides.get_ephemerides(Position(0, 0), date(1789, 5, 5))
-
- def test_get_moon_phase_raises_exception_on_out_of_date_range(self):
- with self.assertRaises(OutOfRangeDateError):
- ephemerides.get_moon_phase(date(1789, 5, 5))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/testutils.py b/tests/testutils.py
deleted file mode 100644
index 8a2b07d..0000000
--- a/tests/testutils.py
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/env python3
-
-# Kosmorrolib - The Library To Compute Your Ephemerides
-# Copyright (C) 2021 Jérôme Deuchnord
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of the
-# License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
-import functools
-from unittest import mock
-
-
-def expect_assertions(assert_fun, num=1):
- """Asserts that an assertion function is called as expected.
-
- This is very useful when the assertions are in loops.
- To use it, create a nested function in the the tests function.
- The nested function will receive as parameter the mocked assertion function to use in place of the original one.
- Finally, run the nested function.
-
- Example of use:
-
- >>> # the function we tests:
- >>> def my_sum_function(n, m):
- >>> # some code here
- >>> pass
-
- >>> # The unit tests:
- >>> def test_sum(self):
- >>> @expect_assertions(self.assertEqual, num=10):
- >>> def make_assertions(assert_equal):
- >>> for i in range (-5, 5):
- >>> for j in range(-5, 5):
- >>> assert_equal(i + j, my_sum_function(i, j)
- >>>
- >>> make_assertions() # You don't need to give any parameter, the decorator does it for you.
-
- :param assert_fun: the assertion function to tests
- :param num: the number of times the assertion function is expected to be called
- """
- assert_fun_mock = mock.Mock(side_effect=assert_fun)
-
- def fun_decorator(fun):
- @functools.wraps(fun)
- def sniff_function():
- fun(assert_fun_mock)
-
- count = assert_fun_mock.call_count
- if count != num:
- raise AssertionError(
- "Expected %d call(s) to function %s but called %d time(s)."
- % (num, assert_fun.__name__, count)
- )
-
- return sniff_function
-
- return fun_decorator