From 8672f5bbf6b1e0e5846534f54a5c949df6313807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Deuchnord?= Date: Sun, 10 Oct 2021 11:45:25 +0200 Subject: [PATCH] test: enhance the coverage (#31) --- .github/workflows/tests.yml | 50 ------------ Makefile | 13 ++- kosmorrolib/dateutil.py | 20 +++-- kosmorrolib/enum.py | 16 ++-- kosmorrolib/ephemerides.py | 152 +++++++++++++++++++---------------- kosmorrolib/events.py | 114 +++++++++++++++++--------- kosmorrolib/exceptions.py | 26 ++---- kosmorrolib/model.py | 156 +++++++++++++++++++++++++++--------- tests.py | 16 ---- tests/__init__.py | 23 ------ tests/core.py | 33 -------- tests/data.py | 38 --------- tests/dateutil.py | 46 ----------- tests/ephemerides.py | 143 --------------------------------- tests/testutils.py | 67 ---------------- 15 files changed, 313 insertions(+), 600 deletions(-) delete mode 100644 tests/__init__.py delete mode 100644 tests/core.py delete mode 100644 tests/data.py delete mode 100644 tests/dateutil.py delete mode 100644 tests/ephemerides.py delete mode 100644 tests/testutils.py 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