Repository: sibson/redbeat

Issue:
Celery 5.3.0rc1 not useable with redbeat anymore.
Since celery 5.3.0rc1 redbeat isnt working anymore. Please see trace. Seems FixedOffset does not exist anymore in Celery 5.3.0:
```

celery-beat      | Traceback (most recent call last):
celery-beat      |   File "/venv/bin/celery", line 8, in <module>
celery-beat      |     sys.exit(main())
celery-beat      |              ^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/celery/__main__.py", line 15, in main
celery-beat      |     sys.exit(_main())
celery-beat      |              ^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/celery/bin/celery.py", line 235, in main
celery-beat      |     return celery(auto_envvar_prefix="CELERY")
celery-beat      |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/click/core.py", line 1130, in __call__
celery-beat      |     return self.main(*args, **kwargs)
celery-beat      |            ^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/click/core.py", line 1055, in main
celery-beat      |     rv = self.invoke(ctx)
celery-beat      |          ^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/click/core.py", line 1657, in invoke
celery-beat      |     return _process_result(sub_ctx.command.invoke(sub_ctx))
celery-beat      |                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/click/core.py", line 1404, in invoke
celery-beat      |     return ctx.invoke(self.callback, **ctx.params)
celery-beat      |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/click/core.py", line 760, in invoke
celery-beat      |     return __callback(*args, **kwargs)
celery-beat      |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/click/decorators.py", line 26, in new_func
celery-beat      |     return f(get_current_context(), *args, **kwargs)
celery-beat      |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/celery/bin/base.py", line 134, in caller
celery-beat      |     return f(ctx, *args, **kwargs)
celery-beat      |            ^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/celery/bin/beat.py", line 72, in beat
celery-beat      |     return beat().run()
celery-beat      |            ^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/celery/apps/beat.py", line 84, in run
celery-beat      |     self.start_scheduler()
celery-beat      |   File "/venv/lib/python3.11/site-packages/celery/apps/beat.py", line 104, in start_scheduler
celery-beat      |     print(self.banner(service))
celery-beat      |           ^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/celery/apps/beat.py", line 126, in banner
celery-beat      |     c.reset(self.startup_info(service))),
celery-beat      |             ^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/celery/apps/beat.py", line 136, in startup_info
celery-beat      |     scheduler = service.get_scheduler(lazy=True)
celery-beat      |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/celery/beat.py", line 665, in get_scheduler
celery-beat      |     return symbol_by_name(self.scheduler_cls, aliases=aliases)(
celery-beat      |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/kombu/utils/imports.py", line 58, in symbol_by_name
celery-beat      |     module = imp(module_name, package=package, **kwargs)
celery-beat      |              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/usr/local/lib/python3.11/importlib/__init__.py", line 126, in import_module
celery-beat      |     return _bootstrap._gcd_import(name[level:], package, level)
celery-beat      |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "<frozen importlib._bootstrap>", line 1206, in _gcd_import
celery-beat      |   File "<frozen importlib._bootstrap>", line 1178, in _find_and_load
celery-beat      |   File "<frozen importlib._bootstrap>", line 1149, in _find_and_load_unlocked
celery-beat      |   File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
celery-beat      |   File "<frozen importlib._bootstrap_external>", line 940, in exec_module
celery-beat      |   File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
celery-beat      |   File "/venv/lib/python3.11/site-packages/redbeat/__init__.py", line 3, in <module>
celery-beat      |     from .schedulers import RedBeatScheduler, RedBeatSchedulerEntry  # noqa
celery-beat      |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
celery-beat      |   File "/venv/lib/python3.11/site-packages/redbeat/schedulers.py", line 30, in <module>
celery-beat      |     from .decoder import RedBeatJSONEncoder, RedBeatJSONDecoder, to_timestamp
celery-beat      |   File "/venv/lib/python3.11/site-packages/redbeat/decoder.py", line 8, in <module>
celery-beat      |     from celery.utils.time import timezone, FixedOffset
celery-beat      | ImportError: cannot import name 'FixedOffset' from 'celery.utils.time' (/venv/lib/python3.11/site-packages/celery/utils/time.py)
```


Comments:

Patch:
diff --git a/redbeat/decoder.py b/redbeat/decoder.py
index 5f630c8..a1b1ed0 100644
--- a/redbeat/decoder.py
+++ b/redbeat/decoder.py
@@ -1,11 +1,15 @@
 import calendar
 import json
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
 
 from celery.schedules import crontab, schedule
-from celery.utils.time import FixedOffset, timezone
 from dateutil.rrule import weekday
 
+try:
+    import zoneinfo
+except ImportError:
+    from backports import zoneinfo
+
 from .schedules import rrule
 
 
@@ -24,7 +28,7 @@ def get_utcoffset_minutes(dt):
 
 def from_timestamp(seconds, tz_minutes=0):
     """convert seconds since the epoch to an UTC aware datetime"""
-    tz = FixedOffset(tz_minutes) if tz_minutes else timezone.utc
+    tz = timezone(timedelta(minutes=tz_minutes)) if tz_minutes else timezone.utc
     return datetime.fromtimestamp(seconds, tz=tz)
 
 
@@ -41,9 +45,9 @@ class RedBeatJSONDecoder(json.JSONDecoder):
         if objtype == 'datetime':
             zone = d.pop('timezone', 'UTC')
             try:
-                tzinfo = FixedOffset(zone / 60)
+                tzinfo = timezone(timedelta(minutes=(zone / 60)))
             except TypeError:
-                tzinfo = timezone.get_timezone(zone)
+                tzinfo = zoneinfo.ZoneInfo(zone)
             return datetime(tzinfo=tzinfo, **d)
 
         if objtype == 'interval':
@@ -74,10 +78,10 @@ class RedBeatJSONEncoder(json.JSONEncoder):
         if isinstance(obj, datetime):
             if obj.tzinfo is None:
                 timezone = 'UTC'
-            elif obj.tzinfo.zone is None:
-                timezone = obj.tzinfo.utcoffset(None).total_seconds()
+            elif hasattr(obj.tzinfo, 'key'):
+                timezone = obj.tzinfo.key
             else:
-                timezone = obj.tzinfo.zone
+                timezone = obj.tzinfo.utcoffset(None).total_seconds()
 
             return {
                 '__type__': 'datetime',
diff --git a/requirements-dev.txt b/requirements-dev.txt
index d3f3b16..9d93a24 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -6,3 +6,4 @@ redis>=3.2
 tenacity
 twine
 wheel
+backports.zoneinfo>=0.2.1; python_version < "3.9.0"
diff --git a/setup.py b/setup.py
index fad7fc4..f76c17b 100644
--- a/setup.py
+++ b/setup.py
@@ -27,6 +27,12 @@ setup(
         'Programming Language :: Python :: Implementation :: CPython',
         'Operating System :: OS Independent',
     ],
-    install_requires=['redis>=3.2', 'celery>=5.0', 'python-dateutil', 'tenacity'],
+    install_requires=[
+        'redis>=3.2',
+        'celery>=5.0',
+        'python-dateutil',
+        'tenacity',
+        'backports.zoneinfo>=0.2.1; python_version < "3.9.0"',
+    ],
     tests_require=['pytest'],
 )


Test patch:
diff --git a/tests/test_json.py b/tests/test_json.py
index c08ad50..67ff61c 100644
--- a/tests/test_json.py
+++ b/tests/test_json.py
@@ -1,14 +1,18 @@
 import json
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
 from unittest import TestCase
 
 from celery.schedules import crontab, schedule
-from celery.utils.time import FixedOffset, timezone
 from dateutil import rrule as dateutil_rrule
 
 from redbeat.decoder import RedBeatJSONDecoder, RedBeatJSONEncoder
 from redbeat.schedules import rrule
 
+try:
+    import zoneinfo
+except ImportError:
+    from backports import zoneinfo
+
 
 class JSONTestCase(TestCase):
     def dumps(self, d):
@@ -99,14 +103,14 @@ class RedBeatJSONEncoderTestCase(JSONTestCase):
         self.assertEqual(json.loads(result), expected)
 
     def test_datetime_with_tz(self):
-        dt = self.now(tzinfo=timezone.get_timezone('Asia/Shanghai'))
+        dt = self.now(tzinfo=zoneinfo.ZoneInfo('Asia/Shanghai'))
         result = self.dumps(dt)
 
         expected = self.datetime_as_dict(timezone='Asia/Shanghai', __type__='datetime')
         self.assertEqual(json.loads(result), expected)
 
     def test_datetime_with_fixedoffset(self):
-        dt = self.now(tzinfo=FixedOffset(4 * 60))
+        dt = self.now(tzinfo=timezone(timedelta(hours=4)))
         result = self.dumps(dt)
 
         expected = self.datetime_as_dict(timezone=4 * 60 * 60.0)
@@ -134,7 +138,7 @@ class RedBeatJSONEncoderTestCase(JSONTestCase):
         self.assertEqual(json.loads(result), self.rrule())
 
     def test_rrule_timezone(self):
-        tz = timezone.get_timezone('US/Eastern')
+        tz = zoneinfo.ZoneInfo('US/Eastern')
 
         start1 = datetime(2015, 12, 30, 12, 59, 22, tzinfo=timezone.utc)
         start2 = start1.astimezone(tz)
@@ -209,14 +213,14 @@ class RedBeatJSONDecoderTestCase(JSONTestCase):
         result = self.loads(json.dumps(d))
         d.pop('__type__')
         d.pop('timezone')
-        self.assertEqual(result, datetime(tzinfo=timezone.get_timezone('Asia/Shanghai'), **d))
+        self.assertEqual(result, datetime(tzinfo=zoneinfo.ZoneInfo('Asia/Shanghai'), **d))
 
     def test_datetime_with_fixed_offset(self):
         d = self.datetime_as_dict(__type__='datetime', timezone=5 * 60 * 60)
         result = self.loads(json.dumps(d))
         d.pop('__type__')
         d.pop('timezone')
-        self.assertEqual(result, datetime(tzinfo=FixedOffset(5 * 60), **d))
+        self.assertEqual(result, datetime(tzinfo=timezone(timedelta(hours=5)), **d))
 
     def test_schedule(self):
         d = self.schedule()