django-crontask 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
crontask/__init__.py ADDED
@@ -0,0 +1,133 @@
1
+ """Cron style scheduler for Django's task framework."""
2
+
3
+ from unittest.mock import Mock
4
+
5
+ from apscheduler.schedulers.base import STATE_STOPPED
6
+ from apscheduler.schedulers.blocking import BlockingScheduler
7
+ from apscheduler.triggers.cron import CronTrigger
8
+ from apscheduler.triggers.interval import IntervalTrigger
9
+ from django.utils import timezone
10
+
11
+ from . import _version
12
+
13
+ try:
14
+ from sentry_sdk.crons import monitor
15
+ except ImportError:
16
+ monitor = None
17
+
18
+ __version__ = _version.version
19
+ VERSION = _version.version_tuple
20
+
21
+ __all__ = ["cron", "interval", "scheduler"]
22
+
23
+
24
+ class LazyBlockingScheduler(BlockingScheduler):
25
+ """Avoid annoying info logs for pending jobs."""
26
+
27
+ def add_job(self, *args, **kwargs):
28
+ logger = self._logger
29
+ if self.state == STATE_STOPPED:
30
+ # We don't want to schedule jobs before the scheduler is started.
31
+ self._logger = Mock()
32
+ super().add_job(*args, **kwargs)
33
+ self._logger = logger
34
+
35
+
36
+ scheduler = LazyBlockingScheduler()
37
+
38
+
39
+ def cron(schedule):
40
+ """
41
+ Run task on a scheduler with a cron schedule.
42
+
43
+ Usage:
44
+ @cron("0 0 * * *")
45
+ @task
46
+ def cron_test():
47
+ print("Cron test")
48
+
49
+
50
+ Please don't forget to set up a sentry monitor for the actor, otherwise you won't
51
+ get any notifications if the cron job fails.
52
+
53
+ The monitor slug is your actor name, the schedule should be set to the same
54
+ cron schedule as the cron decorator. The schedule type should be set to cron.
55
+ The monitors timezone should be set to Europe/Berlin.
56
+ """
57
+
58
+ def decorator(task):
59
+ *_, day_schedule = schedule.split(" ")
60
+
61
+ # CronTrigger uses Python's timezone dependent first weekday,
62
+ # so in Berlin monday is 0 and sunday is 6. We use literals to avoid
63
+ # confusion. Literals are also more readable and crontab conform.
64
+ if any(i.isdigit() for i in day_schedule):
65
+ raise ValueError(
66
+ "Please use a literal day of week (Mon, Tue, Wed, Thu, Fri, Sat, Sun) or *"
67
+ )
68
+
69
+ if monitor is not None:
70
+ task = type(task)(
71
+ priority=task.priority,
72
+ func=monitor(task.name)(task.func),
73
+ queue_name=task.queue_name,
74
+ backend=task.backend,
75
+ takes_context=task.takes_context,
76
+ run_after=task.run_after,
77
+ )
78
+
79
+ scheduler.add_job(
80
+ task.enqueue,
81
+ CronTrigger.from_crontab(
82
+ schedule,
83
+ timezone=timezone.get_default_timezone(),
84
+ ),
85
+ name=task.name,
86
+ )
87
+ # We don't add the Sentry monitor on the actor itself, because we only want to
88
+ # monitor the cron job, not the actor itself, or it's direct invocations.
89
+ return task
90
+
91
+ return decorator
92
+
93
+
94
+ def interval(*, seconds):
95
+ """
96
+ Run task on a periodic interval.
97
+
98
+ Usage:
99
+ @interval(seconds=30)
100
+ @task
101
+ def interval_test():
102
+ print("Interval test")
103
+
104
+ Please note that the interval is relative to the time the scheduler is started. For
105
+ example, if you start the scheduler at 12:00:00, the first run will be at 12:00:30.
106
+ However, if you restart the scheduler at 12:00:15, the first run will be at
107
+ 12:00:45.
108
+
109
+ For an interval that is consistent with the clock, use the `cron` decorator instead.
110
+ """
111
+
112
+ def decorator(task):
113
+ if monitor is not None:
114
+ task = type(task)(
115
+ priority=task.priority,
116
+ func=monitor(task.name)(task.func),
117
+ queue_name=task.queue_name,
118
+ backend=task.backend,
119
+ takes_context=task.takes_context,
120
+ run_after=task.run_after,
121
+ )
122
+
123
+ scheduler.add_job(
124
+ task.enqueue,
125
+ IntervalTrigger(
126
+ seconds=seconds,
127
+ timezone=timezone.get_default_timezone(),
128
+ ),
129
+ name=task.name,
130
+ )
131
+ return task
132
+
133
+ return decorator
crontask/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '1.0.0'
32
+ __version_tuple__ = version_tuple = (1, 0, 0)
33
+
34
+ __commit_id__ = commit_id = 'g6bc77cf86'
crontask/conf.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from django.conf import settings
4
+
5
+
6
+ def get_settings():
7
+ return type(
8
+ "Settings",
9
+ (),
10
+ {
11
+ "REDIS_URL": None,
12
+ "LOCK_REFRESH_INTERVAL": 5,
13
+ "LOCK_TIMEOUT": 10,
14
+ "LOCK_BLOCKING_TIMEOUT": 15,
15
+ **getattr(settings, "CRONTASK", {}),
16
+ },
17
+ )
File without changes
File without changes
@@ -0,0 +1,99 @@
1
+ import importlib
2
+ import signal
3
+
4
+ from apscheduler.triggers.interval import IntervalTrigger
5
+ from django.apps import apps
6
+ from django.core.management import BaseCommand
7
+
8
+ from ... import conf, utils
9
+
10
+ try:
11
+ from sentry_sdk import capture_exception
12
+ except ImportError:
13
+ capture_exception = lambda e: None # noqa: E731
14
+
15
+ from ... import scheduler
16
+
17
+
18
+ def kill_softly(signum, frame):
19
+ """Raise a KeyboardInterrupt to stop the scheduler and release the lock."""
20
+ signame = signal.Signals(signum).name
21
+ raise KeyboardInterrupt(f"Received {signame} ({signum}), shutting down…")
22
+
23
+
24
+ class Command(BaseCommand):
25
+ """Run task scheduler for all tasks with the `cron` decorator."""
26
+
27
+ help = __doc__
28
+
29
+ def add_arguments(self, parser):
30
+ parser.add_argument(
31
+ "--no-task-loading",
32
+ action="store_true",
33
+ help="Don't load tasks from installed apps.",
34
+ )
35
+ parser.add_argument(
36
+ "--no-heartbeat",
37
+ action="store_true",
38
+ help="Don't start the heartbeat actor.",
39
+ )
40
+
41
+ def handle(self, *args, **options):
42
+ if not options["no_task_loading"]:
43
+ self.load_tasks(options)
44
+ if not options["no_heartbeat"]:
45
+ importlib.import_module("crontask.tasks")
46
+ self.stdout.write("Scheduling heartbeat.")
47
+ try:
48
+ if not isinstance(utils.lock, utils.FakeLock):
49
+ self.stdout.write("Acquiring lock…")
50
+ # Lock scheduler to prevent multiple instances from running.
51
+ with utils.lock as lock:
52
+ self.launch_scheduler(lock, scheduler)
53
+ except utils.LockNotOwnedError as e:
54
+ capture_exception(e)
55
+ self.stderr.write(
56
+ "The lock is no longer owned by the scheduler. Shutting down."
57
+ )
58
+ except utils.LockError as e:
59
+ capture_exception(e)
60
+ self.stderr.write("Another scheduler is already running.")
61
+
62
+ def launch_scheduler(self, lock, scheduler):
63
+ signal.signal(signal.SIGHUP, kill_softly)
64
+ signal.signal(signal.SIGTERM, kill_softly)
65
+ signal.signal(signal.SIGINT, kill_softly)
66
+ self.stdout.write(self.style.SUCCESS("Starting scheduler…"))
67
+ # Periodically extend TTL of lock if needed
68
+ # https://redis-py.readthedocs.io/en/stable/lock.html#redis.lock.Lock.extend
69
+ scheduler.add_job(
70
+ utils.extend_lock,
71
+ IntervalTrigger(seconds=conf.get_settings().LOCK_REFRESH_INTERVAL),
72
+ args=(lock, scheduler),
73
+ name="contask.utils.lock.extend",
74
+ )
75
+ try:
76
+ scheduler.start()
77
+ except KeyboardInterrupt as e:
78
+ self.stdout.write(self.style.WARNING(str(e)))
79
+ self.stdout.write(self.style.NOTICE("Shutting down scheduler…"))
80
+ scheduler.shutdown()
81
+
82
+ def load_tasks(self, options):
83
+ """
84
+ Load all tasks modules within installed apps.
85
+
86
+ If they are not imported, they will not have registered
87
+ their tasks with the scheduler.
88
+ """
89
+ for app in apps.get_app_configs():
90
+ if app.name == "contask":
91
+ continue
92
+ if app.ready:
93
+ try:
94
+ importlib.import_module(f"{app.name}.tasks")
95
+ self.stdout.write(
96
+ f"Loaded tasks from {self.style.NOTICE(app.name)}."
97
+ )
98
+ except ImportError:
99
+ pass
crontask/tasks.py ADDED
@@ -0,0 +1,13 @@
1
+ import logging
2
+
3
+ from django.tasks import task
4
+
5
+ from . import cron
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ @cron("* * * * *")
11
+ @task
12
+ def heartbeat():
13
+ logger.info("ﮩ٨ـﮩﮩ٨ـ♡ﮩ٨ـﮩﮩ٨ـ")
crontask/utils.py ADDED
@@ -0,0 +1,45 @@
1
+ from crontask.conf import get_settings
2
+
3
+ __all__ = ["LockError", "lock"]
4
+
5
+
6
+ class FakeLock:
7
+ def __enter__(self):
8
+ return self
9
+
10
+ def __exit__(self, exc_type, exc_val, exc_tb):
11
+ pass
12
+
13
+ def extend(self, additional_time=None, replace_ttl=False):
14
+ return True
15
+
16
+
17
+ if redis_url := get_settings().REDIS_URL:
18
+ import redis
19
+ from redis.exceptions import LockError, LockNotOwnedError # noqa
20
+
21
+ redis_client = redis.Redis.from_url(redis_url)
22
+ lock = redis_client.lock(
23
+ "crontask-lock",
24
+ blocking_timeout=get_settings().LOCK_BLOCKING_TIMEOUT,
25
+ timeout=get_settings().LOCK_TIMEOUT,
26
+ thread_local=False,
27
+ )
28
+ else:
29
+
30
+ class LockError(Exception):
31
+ pass
32
+
33
+ class LockNotOwnedError(LockError):
34
+ pass
35
+
36
+ lock = FakeLock()
37
+
38
+
39
+ def extend_lock(lock, scheduler):
40
+ """Extend the lock for a scheduler or shut it down."""
41
+ try:
42
+ lock.extend(get_settings().LOCK_TIMEOUT, True)
43
+ except LockError:
44
+ scheduler.shutdown()
45
+ raise
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-crontask
3
+ Version: 1.0.0
4
+ Summary: Cron style scheduler for Django's task framework.
5
+ Keywords: Django,cron,tasks,scheduler
6
+ Author-email: Rust Saiargaliev <fly.amureki@gmail.com>, Johannes Maron <johannes@maron.family>, Mostafa Mohamed <mostafa.anm91@gmail.com>, Jacqueline Kraus <jacquelinekraus1992@gmail.com>
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: License :: OSI Approved :: BSD License
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Topic :: Communications :: Email
16
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
17
+ Classifier: Topic :: Software Development
18
+ Classifier: Programming Language :: Python
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Framework :: Django
25
+ Classifier: Framework :: Django :: 6.0
26
+ License-File: LICENSE
27
+ Requires-Dist: apscheduler
28
+ Requires-Dist: django>=6.0
29
+ Requires-Dist: redis ; extra == "redis"
30
+ Requires-Dist: sentry-sdk ; extra == "sentry"
31
+ Requires-Dist: pytest ; extra == "test"
32
+ Requires-Dist: pytest-cov ; extra == "test"
33
+ Requires-Dist: pytest-django ; extra == "test"
34
+ Project-URL: Changelog, https://github.com/codingjoe/django-crontask/releases
35
+ Project-URL: Project-URL, https://github.com/codingjoe/django-crontask
36
+ Provides-Extra: redis
37
+ Provides-Extra: sentry
38
+ Provides-Extra: test
39
+
40
+ # Django CronTask
41
+
42
+ <p align="center">
43
+ <picture>
44
+ <source media="(prefers-color-scheme: dark)" srcset="./images/logo-dark.svg">
45
+ <source media="(prefers-color-scheme: light)" srcset="./images/logo-light.svg">
46
+ <img alt="esimport: Blazing fast ESM compiler and importmap generator" src="./images/logo-light.svg">
47
+ </picture>
48
+ </p>
49
+
50
+ **Cron style scheduler for asynchronous tasks in Django.**
51
+
52
+ - setup recurring tasks via crontab syntax
53
+ - lightweight helpers build [APScheduler]
54
+ - [Sentry] cron monitor support
55
+
56
+ [![PyPi Version](https://img.shields.io/pypi/v/django-crontask.svg)](https://pypi.python.org/pypi/django-crontask/)
57
+ [![Test Coverage](https://codecov.io/gh/codingjoe/django-crontask/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/django-crontask)
58
+ [![GitHub License](https://img.shields.io/github/license/codingjoe/django-crontask)](https://raw.githubusercontent.com/codingjoe/django-crontask/master/LICENSE)
59
+
60
+ ## Setup
61
+
62
+ You need to have [Django's Task framework][django-tasks] setup properly.
63
+
64
+ ```ShellSession
65
+ python3 -m pip install django-crontask
66
+ # or
67
+ python3 -m pip install django-crontask[sentry] # with sentry cron monitor support
68
+ ```
69
+
70
+ Add `crontask` to your `INSTALLED_APPS` in `settings.py`:
71
+
72
+ ```python
73
+ # settings.py
74
+ INSTALLED_APPS = [
75
+ "crontask",
76
+ # ...
77
+ ]
78
+ ```
79
+
80
+ Finally, you lauch the scheduler in a separate process:
81
+
82
+ ```ShellSession
83
+ python3 manage.py crontask
84
+ ```
85
+
86
+ ### Setup Redis as a lock backend (optional)
87
+
88
+ If you use Redis as a broker, you can use Redis as a lock backend as well.
89
+ The lock backend is used to prevent multiple instances of the scheduler
90
+ from running at the same time. This is important if you have multiple
91
+ instances of your application running.
92
+
93
+ ```python
94
+ # settings.py
95
+ CRONTASK = {
96
+ "REDIS_URL": "redis://localhost:6379/0",
97
+ }
98
+ ```
99
+
100
+ ## Usage
101
+
102
+ ```python
103
+ # tasks.py
104
+ from django.tasks import task
105
+ from crontask import cron
106
+
107
+
108
+ @cron("*/5 * * * *") # every 5 minutes
109
+ @task
110
+ def my_task():
111
+ my_task.logger.info("Hello World")
112
+ ```
113
+
114
+ ### Interval
115
+
116
+ If you want to run a task more frequently than once a minute, you can use the
117
+ `interval` decorator.
118
+
119
+ ```python
120
+ # tasks.py
121
+ from django.tasks import task
122
+ from crontask import interval
123
+
124
+
125
+ @interval(seconds=30)
126
+ @task
127
+ def my_task():
128
+ my_task.logger.info("Hello World")
129
+ ```
130
+
131
+ Please note that the interval is relative to the time the scheduler is started.
132
+ For example, if you start the scheduler at 12:00:00, the first run will be at
133
+ 12:00:30. However, if you restart the scheduler at 12:00:15, the first run will
134
+ be at 12:00:45.
135
+
136
+ ### Sentry Cron Monitors
137
+
138
+ If you use [Sentry] you can add cron monitors to your tasks.
139
+ The monitor's slug will be the actor's name. Like `my_task` in the example above.
140
+
141
+ ### The crontab command
142
+
143
+ ```ShellSession
144
+ $ python3 manage.py crontab --help
145
+ usage: manage.py crontab [-h] [--no-task-loading] [--no-heartbeat] [--version] [-v {0,1,2,3}]
146
+ [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color]
147
+ [--force-color] [--skip-checks]
148
+
149
+ Run task scheduler for all tasks with the `cron` decorator.
150
+
151
+ options:
152
+ -h, --help show this help message and exit
153
+ --no-task-loading Don't load tasks from installed apps.
154
+ --no-heartbeat Don't start the heartbeat actor.
155
+ ```
156
+
157
+ [apscheduler]: https://apscheduler.readthedocs.io/en/stable/
158
+ [django-tasks]: https://docs.djangoproject.com/en/6.0/topics/tasks/
159
+ [sentry]: https://docs.sentry.io/product/crons/
160
+
@@ -0,0 +1,12 @@
1
+ crontask/__init__.py,sha256=wKdFUbifg1Aas1O_JivgUaOeFCyeRJr43c9rIVEFQbU,4023
2
+ crontask/_version.py,sha256=nmABswtRZESJbYL80328NqscwdgtDAHAxjah3c4EY5w,712
3
+ crontask/conf.py,sha256=Xpkf3XqcBfAVmVNPk3iub1YuP7GA7VNQMEntzX7FdmI,360
4
+ crontask/tasks.py,sha256=QJvg5nH7kEoQRMKkhjmqEjtaD5xGK5lJckQYqgvDdJU,205
5
+ crontask/utils.py,sha256=j_0UISFmjYiyYHU5-hXM9Zqd3eTZ9tz01kfP28GpGGg,1017
6
+ crontask/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ crontask/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ crontask/management/commands/crontask.py,sha256=fk5KK3Ik68kC8bqcj3CR3JZrIv_ysGTWg7bRWN1KYyI,3489
9
+ django_crontask-1.0.0.dist-info/licenses/LICENSE,sha256=Coot8xpmcvR9BOTZs8b0zU6-FIfvh3Pjh8ktxvSzTRc,1528
10
+ django_crontask-1.0.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
11
+ django_crontask-1.0.0.dist-info/METADATA,sha256=NfpBxhvHYBWhxmjedZk02TrhuhTta4ZFeU1xTMIMy7M,5184
12
+ django_crontask-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Johannes Maron, voiio GmbH & contributors
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.