django-crontask 1.0.0__tar.gz
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.
- django_crontask-1.0.0/LICENSE +28 -0
- django_crontask-1.0.0/PKG-INFO +160 -0
- django_crontask-1.0.0/README.md +120 -0
- django_crontask-1.0.0/crontask/__init__.py +133 -0
- django_crontask-1.0.0/crontask/_version.py +34 -0
- django_crontask-1.0.0/crontask/conf.py +17 -0
- django_crontask-1.0.0/crontask/management/__init__.py +0 -0
- django_crontask-1.0.0/crontask/management/commands/__init__.py +0 -0
- django_crontask-1.0.0/crontask/management/commands/crontask.py +99 -0
- django_crontask-1.0.0/crontask/tasks.py +13 -0
- django_crontask-1.0.0/crontask/utils.py +45 -0
- django_crontask-1.0.0/pyproject.toml +95 -0
|
@@ -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.
|
|
@@ -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
|
+
[](https://pypi.python.org/pypi/django-crontask/)
|
|
57
|
+
[](https://codecov.io/gh/codingjoe/django-crontask)
|
|
58
|
+
[](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,120 @@
|
|
|
1
|
+
# Django CronTask
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<picture>
|
|
5
|
+
<source media="(prefers-color-scheme: dark)" srcset="./images/logo-dark.svg">
|
|
6
|
+
<source media="(prefers-color-scheme: light)" srcset="./images/logo-light.svg">
|
|
7
|
+
<img alt="esimport: Blazing fast ESM compiler and importmap generator" src="./images/logo-light.svg">
|
|
8
|
+
</picture>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
**Cron style scheduler for asynchronous tasks in Django.**
|
|
12
|
+
|
|
13
|
+
- setup recurring tasks via crontab syntax
|
|
14
|
+
- lightweight helpers build [APScheduler]
|
|
15
|
+
- [Sentry] cron monitor support
|
|
16
|
+
|
|
17
|
+
[](https://pypi.python.org/pypi/django-crontask/)
|
|
18
|
+
[](https://codecov.io/gh/codingjoe/django-crontask)
|
|
19
|
+
[](https://raw.githubusercontent.com/codingjoe/django-crontask/master/LICENSE)
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
You need to have [Django's Task framework][django-tasks] setup properly.
|
|
24
|
+
|
|
25
|
+
```ShellSession
|
|
26
|
+
python3 -m pip install django-crontask
|
|
27
|
+
# or
|
|
28
|
+
python3 -m pip install django-crontask[sentry] # with sentry cron monitor support
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Add `crontask` to your `INSTALLED_APPS` in `settings.py`:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
# settings.py
|
|
35
|
+
INSTALLED_APPS = [
|
|
36
|
+
"crontask",
|
|
37
|
+
# ...
|
|
38
|
+
]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Finally, you lauch the scheduler in a separate process:
|
|
42
|
+
|
|
43
|
+
```ShellSession
|
|
44
|
+
python3 manage.py crontask
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Setup Redis as a lock backend (optional)
|
|
48
|
+
|
|
49
|
+
If you use Redis as a broker, you can use Redis as a lock backend as well.
|
|
50
|
+
The lock backend is used to prevent multiple instances of the scheduler
|
|
51
|
+
from running at the same time. This is important if you have multiple
|
|
52
|
+
instances of your application running.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
# settings.py
|
|
56
|
+
CRONTASK = {
|
|
57
|
+
"REDIS_URL": "redis://localhost:6379/0",
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# tasks.py
|
|
65
|
+
from django.tasks import task
|
|
66
|
+
from crontask import cron
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@cron("*/5 * * * *") # every 5 minutes
|
|
70
|
+
@task
|
|
71
|
+
def my_task():
|
|
72
|
+
my_task.logger.info("Hello World")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Interval
|
|
76
|
+
|
|
77
|
+
If you want to run a task more frequently than once a minute, you can use the
|
|
78
|
+
`interval` decorator.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
# tasks.py
|
|
82
|
+
from django.tasks import task
|
|
83
|
+
from crontask import interval
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@interval(seconds=30)
|
|
87
|
+
@task
|
|
88
|
+
def my_task():
|
|
89
|
+
my_task.logger.info("Hello World")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Please note that the interval is relative to the time the scheduler is started.
|
|
93
|
+
For example, if you start the scheduler at 12:00:00, the first run will be at
|
|
94
|
+
12:00:30. However, if you restart the scheduler at 12:00:15, the first run will
|
|
95
|
+
be at 12:00:45.
|
|
96
|
+
|
|
97
|
+
### Sentry Cron Monitors
|
|
98
|
+
|
|
99
|
+
If you use [Sentry] you can add cron monitors to your tasks.
|
|
100
|
+
The monitor's slug will be the actor's name. Like `my_task` in the example above.
|
|
101
|
+
|
|
102
|
+
### The crontab command
|
|
103
|
+
|
|
104
|
+
```ShellSession
|
|
105
|
+
$ python3 manage.py crontab --help
|
|
106
|
+
usage: manage.py crontab [-h] [--no-task-loading] [--no-heartbeat] [--version] [-v {0,1,2,3}]
|
|
107
|
+
[--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color]
|
|
108
|
+
[--force-color] [--skip-checks]
|
|
109
|
+
|
|
110
|
+
Run task scheduler for all tasks with the `cron` decorator.
|
|
111
|
+
|
|
112
|
+
options:
|
|
113
|
+
-h, --help show this help message and exit
|
|
114
|
+
--no-task-loading Don't load tasks from installed apps.
|
|
115
|
+
--no-heartbeat Don't start the heartbeat actor.
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
[apscheduler]: https://apscheduler.readthedocs.io/en/stable/
|
|
119
|
+
[django-tasks]: https://docs.djangoproject.com/en/6.0/topics/tasks/
|
|
120
|
+
[sentry]: https://docs.sentry.io/product/crons/
|
|
@@ -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
|
|
@@ -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'
|
|
@@ -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
|
|
@@ -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,95 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["flit_core>=3.2", "flit_scm", "wheel"]
|
|
3
|
+
build-backend = "flit_scm:buildapi"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-crontask"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Rust Saiargaliev", email = "fly.amureki@gmail.com" },
|
|
9
|
+
{ name = "Johannes Maron", email = "johannes@maron.family" },
|
|
10
|
+
{ name = "Mostafa Mohamed", email = "mostafa.anm91@gmail.com" },
|
|
11
|
+
{ name = "Jacqueline Kraus", email = "jacquelinekraus1992@gmail.com" },
|
|
12
|
+
]
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
license = { file = "LICENSE" }
|
|
15
|
+
keywords = ["Django", "cron", "tasks", "scheduler"]
|
|
16
|
+
dynamic = ["version", "description"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 5 - Production/Stable",
|
|
19
|
+
"Programming Language :: Python",
|
|
20
|
+
"Environment :: Web Environment",
|
|
21
|
+
"License :: OSI Approved :: BSD License",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Topic :: Communications :: Email",
|
|
25
|
+
"Topic :: Text Processing :: Markup :: Markdown",
|
|
26
|
+
"Topic :: Software Development",
|
|
27
|
+
"Programming Language :: Python",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Programming Language :: Python :: 3.14",
|
|
33
|
+
"Framework :: Django",
|
|
34
|
+
"Framework :: Django :: 6.0",
|
|
35
|
+
]
|
|
36
|
+
requires-python = ">=3.12"
|
|
37
|
+
dependencies = ["apscheduler", "django>=6.0"]
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
test = [
|
|
41
|
+
"pytest",
|
|
42
|
+
"pytest-cov",
|
|
43
|
+
"pytest-django",
|
|
44
|
+
]
|
|
45
|
+
sentry = ["sentry-sdk"]
|
|
46
|
+
redis = ["redis"]
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Project-URL = "https://github.com/codingjoe/django-crontask"
|
|
50
|
+
Changelog = "https://github.com/codingjoe/django-crontask/releases"
|
|
51
|
+
|
|
52
|
+
[tool.flit.module]
|
|
53
|
+
name = "crontask"
|
|
54
|
+
|
|
55
|
+
[tool.setuptools_scm]
|
|
56
|
+
write_to = "crontask/_version.py"
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
minversion = "6.0"
|
|
60
|
+
addopts = "--cov --tb=short -rxs"
|
|
61
|
+
testpaths = ["tests"]
|
|
62
|
+
DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
|
|
63
|
+
|
|
64
|
+
[tool.coverage.run]
|
|
65
|
+
source = ["crontask"]
|
|
66
|
+
|
|
67
|
+
[tool.coverage.report]
|
|
68
|
+
show_missing = true
|
|
69
|
+
|
|
70
|
+
[tool.ruff]
|
|
71
|
+
src = ["crontask", "tests"]
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint]
|
|
74
|
+
select = [
|
|
75
|
+
"E", # pycodestyle errors
|
|
76
|
+
"W", # pycodestyle warnings
|
|
77
|
+
"F", # pyflakes
|
|
78
|
+
"I", # isort
|
|
79
|
+
"S", # flake8-bandit
|
|
80
|
+
"D", # pydocstyle
|
|
81
|
+
"UP", # pyupgrade
|
|
82
|
+
"B", # flake8-bugbear
|
|
83
|
+
"C", # flake8-comprehensions
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
ignore = ["B904", "D1", "E501", "S101"]
|
|
87
|
+
|
|
88
|
+
[tool.ruff.lint.isort]
|
|
89
|
+
combine-as-imports = true
|
|
90
|
+
split-on-trailing-comma = true
|
|
91
|
+
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
|
|
92
|
+
force-wrap-aliases = true
|
|
93
|
+
|
|
94
|
+
[tool.ruff.lint.pydocstyle]
|
|
95
|
+
convention = "pep257"
|