Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions procrastinate/blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import sys
from typing import TYPE_CHECKING, Callable, Literal, Union, cast, overload
from zoneinfo import ZoneInfo

from typing_extensions import Concatenate, ParamSpec, TypeVar, Unpack

Expand Down Expand Up @@ -195,6 +196,7 @@ def add_tasks_from(self, blueprint: Blueprint, *, namespace: str) -> None:
task=periodic_task.task,
cron=periodic_task.cron,
periodic_id=periodic_task.periodic_id,
tzinfo=periodic_task.tzinfo,
configure_kwargs=periodic_task.configure_kwargs,
)

Expand Down Expand Up @@ -359,6 +361,7 @@ def periodic(
*,
cron: str,
periodic_id: str = "",
tzinfo: str | None | ZoneInfo = None,
**configure_kwargs: Unpack[ConfigureTaskOptions],
):
"""
Expand All @@ -371,11 +374,17 @@ def periodic(
Cron-like string. Optionally add a 6th column for seconds.
periodic_id :
Task name suffix. Used to distinguish periodic tasks with different kwargs.
**kwargs :
Additional parameters are passed to `Task.configure`.
tzinfo :
Timezone in which the cron expression should be interpreted. Accepts a
timezone name string (e.g., "Africa/Blantyre"), a `zoneinfo.ZoneInfo` instance,
or `None`. When `None` (the default), the underlying `croniter` library
will interpret the schedule in UTC (the current behaviour).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mention (the current behaviour) makes sense as a PR comment because we're talking code evolution, but doesn't make sense in the docstring. In 2 years time, what will "current" refer to ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense. It's indeed confusing.

**configure_kwargs :
Additional parameters are passed to ``Task.configure``.
"""

return self.periodic_registry.periodic_decorator(
cron=cron, periodic_id=periodic_id, **configure_kwargs
cron=cron, periodic_id=periodic_id, tzinfo=tzinfo, **configure_kwargs
)

def will_configure_task(self) -> None:
Expand Down
24 changes: 22 additions & 2 deletions procrastinate/periodic.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import time
from collections.abc import Iterable
from typing import Callable, Generic, cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import attr
import croniter
Expand Down Expand Up @@ -35,10 +36,26 @@ class PeriodicTask(Generic[P, R, Args]):
cron: str
periodic_id: str
configure_kwargs: tasks.ConfigureTaskOptions
tzinfo: str | None | ZoneInfo = None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather we convert str to ZoneInfo at constructor time, and we only ever store ZoneInfo (or None) on the object

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meaning we shouldn't accept a ZoneInfo object?

Other than being simpler for the user, do you have any other specific reason for restricting it to a string value?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah no, I meant: whether we recieve a str or a ZoneInfo, what we store is a ZoneInfo.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was confused. Okay.


@cached_property
def croniter(self) -> croniter.croniter:
return croniter.croniter(self.cron)
croniter_instance = croniter.croniter(self.cron)
# croniter sets the timezone info object in
croniter_instance.tzinfo = self.get_tzinfo()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that the public API of croniter ? I couldn't find the official doc, so it's hard to say.

Also, I believe, support of ZoneInfo is recent, which means we need to severly restrict the accepted versions in pyproject.toml (which might create some conflict, but the lib didn't move a lot)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realise the comment was incomplete. It's not the public API, but at least it's not a private attribute. They set tzinfo attribute in the initialiser of the croniter instance.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you mean by the ZoneInfo support being recent? As I believe it's supported in v6.0.0 (from December, 2024)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will see if I run into any issues once I write tests.

return croniter_instance

def get_tzinfo(self):
tzinfo = self.tzinfo
if isinstance(tzinfo, str):
try:
return ZoneInfo(tzinfo)
except (ZoneInfoNotFoundError, ValueError):
logger.error(f"{tzinfo} is not a valid timezone.")
return None
Comment on lines +54 to +55
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failure could be impactful, I think we need to raise.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only issue I can see is that it might confuse users when their chosen timezone isn’t applied. Besides that, does it have any other impact?

And if we’re okay with it crashing, wouldn’t it be simpler not to catch the error in the first place?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only issue I can see is that it might confuse users when their chosen timezone isn’t applied. Besides that, does it have any other impact?

If I have scheduled batch processing at 12AM while there's no activity, and it happens at 2PM during peak hours, it could easily cause disruption.

if isinstance(tzinfo, ZoneInfo):
return tzinfo
return None


TaskAtTime = tuple[PeriodicTask, int]
Expand All @@ -52,6 +69,7 @@ def periodic_decorator(
self,
cron: str,
periodic_id: str,
tzinfo: str | None | ZoneInfo = None,
**configure_kwargs: Unpack[tasks.ConfigureTaskOptions],
) -> Callable[[tasks.Task[P, R, Concatenate[int, Args]]], tasks.Task[P, R, Args]]:
"""
Expand All @@ -68,6 +86,7 @@ def wrapper(
cron=cron,
periodic_id=periodic_id,
configure_kwargs=configure_kwargs,
tzinfo=tzinfo,
)
return cast(tasks.Task[P, R, Args], task)

Expand All @@ -79,6 +98,7 @@ def register_task(
cron: str,
periodic_id: str,
configure_kwargs: tasks.ConfigureTaskOptions,
tzinfo: str | None | ZoneInfo = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Please put None last in the types)

) -> PeriodicTask[P, R, Concatenate[int, Args]]:
key = (task.name, periodic_id)
if key in self.periodic_tasks:
Expand All @@ -99,12 +119,12 @@ def register_task(
"kwargs": str(configure_kwargs),
},
)

self.periodic_tasks[key] = periodic_task = PeriodicTask(
task=task,
cron=cron,
periodic_id=periodic_id,
configure_kwargs=configure_kwargs,
tzinfo=tzinfo,
)
return periodic_task

Expand Down
Loading