From d368ca40df17e6c70f25d6bda95cd54eb04f41ab Mon Sep 17 00:00:00 2001 From: "K.Stopa" Date: Fri, 4 Sep 2026 18:21:43 +0000 Subject: [PATCH 1/4] test: added more tests --- tests.py | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 1 deletion(-) diff --git a/tests.py b/tests.py index 7cfb167..7117310 100644 --- a/tests.py +++ b/tests.py @@ -1,5 +1,5 @@ import unittest -from datetime import datetime +from datetime import datetime, timedelta, timezone from dateutil import tz from suntime import Sun, SunTimeException, MidnightSunException, PolarNightException @@ -16,6 +16,12 @@ _NORTH_POLE_LAT = 90 _NORTH_POLE_LON = 0 +_SOUTH_POLE_LAT = -90 +_SOUTH_POLE_LON = 0 + +_QUITO_LAT = -0.1807 +_QUITO_LON = -78.4678 + class TestWestSun(unittest.TestCase): """Test on a location where the sun always rises and sets (i.e. San Francisco)""" @@ -119,5 +125,124 @@ def test_get_sunset_time(self): self.sun.get_sunset_time(d) +class TestSouthPoleNoSun(unittest.TestCase): + """Mirror of TestNoSun for the southern polar location, to guard the exception + branches against a hemisphere-specific regression.""" + + def setUp(self): + self.sun = Sun(_SOUTH_POLE_LAT, _SOUTH_POLE_LON) + + def test_get_sunset_time_polar_night(self): + # Winter in the southern hemisphere: sun never rises above the horizon + d = datetime(2024, 6, 21) + with self.assertRaisesRegex(PolarNightException, "The sun"): + self.sun.get_sunset_time(d) + + def test_get_sunrise_time_midnight_sun(self): + # Summer in the southern hemisphere: sun never sets + d = datetime(2024, 12, 21) + with self.assertRaisesRegex(MidnightSunException, "The sun"): + self.sun.get_sunrise_time(d) + + +class TestEquatorSun(unittest.TestCase): + """Near the equator sunrise/sunset should stay close to 6 AM/6 PM local time + with little seasonal variation.""" + + def setUp(self): + self.sun = Sun(_QUITO_LAT, _QUITO_LON) + + def test_get_sunrise_time(self): + expected_sunrise = datetime(2024, 3, 20, 11, 18, 0, tzinfo=tz.UTC) + utc_sunrise = self.sun.get_sunrise_time(datetime(2024, 3, 20)) + self.assertEqual(utc_sunrise, expected_sunrise) + + def test_get_sunset_time(self): + expected_sunset = datetime(2024, 3, 20, 23, 24, 0, tzinfo=tz.UTC) + utc_sunset = self.sun.get_sunset_time(datetime(2024, 3, 20)) + self.assertEqual(utc_sunset, expected_sunset) + + +class TestUtcDayOffset(unittest.TestCase): + """CLAUDE.md calls out utc_day_offset as a past source of bugs: a western + longitude's sunset can land on the following UTC calendar date, and an eastern + longitude's sunrise can land on the previous one. Pin that invariant explicitly.""" + + def test_west_sunset_rolls_to_next_utc_day(self): + sun = Sun(_SF_LAT, _SF_LON) + requested = datetime(2024, 3, 11) + utc_sunset = sun.get_sunset_time(requested) + self.assertEqual(utc_sunset.date(), requested.date() + timedelta(days=1)) + + def test_east_sunrise_rolls_to_previous_utc_day(self): + sun = Sun(_TOKYO_LAT, _TOKYO_LON) + requested = datetime(2024, 3, 11) + utc_sunrise = sun.get_sunrise_time(requested) + self.assertEqual(utc_sunrise.date(), requested.date() - timedelta(days=1)) + + +class TestCustomZenith(unittest.TestCase): + """get_sun_timedelta accepts a custom zenith (e.g. for civil twilight); a wider + zenith angle should produce an earlier rise / later set than the default 90.8.""" + + def setUp(self): + self.sun = Sun(_SF_LAT, _SF_LON) + + def test_civil_twilight_precedes_standard_sunrise(self): + at_date = datetime(2024, 3, 11) + standard_rise = self.sun.get_sun_timedelta(at_date, timezone.utc, is_rise_time=True) + civil_twilight_rise = self.sun.get_sun_timedelta(at_date, timezone.utc, is_rise_time=True, zenith=96) + self.assertLess(civil_twilight_rise, standard_rise) + + +class TestNoneTimezone(unittest.TestCase): + """time_zone=None falls back to the machine's local timezone (see CLAUDE.md).""" + + def test_get_sunrise_time_matches_explicit_local_offset(self): + sun = Sun(_SF_LAT, _SF_LON) + at_date = datetime(2024, 3, 11) + utc_sunrise = sun.get_sunrise_time(at_date, time_zone=timezone.utc) + local_offset = datetime.now().astimezone().utcoffset() + expected_naive_local = (utc_sunrise + local_offset).replace(tzinfo=None) + + result = sun.get_sunrise_time(at_date, time_zone=None) + + self.assertIsNone(result.tzinfo) + self.assertEqual(result, expected_naive_local) + + +class TestDeprecatedWrappers(unittest.TestCase): + """get_local_sunrise_time/get_local_sunset_time are deprecated but must keep + warning and delegating to the non-deprecated methods with identical results.""" + + def setUp(self): + self.sun = Sun(_SF_LAT, _SF_LON) + + def test_get_local_sunrise_time_warns_and_matches(self): + at_date = datetime(2024, 3, 11) + with self.assertWarns(DeprecationWarning): + deprecated_result = self.sun.get_local_sunrise_time(at_date, tz.gettz("America/Los_Angeles")) + direct_result = self.sun.get_sunrise_time(at_date, tz.gettz("America/Los_Angeles")) + self.assertEqual(deprecated_result, direct_result) + + def test_get_local_sunset_time_warns_and_matches(self): + at_date = datetime(2024, 3, 11) + with self.assertWarns(DeprecationWarning): + deprecated_result = self.sun.get_local_sunset_time(at_date, tz.gettz("America/Los_Angeles")) + direct_result = self.sun.get_sunset_time(at_date, tz.gettz("America/Los_Angeles")) + self.assertEqual(deprecated_result, direct_result) + + +class TestSunConstructor(unittest.TestCase): + def test_stores_lat_lon_and_computes_lngHour(self): + sun = Sun(_SF_LAT, _SF_LON) + self.assertEqual(sun._lat, _SF_LAT) + self.assertEqual(sun._lon, _SF_LON) + self.assertAlmostEqual(sun.lngHour, _SF_LON / 15) + + def test_computes_lngHour_for_eastern_longitude(self): + sun = Sun(_TOKYO_LAT, _TOKYO_LON) + self.assertAlmostEqual(sun.lngHour, _TOKYO_LON / 15) + if __name__ == "__main__": unittest.main() From 7bb2d86bf1665cf63c3a4441d21d41d549797d2a Mon Sep 17 00:00:00 2001 From: "K.Stopa" Date: Fri, 4 Sep 2026 18:46:47 +0000 Subject: [PATCH 2/4] chore: drop python-dateutils dep, migrated to ZoneInfo, drop support for python 3.8, extend to 3.14 --- .github/workflows/develop.yml | 2 +- README.md | 6 ++--- pyproject.toml | 3 ++- tests.py | 41 ++++++++++++++++++----------------- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 47532ed..2810c83 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -13,7 +13,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: [ "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 diff --git a/README.md b/README.md index ad2ef0a..6a526cc 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ You can use the library to get UTC and local time sunrise and sunset times typin ```python3 import datetime -from dateutil import tz +from zoneinfo import ZoneInfo from suntime import Sun, SunTimeException warsaw_lat = 51.21 @@ -34,8 +34,8 @@ print('Today at Warsaw the sun raised at {} and get down at {} UTC'. # On a special date in your machine's local time zone abd = datetime.datetime(2014, 10, 3) -abd_sr = sun.get_sunrise_time(abd, tz.gettz('Europe/Warsaw')) -abd_ss = sun.get_sunset_time(abd, tz.gettz('Europe/Warsaw')) +abd_sr = sun.get_sunrise_time(abd, ZoneInfo('Europe/Warsaw')) +abd_ss = sun.get_sunset_time(abd, ZoneInfo('Europe/Warsaw')) print('On {} the sun at Warsaw raised at {} and get down at {}.'. format(abd, abd_sr.strftime('%H:%M'), abd_ss.strftime('%H:%M'))) diff --git a/pyproject.toml b/pyproject.toml index bc640ab..f2ce13c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dynamic = ["version"] description = "Simple sunset and sunrise time calculation python library" readme = "README.md" license = { text = "LGPLv3" } +requires-python = ">=3.9" authors = [ { name = "Andrey Kobyshev" }, { name = "Hadrien Bertrand" }, @@ -20,7 +21,7 @@ authors = [ { name = "M. Amrehn" }, { name = "Sijmen Huizenga" }, ] -dependencies = ["python-dateutil>=2.1.0"] +dependencies = [] [project.optional-dependencies] dev = ["pytest", "ruff"] diff --git a/tests.py b/tests.py index 7117310..4292b68 100644 --- a/tests.py +++ b/tests.py @@ -1,6 +1,6 @@ import unittest from datetime import datetime, timedelta, timezone -from dateutil import tz +from zoneinfo import ZoneInfo from suntime import Sun, SunTimeException, MidnightSunException, PolarNightException @@ -31,25 +31,25 @@ def setUp(self): def test_get_sunrise_time(self): # Sunrise in San Francisco (winter time) - expected_sunrise = datetime(2024, 3, 11, 14, 25, 48, tzinfo=tz.UTC) # 6:26 AM local time + expected_sunrise = datetime(2024, 3, 11, 14, 25, 48, tzinfo=timezone.utc) # 6:26 AM local time utc_sunrise = self.sun.get_sunrise_time(datetime(2024, 3, 11)) - local_sunrise = self.sun.get_local_sunrise_time(datetime(2024, 3, 11), tz.gettz("America/Los_Angeles")) + local_sunrise = self.sun.get_local_sunrise_time(datetime(2024, 3, 11), ZoneInfo("America/Los_Angeles")) # Assert time matches 14:40 UTC self.assertEqual(utc_sunrise, expected_sunrise) self.assertEqual(local_sunrise, expected_sunrise) # Sunrise in San Francisco (summer time) - expected_sunrise = datetime(2024, 6, 20, 12, 48, 0, tzinfo=tz.UTC) + expected_sunrise = datetime(2024, 6, 20, 12, 48, 0, tzinfo=timezone.utc) utc_sunrise = self.sun.get_sunrise_time(datetime(2024, 6, 20)) - local_sunrise = self.sun.get_local_sunrise_time(datetime(2024, 6, 20), tz.gettz("America/Los_Angeles")) + local_sunrise = self.sun.get_local_sunrise_time(datetime(2024, 6, 20), ZoneInfo("America/Los_Angeles")) # Assert time matches 13:25 UTC self.assertEqual(utc_sunrise, expected_sunrise) self.assertEqual(local_sunrise, expected_sunrise) def test_get_sunset_time(self): # Test sunset in San Francisco - expected_sunset = datetime(2024, 3, 12, 2, 13, 48, tzinfo=tz.tzutc()) + expected_sunset = datetime(2024, 3, 12, 2, 13, 48, tzinfo=timezone.utc) utc_sunset = self.sun.get_sunset_time(datetime(2024, 3, 11)) - local_sunset = self.sun.get_local_sunset_time(datetime(2024, 3, 11), tz.gettz("America/Los_Angeles")) + local_sunset = self.sun.get_local_sunset_time(datetime(2024, 3, 11), ZoneInfo("America/Los_Angeles")) self.assertEqual(utc_sunset, expected_sunset) self.assertEqual(local_sunset, expected_sunset) # Check with no params @@ -65,13 +65,13 @@ def setUp(self): def test_get_sunrise_time(self): # Sunrise in Tokyo - expected_utc_sunrise = datetime(2024, 3, 10, 20, 57, 36, tzinfo=tz.UTC) - expected_local_sunrise = datetime(2024, 3, 11, 5, 57, 36, tzinfo=tz.gettz("Asia/Tokyo")) + expected_utc_sunrise = datetime(2024, 3, 10, 20, 57, 36, tzinfo=timezone.utc) + expected_local_sunrise = datetime(2024, 3, 11, 5, 57, 36, tzinfo=ZoneInfo("Asia/Tokyo")) utc_sunrise = self.sun.get_sunrise_time(datetime(2024, 3, 11)) self.assertEqual(utc_sunrise, expected_utc_sunrise) - local_time_sunrise = self.sun.get_local_sunrise_time(datetime(2024, 3, 11), time_zone=tz.gettz("Asia/Tokyo")) + local_time_sunrise = self.sun.get_local_sunrise_time(datetime(2024, 3, 11), time_zone=ZoneInfo("Asia/Tokyo")) self.assertEqual(local_time_sunrise, expected_local_sunrise) @@ -83,14 +83,14 @@ def setUp(self): def test_get_sunrise_time(self): # Sunrise in Sydney - expected_sunrise = datetime(2024, 3, 11, 6, 51, 36, tzinfo=tz.gettz("Australia/Sydney")) - local_sunrise = self.sun.get_sunrise_time(datetime(2024, 3, 11), tz.gettz("Australia/Sydney")) + expected_sunrise = datetime(2024, 3, 11, 6, 51, 36, tzinfo=ZoneInfo("Australia/Sydney")) + local_sunrise = self.sun.get_sunrise_time(datetime(2024, 3, 11), ZoneInfo("Australia/Sydney")) self.assertEqual(expected_sunrise, local_sunrise) def test_get_sunset_time(self): # Test sunset in Sydney - expected_sunset = datetime(2024, 3, 11, 19, 18, 0, tzinfo=tz.gettz("Australia/Sydney")) - local_sunset = self.sun.get_sunset_time(datetime(2024, 3, 11), tz.gettz("Australia/Sydney")) + expected_sunset = datetime(2024, 3, 11, 19, 18, 0, tzinfo=ZoneInfo("Australia/Sydney")) + local_sunset = self.sun.get_sunset_time(datetime(2024, 3, 11), ZoneInfo("Australia/Sydney")) self.assertEqual(expected_sunset, local_sunset) @@ -153,12 +153,12 @@ def setUp(self): self.sun = Sun(_QUITO_LAT, _QUITO_LON) def test_get_sunrise_time(self): - expected_sunrise = datetime(2024, 3, 20, 11, 18, 0, tzinfo=tz.UTC) + expected_sunrise = datetime(2024, 3, 20, 11, 18, 0, tzinfo=timezone.utc) utc_sunrise = self.sun.get_sunrise_time(datetime(2024, 3, 20)) self.assertEqual(utc_sunrise, expected_sunrise) def test_get_sunset_time(self): - expected_sunset = datetime(2024, 3, 20, 23, 24, 0, tzinfo=tz.UTC) + expected_sunset = datetime(2024, 3, 20, 23, 24, 0, tzinfo=timezone.utc) utc_sunset = self.sun.get_sunset_time(datetime(2024, 3, 20)) self.assertEqual(utc_sunset, expected_sunset) @@ -221,15 +221,15 @@ def setUp(self): def test_get_local_sunrise_time_warns_and_matches(self): at_date = datetime(2024, 3, 11) with self.assertWarns(DeprecationWarning): - deprecated_result = self.sun.get_local_sunrise_time(at_date, tz.gettz("America/Los_Angeles")) - direct_result = self.sun.get_sunrise_time(at_date, tz.gettz("America/Los_Angeles")) + deprecated_result = self.sun.get_local_sunrise_time(at_date, ZoneInfo("America/Los_Angeles")) + direct_result = self.sun.get_sunrise_time(at_date, ZoneInfo("America/Los_Angeles")) self.assertEqual(deprecated_result, direct_result) def test_get_local_sunset_time_warns_and_matches(self): at_date = datetime(2024, 3, 11) with self.assertWarns(DeprecationWarning): - deprecated_result = self.sun.get_local_sunset_time(at_date, tz.gettz("America/Los_Angeles")) - direct_result = self.sun.get_sunset_time(at_date, tz.gettz("America/Los_Angeles")) + deprecated_result = self.sun.get_local_sunset_time(at_date, ZoneInfo("America/Los_Angeles")) + direct_result = self.sun.get_sunset_time(at_date, ZoneInfo("America/Los_Angeles")) self.assertEqual(deprecated_result, direct_result) @@ -244,5 +244,6 @@ def test_computes_lngHour_for_eastern_longitude(self): sun = Sun(_TOKYO_LAT, _TOKYO_LON) self.assertAlmostEqual(sun.lngHour, _TOKYO_LON / 15) + if __name__ == "__main__": unittest.main() From 179b0d2eb9f9fbda0da693130b144437f267016c Mon Sep 17 00:00:00 2001 From: "K.Stopa" Date: Fri, 4 Sep 2026 18:47:49 +0000 Subject: [PATCH 3/4] chore: updated git workflow --- .github/workflows/develop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 2810c83..f7dcc9b 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -13,7 +13,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [ "3.9", "3.10", "3.11", "3.12"] + python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v2 From 167612cb66bd15457dc384465f607925f422b395 Mon Sep 17 00:00:00 2001 From: "K.Stopa" Date: Fri, 4 Sep 2026 19:44:28 +0000 Subject: [PATCH 4/4] chore: switch branch to main in workflow --- .github/workflows/develop.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index f7dcc9b..2634709 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -2,9 +2,9 @@ name: Test and Quality CI on: push: - branches: [ master, develop ] + branches: [ main, develop ] pull_request: - branches: [ master, develop ] + branches: [ main, develop ] jobs: build: