diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..c86de5e
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,6 @@
+# Dependencies
+## Python
+pyproject.toml @herrbenesch @amureki
+## GitHub Actions
+.github/workflows/ci.yml @herrbenesch @amureki
+.github/workflows/release.yml @herrbenesch @amureki
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..1ceeb39
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+github:
+ - amureki
+ - codingjoe
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 563cc5c..dd93a24 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -3,8 +3,8 @@ updates:
- package-ecosystem: pip
directory: "/"
schedule:
- interval: weekly
+ interval: daily
- package-ecosystem: github-actions
directory: "/"
schedule:
- interval: weekly
+ interval: daily
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ba3c53f..d97e800 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,15 +24,24 @@ jobs:
os:
- "ubuntu-latest"
python-version:
+ - "3.10"
+ - "3.11"
- "3.12"
- "3.13"
- - "3.14"
django-version:
- - "6.0"
+ - "4.2" # LTS
+ - "5.1"
+ - "5.2"
extras:
- "test"
- "test,sentry"
- "test,redis"
+ include:
+ # 4.2 is the last version to support Python 3.9
+ - os: "ubuntu-latest"
+ python-version: "3.9"
+ django-version: "4.2"
+ extras: "test"
services:
redis:
image: redis
diff --git a/.gitignore b/.gitignore
index 92f6df7..c69bc64 100644
--- a/.gitignore
+++ b/.gitignore
@@ -131,4 +131,4 @@ dmypy.json
# Packaging
-crontask/_version.py
+dramatiq_crontab/_version.py
diff --git a/LICENSE b/LICENSE
index d8fa35c..72ab18b 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
BSD 3-Clause License
-Copyright (c) 2025, Johannes Maron, voiio GmbH & contributors
+Copyright (c) 2023, voiio GmbH
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
diff --git a/README.md b/README.md
index 3d6d8c0..002f82e 100644
--- a/README.md
+++ b/README.md
@@ -1,39 +1,33 @@
-# Django CronTask
+# Dramatiq Crontab
-
-
-
-
-
-
-
+
-**Cron style scheduler for asynchronous tasks in Django.**
+**Cron style scheduler for asynchronous Dramatiq tasks in Django.**
- setup recurring tasks via crontab syntax
-- lightweight helpers build [APScheduler]
+- lightweight helpers build on robust tools like [Dramatiq] and [APScheduler]
- [Sentry] cron monitor support
-[](https://pypi.python.org/pypi/django-crontask/)
-[](https://codecov.io/gh/codingjoe/django-crontask)
-[](https://raw.githubusercontent.com/codingjoe/django-crontask/master/LICENSE)
+[](https://pypi.python.org/pypi/dramatiq-crontab/)
+[](https://codecov.io/gh/voiio/dramatiq-crontab)
+[](https://raw.githubusercontent.com/voiio/dramatiq-crontab/master/LICENSE)
## Setup
-You need to have [Django's Task framework][django-tasks] setup properly.
+You need to have [Dramatiq] installed and setup properly.
```ShellSession
-python3 -m pip install django-crontask
+python3 -m pip install dramatiq-crontab
# or
-python3 -m pip install django-crontask[sentry] # with sentry cron monitor support
+python3 -m pip install dramatiq-crontab[sentry] # with sentry cron monitor support
```
-Add `crontask` to your `INSTALLED_APPS` in `settings.py`:
+Add `dramatiq_crontab` to your `INSTALLED_APPS` in `settings.py`:
```python
# settings.py
INSTALLED_APPS = [
- "crontask",
+ "dramatiq_crontab",
# ...
]
```
@@ -41,7 +35,7 @@ INSTALLED_APPS = [
Finally, you lauch the scheduler in a separate process:
```ShellSession
-python3 manage.py crontask
+python3 manage.py crontab
```
### Setup Redis as a lock backend (optional)
@@ -53,7 +47,7 @@ instances of your application running.
```python
# settings.py
-CRONTASK = {
+DRAMATIQ_CRONTAB = {
"REDIS_URL": "redis://localhost:6379/0",
}
```
@@ -62,12 +56,12 @@ CRONTASK = {
```python
# tasks.py
-from django.tasks import task
-from crontask import cron
+import dramatiq
+from dramatiq_crontab import cron
@cron("*/5 * * * *") # every 5 minutes
-@task
+@dramatiq.actor
def my_task():
my_task.logger.info("Hello World")
```
@@ -79,12 +73,12 @@ If you want to run a task more frequently than once a minute, you can use the
```python
# tasks.py
-from django.tasks import task
-from crontask import interval
+import dramatiq
+from dramatiq_crontab import interval
@interval(seconds=30)
-@task
+@dramatiq.actor
def my_task():
my_task.logger.info("Hello World")
```
@@ -107,7 +101,7 @@ usage: manage.py crontab [-h] [--no-task-loading] [--no-heartbeat] [--version] [
[--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color]
[--force-color] [--skip-checks]
-Run task scheduler for all tasks with the `cron` decorator.
+Run dramatiq task scheduler for all tasks with the `cron` decorator.
options:
-h, --help show this help message and exit
@@ -116,5 +110,5 @@ options:
```
[apscheduler]: https://apscheduler.readthedocs.io/en/stable/
-[django-tasks]: https://docs.djangoproject.com/en/6.0/topics/tasks/
+[dramatiq]: https://dramatiq.io/
[sentry]: https://docs.sentry.io/product/crons/
diff --git a/crontask/tasks.py b/crontask/tasks.py
deleted file mode 100644
index e8bcc6d..0000000
--- a/crontask/tasks.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import logging
-
-from django.tasks import task
-
-from . import cron
-
-logger = logging.getLogger(__name__)
-
-
-@cron("* * * * *")
-@task
-def heartbeat():
- logger.info("ﮩ٨ـﮩﮩ٨ـ♡ﮩ٨ـﮩﮩ٨ـ")
diff --git a/dramatiq-crontab.png b/dramatiq-crontab.png
new file mode 100644
index 0000000..db0ba9c
Binary files /dev/null and b/dramatiq-crontab.png differ
diff --git a/crontask/__init__.py b/dramatiq_crontab/__init__.py
similarity index 77%
rename from crontask/__init__.py
rename to dramatiq_crontab/__init__.py
index 1dcc6c9..0bea748 100644
--- a/crontask/__init__.py
+++ b/dramatiq_crontab/__init__.py
@@ -1,4 +1,4 @@
-"""Cron style scheduler for Django's task framework."""
+"""Cron style scheduler for asynchronous Dramatiq tasks in Django."""
from unittest.mock import Mock
@@ -42,7 +42,7 @@ def cron(schedule):
Usage:
@cron("0 0 * * *")
- @task
+ @dramatiq.actor
def cron_test():
print("Cron test")
@@ -55,7 +55,7 @@ def cron_test():
The monitors timezone should be set to Europe/Berlin.
"""
- def decorator(task):
+ def decorator(actor):
*_, day_schedule = schedule.split(" ")
# CronTrigger uses Python's timezone dependent first weekday,
@@ -67,26 +67,19 @@ def decorator(task):
)
if monitor is not None:
- task = type(task)(
- priority=task.priority,
- func=monitor(task.name)(task.func),
- queue_name=task.queue_name,
- backend=task.backend,
- takes_context=task.takes_context,
- run_after=task.run_after,
- )
+ actor.fn = monitor(actor.actor_name)(actor.fn)
scheduler.add_job(
- task.enqueue,
+ actor.send,
CronTrigger.from_crontab(
schedule,
timezone=timezone.get_default_timezone(),
),
- name=task.name,
+ name=actor.actor_name,
)
# We don't add the Sentry monitor on the actor itself, because we only want to
# monitor the cron job, not the actor itself, or it's direct invocations.
- return task
+ return actor
return decorator
@@ -97,7 +90,7 @@ def interval(*, seconds):
Usage:
@interval(seconds=30)
- @task
+ @dramatiq.actor
def interval_test():
print("Interval test")
@@ -109,25 +102,18 @@ def interval_test():
For an interval that is consistent with the clock, use the `cron` decorator instead.
"""
- def decorator(task):
+ def decorator(actor):
if monitor is not None:
- task = type(task)(
- priority=task.priority,
- func=monitor(task.name)(task.func),
- queue_name=task.queue_name,
- backend=task.backend,
- takes_context=task.takes_context,
- run_after=task.run_after,
- )
+ actor.fn = monitor(actor.actor_name)(actor.fn)
scheduler.add_job(
- task.enqueue,
+ actor.send,
IntervalTrigger(
seconds=seconds,
timezone=timezone.get_default_timezone(),
),
- name=task.name,
+ name=actor.actor_name,
)
- return task
+ return actor
return decorator
diff --git a/crontask/conf.py b/dramatiq_crontab/conf.py
similarity index 84%
rename from crontask/conf.py
rename to dramatiq_crontab/conf.py
index 7a42f1f..d21801c 100644
--- a/crontask/conf.py
+++ b/dramatiq_crontab/conf.py
@@ -12,6 +12,6 @@ def get_settings():
"LOCK_REFRESH_INTERVAL": 5,
"LOCK_TIMEOUT": 10,
"LOCK_BLOCKING_TIMEOUT": 15,
- **getattr(settings, "CRONTASK", {}),
+ **getattr(settings, "DRAMATIQ_CRONTAB", {}),
},
)
diff --git a/crontask/management/__init__.py b/dramatiq_crontab/management/__init__.py
similarity index 100%
rename from crontask/management/__init__.py
rename to dramatiq_crontab/management/__init__.py
diff --git a/crontask/management/commands/__init__.py b/dramatiq_crontab/management/commands/__init__.py
similarity index 100%
rename from crontask/management/commands/__init__.py
rename to dramatiq_crontab/management/commands/__init__.py
diff --git a/crontask/management/commands/crontask.py b/dramatiq_crontab/management/commands/crontab.py
similarity index 93%
rename from crontask/management/commands/crontask.py
rename to dramatiq_crontab/management/commands/crontab.py
index 7fa71ca..6c796d1 100644
--- a/crontask/management/commands/crontask.py
+++ b/dramatiq_crontab/management/commands/crontab.py
@@ -22,7 +22,7 @@ def kill_softly(signum, frame):
class Command(BaseCommand):
- """Run task scheduler for all tasks with the `cron` decorator."""
+ """Run dramatiq task scheduler for all tasks with the `cron` decorator."""
help = __doc__
@@ -42,7 +42,7 @@ def handle(self, *args, **options):
if not options["no_task_loading"]:
self.load_tasks(options)
if not options["no_heartbeat"]:
- importlib.import_module("crontask.tasks")
+ importlib.import_module("dramatiq_crontab.tasks")
self.stdout.write("Scheduling heartbeat.")
try:
if not isinstance(utils.lock, utils.FakeLock):
@@ -70,7 +70,7 @@ def launch_scheduler(self, lock, scheduler):
utils.extend_lock,
IntervalTrigger(seconds=conf.get_settings().LOCK_REFRESH_INTERVAL),
args=(lock, scheduler),
- name="contask.utils.lock.extend",
+ name="dramatiq_crontab.utils.lock.extend",
)
try:
scheduler.start()
@@ -87,7 +87,7 @@ def load_tasks(self, options):
their tasks with the scheduler.
"""
for app in apps.get_app_configs():
- if app.name == "contask":
+ if app.name == "dramatiq_crontab":
continue
if app.ready:
try:
diff --git a/dramatiq_crontab/tasks.py b/dramatiq_crontab/tasks.py
new file mode 100644
index 0000000..9019be3
--- /dev/null
+++ b/dramatiq_crontab/tasks.py
@@ -0,0 +1,9 @@
+import dramatiq
+
+from . import cron
+
+
+@cron("* * * * *")
+@dramatiq.actor
+def heartbeat():
+ heartbeat.logger.info("ﮩ٨ـﮩﮩ٨ـ♡ﮩ٨ـﮩﮩ٨ـ")
diff --git a/crontask/utils.py b/dramatiq_crontab/utils.py
similarity index 92%
rename from crontask/utils.py
rename to dramatiq_crontab/utils.py
index 7cfe7df..f08f0e3 100644
--- a/crontask/utils.py
+++ b/dramatiq_crontab/utils.py
@@ -1,4 +1,4 @@
-from crontask.conf import get_settings
+from dramatiq_crontab.conf import get_settings
__all__ = ["LockError", "lock"]
@@ -20,7 +20,7 @@ def extend(self, additional_time=None, replace_ttl=False):
redis_client = redis.Redis.from_url(redis_url)
lock = redis_client.lock(
- "crontask-lock",
+ "dramatiq-scheduler",
blocking_timeout=get_settings().LOCK_BLOCKING_TIMEOUT,
timeout=get_settings().LOCK_TIMEOUT,
thread_local=False,
diff --git a/images/logo-dark.svg b/images/logo-dark.svg
deleted file mode 100644
index 1778ddb..0000000
--- a/images/logo-dark.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
diff --git a/images/logo-light.svg b/images/logo-light.svg
deleted file mode 100644
index 9eba4e4..0000000
--- a/images/logo-light.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
diff --git a/pyproject.toml b/pyproject.toml
index 183802e..d6eb627 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@ requires = ["flit_core>=3.2", "flit_scm", "wheel"]
build-backend = "flit_scm:buildapi"
[project]
-name = "django-crontask"
+name = "dramatiq-crontab"
authors = [
{ name = "Rust Saiargaliev", email = "fly.amureki@gmail.com" },
{ name = "Johannes Maron", email = "johannes@maron.family" },
@@ -12,7 +12,7 @@ authors = [
]
readme = "README.md"
license = { file = "LICENSE" }
-keywords = ["Django", "cron", "tasks", "scheduler"]
+keywords = ["Django", "Dramatiq", "tasks", "scheduler"]
dynamic = ["version", "description"]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -27,33 +27,39 @@ classifiers = [
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
- "Programming Language :: Python :: 3.14",
"Framework :: Django",
- "Framework :: Django :: 6.0",
+ "Framework :: Django :: 4.2",
+ "Framework :: Django :: 5.1",
+ "Framework :: Django :: 5.2",
]
-requires-python = ">=3.12"
-dependencies = ["apscheduler", "django>=6.0"]
+requires-python = ">=3.9"
+dependencies = ["dramatiq", "apscheduler", "django"]
[project.optional-dependencies]
test = [
"pytest",
"pytest-cov",
"pytest-django",
+ "dramatiq",
+ "backports.zoneinfo;python_version<'3.9'"
]
sentry = ["sentry-sdk"]
redis = ["redis"]
[project.urls]
-Project-URL = "https://github.com/codingjoe/django-crontask"
-Changelog = "https://github.com/codingjoe/django-crontask/releases"
+Project-URL = "https://github.com/voiio/dramatiq-crontab"
+Changelog = "https://github.com/voiio/dramatiq-crontab/releases"
[tool.flit.module]
-name = "crontask"
+name = "dramatiq_crontab"
[tool.setuptools_scm]
-write_to = "crontask/_version.py"
+write_to = "dramatiq_crontab/_version.py"
[tool.pytest.ini_options]
minversion = "6.0"
@@ -62,13 +68,13 @@ testpaths = ["tests"]
DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
[tool.coverage.run]
-source = ["crontask"]
+source = ["dramatiq_crontab"]
[tool.coverage.report]
show_missing = true
[tool.ruff]
-src = ["crontask", "tests"]
+src = ["dramatiq_crontab", "tests"]
[tool.ruff.lint]
select = [
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 043aad5..32b455a 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -2,77 +2,77 @@
from unittest.mock import Mock
import pytest
-from crontask import utils
-from crontask.management.commands import crontask
from django.core.management import call_command
+from dramatiq_crontab import utils
+from dramatiq_crontab.management.commands import crontab
def test_kill_softly():
with pytest.raises(KeyboardInterrupt) as e:
- crontask.kill_softly(15, None)
+ crontab.kill_softly(15, None)
assert "Received SIGTERM (15), shutting down…" in str(e.value)
-class Testcrontask:
+class TestCrontab:
@pytest.fixture()
def patch_launch(self, monkeypatch):
monkeypatch.setattr(
- "crontask.management.commands.crontask.Command.launch_scheduler",
+ "dramatiq_crontab.management.commands.crontab.Command.launch_scheduler",
lambda *args, **kwargs: None,
)
def test_default(self, patch_launch):
with io.StringIO() as stdout:
- call_command("crontask", stdout=stdout)
+ call_command("crontab", stdout=stdout)
assert "Loaded tasks from tests.testapp." in stdout.getvalue()
assert "Scheduling heartbeat." in stdout.getvalue()
def test_no_task_loading(self, patch_launch):
with io.StringIO() as stdout:
- call_command("crontask", "--no-task-loading", stdout=stdout)
+ call_command("crontab", "--no-task-loading", stdout=stdout)
assert "Loaded tasks from tests.testapp." not in stdout.getvalue()
assert "Scheduling heartbeat." in stdout.getvalue()
def test_no_heartbeat(self, patch_launch):
with io.StringIO() as stdout:
- call_command("crontask", "--no-heartbeat", stdout=stdout)
+ call_command("crontab", "--no-heartbeat", stdout=stdout)
assert "Loaded tasks from tests.testapp." in stdout.getvalue()
assert "Scheduling heartbeat." not in stdout.getvalue()
def test_locked(self):
"""A lock was already acquired by another process."""
pytest.importorskip("redis", reason="redis is not installed")
- with utils.redis_client.lock("crontask-lock", blocking_timeout=0):
+ with utils.redis_client.lock("dramatiq-scheduler", blocking_timeout=0):
with io.StringIO() as stderr:
- call_command("crontask", stderr=stderr)
+ call_command("crontab", stderr=stderr)
assert "Another scheduler is already running." in stderr.getvalue()
def test_locked_no_refresh(self, monkeypatch):
"""A lock was acquired, but it was not refreshed."""
pytest.importorskip("redis", reason="redis is not installed")
scheduler = Mock()
- monkeypatch.setattr(crontask, "scheduler", scheduler)
+ monkeypatch.setattr(crontab, "scheduler", scheduler)
utils.redis_client.lock(
- "crontask-lock", blocking_timeout=0, timeout=1
+ "dramatiq-scheduler", blocking_timeout=0, timeout=1
).acquire()
with io.StringIO() as stdout:
- call_command("crontask", stdout=stdout)
+ call_command("crontab", stdout=stdout)
assert "Starting scheduler…" in stdout.getvalue()
def test_handle(self, monkeypatch):
scheduler = Mock()
- monkeypatch.setattr(crontask, "scheduler", scheduler)
+ monkeypatch.setattr(crontab, "scheduler", scheduler)
with io.StringIO() as stdout:
- call_command("crontask", stdout=stdout)
+ call_command("crontab", stdout=stdout)
assert "Starting scheduler…" in stdout.getvalue()
scheduler.start.assert_called_once()
def test_handle__keyboard_interrupt(self, monkeypatch):
scheduler = Mock()
scheduler.start.side_effect = KeyboardInterrupt()
- monkeypatch.setattr(crontask, "scheduler", scheduler)
+ monkeypatch.setattr(crontab, "scheduler", scheduler)
with io.StringIO() as stdout:
- call_command("crontask", stdout=stdout)
+ call_command("crontab", stdout=stdout)
assert "Shutting down scheduler…" in stdout.getvalue()
scheduler.shutdown.assert_called_once()
scheduler.start.assert_called_once()
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index ecfe3e5..9b4caf4 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -1,13 +1,13 @@
import datetime
import pytest
-from crontask import interval, scheduler, tasks
from django.utils.timezone import make_aware
+from dramatiq_crontab import interval, scheduler, tasks
def test_heartbeat(caplog):
with caplog.at_level("INFO"):
- tasks.heartbeat.func()
+ tasks.heartbeat()
assert "ﮩ٨ـﮩﮩ٨ـ♡ﮩ٨ـﮩﮩ٨ـ" in caplog.text
diff --git a/tests/test_utils.py b/tests/test_utils.py
index a22dca1..41db7e8 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,7 @@
from unittest.mock import Mock
import pytest
-from crontask import utils
+from dramatiq_crontab import utils
def test_extend_lock():
diff --git a/tests/testapp/settings.py b/tests/testapp/settings.py
index 6716342..7a676f8 100644
--- a/tests/testapp/settings.py
+++ b/tests/testapp/settings.py
@@ -13,6 +13,9 @@
import os
from pathlib import Path
+import dramatiq
+from dramatiq.brokers.stub import StubBroker
+
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -38,7 +41,7 @@
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
- "crontask",
+ "dramatiq_crontab",
"tests.testapp",
]
@@ -83,10 +86,7 @@
}
}
-TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
-
-
-CRONTASK = {
+DRAMATIQ_CRONTAB = {
"LOCK_REFRESH_INTERVAL": 1,
"LOCK_TIMEOUT": 2,
"LOCK_BLOCKING_TIMEOUT": 3,
@@ -97,7 +97,10 @@
except ImportError:
pass
else:
- CRONTASK["REDIS_URL"] = os.getenv("REDIS_URL", "redis:///0")
+ DRAMATIQ_CRONTAB["REDIS_URL"] = os.getenv("REDIS_URL", "redis:///0")
+
+dramatiq.set_broker(StubBroker())
+
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
@@ -137,3 +140,5 @@
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
diff --git a/tests/testapp/tasks.py b/tests/testapp/tasks.py
index 2a75e62..6dcf93a 100644
--- a/tests/testapp/tasks.py
+++ b/tests/testapp/tasks.py
@@ -1,8 +1,8 @@
-from crontask import cron
-from django.tasks import task
+import dramatiq
+from dramatiq_crontab import cron
@cron("*/5 * * * *")
-@task
+@dramatiq.actor
def my_task():
my_task.logger.info("Hello World!")