├── 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 | 14 | -------------------------------------------------------------------------------- /images/logo-light.svg: -------------------------------------------------------------------------------- 1 | 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 |
8 |