├── tests ├── __init__.py ├── conftest.py ├── testapp │ ├── __init__.py │ ├── tasks.py │ ├── urls.py │ └── settings.py ├── test_utils.py ├── test_tasks.py └── test_commands.py ├── crontask ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── crontask.py ├── tasks.py ├── conf.py ├── utils.py └── __init__.py ├── .codecov.yml ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── ci.yml ├── .editorconfig ├── images ├── logo-dark.svg └── logo-light.svg ├── .pre-commit-config.yaml ├── LICENSE ├── .gitignore ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crontask/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crontask/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/tasks.py: -------------------------------------------------------------------------------- 1 | from crontask import cron 2 | from django.tasks import task 3 | 4 | 5 | @cron("*/5 * * * *") 6 | @task 7 | def my_task(): 8 | my_task.logger.info("Hello World!") 9 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | # https://docs.codecov.com/docs/codecovyml-reference 2 | coverage: 3 | status: 4 | patch: 5 | default: 6 | target: 100% 7 | only_pulls: true 8 | codecov: 9 | require_ci_to_pass: true 10 | -------------------------------------------------------------------------------- /crontask/tasks.py: -------------------------------------------------------------------------------- 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("ﮩ٨ـﮩﮩ٨ـ♡ﮩ٨ـﮩﮩ٨ـ") 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: github-actions 8 | directory: "/" 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /crontask/conf.py: -------------------------------------------------------------------------------- 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 | ) 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | max_line_length = 88 13 | 14 | [*.{json,yml,yaml,js,jsx,vue,toml}] 15 | indent_size = 2 16 | 17 | [*.{html,htm,svg,xml}] 18 | indent_size = 2 19 | max_line_length = 120 20 | 21 | [*.{css,scss}] 22 | indent_size = 2 23 | 24 | [LICENSE] 25 | insert_final_newline = false 26 | 27 | [*.{md,markdown}] 28 | indent_size = 2 29 | max_line_length = 80 30 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for testapp project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.contrib import admin 19 | from django.urls import path 20 | 21 | urlpatterns = [ 22 | path("admin/", admin.site.urls), 23 | ] 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | permissions: 7 | id-token: write 8 | jobs: 9 | release-build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - uses: actions/setup-python@v6 14 | with: 15 | python-version: "3.x" 16 | - run: python -m pip install --upgrade pip build wheel 17 | - run: python -m build --sdist --wheel 18 | - uses: actions/upload-artifact@v6 19 | with: 20 | name: release-dists 21 | path: dist/ 22 | pypi-publish: 23 | runs-on: ubuntu-latest 24 | needs: 25 | - release-build 26 | permissions: 27 | id-token: write 28 | steps: 29 | - uses: actions/download-artifact@v7 30 | with: 31 | name: release-dists 32 | path: dist/ 33 | - uses: pypa/gh-action-pypi-publish@release/v1 34 | -------------------------------------------------------------------------------- /images/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Django 6 | 7 | 8 | CronTask 9 | 10 | 11 | Cron style scheduler for Django's task framework 12 | 13 | 14 | -------------------------------------------------------------------------------- /images/logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Django 6 | 7 | 8 | CronTask 9 | 10 | 11 | Cron style scheduler for Django's task framework 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | from crontask import utils 5 | 6 | 7 | def test_extend_lock(): 8 | lock = Mock() 9 | scheduler = Mock() 10 | utils.extend_lock(lock, scheduler) 11 | assert lock.extend.call_count == 1 12 | assert scheduler.shutdown.call_count == 0 13 | 14 | 15 | def test_extend_lock__error(): 16 | lock = Mock() 17 | lock.extend.side_effect = utils.LockError() 18 | scheduler = Mock() 19 | with pytest.raises(utils.LockError): 20 | utils.extend_lock(lock, scheduler) 21 | assert lock.extend.call_count == 1 22 | assert scheduler.shutdown.call_count == 1 23 | 24 | 25 | class TestFakeLock: 26 | def test_enter(self): 27 | fake_lock = utils.FakeLock() 28 | assert fake_lock.__enter__() is fake_lock 29 | 30 | def test_exit(self): 31 | fake_lock = utils.FakeLock() 32 | assert fake_lock.__exit__(None, None, None) is None 33 | 34 | def test_extend(self): 35 | fake_lock = utils.FakeLock() 36 | assert fake_lock.extend(additional_time=10, replace_ttl=True) 37 | -------------------------------------------------------------------------------- /crontask/utils.py: -------------------------------------------------------------------------------- 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 46 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v6.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: check-merge-conflict 9 | - id: check-ast 10 | - id: check-toml 11 | - id: check-yaml 12 | - id: debug-statements 13 | - id: end-of-file-fixer 14 | - id: name-tests-test 15 | args: ["--pytest-test-first"] 16 | exclude: ^tests\/(testapp\/|manage.py) 17 | - id: no-commit-to-branch 18 | args: [--branch, main] 19 | - repo: https://github.com/asottile/pyupgrade 20 | rev: v3.21.2 21 | hooks: 22 | - id: pyupgrade 23 | - repo: https://github.com/adamchainz/django-upgrade 24 | rev: 1.29.1 25 | hooks: 26 | - id: django-upgrade 27 | - repo: https://github.com/hukkin/mdformat 28 | rev: 1.0.0 29 | hooks: 30 | - id: mdformat 31 | additional_dependencies: 32 | - mdformat-ruff 33 | - mdformat-footnote 34 | - mdformat-gfm 35 | - mdformat-gfm-alerts 36 | - repo: https://github.com/astral-sh/ruff-pre-commit 37 | rev: v0.14.9 38 | hooks: 39 | - id: ruff-check 40 | args: [--fix, --exit-non-zero-on-fix] 41 | - id: ruff-format 42 | - repo: https://github.com/google/yamlfmt 43 | rev: v0.20.0 44 | hooks: 45 | - id: yamlfmt 46 | # See https://pre-commit.ci/ 47 | ci: 48 | autoupdate_schedule: weekly 49 | skip: 50 | - no-commit-to-branch 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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. 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | dist: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6 12 | - uses: actions/setup-python@v6 13 | with: 14 | python-version: "3.x" 15 | - run: python -m pip install --upgrade pip build wheel twine 16 | - run: python -m build --sdist --wheel 17 | - run: python -m twine check dist/* 18 | - uses: actions/upload-artifact@v6 19 | with: 20 | path: dist/* 21 | pytest: 22 | strategy: 23 | matrix: 24 | os: 25 | - "ubuntu-latest" 26 | python-version: 27 | - "3.12" 28 | - "3.13" 29 | - "3.14" 30 | django-version: 31 | - "6.0" 32 | extras: 33 | - "" # We try a run without any extras 34 | - "--extra sentry" 35 | - "--extra redis" 36 | services: 37 | redis: 38 | image: redis 39 | ports: 40 | - 6379:6379 41 | options: --entrypoint redis-server 42 | env: 43 | REDIS_URL: redis:///0 44 | runs-on: ${{ matrix.os }} 45 | steps: 46 | - uses: actions/checkout@v6 47 | - uses: actions/setup-python@v6 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | - name: Install uv 51 | uses: astral-sh/setup-uv@v7 52 | - run: uv run ${{ matrix.extras }} --with django~=${{ matrix.django-version }}.0 pytest 53 | - uses: codecov/codecov-action@v5 54 | with: 55 | token: ${{ secrets.CODECOV_TOKEN }} 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | 132 | # Packaging 133 | 134 | crontask/_version.py 135 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 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 | sentry = ["sentry-sdk"] 41 | redis = ["redis"] 42 | 43 | [project.urls] 44 | Project-URL = "https://github.com/codingjoe/django-crontask" 45 | Changelog = "https://github.com/codingjoe/django-crontask/releases" 46 | 47 | [tool.flit.module] 48 | name = "crontask" 49 | 50 | [tool.setuptools_scm] 51 | write_to = "crontask/_version.py" 52 | 53 | [tool.pytest.ini_options] 54 | minversion = "6.0" 55 | addopts = "--cov --tb=short -rxs" 56 | testpaths = ["tests"] 57 | DJANGO_SETTINGS_MODULE = "tests.testapp.settings" 58 | 59 | [tool.coverage.run] 60 | source = ["crontask"] 61 | 62 | [tool.coverage.report] 63 | show_missing = true 64 | 65 | [tool.ruff] 66 | src = ["crontask", "tests"] 67 | 68 | [tool.ruff.lint] 69 | select = [ 70 | "E", # pycodestyle errors 71 | "W", # pycodestyle warnings 72 | "F", # pyflakes 73 | "I", # isort 74 | "S", # flake8-bandit 75 | "D", # pydocstyle 76 | "UP", # pyupgrade 77 | "B", # flake8-bugbear 78 | "C", # flake8-comprehensions 79 | ] 80 | 81 | ignore = ["B904", "D1", "E501", "S101"] 82 | 83 | [tool.ruff.lint.isort] 84 | combine-as-imports = true 85 | split-on-trailing-comma = true 86 | section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] 87 | force-wrap-aliases = true 88 | 89 | [tool.ruff.lint.pydocstyle] 90 | convention = "pep257" 91 | 92 | [dependency-groups] 93 | dev = [ 94 | { include-group = "test" }, 95 | ] 96 | test = [ 97 | "pytest", 98 | "pytest-cov", 99 | "pytest-django", 100 | ] 101 | -------------------------------------------------------------------------------- /tests/test_tasks.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import zoneinfo 3 | 4 | import pytest 5 | from crontask import interval, scheduler, tasks 6 | 7 | 8 | def test_heartbeat(caplog): 9 | with caplog.at_level("INFO"): 10 | tasks.heartbeat.func() 11 | assert "ﮩ٨ـﮩﮩ٨ـ♡ﮩ٨ـﮩﮩ٨ـ" in caplog.text 12 | 13 | 14 | def test_cron__stars(): 15 | assert not scheduler.remove_all_jobs() 16 | assert tasks.cron("* * * * *")(tasks.heartbeat) 17 | init = datetime.datetime(2021, 1, 1, 0, 0, 0) 18 | assert scheduler.get_jobs()[0].trigger.get_next_fire_time( 19 | init, init 20 | ) == datetime.datetime( 21 | 2021, 1, 1, 0, 1, tzinfo=zoneinfo.ZoneInfo(key="Europe/Berlin") 22 | ) 23 | 24 | 25 | def test_cron__day_of_week(): 26 | assert not scheduler.remove_all_jobs() 27 | assert tasks.cron("* * * * Mon")(tasks.heartbeat) 28 | init = datetime.datetime(2021, 1, 1, 0, 0, 0) # Friday 29 | assert scheduler.get_jobs()[0].trigger.get_next_fire_time( 30 | init, init 31 | ) == datetime.datetime( 32 | 2021, 1, 4, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="Europe/Berlin") 33 | ) 34 | 35 | 36 | @pytest.mark.parametrize( 37 | "schedule", 38 | [ 39 | "0 0 * * Tue-Wed", 40 | "0 0 * * Tue,Wed", 41 | ], 42 | ) 43 | def test_cron_day_range(schedule): 44 | assert not scheduler.remove_all_jobs() 45 | assert tasks.cron(schedule)(tasks.heartbeat) 46 | init = datetime.datetime(2021, 1, 1, 0, 0, 0) # Friday 47 | assert scheduler.get_jobs()[0].trigger.get_next_fire_time( 48 | init, init 49 | ) == datetime.datetime( 50 | 2021, 1, 5, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="Europe/Berlin") 51 | ) 52 | init = datetime.datetime(2021, 1, 5, 0, 0, 0) # Tuesday 53 | assert scheduler.get_jobs()[0].trigger.get_next_fire_time( 54 | init, init 55 | ) == datetime.datetime( 56 | 2021, 1, 6, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="Europe/Berlin") 57 | ) 58 | 59 | 60 | def test_cron__every_15_minutes(): 61 | assert not scheduler.remove_all_jobs() 62 | assert tasks.cron("*/15 * * * *")(tasks.heartbeat) 63 | init = datetime.datetime(2021, 1, 1, 0, 0, 0) 64 | assert scheduler.get_jobs()[0].trigger.get_next_fire_time( 65 | init, init 66 | ) == datetime.datetime( 67 | 2021, 1, 1, 0, 15, tzinfo=zoneinfo.ZoneInfo(key="Europe/Berlin") 68 | ) 69 | 70 | 71 | @pytest.mark.parametrize( 72 | "schedule", 73 | [ 74 | "* * * * 1", 75 | "* * * * 2-3", 76 | "* * * * 1,7", 77 | ], 78 | ) 79 | def test_cron__error(schedule): 80 | assert not scheduler.remove_all_jobs() 81 | with pytest.raises(ValueError) as e: 82 | tasks.cron(schedule)(tasks.heartbeat) 83 | assert ( 84 | "Please use a literal day of week (Mon, Tue, Wed, Thu, Fri, Sat, Sun) or *" 85 | in str(e.value) 86 | ) 87 | 88 | 89 | def test_interval__seconds(): 90 | assert not scheduler.remove_all_jobs() 91 | assert interval(seconds=30)(tasks.heartbeat) 92 | init = datetime.datetime(2021, 1, 1, 0, 0, 0) 93 | assert scheduler.get_jobs()[0].trigger.get_next_fire_time( 94 | init, init 95 | ) == datetime.datetime( 96 | 2021, 1, 1, 0, 0, 30, tzinfo=zoneinfo.ZoneInfo(key="Europe/Berlin") 97 | ) 98 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | import io 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from crontask import utils 6 | from crontask.management.commands import crontask 7 | from django.core.management import call_command 8 | 9 | 10 | def test_kill_softly(): 11 | with pytest.raises(KeyboardInterrupt) as e: 12 | crontask.kill_softly(15, None) 13 | assert "Received SIGTERM (15), shutting down…" in str(e.value) 14 | 15 | 16 | class Testcrontask: 17 | @pytest.fixture() 18 | def patch_launch(self, monkeypatch): 19 | monkeypatch.setattr( 20 | "crontask.management.commands.crontask.Command.launch_scheduler", 21 | lambda *args, **kwargs: None, 22 | ) 23 | 24 | def test_default(self, patch_launch): 25 | with io.StringIO() as stdout: 26 | call_command("crontask", stdout=stdout) 27 | assert "Loaded tasks from tests.testapp." in stdout.getvalue() 28 | assert "Scheduling heartbeat." in stdout.getvalue() 29 | 30 | def test_no_task_loading(self, patch_launch): 31 | with io.StringIO() as stdout: 32 | call_command("crontask", "--no-task-loading", stdout=stdout) 33 | assert "Loaded tasks from tests.testapp." not in stdout.getvalue() 34 | assert "Scheduling heartbeat." in stdout.getvalue() 35 | 36 | def test_no_heartbeat(self, patch_launch): 37 | with io.StringIO() as stdout: 38 | call_command("crontask", "--no-heartbeat", stdout=stdout) 39 | assert "Loaded tasks from tests.testapp." in stdout.getvalue() 40 | assert "Scheduling heartbeat." not in stdout.getvalue() 41 | 42 | def test_locked(self): 43 | """A lock was already acquired by another process.""" 44 | pytest.importorskip("redis", reason="redis is not installed") 45 | with utils.redis_client.lock("crontask-lock", blocking_timeout=0): 46 | with io.StringIO() as stderr: 47 | call_command("crontask", stderr=stderr) 48 | assert "Another scheduler is already running." in stderr.getvalue() 49 | 50 | def test_locked_no_refresh(self, monkeypatch): 51 | """A lock was acquired, but it was not refreshed.""" 52 | pytest.importorskip("redis", reason="redis is not installed") 53 | scheduler = Mock() 54 | monkeypatch.setattr(crontask, "scheduler", scheduler) 55 | utils.redis_client.lock( 56 | "crontask-lock", blocking_timeout=0, timeout=1 57 | ).acquire() 58 | with io.StringIO() as stdout: 59 | call_command("crontask", stdout=stdout) 60 | assert "Starting scheduler…" in stdout.getvalue() 61 | 62 | def test_handle(self, monkeypatch): 63 | scheduler = Mock() 64 | monkeypatch.setattr(crontask, "scheduler", scheduler) 65 | with io.StringIO() as stdout: 66 | call_command("crontask", stdout=stdout) 67 | assert "Starting scheduler…" in stdout.getvalue() 68 | scheduler.start.assert_called_once() 69 | 70 | def test_handle__keyboard_interrupt(self, monkeypatch): 71 | scheduler = Mock() 72 | scheduler.start.side_effect = KeyboardInterrupt() 73 | monkeypatch.setattr(crontask, "scheduler", scheduler) 74 | with io.StringIO() as stdout: 75 | call_command("crontask", stdout=stdout) 76 | assert "Shutting down scheduler…" in stdout.getvalue() 77 | scheduler.shutdown.assert_called_once() 78 | scheduler.start.assert_called_once() 79 | -------------------------------------------------------------------------------- /crontask/management/commands/crontask.py: -------------------------------------------------------------------------------- 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 == "crontask": 91 | # Our heartbeat is loaded earlier 92 | continue 93 | if app.ready: 94 | try: 95 | importlib.import_module(f"{app.name}.tasks") 96 | self.stdout.write( 97 | f"Loaded tasks from {self.style.NOTICE(app.name)}." 98 | ) 99 | except ImportError: 100 | pass 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django CronTask 2 | 3 |

4 | 5 | 6 | 7 | Django CronTask: Cron style scheduler for Django's task framework 8 | 9 |

10 | 11 | **Cron style scheduler for asynchronous tasks in Django.** 12 | 13 | - setup recurring tasks via crontab syntax 14 | - lightweight helpers build on top of [APScheduler] 15 | - [Sentry] cron monitor support 16 | 17 | [![PyPi Version](https://img.shields.io/pypi/v/django-crontask.svg)](https://pypi.python.org/pypi/django-crontask/) 18 | [![Test Coverage](https://codecov.io/gh/codingjoe/django-crontask/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/django-crontask) 19 | [![GitHub License](https://img.shields.io/github/license/codingjoe/django-crontask)](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 launch 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 crontask command 103 | 104 | ```ShellSession 105 | $ python3 manage.py crontask --help 106 | usage: manage.py crontask [-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/ 121 | -------------------------------------------------------------------------------- /tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for a testapp project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "django-insecure--@w!1qsi6azo&=a**ia3b43k^@q9aor_rqyu=1i5yd-zinpo)0" # noqa: S105 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | "crontask", 42 | "tests.testapp", 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | "django.middleware.security.SecurityMiddleware", 47 | "django.contrib.sessions.middleware.SessionMiddleware", 48 | "django.middleware.common.CommonMiddleware", 49 | "django.middleware.csrf.CsrfViewMiddleware", 50 | "django.contrib.auth.middleware.AuthenticationMiddleware", 51 | "django.contrib.messages.middleware.MessageMiddleware", 52 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 53 | ] 54 | 55 | ROOT_URLCONF = "testapp.urls" 56 | 57 | TEMPLATES = [ 58 | { 59 | "BACKEND": "django.template.backends.django.DjangoTemplates", 60 | "DIRS": [], 61 | "APP_DIRS": True, 62 | "OPTIONS": { 63 | "context_processors": [ 64 | "django.template.context_processors.debug", 65 | "django.template.context_processors.request", 66 | "django.contrib.auth.context_processors.auth", 67 | "django.contrib.messages.context_processors.messages", 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = "testapp.wsgi.application" 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 78 | 79 | DATABASES = { 80 | "default": { 81 | "ENGINE": "django.db.backends.sqlite3", 82 | "NAME": ":memory:", 83 | } 84 | } 85 | 86 | TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}} 87 | 88 | 89 | CRONTASK = { 90 | "LOCK_REFRESH_INTERVAL": 1, 91 | "LOCK_TIMEOUT": 2, 92 | "LOCK_BLOCKING_TIMEOUT": 3, 93 | } 94 | 95 | try: 96 | import redis # noqa 97 | except ImportError: 98 | pass 99 | else: 100 | CRONTASK["REDIS_URL"] = os.getenv("REDIS_URL", "redis:///0") 101 | 102 | # Password validation 103 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 104 | 105 | AUTH_PASSWORD_VALIDATORS = [ 106 | { 107 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 108 | }, 109 | { 110 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 111 | }, 112 | { 113 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 114 | }, 115 | { 116 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 117 | }, 118 | ] 119 | 120 | 121 | # Internationalization 122 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 123 | 124 | LANGUAGE_CODE = "en-us" 125 | 126 | TIME_ZONE = "Europe/Berlin" 127 | 128 | USE_I18N = True 129 | 130 | USE_TZ = True 131 | 132 | 133 | # Static files (CSS, JavaScript, Images) 134 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 135 | 136 | STATIC_URL = "static/" 137 | 138 | # Default primary key field type 139 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 140 | -------------------------------------------------------------------------------- /crontask/__init__.py: -------------------------------------------------------------------------------- 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 134 | --------------------------------------------------------------------------------