agronholm / apscheduler

Task scheduling library for Python
MIT License
6.3k stars 712 forks source link

__attrs_post_init__ hooks bypassed during unmarshalling #974

Open ochachacha opened 1 month ago

ochachacha commented 1 month ago

Things to check first

Version

4.0.0a5

What happened?

The following function inside _marshalling.py is called during unmarshalling of objects (e.g. inside cbor and json serializers):

def unmarshal_object(ref: str, state: Any) -> Any:
    cls = callable_from_ref(ref)
    if not isinstance(cls, type):
        raise TypeError(f"{ref} is not a class")

    instance = cls.__new__(cls) # << problematic line
    instance.__setstate__(state)
    return instance

However, the marked line calls __new__, which causes __attrs_post_init__ hooks to be bypassed -- see here. As a result, instances of classes that rely on such hooks (such as CronTrigger) will not have all their fields filled in during unmarshalling.

Changing the aforementioned line to

instance = cls()

seems to solve this problem.

How can we reproduce the bug?

Here's a minimal example of something that this breaks:

from apscheduler.serializers.json import JSONSerializer
from apscheduler.datastores.sqlalchemy import SQLAlchemyDataStore
from apscheduler import Scheduler
from apscheduler.triggers.cron import CronTrigger

def hello_world():
    print("hello")

json_serializer = JSONSerializer()
data_store = SQLAlchemyDataStore("sqlite:///example.sqlite", serializer=json_serializer)
scheduler = Scheduler(data_store)
cron_trigger = CronTrigger(hour = 0)
schedule_id = scheduler.add_schedule(hello_world, cron_trigger)
scheduler.pause_schedule(schedule_id) # << AttributeError will be raised here

The traceback will look like this:

Traceback (most recent call last):
  File "/workspace/example.py", line 15, in <module>
    scheduler.pause_schedule(schedule_id)
  File "/workspace/.venv/lib/python3.12/site-packages/apscheduler/_schedulers/sync.py", line 282, in pause_schedule
    self._portal.call(self._async_scheduler.pause_schedule, id)
  File "/workspace/.venv/lib/python3.12/site-packages/anyio/from_thread.py", line 287, in call
    return cast(T_Retval, self.start_task_soon(func, *args).result())
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.6/Frameworks/Python.framework/Versions/3.12/lib/python3.12/concurrent/futures/_base.py", line 456, in result
    return self.__get_result()
           ^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.6/Frameworks/Python.framework/Versions/3.12/lib/python3.12/concurrent/futures/_base.py", line 401, in __get_result
    raise self._exception
  File "/workspace/.venv/lib/python3.12/site-packages/anyio/from_thread.py", line 218, in _call_func
    retval = await retval_or_awaitable
             ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/.venv/lib/python3.12/site-packages/apscheduler/_schedulers/async_.py", line 538, in pause_schedule
    await self.data_store.add_schedule(
  File "/workspace/.venv/lib/python3.12/site-packages/apscheduler/datastores/sqlalchemy.py", line 504, in add_schedule
    schedule.marshal(self.serializer)
  File "/workspace/.venv/lib/python3.12/site-packages/apscheduler/_structures.py", line 143, in marshal
    marshalled = attrs.asdict(self, value_serializer=serialize)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/.venv/lib/python3.12/site-packages/attr/_next_gen.py", line 613, in asdict
    return _asdict(
           ^^^^^^^^
  File "/workspace/.venv/lib/python3.12/site-packages/attr/_funcs.py", line 75, in asdict
    rv[a.name] = asdict(
                 ^^^^^^^
  File "/workspace/.venv/lib/python3.12/site-packages/attr/_funcs.py", line 66, in asdict
    v = getattr(inst, a.name)
        ^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'CronTrigger' object has no attribute 'year'