Compare commits

...
7 Commits
15 changed files with 200 additions and 106 deletions
-37
View File
@@ -1,37 +0,0 @@
categories:
-
label: breaking
title: Breaking
-
label: feature
title: New
-
label: bug
title: "Bug Fixes"
-
label: dependencies
title: "Dependency Updates"
-
label: security
title: Security
name-template: v$NEXT_PATCH_VERSION
tag-template: v$NEXT_PATCH_VERSION
template: |
# Changes
$CHANGES
version-resolver:
major:
labels:
- breaking
- major
minor:
labels:
- feature
- minor
patch:
labels:
- bug
- dependencies
- security
- patch
default: patch
-14
View File
@@ -1,14 +0,0 @@
name: Update release draft
on:
push:
branches:
- master
jobs:
update_release_draft:
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v5
with:
config-name: release-drafter.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+14
View File
@@ -2,6 +2,20 @@
## [Unreleased](https://github.com/GDay/django-q2/tree/HEAD) ## [Unreleased](https://github.com/GDay/django-q2/tree/HEAD)
## [v1.4.8](https://github.com/GDay/django-q2/tree/v1.4.8) (2022-12-21)
**Merged pull requests:**
- Fix: allow both ZoneInfo and Pytz depending on django version https://github.com/GDay/django-q2/pull/55
## [v1.4.7](https://github.com/GDay/django-q2/tree/v1.4.7) (2022-12-21)
**Merged pull requests:**
- Fix: handling exceptions inside job function https://github.com/GDay/django-q2/pull/51
- Fix: Daylight saving time issue with scheduler https://github.com/GDay/django-q2/pull/47
- Chore: Fix badge and add download badge https://github.com/GDay/django-q2/pull/52
- Chore: Remove release drafter https://github.com/GDay/django-q2/pull/53
## [v1.4.6](https://github.com/GDay/django-q2/tree/v1.4.6) (2022-11-30) ## [v1.4.6](https://github.com/GDay/django-q2/tree/v1.4.6) (2022-11-30)
+4 -2
View File
@@ -1,7 +1,7 @@
A multiprocessing distributed task queue for Django A multiprocessing distributed task queue for Django
--------------------------------------------------- ---------------------------------------------------
|image0| |image1| |docs| |image0| |image1| |docs| |downloads|
:: ::
@@ -245,4 +245,6 @@ Acknowledgements
.. |docs| image:: https://readthedocs.org/projects/docs/badge/?version=latest .. |docs| image:: https://readthedocs.org/projects/docs/badge/?version=latest
:alt: Documentation Status :alt: Documentation Status
:scale: 100 :scale: 100
:target: https://django-q.readthedocs.org/ :target: https://django-q2.readthedocs.org/
.. |downloads| image:: https://img.shields.io/pypi/dm/django-q2
:target: https://img.shields.io/pypi/dm/django-q2
+1 -1
View File
@@ -1,6 +1,6 @@
import django import django
VERSION = (1, 4, 6) VERSION = (1, 4, 8)
if django.VERSION < (3, 2): if django.VERSION < (3, 2):
default_app_config = "django_q.apps.DjangoQConfig" default_app_config = "django_q.apps.DjangoQConfig"
+5 -45
View File
@@ -43,7 +43,7 @@ from django_q.signals import post_execute, pre_execute
from django_q.signing import BadSignature, SignedPackage from django_q.signing import BadSignature, SignedPackage
from django_q.status import Stat, Status from django_q.status import Stat, Status
from .utils import add_months, add_years, get_func_repr from .utils import get_func_repr, localtime
class Cluster: class Cluster:
@@ -484,19 +484,12 @@ def worker(
try: try:
res = f(*task["args"], **task["kwargs"]) res = f(*task["args"], **task["kwargs"])
result = (res, True) result = (res, True)
except Exception: except Exception as e:
result = ( result = (f"{e} : {traceback.format_exc()}", False)
_(
"Could not process '%(func_name)s'. Check the location of the "
"function and the args/kwargs."
)
% {"func_name": func_name},
False,
)
if error_reporter: if error_reporter:
error_reporter.report() error_reporter.report()
if task.get("sync", False): if task.get("sync", False):
raise Exception(result) raise
with timer.get_lock(): with timer.get_lock():
# Process result # Process result
task["result"] = result[0] task["result"] = result[0]
@@ -700,33 +693,7 @@ def scheduler(broker: Broker = None):
if s.schedule_type != s.ONCE: if s.schedule_type != s.ONCE:
next_run = s.next_run next_run = s.next_run
while True: while True:
if s.schedule_type == s.MINUTES: next_run = s.calculate_next_run(next_run)
next_run = next_run + timedelta(minutes=(s.minutes or 1))
elif s.schedule_type == s.HOURLY:
next_run = next_run + timedelta(hours=1)
elif s.schedule_type == s.DAILY:
next_run = next_run + timedelta(days=1)
elif s.schedule_type == s.WEEKLY:
next_run = next_run + timedelta(weeks=1)
elif s.schedule_type == s.BIWEEKLY:
next_run = next_run + timedelta(weeks=2)
elif s.schedule_type == s.MONTHLY:
next_run = add_months(next_run, 1)
elif s.schedule_type == s.BIMONTHLY:
next_run = add_months(next_run, 2)
elif s.schedule_type == s.QUARTERLY:
next_run = add_months(next_run, 3)
elif s.schedule_type == s.YEARLY:
next_run = add_years(next_run, 1)
elif s.schedule_type == s.CRON:
if not croniter:
raise ImportError(
_(
"Please install croniter to enable cron "
"expressions"
)
)
next_run = croniter(s.cron, localtime()).get_next(datetime)
if Conf.CATCH_UP or next_run > localtime(): if Conf.CATCH_UP or next_run > localtime():
break break
@@ -842,10 +809,3 @@ def rss_check():
elif psutil: elif psutil:
return psutil.Process().memory_info().rss >= Conf.MAX_RSS * 1024 return psutil.Process().memory_info().rss >= Conf.MAX_RSS * 1024
return False return False
def localtime() -> datetime:
"""Override for timezone.localtime to deal with naive times and local times"""
if settings.USE_TZ:
return timezone.localtime()
return datetime.now()
+5
View File
@@ -211,6 +211,11 @@ class Conf:
# to manage workarounds during testing # to manage workarounds during testing
TESTING = conf.get("testing", False) TESTING = conf.get("testing", False)
# Timezone for next_run, overrules Django timezone
TIME_ZONE = None
if settings.USE_TZ:
TIME_ZONE = conf.get("time_zone", settings.TIME_ZONE)
# logger # logger
logger = logging.getLogger("django-q") logger = logging.getLogger("django-q")
+58 -1
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timedelta
# Django # Django
from django import get_version from django import get_version
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -5,6 +7,7 @@ from django.db import models
from django.template.defaultfilters import truncatechars from django.template.defaultfilters import truncatechars
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import is_aware
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -13,8 +16,9 @@ from picklefield import PickledObjectField
from picklefield.fields import dbsafe_decode from picklefield.fields import dbsafe_decode
# Local # Local
from django_q.conf import croniter from django_q.conf import croniter, Conf
from django_q.signing import SignedPackage from django_q.signing import SignedPackage
from django_q.utils import localtime, add_months, add_years
from .utils import get_func_repr from .utils import get_func_repr
@@ -208,6 +212,59 @@ class Schedule(models.Model):
task = models.CharField(max_length=100, null=True, editable=False) task = models.CharField(max_length=100, null=True, editable=False)
cluster = models.CharField(max_length=100, default=None, null=True, blank=True) cluster = models.CharField(max_length=100, default=None, null=True, blank=True)
def calculate_next_run(self, next_run=None):
# next run is always in UTC
next_run = next_run or self.next_run
if self.schedule_type == self.CRON:
if not croniter:
raise ImportError(
_("Please install croniter to enable cron expressions")
)
return croniter(self.cron, localtime()).get_next(datetime)
if self.schedule_type == self.MINUTES:
add = timedelta(minutes=(self.minutes or 1))
elif self.schedule_type == self.HOURLY:
add = timedelta(hours=1)
elif self.schedule_type == self.DAILY:
add = timedelta(days=1)
elif self.schedule_type == self.WEEKLY:
add = timedelta(weeks=1)
elif self.schedule_type == self.BIWEEKLY:
add = timedelta(weeks=2)
elif self.schedule_type == self.MONTHLY:
add = timedelta(days=(add_months(next_run, 1) - next_run).days)
elif self.schedule_type == self.BIMONTHLY:
add = timedelta(days=(add_months(next_run, 2) - next_run).days)
elif self.schedule_type == self.QUARTERLY:
add = timedelta(days=(add_months(next_run, 3) - next_run).days)
elif self.schedule_type == self.YEARLY:
add = timedelta(days=(add_years(next_run, 1) - next_run).days)
# add normal timedelta, we will correct this later based on timezone
next_run += add
# DST differencers don't matter with minutes, hourly or yearly, so skip those
if self.schedule_type not in [self.MINUTES, self.HOURLY, self.YEARLY]:
# Get localtimes and then remove the tzinfo, so we can get the actual difference
current_next_run = localtime(next_run - add).replace(tzinfo=None)
new_next_run = localtime(next_run).replace(tzinfo=None)
# get the difference between them, this should be (-)1 or (-)0.5 hour
# based on DST active or not
extra_diff = (new_next_run - current_next_run) - add
# if we have one positive hour difference, then subtract it, so we are even
# and vice versa. In most cases, this will be 0, as there won't be a
# timezone diff
if extra_diff > timedelta(hours=0):
next_run -= extra_diff
else:
next_run += extra_diff
return next_run
def success(self): def success(self):
if self.task and Task.objects.filter(id=self.task): if self.task and Task.objects.filter(id=self.task):
return Task.objects.get(id=self.task).success return Task.objects.get(id=self.task).success
+1 -1
View File
@@ -75,7 +75,7 @@ DATABASES = {
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC" TIME_ZONE = "Europe/Amsterdam"
USE_I18N = True USE_I18N = True
+2 -2
View File
@@ -29,7 +29,7 @@ from django_q.tasks import (
result, result,
result_group, result_group,
) )
from django_q.tests.tasks import multiply from django_q.tests.tasks import multiply, TaskError
from django_q.utils import add_months, add_years from django_q.utils import add_months, add_years
myPath = os.path.dirname(os.path.abspath(__file__)) myPath = os.path.dirname(os.path.abspath(__file__))
@@ -64,7 +64,7 @@ def test_sync(broker):
@pytest.mark.django_db @pytest.mark.django_db
def test_sync_raise_exception(broker): def test_sync_raise_exception(broker):
with pytest.raises(Exception): with pytest.raises(TaskError):
async_task("django_q.tests.tasks.raise_exception", broker=broker, sync=True) async_task("django_q.tests.tasks.raise_exception", broker=broker, sync=True)
+68 -1
View File
@@ -1,9 +1,10 @@
import os import os
from datetime import timedelta from datetime import datetime, timedelta
from multiprocessing import Event, Value from multiprocessing import Event, Value
from unittest import mock from unittest import mock
import pytest import pytest
import django
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
from django.test import override_settings from django.test import override_settings
@@ -23,6 +24,15 @@ from django_q.tests.testing_utilities.multiple_database_routers import (
) )
from django_q.utils import add_months from django_q.utils import add_months
if django.VERSION < (4, 0):
# pytz is the default in django 3.2. Remove when no support for 3.2
from pytz import timezone as ZoneInfo
else:
try:
from zoneinfo import ZoneInfo
except ImportError:
from backports.zoneinfo import ZoneInfo
@pytest.fixture @pytest.fixture
def broker(monkeypatch) -> Broker: def broker(monkeypatch) -> Broker:
@@ -83,6 +93,63 @@ MULTIPLE_APPS_DATABASES = {
} }
@pytest.mark.django_db
def test_scheduler_daylight_saving_time_daily(broker, monkeypatch):
# Set up a startdate in the Amsterdam timezone (without dst 1 hour ahead). The
# 28th of March 2021 is the day when sunlight saving starts (at 2 am)
monkeypatch.setattr(Conf, "TIME_ZONE", "Europe/Amsterdam")
tz = ZoneInfo('Europe/Amsterdam')
broker.list_key = "scheduler_test:q"
# Let's start a schedule at 1 am on the 27th of March. This is in AMS timezone.
# So, 2021-03-27 00:00:00 when saved (due to TZ being Amsterdam and saved in UTC)
start_date = datetime(2021, 3, 27, 1, 0, 0)
# Create schedule with the next run date on the start date. It will move one day
# forward when we run the scheduler
schedule = create_schedule(
"math.copysign",
1,
-1,
name="test math",
schedule_type=Schedule.DAILY,
next_run=start_date,
)
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
# It's now the day after exactly at midnight UTC
next_run = schedule.next_run
assert str(next_run) == "2021-03-28 00:00:00+00:00"
# In the Amsterdam timezone, it's 1 hour over midnight (+01)
next_run = next_run.astimezone(tz)
assert str(next_run) == "2021-03-28 01:00:00+01:00"
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
next_run = schedule.next_run
assert str(next_run) == "2021-03-28 23:00:00+00:00"
next_run = next_run.astimezone(tz)
# In the Amsterdam timezone, it's 1 hour over midnight (+02)
assert str(next_run) == "2021-03-29 01:00:00+02:00"
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
next_run = schedule.next_run
assert str(next_run) == "2021-03-29 23:00:00+00:00"
next_run = next_run.astimezone(tz)
assert str(next_run) == "2021-03-30 01:00:00+02:00"
@pytest.mark.django_db @pytest.mark.django_db
def test_scheduler(broker, monkeypatch): def test_scheduler(broker, monkeypatch):
broker.list_key = "scheduler_test:q" broker.list_key = "scheduler_test:q"
+33
View File
@@ -1,7 +1,23 @@
from datetime import datetime
import calendar import calendar
import inspect import inspect
from datetime import date from datetime import date
import django
from django.utils import timezone
from django.conf import settings
from django_q.conf import Conf
if django.VERSION < (4, 0):
# pytz is the default in django 3.2. Remove when no support for 3.2
from pytz import timezone as ZoneInfo
else:
try:
from zoneinfo import ZoneInfo
except ImportError:
from backports.zoneinfo import ZoneInfo
# credits: https://stackoverflow.com/a/4131114 # credits: https://stackoverflow.com/a/4131114
# Made them aware of timezone # Made them aware of timezone
@@ -39,3 +55,20 @@ def get_func_repr(func):
) )
else: else:
return str(func) return str(func)
def localtime(value=None) -> datetime:
"""Override for timezone.localtime to deal with naive times and local times"""
if settings.USE_TZ:
if django.VERSION >= (4, 0) and settings.USE_DEPRECATED_PYTZ:
import pytz
convert_to_tz = pytz.timezone(Conf.TIME_ZONE)
else:
convert_to_tz = ZoneInfo(Conf.TIME_ZONE)
return timezone.localtime(value=value, timezone=convert_to_tz)
if value is None:
return datetime.now()
else:
return value
+1 -1
View File
@@ -75,7 +75,7 @@ author = "Ilan Steemers, Stan Triepels"
# The short X.Y version. # The short X.Y version.
version = "1.4" version = "1.4"
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = "1.4.6" release = "1.4.8"
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.
+7
View File
@@ -70,6 +70,13 @@ Set this to something that makes sense for your project. Can be overridden for i
See :ref:`retry` for details how to set values for timeout and retry. See :ref:`retry` for details how to set values for timeout and retry.
.. _time_zone:
time_zone
~~~~~~~
The timezone that is used for task scheduling. Use this if you are having issue with DST. The scheduler uses UTC to calculate the next date and will therefore ignore any DST changes. This will cause 1 hour or 0.5 hour changes in the schedule when time is moved one hour ahead or back. Defaults to `settings.TIME_ZONE` if `USE_TZ` is enabled.
.. _ack_failures: .. _ack_failures:
ack_failures ack_failures
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "django-q2" name = "django-q2"
version = "1.4.6" version = "1.4.8"
packages = [ packages = [
{ include = "django_q" }, { include = "django_q" },
] ]