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 -

- - - - Django crontask: Cron style scheduler for Django's task framework - -

+![dramtiq-crontab logo: person in front of a schedule](https://raw.githubusercontent.com/voiio/dramatiq-crontab/main/dramatiq-crontab.png) -**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 -[![PyPi Version](https://img.shields.io/pypi/v/django-crontask.svg)](https://pypi.python.org/pypi/django-crontask/) -[![Test Coverage](https://codecov.io/gh/codingjoe/django-crontask/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/django-crontask) -[![GitHub License](https://img.shields.io/github/license/codingjoe/django-crontask)](https://raw.githubusercontent.com/codingjoe/django-crontask/master/LICENSE) +[![PyPi Version](https://img.shields.io/pypi/v/dramatiq-crontab.svg)](https://pypi.python.org/pypi/dramatiq-crontab/) +[![Test Coverage](https://codecov.io/gh/voiio/dramatiq-crontab/branch/main/graph/badge.svg)](https://codecov.io/gh/voiio/dramatiq-crontab) +[![GitHub License](https://img.shields.io/github/license/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 @@ - - - - - Django - - - crontask - - - Cron style scheduler for Django's task framework - - 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 @@ - - - - - Django - - - crontask - - - Cron style scheduler for Django's task framework - - 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!")