django-periodic-tasks 0.1.0a3__tar.gz → 0.1.0a5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/CHANGELOG.md +12 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/PKG-INFO +1 -1
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/admin.py +3 -5
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/decorators.py +14 -6
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/models.py +21 -8
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/registry.py +48 -15
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/scheduler.py +2 -3
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/task_resolver.py +1 -2
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/api/registry.html +7 -4
- django_periodic_tasks-0.1.0a5/public/search/search_index.json +1 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/sitemap.xml.gz +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/pyproject.toml +1 -1
- django_periodic_tasks-0.1.0a5/sandbox/type_checking.py +76 -0
- django_periodic_tasks-0.1.0a5/tests/__init__.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tests/test_decorators.py +3 -3
- django_periodic_tasks-0.1.0a5/tests/test_type_checking.py +29 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/uv.lock +1 -1
- django_periodic_tasks-0.1.0a3/public/search/search_index.json +0 -1
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/.gitignore +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/.gitlab-ci.yml +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/.pre-commit-config.yaml +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/CLAUDE.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/Dockerfile +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/LICENSE +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/README.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/bin/publish.sh +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/__init__.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/apps.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/cron.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/management/__init__.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/management/commands/__init__.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/management/commands/run_scheduler.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/migrations/0001_initial.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/migrations/0002_remove_redundant_next_run_at_db_index.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/migrations/0003_add_task_execution.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/migrations/__init__.py +0 -0
- /django_periodic_tasks-0.1.0a3/sandbox/__init__.py → /django_periodic_tasks-0.1.0a5/django_periodic_tasks/py.typed +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/sync.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docker-compose.yml +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/.pages +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/api/.pages +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/api/decorators.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/api/index.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/api/models.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/api/registry.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/api/settings.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/guides/.pages +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/guides/defining-schedules.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/guides/exactly-once.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/guides/getting-started.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/guides/index.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/guides/running.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/index.md +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/styles.css +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/mise.toml +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/mkdocs.yml +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/404.html +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/api/decorators.html +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/api/index.html +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/api/models.html +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/api/settings.html +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/images/favicon.png +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/bundle.79ae519e.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/bundle.79ae519e.min.js.map +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.ar.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.da.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.de.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.du.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.el.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.es.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.fi.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.fr.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.he.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.hi.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.hu.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.hy.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.it.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.ja.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.jp.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.kn.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.ko.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.multi.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.nl.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.no.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.pt.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.ro.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.ru.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.sa.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.sv.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.ta.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.te.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.th.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.tr.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.vi.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/min/lunr.zh.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/tinyseg.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/lunr/wordcut.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/workers/search.2c215733.min.js +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/javascripts/workers/search.2c215733.min.js.map +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/stylesheets/main.484c7ddc.min.css +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/stylesheets/main.484c7ddc.min.css.map +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/stylesheets/palette.ab4e12ef.min.css +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/guides/defining-schedules.html +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/guides/exactly-once.html +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/guides/getting-started.html +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/guides/index.html +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/guides/running.html +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/index.html +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/sitemap.xml +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/styles.css +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/renovate.json +0 -0
- {django_periodic_tasks-0.1.0a3/sandbox/testapp → django_periodic_tasks-0.1.0a5/sandbox}/__init__.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/sandbox/manage.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/sandbox/settings.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/sandbox/settings_docgen.py +0 -0
- {django_periodic_tasks-0.1.0a3/tests → django_periodic_tasks-0.1.0a5/sandbox/testapp}/__init__.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/sandbox/testapp/apps.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/sandbox/testapp/models.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/sandbox/testapp/tasks.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/sandbox/urls.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tests/test_admin.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tests/test_apps.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tests/test_commands.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tests/test_cron.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tests/test_integration.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tests/test_models.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tests/test_registry.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tests/test_scheduler.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tests/test_sync.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tests/test_task_resolver.py +0 -0
- {django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/tox.ini +0 -0
|
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## v0.1.0a5 (2026-02-05)
|
|
9
|
+
|
|
10
|
+
### Fix
|
|
11
|
+
|
|
12
|
+
- replace all Any usage with proper generics and protocols
|
|
13
|
+
|
|
14
|
+
## v0.1.0a4 (2026-02-05)
|
|
15
|
+
|
|
16
|
+
### Fix
|
|
17
|
+
|
|
18
|
+
- add missing py.typed file
|
|
19
|
+
|
|
8
20
|
## v0.1.0a3 (2026-02-05)
|
|
9
21
|
|
|
10
22
|
## v0.1.0a2 (2026-02-05)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-periodic-tasks
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0a5
|
|
4
4
|
Summary: Periodic/cron task scheduling for django-tasks. Backend-agnostic replacement for celery-beat.
|
|
5
5
|
Project-URL: Homepage, https://gitlab.com/thelabnyc/django-periodic-tasks
|
|
6
6
|
Project-URL: Repository, https://gitlab.com/thelabnyc/django-periodic-tasks
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/admin.py
RENAMED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from typing import Any
|
|
2
|
-
|
|
3
1
|
from django.contrib import admin
|
|
4
2
|
from django.http import HttpRequest
|
|
5
3
|
|
|
@@ -62,7 +60,7 @@ class ScheduledTaskAdmin(admin.ModelAdmin[ScheduledTask]):
|
|
|
62
60
|
def has_delete_permission(
|
|
63
61
|
self,
|
|
64
62
|
request: HttpRequest,
|
|
65
|
-
obj:
|
|
63
|
+
obj: ScheduledTask | None = None,
|
|
66
64
|
) -> bool:
|
|
67
65
|
if obj is not None and obj.source == ScheduledTask.Source.CODE:
|
|
68
66
|
return False
|
|
@@ -79,8 +77,8 @@ class TaskExecutionAdmin(admin.ModelAdmin[TaskExecution]):
|
|
|
79
77
|
def has_add_permission(self, request: HttpRequest) -> bool:
|
|
80
78
|
return False
|
|
81
79
|
|
|
82
|
-
def has_change_permission(self, request: HttpRequest, obj:
|
|
80
|
+
def has_change_permission(self, request: HttpRequest, obj: TaskExecution | None = None) -> bool:
|
|
83
81
|
return False
|
|
84
82
|
|
|
85
|
-
def has_delete_permission(self, request: HttpRequest, obj:
|
|
83
|
+
def has_delete_permission(self, request: HttpRequest, obj: TaskExecution | None = None) -> bool:
|
|
86
84
|
return False
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/decorators.py
RENAMED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections.abc import Callable
|
|
4
|
-
from typing import Any
|
|
5
4
|
import functools
|
|
6
5
|
import logging
|
|
7
6
|
|
|
@@ -10,8 +9,15 @@ from django.utils import timezone
|
|
|
10
9
|
|
|
11
10
|
logger = logging.getLogger(__name__)
|
|
12
11
|
|
|
12
|
+
_exactly_once_funcs: set[Callable[..., object]] = set()
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
def is_exactly_once(func: object) -> bool:
|
|
16
|
+
"""Check whether a function was decorated with ``@exactly_once``."""
|
|
17
|
+
return func in _exactly_once_funcs
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def exactly_once[R](func: Callable[..., R]) -> Callable[..., R | None]:
|
|
15
21
|
"""Decorator ensuring a scheduled task runs at most once per invocation.
|
|
16
22
|
|
|
17
23
|
When the scheduler creates a ``TaskExecution`` row and passes its ID via
|
|
@@ -27,12 +33,14 @@ def exactly_once(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
27
33
|
"""
|
|
28
34
|
|
|
29
35
|
@functools.wraps(func)
|
|
30
|
-
def wrapper(*args:
|
|
31
|
-
|
|
36
|
+
def wrapper(*args: object, **kwargs: object) -> R | None:
|
|
37
|
+
raw_execution_id = kwargs.pop("_periodic_tasks_execution_id", None)
|
|
32
38
|
|
|
33
|
-
if
|
|
39
|
+
if raw_execution_id is None:
|
|
34
40
|
return func(*args, **kwargs)
|
|
35
41
|
|
|
42
|
+
execution_id = str(raw_execution_id)
|
|
43
|
+
|
|
36
44
|
from django_periodic_tasks.models import (
|
|
37
45
|
TaskExecution, # Avoid AppRegistryNotReady
|
|
38
46
|
)
|
|
@@ -55,5 +63,5 @@ def exactly_once(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
55
63
|
|
|
56
64
|
return result
|
|
57
65
|
|
|
58
|
-
wrapper
|
|
66
|
+
_exactly_once_funcs.add(wrapper)
|
|
59
67
|
return wrapper
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/models.py
RENAMED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
from
|
|
1
|
+
from collections.abc import Iterable
|
|
2
2
|
from zoneinfo import ZoneInfo
|
|
3
3
|
import uuid
|
|
4
4
|
|
|
5
5
|
from django.core.exceptions import ValidationError
|
|
6
6
|
from django.db import models
|
|
7
|
+
from django.db.models.base import ModelBase
|
|
7
8
|
|
|
8
9
|
from django_periodic_tasks.cron import compute_next_run_at, validate_cron_expression
|
|
9
10
|
|
|
@@ -87,7 +88,14 @@ class ScheduledTask(models.Model):
|
|
|
87
88
|
if errors:
|
|
88
89
|
raise ValidationError(errors)
|
|
89
90
|
|
|
90
|
-
def save(
|
|
91
|
+
def save(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
force_insert: bool | tuple[ModelBase, ...] = False,
|
|
95
|
+
force_update: bool = False,
|
|
96
|
+
using: str | None = None,
|
|
97
|
+
update_fields: Iterable[str] | None = None,
|
|
98
|
+
) -> None:
|
|
91
99
|
original_next_run_at = self.next_run_at
|
|
92
100
|
|
|
93
101
|
if not self.enabled:
|
|
@@ -95,17 +103,22 @@ class ScheduledTask(models.Model):
|
|
|
95
103
|
elif self.next_run_at is None:
|
|
96
104
|
self.next_run_at = compute_next_run_at(self.cron_expression, self.timezone)
|
|
97
105
|
|
|
98
|
-
|
|
99
|
-
if
|
|
100
|
-
fields = set(
|
|
106
|
+
effective_update_fields: list[str] | None = None
|
|
107
|
+
if update_fields is not None:
|
|
108
|
+
fields = set(update_fields)
|
|
101
109
|
# Always include updated_at so auto_now fires
|
|
102
110
|
fields.add("updated_at")
|
|
103
111
|
# Include next_run_at if save() modified it
|
|
104
112
|
if self.next_run_at != original_next_run_at:
|
|
105
113
|
fields.add("next_run_at")
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
super().save(
|
|
114
|
+
effective_update_fields = list(fields)
|
|
115
|
+
|
|
116
|
+
super().save(
|
|
117
|
+
force_insert=force_insert,
|
|
118
|
+
force_update=force_update,
|
|
119
|
+
using=using,
|
|
120
|
+
update_fields=effective_update_fields,
|
|
121
|
+
)
|
|
109
122
|
|
|
110
123
|
|
|
111
124
|
class TaskExecution(models.Model):
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/registry.py
RENAMED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
|
-
from typing import
|
|
5
|
+
from typing import Protocol
|
|
5
6
|
|
|
6
7
|
from django_periodic_tasks.cron import validate_cron_expression
|
|
7
8
|
|
|
8
9
|
|
|
10
|
+
class TaskLike(Protocol):
|
|
11
|
+
"""Structural type describing the django-tasks ``Task`` interface.
|
|
12
|
+
|
|
13
|
+
Used instead of ``Task[..., object]`` because ``Task`` is invariant in its
|
|
14
|
+
type parameters — a concrete ``Task[[str], None]`` would not satisfy
|
|
15
|
+
``Task[..., object]``. This protocol captures just the attributes the
|
|
16
|
+
registry needs, so any ``Task[P, T]`` matches regardless of its concrete
|
|
17
|
+
parameter and return types.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def module_path(self) -> str: ...
|
|
22
|
+
|
|
23
|
+
|
|
9
24
|
@dataclass(frozen=True)
|
|
10
25
|
class ScheduleEntry:
|
|
11
26
|
"""An immutable record describing a single scheduled task.
|
|
@@ -25,12 +40,12 @@ class ScheduleEntry:
|
|
|
25
40
|
backend: Task backend name passed to ``task.using()``.
|
|
26
41
|
"""
|
|
27
42
|
|
|
28
|
-
task:
|
|
43
|
+
task: TaskLike
|
|
29
44
|
cron_expression: str
|
|
30
45
|
name: str
|
|
31
46
|
timezone: str = "UTC"
|
|
32
|
-
args: list[
|
|
33
|
-
kwargs: dict[str,
|
|
47
|
+
args: list[object] = field(default_factory=list)
|
|
48
|
+
kwargs: dict[str, object] = field(default_factory=dict)
|
|
34
49
|
queue_name: str = "default"
|
|
35
50
|
priority: int = 0
|
|
36
51
|
backend: str = "default"
|
|
@@ -44,13 +59,13 @@ class ScheduleRegistry:
|
|
|
44
59
|
|
|
45
60
|
def register(
|
|
46
61
|
self,
|
|
47
|
-
task:
|
|
62
|
+
task: TaskLike,
|
|
48
63
|
*,
|
|
49
64
|
cron: str,
|
|
50
65
|
name: str,
|
|
51
66
|
timezone: str = "UTC",
|
|
52
|
-
args: list[
|
|
53
|
-
kwargs: dict[str,
|
|
67
|
+
args: list[object] | None = None,
|
|
68
|
+
kwargs: dict[str, object] | None = None,
|
|
54
69
|
queue_name: str = "default",
|
|
55
70
|
priority: int = 0,
|
|
56
71
|
backend: str = "default",
|
|
@@ -95,13 +110,18 @@ class ScheduleRegistry:
|
|
|
95
110
|
schedule_registry = ScheduleRegistry()
|
|
96
111
|
|
|
97
112
|
|
|
98
|
-
def scheduled_task(
|
|
113
|
+
def scheduled_task[T: TaskLike](
|
|
99
114
|
*,
|
|
100
115
|
cron: str,
|
|
101
116
|
name: str | None = None,
|
|
102
117
|
registry: ScheduleRegistry | None = None,
|
|
103
|
-
|
|
104
|
-
|
|
118
|
+
timezone: str = "UTC",
|
|
119
|
+
args: list[object] | None = None,
|
|
120
|
+
kwargs: dict[str, object] | None = None,
|
|
121
|
+
queue_name: str = "default",
|
|
122
|
+
priority: int = 0,
|
|
123
|
+
backend: str = "default",
|
|
124
|
+
) -> Callable[[T], T]:
|
|
105
125
|
"""Decorator that registers a django-tasks ``Task`` with the schedule registry.
|
|
106
126
|
|
|
107
127
|
Apply this decorator **after** ``@task()`` to register the task for periodic
|
|
@@ -117,15 +137,28 @@ def scheduled_task(
|
|
|
117
137
|
name: Unique schedule name. Defaults to the task's ``module_path``.
|
|
118
138
|
registry: An alternate ``ScheduleRegistry`` instance (defaults to the
|
|
119
139
|
global ``schedule_registry``).
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
140
|
+
timezone: IANA timezone for cron matching (default ``"UTC"``).
|
|
141
|
+
args: Positional arguments for ``task.enqueue()``.
|
|
142
|
+
kwargs: Keyword arguments for ``task.enqueue()``.
|
|
143
|
+
queue_name: Queue name for ``task.using()``.
|
|
144
|
+
priority: Priority for ``task.using()``.
|
|
145
|
+
backend: Backend name for ``task.using()``.
|
|
123
146
|
"""
|
|
124
147
|
target_registry = registry or schedule_registry
|
|
125
148
|
|
|
126
|
-
def decorator(task_obj:
|
|
149
|
+
def decorator(task_obj: T) -> T:
|
|
127
150
|
actual_name = name or task_obj.module_path
|
|
128
|
-
target_registry.register(
|
|
151
|
+
target_registry.register(
|
|
152
|
+
task_obj,
|
|
153
|
+
cron=cron,
|
|
154
|
+
name=actual_name,
|
|
155
|
+
timezone=timezone,
|
|
156
|
+
args=args,
|
|
157
|
+
kwargs=kwargs,
|
|
158
|
+
queue_name=queue_name,
|
|
159
|
+
priority=priority,
|
|
160
|
+
backend=backend,
|
|
161
|
+
)
|
|
129
162
|
return task_obj
|
|
130
163
|
|
|
131
164
|
return decorator
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/scheduler.py
RENAMED
|
@@ -7,6 +7,7 @@ from django.db.models import F
|
|
|
7
7
|
from django.utils import timezone
|
|
8
8
|
|
|
9
9
|
from django_periodic_tasks.cron import compute_next_run_at
|
|
10
|
+
from django_periodic_tasks.decorators import is_exactly_once
|
|
10
11
|
from django_periodic_tasks.models import ScheduledTask, TaskExecution
|
|
11
12
|
from django_periodic_tasks.sync import sync_code_schedules
|
|
12
13
|
from django_periodic_tasks.task_resolver import resolve_task
|
|
@@ -106,9 +107,7 @@ class PeriodicTaskScheduler(threading.Thread):
|
|
|
106
107
|
backend=st.backend,
|
|
107
108
|
)
|
|
108
109
|
|
|
109
|
-
is_exactly_once
|
|
110
|
-
|
|
111
|
-
if is_exactly_once:
|
|
110
|
+
if is_exactly_once(task_obj.func):
|
|
112
111
|
execution = TaskExecution.objects.create(scheduled_task=st)
|
|
113
112
|
enqueue_kwargs = {**st.kwargs, "_periodic_tasks_execution_id": str(execution.id)}
|
|
114
113
|
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
from importlib import import_module
|
|
2
|
-
from typing import Any
|
|
3
2
|
|
|
4
3
|
from django_tasks.base import Task
|
|
5
4
|
|
|
6
5
|
|
|
7
|
-
def resolve_task(task_path: str) -> Task[...,
|
|
6
|
+
def resolve_task(task_path: str) -> Task[..., object]:
|
|
8
7
|
"""Import and return a django-tasks Task object from its dotted module path.
|
|
9
8
|
|
|
10
9
|
The task_path should be a dotted path like "myapp.tasks.my_task".
|
|
@@ -831,7 +831,7 @@
|
|
|
831
831
|
<h1 id="registry">Registry<a class="headerlink" href="#registry" title="Permanent link">¶</a></h1>
|
|
832
832
|
<h2 id="scheduled_task">scheduled_task<a class="headerlink" href="#scheduled_task" title="Permanent link">¶</a></h2>
|
|
833
833
|
<div class="autodoc">
|
|
834
|
-
<div class="autodoc-signature"><code>django_periodic_tasks.registry.<strong>scheduled_task</strong></code><span class="autodoc-punctuation">(</span><em class="autodoc-param">*</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">cron</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">name=None</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">registry=None</em><span class="autodoc-punctuation">, </span><em class="autodoc-param"
|
|
834
|
+
<div class="autodoc-signature"><code>django_periodic_tasks.registry.<strong>scheduled_task</strong></code><span class="autodoc-punctuation">(</span><em class="autodoc-param">*</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">cron</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">name=None</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">registry=None</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">timezone=’UTC’</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">args=None</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">kwargs=None</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">queue_name=’default’</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">priority=0</em><span class="autodoc-punctuation">, </span><em class="autodoc-param">backend=’default’</em><span class="autodoc-punctuation">)</span></div>
|
|
835
835
|
<div class="autodoc-docstring"><p>Decorator that registers a django-tasks <code>Task</code> with the schedule registry.</p>
|
|
836
836
|
<p>Apply this decorator <strong>after</strong> <code>@task()</code> to register the task for periodic
|
|
837
837
|
execution::</p>
|
|
@@ -846,9 +846,12 @@ execution::</p>
|
|
|
846
846
|
name: Unique schedule name. Defaults to the task’s <code>module_path</code>.
|
|
847
847
|
registry: An alternate <code>ScheduleRegistry</code> instance (defaults to the
|
|
848
848
|
global <code>schedule_registry</code>).
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
849
|
+
timezone: IANA timezone for cron matching (default <code>“UTC”</code>).
|
|
850
|
+
args: Positional arguments for <code>task.enqueue()</code>.
|
|
851
|
+
kwargs: Keyword arguments for <code>task.enqueue()</code>.
|
|
852
|
+
queue_name: Queue name for <code>task.using()</code>.
|
|
853
|
+
priority: Priority for <code>task.using()</code>.
|
|
854
|
+
backend: Backend name for <code>task.using()</code>.</p></div>
|
|
852
855
|
</div>
|
|
853
856
|
<h2 id="scheduleentry">ScheduleEntry<a class="headerlink" href="#scheduleentry" title="Permanent link">¶</a></h2>
|
|
854
857
|
<div class="autodoc">
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"index.html","title":"django-periodic-tasks","text":"<p>Periodic/cron task scheduling for django-tasks. A backend-agnostic replacement for celery-beat.</p>"},{"location":"index.html#features","title":"Features","text":"<ul> <li>Cron-based scheduling \u2014 Standard 5-field cron expressions with timezone support.</li> <li>Code-defined schedules \u2014 Declare schedules in Python with the <code>@scheduled_task</code> decorator; they sync to the database automatically.</li> <li>Database-defined schedules \u2014 Create and manage schedules through the Django admin for runtime flexibility.</li> <li>Exactly-once execution \u2014 Optional <code>@exactly_once</code> decorator guarantees a task runs at most once per scheduled invocation, even with non-transactional backends.</li> <li>Multi-worker safe \u2014 Uses <code>SELECT FOR UPDATE SKIP LOCKED</code> so multiple scheduler processes never double-enqueue the same task.</li> <li>Backend-agnostic \u2014 Works with any django-tasks backend (database, RQ, etc.).</li> </ul>"},{"location":"index.html#installation","title":"Installation","text":"<pre><code>pip install django-periodic-tasks\n</code></pre> <p>Add to your <code>INSTALLED_APPS</code> and run migrations:</p> <pre><code>INSTALLED_APPS = [\n # ...\n \"django_tasks\",\n \"django_periodic_tasks\",\n # ...\n]\n</code></pre> <pre><code>python manage.py migrate\n</code></pre>"},{"location":"index.html#next-steps","title":"Next Steps","text":"<p>{nav}</p> <code>sandbox.settings_docgen.setup</code>"},{"location":"api/index.html","title":"API Reference","text":"<p>{nav}</p>"},{"location":"api/decorators.html","title":"Decorators","text":""},{"location":"api/decorators.html#exactly_once","title":"exactly_once","text":"<code>django_periodic_tasks.decorators.exactly_once</code>(func) <p>Decorator ensuring a scheduled task runs at most once per invocation.</p> <p>When the scheduler creates a <code>TaskExecution</code> row and passes its ID via the <code>_periodic_tasks_execution_id</code> keyword argument, this decorator will:</p> <ol> <li>Pop <code>_periodic_tasks_execution_id</code> from kwargs.</li> <li>Lock the <code>TaskExecution</code> row with <code>SELECT FOR UPDATE</code>.</li> <li>Run the wrapped function only if the row\u2019s status is <code>PENDING</code>.</li> <li>Mark the row <code>COMPLETED</code> on success.</li> </ol> <p>If <code>_periodic_tasks_execution_id</code> is absent (e.g. manual invocation), the wrapped function runs normally without any execution-permit logic.</p>"},{"location":"api/models.html","title":"Models","text":""},{"location":"api/models.html#scheduledtask","title":"ScheduledTask","text":"class <code>django_periodic_tasks.models.ScheduledTask</code>(*args, **kwargs) <p>A persistent record of a periodic task and its cron schedule.</p> <p>Each row represents one scheduled task. The scheduler queries this table on every tick to find tasks whose <code>next_run_at</code> has passed, then enqueues them via django-tasks.</p> <p>Tasks can originate from two sources (see :class:<code>Source</code>):</p> <ul> <li>Code-defined \u2014 registered with :func:<code>~django_periodic_tasks.registry.scheduled_task</code> and synced to the database on scheduler startup.</li> <li>Database-defined \u2014 created manually through the Django admin.</li> </ul>"},{"location":"api/models.html#scheduledtasksource","title":"ScheduledTask.Source","text":"<p>Where a scheduled task definition comes from.</p> <ul> <li><code>CODE</code> \u2014 Managed by the codebase and synced automatically on scheduler startup.</li> <li><code>DATABASE</code> \u2014 Managed by operators through the Django admin.</li> </ul>"},{"location":"api/models.html#taskexecution","title":"TaskExecution","text":"class <code>django_periodic_tasks.models.TaskExecution</code>(*args, **kwargs) <p>An execution permit for a single scheduled task invocation.</p> <p>Used by the <code>@exactly_once</code> decorator to ensure a task runs at most once per scheduled invocation, even with non-transactional backends (e.g. Redis/RQ).</p>"},{"location":"api/models.html#taskexecutionstatus","title":"TaskExecution.Status","text":"<p>The lifecycle status of an execution permit.</p> <ul> <li><code>PENDING</code> \u2014 Created by the scheduler, awaiting worker pickup.</li> <li><code>COMPLETED</code> \u2014 The <code>@exactly_once</code> decorator ran the task successfully.</li> </ul>"},{"location":"api/registry.html","title":"Registry","text":""},{"location":"api/registry.html#scheduled_task","title":"scheduled_task","text":"<code>django_periodic_tasks.registry.scheduled_task</code>(*, cron, name=None, registry=None, timezone=\u2019UTC\u2019, args=None, kwargs=None, queue_name=\u2019default\u2019, priority=0, backend=\u2019default\u2019) <p>Decorator that registers a django-tasks <code>Task</code> with the schedule registry.</p> <p>Apply this decorator after <code>@task()</code> to register the task for periodic execution::</p> <pre><code>@scheduled_task(cron=\"/5 * * * \")\n@task()\ndef send_digest(user_id: int) -> None:\n \u2026\n</code></pre> <p>Args: cron: A 5-field cron expression (e.g. <code>\u201c0 8 * * 1-5\u201d</code>). name: Unique schedule name. Defaults to the task\u2019s <code>module_path</code>. registry: An alternate <code>ScheduleRegistry</code> instance (defaults to the global <code>schedule_registry</code>). timezone: IANA timezone for cron matching (default <code>\u201cUTC\u201d</code>). args: Positional arguments for <code>task.enqueue()</code>. kwargs: Keyword arguments for <code>task.enqueue()</code>. queue_name: Queue name for <code>task.using()</code>. priority: Priority for <code>task.using()</code>. backend: Backend name for <code>task.using()</code>.</p>"},{"location":"api/registry.html#scheduleentry","title":"ScheduleEntry","text":"class <code>django_periodic_tasks.registry.ScheduleEntry</code>(task, cron_expression, name, timezone=\u2019UTC\u2019, args=, kwargs=, queue_name=\u2019default\u2019, priority=0, backend=\u2019default\u2019) <p>An immutable record describing a single scheduled task.</p> <p>Each entry holds the task object, its cron schedule, and the options that will be forwarded to <code>task.using()</code> / <code>task.enqueue()</code> at execution time.</p> <p>Attributes: task: The django-tasks <code>Task</code> object to enqueue. cron_expression: A standard 5-field cron expression (e.g. <code>\u201c/15 * * * \u201c</code>). name: Unique name for this schedule (used as the DB primary key). timezone: IANA timezone name used for cron matching (default <code>\u201cUTC\u201d</code>). args: Positional arguments passed to <code>task.enqueue()</code>. kwargs: Keyword arguments passed to <code>task.enqueue()</code>. queue_name: Task queue name passed to <code>task.using()</code>. priority: Task priority passed to <code>task.using()</code>. backend: Task backend name passed to <code>task.using()</code>.</p>"},{"location":"api/registry.html#scheduleregistry","title":"ScheduleRegistry","text":"class <code>django_periodic_tasks.registry.ScheduleRegistry</code>() <p>Singleton registry for code-defined schedules.</p> <code>get_entries</code>(self) <p>Return a copy of all registered schedule entries, keyed by name.</p> <code>register</code>(self, task, *, cron, name, timezone=\u2019UTC\u2019, args=None, kwargs=None, queue_name=\u2019default\u2019, priority=0, backend=\u2019default\u2019) <p>Register a task with the given cron schedule.</p> <p>Args: task: A django-tasks <code>Task</code> object. cron: A 5-field cron expression (e.g. <code>\u201c0 /6 * * \u201c</code>). name: Unique name for this schedule. timezone: IANA timezone for cron matching (default <code>\u201cUTC\u201d</code>). args: Positional arguments for <code>task.enqueue()</code>. kwargs: Keyword arguments for <code>task.enqueue()</code>. queue_name: Queue name for <code>task.using()</code>. priority: Priority for <code>task.using()</code>. backend: Backend name for <code>task.using()</code>.</p> <p>Raises: ValueError: If the cron expression is invalid or the name is already registered.</p>"},{"location":"api/settings.html","title":"Settings","text":"<p>django-periodic-tasks is configured through Django settings.</p>"},{"location":"api/settings.html#periodic_tasks_autostart","title":"PERIODIC_TASKS_AUTOSTART","text":"Type <code>bool</code> Default <code>False</code> <p>When <code>True</code>, the scheduler starts automatically as a daemon thread during <code>AppConfig.ready()</code>. Useful for development and simple single-process deployments.</p> <pre><code>PERIODIC_TASKS_AUTOSTART = True\n</code></pre>"},{"location":"api/settings.html#periodic_tasks_scheduler_interval","title":"PERIODIC_TASKS_SCHEDULER_INTERVAL","text":"Type <code>int</code> Default <code>15</code> <p>Seconds between scheduler ticks. On each tick the scheduler queries the database for due tasks and enqueues them.</p> <pre><code>PERIODIC_TASKS_SCHEDULER_INTERVAL = 30 # check every 30 seconds\n</code></pre> <p>Lower values mean tasks are enqueued closer to their scheduled time, but increase database load. The default of 15 seconds is a good balance for most applications.</p>"},{"location":"guides/index.html","title":"Guides","text":"<p>{nav}</p>"},{"location":"guides/defining-schedules.html","title":"Defining Schedules","text":"<p>django-periodic-tasks supports two ways to define scheduled tasks: in code (version-controlled, deployed with your app) and in the database (managed at runtime through the Django admin).</p>"},{"location":"guides/defining-schedules.html#code-defined-schedules","title":"Code-Defined Schedules","text":"<p>Use the <code>@scheduled_task</code> decorator to register a django-tasks <code>Task</code> for periodic execution. Place your tasks in a <code>tasks.py</code> module inside any installed app \u2014 django-periodic-tasks automatically discovers these modules at startup using Django\u2019s <code>autodiscover_modules</code>, similar to how <code>admin.py</code> files are discovered:</p> <pre><code># myapp/tasks.py\nfrom django_tasks import task\nfrom django_periodic_tasks.registry import scheduled_task\n\n\n@scheduled_task(cron=\"0 8 * * 1-5\") # Weekdays at 8:00 AM UTC\n@task()\ndef morning_report() -> None:\n ...\n</code></pre> <p>Note</p> <p>You can define tasks in any module, but <code>tasks.py</code> is auto-discovered. If you use a different module name, make sure it gets imported somewhere (e.g. in your app\u2019s <code>AppConfig.ready()</code>).</p>"},{"location":"guides/defining-schedules.html#decorator-parameters","title":"Decorator Parameters","text":"Parameter Type Default Description <code>cron</code> <code>str</code> (required) 5-field cron expression <code>name</code> <code>str</code> task\u2019s <code>module_path</code> Unique schedule name <code>timezone</code> <code>str</code> <code>\"UTC\"</code> IANA timezone for cron matching <code>args</code> <code>list</code> <code>[]</code> Positional arguments for <code>task.enqueue()</code> <code>kwargs</code> <code>dict</code> <code>{}</code> Keyword arguments for <code>task.enqueue()</code> <code>queue_name</code> <code>str</code> <code>\"default\"</code> Queue name for <code>task.using()</code> <code>priority</code> <code>int</code> <code>0</code> Priority for <code>task.using()</code> <code>backend</code> <code>str</code> <code>\"default\"</code> Backend name for <code>task.using()</code>"},{"location":"guides/defining-schedules.html#task-arguments","title":"Task Arguments","text":"<p>Pass arguments that will be forwarded to <code>task.enqueue()</code> on each execution:</p> <pre><code>@scheduled_task(\n cron=\"0 0 * * 0\",\n args=[42],\n kwargs={\"full\": True},\n)\n@task()\ndef weekly_sync(tenant_id: int, full: bool = False) -> None:\n ...\n</code></pre>"},{"location":"guides/defining-schedules.html#task-options","title":"Task Options","text":"<p>Control which queue, priority, and backend the task uses:</p> <pre><code>@scheduled_task(\n cron=\"*/10 * * * *\",\n queue_name=\"high-priority\",\n priority=10,\n backend=\"default\",\n)\n@task()\ndef health_check() -> None:\n ...\n</code></pre>"},{"location":"guides/defining-schedules.html#cron-expression-syntax","title":"Cron Expression Syntax","text":"<p>django-periodic-tasks uses standard 5-field cron expressions:</p> <pre><code>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 minute (0\u201359)\n\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 hour (0\u201323)\n\u2502 \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 day of month (1\u201331)\n\u2502 \u2502 \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 month (1\u201312)\n\u2502 \u2502 \u2502 \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 day of week (0\u20136, Sun=0)\n\u2502 \u2502 \u2502 \u2502 \u2502\n* * * * *\n</code></pre> <p>Examples:</p> Expression Meaning <code>* * * * *</code> Every minute <code>*/15 * * * *</code> Every 15 minutes <code>0 * * * *</code> Every hour <code>0 8 * * *</code> Daily at 8:00 AM <code>0 8 * * 1-5</code> Weekdays at 8:00 AM <code>0 0 1 * *</code> First of every month at midnight <code>30 2 * * 0</code> Sundays at 2:30 AM"},{"location":"guides/defining-schedules.html#using-fluentcron","title":"Using fluentcron","text":"<p>If you prefer a type-safe, self-documenting API over raw cron strings, use the fluentcron library:</p> <pre><code>pip install fluentcron\n</code></pre> <p>fluentcron provides a fluent builder and shortcut functions that produce standard cron expression strings:</p> <pre><code>from django_tasks import task\nfrom django_periodic_tasks import scheduled_task\nfrom fluentcron import CronSchedule, daily_at, every_n_minutes\n\n# Shortcut function \u2014 returns a cron string directly\n@scheduled_task(cron=every_n_minutes(15)) # \"*/15 * * * *\"\n@task()\ndef health_check() -> None:\n ...\n\n\n# Shortcut function\n@scheduled_task(cron=daily_at(8)) # \"0 8 * * *\"\n@task()\ndef morning_report() -> None:\n ...\n\n\n# Fluent builder \u2014 call str() or .to_str() for the cron string\n@scheduled_task(\n cron=str(CronSchedule().weekly().on_monday().at(9, 30)), # \"30 9 * * 1\"\n)\n@task()\ndef weekly_digest() -> None:\n ...\n</code></pre> <p>fluentcron also ships with <code>CommonSchedules</code> presets for frequently used expressions:</p> <pre><code>from fluentcron import CommonSchedules\n\n@scheduled_task(cron=CommonSchedules.EVERY_HOUR) # \"0 * * * *\"\n@task()\ndef sync_data() -> None:\n ...\n</code></pre>"},{"location":"guides/defining-schedules.html#timezone-support","title":"Timezone Support","text":"<p>By default, cron expressions are evaluated in UTC. Specify a timezone to match against local time:</p> <pre><code>@scheduled_task(\n cron=\"0 9 * * *\", # 9:00 AM\n timezone=\"US/Eastern\", # in Eastern time\n)\n@task()\ndef east_coast_morning() -> None:\n ...\n</code></pre> <p>The <code>timezone</code> parameter accepts any IANA timezone name (e.g. <code>\"US/Pacific\"</code>, <code>\"Europe/London\"</code>, <code>\"Asia/Tokyo\"</code>).</p>"},{"location":"guides/defining-schedules.html#database-defined-schedules","title":"Database-Defined Schedules","text":"<p>For schedules that need to be managed at runtime without a code deployment, create them through the Django admin:</p> <ol> <li>Navigate to Django Periodic Tasks > Scheduled tasks in the admin.</li> <li>Click Add scheduled task.</li> <li>Fill in the task path (e.g. <code>myapp.tasks.morning_report</code>), cron expression, and any arguments.</li> <li>The schedule takes effect on the next scheduler tick.</li> </ol> <p>Database-defined schedules have <code>source = Database</code> and can be freely edited or disabled through the admin.</p>"},{"location":"guides/defining-schedules.html#schedule-sync","title":"Schedule Sync","text":"<p>When the scheduler starts, it syncs code-defined schedules to the database:</p> <ul> <li>New code schedules are created as <code>ScheduledTask</code> rows with <code>source = Code</code>.</li> <li>Changed code schedules (cron expression, task path, options) are updated in place.</li> <li>Removed code schedules (present in DB but no longer in the registry) are disabled.</li> <li>Database-defined schedules are never modified by the sync process.</li> </ul> <p>Code-defined schedules appear as read-only in the Django admin.</p>"},{"location":"guides/exactly-once.html","title":"Exactly-Once Execution","text":"<p>By default, the scheduler enqueues a task on each tick and trusts the worker to execute it. With non-transactional backends (e.g. Redis/RQ), there is a small window where a crash or retry could cause the same invocation to run more than once.</p> <p>The <code>@exactly_once</code> decorator closes that window. It guarantees that each scheduled invocation runs at most once, regardless of the backend.</p>"},{"location":"guides/exactly-once.html#how-it-works","title":"How It Works","text":"<p>When a task is decorated with <code>@exactly_once</code>, the scheduler and worker cooperate through a <code>TaskExecution</code> row in the database:</p> sequenceDiagram participant S as Scheduler participant DB as Database participant W as Worker S->>DB: INSERT TaskExecution (status=PENDING) S->>W: enqueue task with execution ID W->>DB: SELECT FOR UPDATE TaskExecution alt status is PENDING W->>W: run task function W->>DB: UPDATE status=COMPLETED else already COMPLETED or missing W->>W: skip (no-op) end <ol> <li>Scheduler tick \u2014 Creates a <code>TaskExecution</code> row with <code>status=PENDING</code> and passes its ID to the task as a keyword argument.</li> <li>Worker picks up the task \u2014 The <code>@exactly_once</code> wrapper locks the <code>TaskExecution</code> row with <code>SELECT FOR UPDATE</code>.</li> <li>If PENDING \u2014 Runs the wrapped function and marks the row <code>COMPLETED</code>.</li> <li>If not PENDING (already completed, or row missing) \u2014 Skips execution entirely.</li> </ol> <p>Because the row lock is held for the duration of the function call, even if two workers receive the same task, only one will find the row in <code>PENDING</code> status.</p>"},{"location":"guides/exactly-once.html#usage","title":"Usage","text":"<p>Apply <code>@exactly_once</code> below <code>@task()</code> so it wraps the raw function before django-tasks wraps it as a <code>Task</code>:</p> <pre><code>from django_tasks import task\nfrom django_periodic_tasks import scheduled_task, exactly_once\n\n\n@scheduled_task(cron=\"0 2 * * *\")\n@task()\n@exactly_once\ndef nightly_billing() -> None:\n \"\"\"Charge customers \u2014 must not run twice.\"\"\"\n ...\n</code></pre> <p>Decorator order matters</p> <p><code>@exactly_once</code> must be below <code>@task()</code>. If placed above, the scheduler won\u2019t detect the <code>_exactly_once</code> marker and will skip creating the <code>TaskExecution</code> row.</p>"},{"location":"guides/exactly-once.html#manual-invocation","title":"Manual Invocation","text":"<p>When a task decorated with <code>@exactly_once</code> is called without the special <code>_periodic_tasks_execution_id</code> keyword argument (e.g. enqueued manually or called directly), the decorator is a no-op and the function runs normally:</p> <pre><code># These both work \u2014 exactly_once is bypassed\nnightly_billing.enqueue()\nnightly_billing()\n</code></pre>"},{"location":"guides/exactly-once.html#stale-execution-recovery","title":"Stale Execution Recovery","text":"<p>If the scheduler creates a <code>TaskExecution</code> row but the <code>on_commit</code> callback that enqueues the task never fires (e.g. process crash, connection reset), the row becomes stale.</p> <p>The scheduler automatically detects stale <code>PENDING</code> executions older than <code>max(60s, 2 \u00d7 interval)</code> and re-enqueues them on the next tick. This ensures tasks are not silently lost.</p>"},{"location":"guides/exactly-once.html#automatic-cleanup","title":"Automatic Cleanup","text":"<p>Completed <code>TaskExecution</code> rows are automatically deleted after 24 hours. <code>PENDING</code> rows are preserved until they are delivered or re-enqueued by the stale execution recovery process.</p>"},{"location":"guides/exactly-once.html#when-to-use-it","title":"When to Use It","text":"<p>Use <code>@exactly_once</code> when:</p> <ul> <li>The task has side effects that must not be duplicated (billing, sending emails, external API calls).</li> <li>You\u2019re using a non-transactional backend where at-least-once delivery is the default.</li> <li>You need a database-backed audit trail of each invocation.</li> </ul> <p>You can skip it when:</p> <ul> <li>The task is idempotent (safe to run multiple times with the same result).</li> <li>You\u2019re using the database backend with transactional enqueue, which already provides strong delivery guarantees.</li> <li>Performance is critical and the extra <code>SELECT FOR UPDATE</code> round-trip is not acceptable.</li> </ul>"},{"location":"guides/getting-started.html","title":"Getting Started","text":"<p>This guide walks you through installing django-periodic-tasks, defining your first scheduled task, and running the scheduler.</p>"},{"location":"guides/getting-started.html#prerequisites","title":"Prerequisites","text":"<ul> <li>Python 3.13+</li> <li>Django 5.2+</li> <li>django-tasks installed and configured with at least one backend</li> </ul>"},{"location":"guides/getting-started.html#installation","title":"Installation","text":"<p>Install the package:</p> <pre><code>pip install django-periodic-tasks\n</code></pre> <p>Add both <code>django_tasks</code> and <code>django_periodic_tasks</code> to your <code>INSTALLED_APPS</code>:</p> <pre><code>INSTALLED_APPS = [\n # ...\n \"django_tasks\",\n \"django_tasks.backends.database\", # if using the database backend\n \"django_periodic_tasks\",\n # ...\n]\n</code></pre> <p>Run migrations to create the <code>ScheduledTask</code> table:</p> <pre><code>python manage.py migrate\n</code></pre>"},{"location":"guides/getting-started.html#define-your-first-scheduled-task","title":"Define Your First Scheduled Task","text":"<p>Create a task using django-tasks\u2019 <code>@task()</code> decorator, then register it for periodic execution with <code>@scheduled_task()</code>. Place your tasks in a <code>tasks.py</code> module inside any installed app \u2014 django-periodic-tasks automatically discovers these modules at startup (similar to how Django discovers <code>admin.py</code> files):</p> <pre><code># myapp/tasks.py\nfrom django_tasks import task\nfrom django_periodic_tasks.registry import scheduled_task\n\n\n@scheduled_task(cron=\"*/5 * * * *\") # Every 5 minutes\n@task()\ndef send_digest() -> None:\n \"\"\"Send the email digest.\"\"\"\n ...\n</code></pre> <p>The <code>@scheduled_task</code> decorator must be applied above <code>@task()</code>. The <code>cron</code> parameter accepts any standard 5-field cron expression.</p>"},{"location":"guides/getting-started.html#run-the-scheduler","title":"Run the Scheduler","text":"<p>Enable the scheduler by adding this to your settings:</p> <pre><code># settings.py\nPERIODIC_TASKS_AUTOSTART = True\n</code></pre> <p>The scheduler starts automatically as a daemon thread when Django starts. It checks for due tasks every 15 seconds (configurable via <code>PERIODIC_TASKS_SCHEDULER_INTERVAL</code>).</p> <p>Run your task worker as usual (e.g. <code>python manage.py db_worker</code> for the database backend). The scheduler thread enqueues tasks; the worker executes them.</p>"},{"location":"guides/getting-started.html#verify-it-works","title":"Verify It Works","text":"<ol> <li>Open the Django admin at <code>/admin/django_periodic_tasks/scheduledtask/</code>.</li> <li>You should see your task listed with <code>source = Code</code>.</li> <li>The <code>next_run_at</code> field shows when the task will next be enqueued.</li> <li>After the scheduled time passes, <code>last_run_at</code> and <code>total_run_count</code> will update.</li> </ol>"},{"location":"guides/running.html","title":"Running the Scheduler","text":"<p>django-periodic-tasks runs as a daemon thread inside your Django process. Enable it with a single setting and the scheduler starts automatically alongside your application.</p>"},{"location":"guides/running.html#autostart-recommended","title":"Autostart (Recommended)","text":"<p>Add the following to your Django settings:</p> <pre><code># settings.py\nPERIODIC_TASKS_AUTOSTART = True\n</code></pre> <p>When Django starts, <code>AppConfig.ready()</code> launches the scheduler as a daemon thread. It syncs code-defined schedules to the database, then enters a tick-sleep loop that checks for due tasks every <code>PERIODIC_TASKS_SCHEDULER_INTERVAL</code> seconds (default: 15).</p> <p>The daemon thread exits automatically when the main process shuts down \u2014 no signal handling or cleanup is needed on your part.</p>"},{"location":"guides/running.html#tuning-the-interval","title":"Tuning the Interval","text":"<pre><code>PERIODIC_TASKS_SCHEDULER_INTERVAL = 30 # check every 30 seconds\n</code></pre> <p>Lower values mean tasks are enqueued closer to their scheduled time but increase database load. The default of 15 seconds is a good balance for most applications.</p>"},{"location":"guides/running.html#standalone-scheduler","title":"Standalone Scheduler","text":"<p>For cases where you want the scheduler in its own dedicated process (e.g. separating scheduling from request serving), use the <code>run_scheduler</code> management command:</p> <pre><code>python manage.py run_scheduler\n</code></pre> <p>This runs the scheduler loop in the main thread (blocking). You\u2019ll need to run your task worker separately.</p>"},{"location":"guides/running.html#options","title":"Options","text":"Flag Default Description <code>--interval</code> <code>15</code> Seconds between scheduler ticks <code>-v 0</code> \u2014 Suppress all output <code>-v 2</code> \u2014 Enable debug logging"},{"location":"guides/running.html#multi-worker-deployment","title":"Multi-Worker Deployment","text":"<p>The scheduler is safe to run across multiple processes. Each scheduler tick uses <code>SELECT FOR UPDATE SKIP LOCKED</code> to claim due tasks, so even if multiple scheduler instances run simultaneously, each task is enqueued exactly once.</p> <p>A typical production setup with two web/worker processes:</p> <pre><code>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 gunicorn / daphne \u2502 \u2502 gunicorn / daphne \u2502\n\u2502 (autostart=True) \u2502 \u2502 (autostart=True) \u2502\n\u2502 scheduler + app \u2502 \u2502 scheduler + app \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25bc\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 PostgreSQL \u2502\n \u2502 (shared DB) \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n</code></pre> <p>Both instances run the scheduler, but <code>SKIP LOCKED</code> ensures no duplicate enqueues. Scale horizontally by adding more processes.</p>"},{"location":"guides/running.html#graceful-shutdown","title":"Graceful Shutdown","text":"<p>The <code>run_scheduler</code> command handles <code>SIGINT</code> and <code>SIGTERM</code> for graceful shutdown:</p> <ul> <li>The current scheduler tick completes.</li> <li>The stop event is set, and the scheduler thread exits.</li> <li>The process terminates cleanly.</li> </ul> <p>When using autostart, the scheduler thread is a daemon thread and exits automatically when the main process terminates. In container environments (Docker, Kubernetes), this means <code>docker stop</code> and Kubernetes pod termination work correctly out of the box.</p>"}]}
|
|
Binary file
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "django-periodic-tasks"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.0a5"
|
|
8
8
|
description = "Periodic/cron task scheduling for django-tasks. Backend-agnostic replacement for celery-beat."
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "thelab", email = "thelabdev@thelab.co"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Type-checking assertions for django_periodic_tasks.
|
|
2
|
+
|
|
3
|
+
This file is analyzed by mypy (via ``mypy sandbox/``) but never imported at
|
|
4
|
+
runtime. It verifies that our generic type annotations actually work:
|
|
5
|
+
|
|
6
|
+
* **Positive cases** — valid code that must type-check without errors.
|
|
7
|
+
* **Negative cases** — invalid code with ``# type: ignore[error-code]``.
|
|
8
|
+
If a ``type: ignore`` becomes unused (the error disappears), mypy's
|
|
9
|
+
``warn_unused_ignores`` setting will flag the regression.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from django_tasks import task
|
|
13
|
+
|
|
14
|
+
from django_periodic_tasks.decorators import exactly_once, is_exactly_once
|
|
15
|
+
from django_periodic_tasks.registry import ScheduleRegistry, scheduled_task
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Positive: valid usage (must type-check cleanly)
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@scheduled_task(cron="* * * * *")
|
|
23
|
+
@task()
|
|
24
|
+
def my_task(foo: str, bar: int = 0) -> None:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@task()
|
|
29
|
+
@exactly_once
|
|
30
|
+
def my_once_task() -> str:
|
|
31
|
+
return "done"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Correct param types — must not error
|
|
35
|
+
my_task.enqueue(foo="hello", bar=1)
|
|
36
|
+
my_task.enqueue(foo="hello") # bar is optional
|
|
37
|
+
|
|
38
|
+
# is_exactly_once returns bool
|
|
39
|
+
check: bool = is_exactly_once(lambda: None)
|
|
40
|
+
|
|
41
|
+
# Valid registry with explicit params
|
|
42
|
+
_registry = ScheduleRegistry()
|
|
43
|
+
|
|
44
|
+
# scheduled_task with all explicit options
|
|
45
|
+
scheduled_task(
|
|
46
|
+
cron="0 * * * *",
|
|
47
|
+
name="my-schedule",
|
|
48
|
+
timezone="America/New_York",
|
|
49
|
+
args=[1, "two"],
|
|
50
|
+
kwargs={"key": "value"},
|
|
51
|
+
queue_name="low",
|
|
52
|
+
priority=5,
|
|
53
|
+
backend="default",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Negative: wrong types (each MUST trigger the marked mypy error)
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
# cron must be str, not int
|
|
61
|
+
scheduled_task(cron=123) # type: ignore[arg-type]
|
|
62
|
+
|
|
63
|
+
# priority must be int, not str
|
|
64
|
+
scheduled_task(cron="* * * * *", priority="high") # type: ignore[arg-type]
|
|
65
|
+
|
|
66
|
+
# timezone must be str, not int
|
|
67
|
+
scheduled_task(cron="* * * * *", timezone=0) # type: ignore[arg-type]
|
|
68
|
+
|
|
69
|
+
# exactly_once requires a callable, not a string
|
|
70
|
+
exactly_once("not a function") # type: ignore[arg-type]
|
|
71
|
+
|
|
72
|
+
# foo expects str, not int — param types must flow through @task + @scheduled_task
|
|
73
|
+
my_task.enqueue(foo=123) # type: ignore[arg-type]
|
|
74
|
+
|
|
75
|
+
# bar expects int, not str
|
|
76
|
+
my_task.enqueue(foo="ok", bar="wrong") # type: ignore[arg-type]
|
|
File without changes
|
|
@@ -3,7 +3,7 @@ import uuid
|
|
|
3
3
|
from django.test import TestCase
|
|
4
4
|
from django.utils import timezone
|
|
5
5
|
|
|
6
|
-
from django_periodic_tasks.decorators import exactly_once
|
|
6
|
+
from django_periodic_tasks.decorators import exactly_once, is_exactly_once
|
|
7
7
|
from django_periodic_tasks.models import ScheduledTask, TaskExecution
|
|
8
8
|
|
|
9
9
|
|
|
@@ -80,13 +80,13 @@ class TestExactlyOnceDecorator(TestCase):
|
|
|
80
80
|
self.assertEqual(call_log, [])
|
|
81
81
|
|
|
82
82
|
def test_exactly_once_marker_attribute(self) -> None:
|
|
83
|
-
"""The decorator should
|
|
83
|
+
"""The decorator should register the wrapper in the exactly_once registry."""
|
|
84
84
|
|
|
85
85
|
@exactly_once
|
|
86
86
|
def my_func() -> None:
|
|
87
87
|
pass
|
|
88
88
|
|
|
89
|
-
self.assertTrue(
|
|
89
|
+
self.assertTrue(is_exactly_once(my_func))
|
|
90
90
|
|
|
91
91
|
def test_preserves_function_metadata(self) -> None:
|
|
92
92
|
"""The decorator should use functools.wraps to preserve __qualname__ etc."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from django.test import SimpleTestCase
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestTypeAnnotations(SimpleTestCase):
|
|
8
|
+
def test_mypy_type_checking(self) -> None:
|
|
9
|
+
"""Verify mypy catches type errors in our decorators and registry.
|
|
10
|
+
|
|
11
|
+
``sandbox/type_checking.py`` contains:
|
|
12
|
+
|
|
13
|
+
- **Positive assertions**: valid code that must type-check cleanly.
|
|
14
|
+
- **Negative assertions**: invalid code with ``# type: ignore[error-code]``.
|
|
15
|
+
|
|
16
|
+
If a negative assertion's ``# type: ignore`` becomes unused (the
|
|
17
|
+
expected error disappears), mypy's ``warn_unused_ignores`` setting
|
|
18
|
+
reports it — meaning our types have regressed.
|
|
19
|
+
"""
|
|
20
|
+
result = subprocess.run(
|
|
21
|
+
[sys.executable, "-m", "mypy", "sandbox/type_checking.py"],
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
)
|
|
25
|
+
self.assertEqual(
|
|
26
|
+
result.returncode,
|
|
27
|
+
0,
|
|
28
|
+
f"mypy found unexpected type errors:\n{result.stdout}{result.stderr}",
|
|
29
|
+
)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"index.html","title":"django-periodic-tasks","text":"<p>Periodic/cron task scheduling for django-tasks. A backend-agnostic replacement for celery-beat.</p>"},{"location":"index.html#features","title":"Features","text":"<ul> <li>Cron-based scheduling \u2014 Standard 5-field cron expressions with timezone support.</li> <li>Code-defined schedules \u2014 Declare schedules in Python with the <code>@scheduled_task</code> decorator; they sync to the database automatically.</li> <li>Database-defined schedules \u2014 Create and manage schedules through the Django admin for runtime flexibility.</li> <li>Exactly-once execution \u2014 Optional <code>@exactly_once</code> decorator guarantees a task runs at most once per scheduled invocation, even with non-transactional backends.</li> <li>Multi-worker safe \u2014 Uses <code>SELECT FOR UPDATE SKIP LOCKED</code> so multiple scheduler processes never double-enqueue the same task.</li> <li>Backend-agnostic \u2014 Works with any django-tasks backend (database, RQ, etc.).</li> </ul>"},{"location":"index.html#installation","title":"Installation","text":"<pre><code>pip install django-periodic-tasks\n</code></pre> <p>Add to your <code>INSTALLED_APPS</code> and run migrations:</p> <pre><code>INSTALLED_APPS = [\n # ...\n \"django_tasks\",\n \"django_periodic_tasks\",\n # ...\n]\n</code></pre> <pre><code>python manage.py migrate\n</code></pre>"},{"location":"index.html#next-steps","title":"Next Steps","text":"<p>{nav}</p> <code>sandbox.settings_docgen.setup</code>"},{"location":"api/index.html","title":"API Reference","text":"<p>{nav}</p>"},{"location":"api/decorators.html","title":"Decorators","text":""},{"location":"api/decorators.html#exactly_once","title":"exactly_once","text":"<code>django_periodic_tasks.decorators.exactly_once</code>(func) <p>Decorator ensuring a scheduled task runs at most once per invocation.</p> <p>When the scheduler creates a <code>TaskExecution</code> row and passes its ID via the <code>_periodic_tasks_execution_id</code> keyword argument, this decorator will:</p> <ol> <li>Pop <code>_periodic_tasks_execution_id</code> from kwargs.</li> <li>Lock the <code>TaskExecution</code> row with <code>SELECT FOR UPDATE</code>.</li> <li>Run the wrapped function only if the row\u2019s status is <code>PENDING</code>.</li> <li>Mark the row <code>COMPLETED</code> on success.</li> </ol> <p>If <code>_periodic_tasks_execution_id</code> is absent (e.g. manual invocation), the wrapped function runs normally without any execution-permit logic.</p>"},{"location":"api/models.html","title":"Models","text":""},{"location":"api/models.html#scheduledtask","title":"ScheduledTask","text":"class <code>django_periodic_tasks.models.ScheduledTask</code>(*args, **kwargs) <p>A persistent record of a periodic task and its cron schedule.</p> <p>Each row represents one scheduled task. The scheduler queries this table on every tick to find tasks whose <code>next_run_at</code> has passed, then enqueues them via django-tasks.</p> <p>Tasks can originate from two sources (see :class:<code>Source</code>):</p> <ul> <li>Code-defined \u2014 registered with :func:<code>~django_periodic_tasks.registry.scheduled_task</code> and synced to the database on scheduler startup.</li> <li>Database-defined \u2014 created manually through the Django admin.</li> </ul>"},{"location":"api/models.html#scheduledtasksource","title":"ScheduledTask.Source","text":"<p>Where a scheduled task definition comes from.</p> <ul> <li><code>CODE</code> \u2014 Managed by the codebase and synced automatically on scheduler startup.</li> <li><code>DATABASE</code> \u2014 Managed by operators through the Django admin.</li> </ul>"},{"location":"api/models.html#taskexecution","title":"TaskExecution","text":"class <code>django_periodic_tasks.models.TaskExecution</code>(*args, **kwargs) <p>An execution permit for a single scheduled task invocation.</p> <p>Used by the <code>@exactly_once</code> decorator to ensure a task runs at most once per scheduled invocation, even with non-transactional backends (e.g. Redis/RQ).</p>"},{"location":"api/models.html#taskexecutionstatus","title":"TaskExecution.Status","text":"<p>The lifecycle status of an execution permit.</p> <ul> <li><code>PENDING</code> \u2014 Created by the scheduler, awaiting worker pickup.</li> <li><code>COMPLETED</code> \u2014 The <code>@exactly_once</code> decorator ran the task successfully.</li> </ul>"},{"location":"api/registry.html","title":"Registry","text":""},{"location":"api/registry.html#scheduled_task","title":"scheduled_task","text":"<code>django_periodic_tasks.registry.scheduled_task</code>(*, cron, name=None, registry=None, **kwargs) <p>Decorator that registers a django-tasks <code>Task</code> with the schedule registry.</p> <p>Apply this decorator after <code>@task()</code> to register the task for periodic execution::</p> <pre><code>@scheduled_task(cron=\"/5 * * * \")\n@task()\ndef send_digest(user_id: int) -> None:\n \u2026\n</code></pre> <p>Args: cron: A 5-field cron expression (e.g. <code>\u201c0 8 * * 1-5\u201d</code>). name: Unique schedule name. Defaults to the task\u2019s <code>module_path</code>. registry: An alternate <code>ScheduleRegistry</code> instance (defaults to the global <code>schedule_registry</code>). **kwargs: Extra options forwarded to :meth:<code>ScheduleRegistry.register</code> (<code>timezone</code>, <code>args</code>, <code>kwargs</code>, <code>queue_name</code>, <code>priority</code>, <code>backend</code>).</p>"},{"location":"api/registry.html#scheduleentry","title":"ScheduleEntry","text":"class <code>django_periodic_tasks.registry.ScheduleEntry</code>(task, cron_expression, name, timezone=\u2019UTC\u2019, args=, kwargs=, queue_name=\u2019default\u2019, priority=0, backend=\u2019default\u2019) <p>An immutable record describing a single scheduled task.</p> <p>Each entry holds the task object, its cron schedule, and the options that will be forwarded to <code>task.using()</code> / <code>task.enqueue()</code> at execution time.</p> <p>Attributes: task: The django-tasks <code>Task</code> object to enqueue. cron_expression: A standard 5-field cron expression (e.g. <code>\u201c/15 * * * \u201c</code>). name: Unique name for this schedule (used as the DB primary key). timezone: IANA timezone name used for cron matching (default <code>\u201cUTC\u201d</code>). args: Positional arguments passed to <code>task.enqueue()</code>. kwargs: Keyword arguments passed to <code>task.enqueue()</code>. queue_name: Task queue name passed to <code>task.using()</code>. priority: Task priority passed to <code>task.using()</code>. backend: Task backend name passed to <code>task.using()</code>.</p>"},{"location":"api/registry.html#scheduleregistry","title":"ScheduleRegistry","text":"class <code>django_periodic_tasks.registry.ScheduleRegistry</code>() <p>Singleton registry for code-defined schedules.</p> <code>get_entries</code>(self) <p>Return a copy of all registered schedule entries, keyed by name.</p> <code>register</code>(self, task, *, cron, name, timezone=\u2019UTC\u2019, args=None, kwargs=None, queue_name=\u2019default\u2019, priority=0, backend=\u2019default\u2019) <p>Register a task with the given cron schedule.</p> <p>Args: task: A django-tasks <code>Task</code> object. cron: A 5-field cron expression (e.g. <code>\u201c0 /6 * * \u201c</code>). name: Unique name for this schedule. timezone: IANA timezone for cron matching (default <code>\u201cUTC\u201d</code>). args: Positional arguments for <code>task.enqueue()</code>. kwargs: Keyword arguments for <code>task.enqueue()</code>. queue_name: Queue name for <code>task.using()</code>. priority: Priority for <code>task.using()</code>. backend: Backend name for <code>task.using()</code>.</p> <p>Raises: ValueError: If the cron expression is invalid or the name is already registered.</p>"},{"location":"api/settings.html","title":"Settings","text":"<p>django-periodic-tasks is configured through Django settings.</p>"},{"location":"api/settings.html#periodic_tasks_autostart","title":"PERIODIC_TASKS_AUTOSTART","text":"Type <code>bool</code> Default <code>False</code> <p>When <code>True</code>, the scheduler starts automatically as a daemon thread during <code>AppConfig.ready()</code>. Useful for development and simple single-process deployments.</p> <pre><code>PERIODIC_TASKS_AUTOSTART = True\n</code></pre>"},{"location":"api/settings.html#periodic_tasks_scheduler_interval","title":"PERIODIC_TASKS_SCHEDULER_INTERVAL","text":"Type <code>int</code> Default <code>15</code> <p>Seconds between scheduler ticks. On each tick the scheduler queries the database for due tasks and enqueues them.</p> <pre><code>PERIODIC_TASKS_SCHEDULER_INTERVAL = 30 # check every 30 seconds\n</code></pre> <p>Lower values mean tasks are enqueued closer to their scheduled time, but increase database load. The default of 15 seconds is a good balance for most applications.</p>"},{"location":"guides/index.html","title":"Guides","text":"<p>{nav}</p>"},{"location":"guides/defining-schedules.html","title":"Defining Schedules","text":"<p>django-periodic-tasks supports two ways to define scheduled tasks: in code (version-controlled, deployed with your app) and in the database (managed at runtime through the Django admin).</p>"},{"location":"guides/defining-schedules.html#code-defined-schedules","title":"Code-Defined Schedules","text":"<p>Use the <code>@scheduled_task</code> decorator to register a django-tasks <code>Task</code> for periodic execution. Place your tasks in a <code>tasks.py</code> module inside any installed app \u2014 django-periodic-tasks automatically discovers these modules at startup using Django\u2019s <code>autodiscover_modules</code>, similar to how <code>admin.py</code> files are discovered:</p> <pre><code># myapp/tasks.py\nfrom django_tasks import task\nfrom django_periodic_tasks.registry import scheduled_task\n\n\n@scheduled_task(cron=\"0 8 * * 1-5\") # Weekdays at 8:00 AM UTC\n@task()\ndef morning_report() -> None:\n ...\n</code></pre> <p>Note</p> <p>You can define tasks in any module, but <code>tasks.py</code> is auto-discovered. If you use a different module name, make sure it gets imported somewhere (e.g. in your app\u2019s <code>AppConfig.ready()</code>).</p>"},{"location":"guides/defining-schedules.html#decorator-parameters","title":"Decorator Parameters","text":"Parameter Type Default Description <code>cron</code> <code>str</code> (required) 5-field cron expression <code>name</code> <code>str</code> task\u2019s <code>module_path</code> Unique schedule name <code>timezone</code> <code>str</code> <code>\"UTC\"</code> IANA timezone for cron matching <code>args</code> <code>list</code> <code>[]</code> Positional arguments for <code>task.enqueue()</code> <code>kwargs</code> <code>dict</code> <code>{}</code> Keyword arguments for <code>task.enqueue()</code> <code>queue_name</code> <code>str</code> <code>\"default\"</code> Queue name for <code>task.using()</code> <code>priority</code> <code>int</code> <code>0</code> Priority for <code>task.using()</code> <code>backend</code> <code>str</code> <code>\"default\"</code> Backend name for <code>task.using()</code>"},{"location":"guides/defining-schedules.html#task-arguments","title":"Task Arguments","text":"<p>Pass arguments that will be forwarded to <code>task.enqueue()</code> on each execution:</p> <pre><code>@scheduled_task(\n cron=\"0 0 * * 0\",\n args=[42],\n kwargs={\"full\": True},\n)\n@task()\ndef weekly_sync(tenant_id: int, full: bool = False) -> None:\n ...\n</code></pre>"},{"location":"guides/defining-schedules.html#task-options","title":"Task Options","text":"<p>Control which queue, priority, and backend the task uses:</p> <pre><code>@scheduled_task(\n cron=\"*/10 * * * *\",\n queue_name=\"high-priority\",\n priority=10,\n backend=\"default\",\n)\n@task()\ndef health_check() -> None:\n ...\n</code></pre>"},{"location":"guides/defining-schedules.html#cron-expression-syntax","title":"Cron Expression Syntax","text":"<p>django-periodic-tasks uses standard 5-field cron expressions:</p> <pre><code>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 minute (0\u201359)\n\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 hour (0\u201323)\n\u2502 \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 day of month (1\u201331)\n\u2502 \u2502 \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 month (1\u201312)\n\u2502 \u2502 \u2502 \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 day of week (0\u20136, Sun=0)\n\u2502 \u2502 \u2502 \u2502 \u2502\n* * * * *\n</code></pre> <p>Examples:</p> Expression Meaning <code>* * * * *</code> Every minute <code>*/15 * * * *</code> Every 15 minutes <code>0 * * * *</code> Every hour <code>0 8 * * *</code> Daily at 8:00 AM <code>0 8 * * 1-5</code> Weekdays at 8:00 AM <code>0 0 1 * *</code> First of every month at midnight <code>30 2 * * 0</code> Sundays at 2:30 AM"},{"location":"guides/defining-schedules.html#using-fluentcron","title":"Using fluentcron","text":"<p>If you prefer a type-safe, self-documenting API over raw cron strings, use the fluentcron library:</p> <pre><code>pip install fluentcron\n</code></pre> <p>fluentcron provides a fluent builder and shortcut functions that produce standard cron expression strings:</p> <pre><code>from django_tasks import task\nfrom django_periodic_tasks import scheduled_task\nfrom fluentcron import CronSchedule, daily_at, every_n_minutes\n\n# Shortcut function \u2014 returns a cron string directly\n@scheduled_task(cron=every_n_minutes(15)) # \"*/15 * * * *\"\n@task()\ndef health_check() -> None:\n ...\n\n\n# Shortcut function\n@scheduled_task(cron=daily_at(8)) # \"0 8 * * *\"\n@task()\ndef morning_report() -> None:\n ...\n\n\n# Fluent builder \u2014 call str() or .to_str() for the cron string\n@scheduled_task(\n cron=str(CronSchedule().weekly().on_monday().at(9, 30)), # \"30 9 * * 1\"\n)\n@task()\ndef weekly_digest() -> None:\n ...\n</code></pre> <p>fluentcron also ships with <code>CommonSchedules</code> presets for frequently used expressions:</p> <pre><code>from fluentcron import CommonSchedules\n\n@scheduled_task(cron=CommonSchedules.EVERY_HOUR) # \"0 * * * *\"\n@task()\ndef sync_data() -> None:\n ...\n</code></pre>"},{"location":"guides/defining-schedules.html#timezone-support","title":"Timezone Support","text":"<p>By default, cron expressions are evaluated in UTC. Specify a timezone to match against local time:</p> <pre><code>@scheduled_task(\n cron=\"0 9 * * *\", # 9:00 AM\n timezone=\"US/Eastern\", # in Eastern time\n)\n@task()\ndef east_coast_morning() -> None:\n ...\n</code></pre> <p>The <code>timezone</code> parameter accepts any IANA timezone name (e.g. <code>\"US/Pacific\"</code>, <code>\"Europe/London\"</code>, <code>\"Asia/Tokyo\"</code>).</p>"},{"location":"guides/defining-schedules.html#database-defined-schedules","title":"Database-Defined Schedules","text":"<p>For schedules that need to be managed at runtime without a code deployment, create them through the Django admin:</p> <ol> <li>Navigate to Django Periodic Tasks > Scheduled tasks in the admin.</li> <li>Click Add scheduled task.</li> <li>Fill in the task path (e.g. <code>myapp.tasks.morning_report</code>), cron expression, and any arguments.</li> <li>The schedule takes effect on the next scheduler tick.</li> </ol> <p>Database-defined schedules have <code>source = Database</code> and can be freely edited or disabled through the admin.</p>"},{"location":"guides/defining-schedules.html#schedule-sync","title":"Schedule Sync","text":"<p>When the scheduler starts, it syncs code-defined schedules to the database:</p> <ul> <li>New code schedules are created as <code>ScheduledTask</code> rows with <code>source = Code</code>.</li> <li>Changed code schedules (cron expression, task path, options) are updated in place.</li> <li>Removed code schedules (present in DB but no longer in the registry) are disabled.</li> <li>Database-defined schedules are never modified by the sync process.</li> </ul> <p>Code-defined schedules appear as read-only in the Django admin.</p>"},{"location":"guides/exactly-once.html","title":"Exactly-Once Execution","text":"<p>By default, the scheduler enqueues a task on each tick and trusts the worker to execute it. With non-transactional backends (e.g. Redis/RQ), there is a small window where a crash or retry could cause the same invocation to run more than once.</p> <p>The <code>@exactly_once</code> decorator closes that window. It guarantees that each scheduled invocation runs at most once, regardless of the backend.</p>"},{"location":"guides/exactly-once.html#how-it-works","title":"How It Works","text":"<p>When a task is decorated with <code>@exactly_once</code>, the scheduler and worker cooperate through a <code>TaskExecution</code> row in the database:</p> sequenceDiagram participant S as Scheduler participant DB as Database participant W as Worker S->>DB: INSERT TaskExecution (status=PENDING) S->>W: enqueue task with execution ID W->>DB: SELECT FOR UPDATE TaskExecution alt status is PENDING W->>W: run task function W->>DB: UPDATE status=COMPLETED else already COMPLETED or missing W->>W: skip (no-op) end <ol> <li>Scheduler tick \u2014 Creates a <code>TaskExecution</code> row with <code>status=PENDING</code> and passes its ID to the task as a keyword argument.</li> <li>Worker picks up the task \u2014 The <code>@exactly_once</code> wrapper locks the <code>TaskExecution</code> row with <code>SELECT FOR UPDATE</code>.</li> <li>If PENDING \u2014 Runs the wrapped function and marks the row <code>COMPLETED</code>.</li> <li>If not PENDING (already completed, or row missing) \u2014 Skips execution entirely.</li> </ol> <p>Because the row lock is held for the duration of the function call, even if two workers receive the same task, only one will find the row in <code>PENDING</code> status.</p>"},{"location":"guides/exactly-once.html#usage","title":"Usage","text":"<p>Apply <code>@exactly_once</code> below <code>@task()</code> so it wraps the raw function before django-tasks wraps it as a <code>Task</code>:</p> <pre><code>from django_tasks import task\nfrom django_periodic_tasks import scheduled_task, exactly_once\n\n\n@scheduled_task(cron=\"0 2 * * *\")\n@task()\n@exactly_once\ndef nightly_billing() -> None:\n \"\"\"Charge customers \u2014 must not run twice.\"\"\"\n ...\n</code></pre> <p>Decorator order matters</p> <p><code>@exactly_once</code> must be below <code>@task()</code>. If placed above, the scheduler won\u2019t detect the <code>_exactly_once</code> marker and will skip creating the <code>TaskExecution</code> row.</p>"},{"location":"guides/exactly-once.html#manual-invocation","title":"Manual Invocation","text":"<p>When a task decorated with <code>@exactly_once</code> is called without the special <code>_periodic_tasks_execution_id</code> keyword argument (e.g. enqueued manually or called directly), the decorator is a no-op and the function runs normally:</p> <pre><code># These both work \u2014 exactly_once is bypassed\nnightly_billing.enqueue()\nnightly_billing()\n</code></pre>"},{"location":"guides/exactly-once.html#stale-execution-recovery","title":"Stale Execution Recovery","text":"<p>If the scheduler creates a <code>TaskExecution</code> row but the <code>on_commit</code> callback that enqueues the task never fires (e.g. process crash, connection reset), the row becomes stale.</p> <p>The scheduler automatically detects stale <code>PENDING</code> executions older than <code>max(60s, 2 \u00d7 interval)</code> and re-enqueues them on the next tick. This ensures tasks are not silently lost.</p>"},{"location":"guides/exactly-once.html#automatic-cleanup","title":"Automatic Cleanup","text":"<p>Completed <code>TaskExecution</code> rows are automatically deleted after 24 hours. <code>PENDING</code> rows are preserved until they are delivered or re-enqueued by the stale execution recovery process.</p>"},{"location":"guides/exactly-once.html#when-to-use-it","title":"When to Use It","text":"<p>Use <code>@exactly_once</code> when:</p> <ul> <li>The task has side effects that must not be duplicated (billing, sending emails, external API calls).</li> <li>You\u2019re using a non-transactional backend where at-least-once delivery is the default.</li> <li>You need a database-backed audit trail of each invocation.</li> </ul> <p>You can skip it when:</p> <ul> <li>The task is idempotent (safe to run multiple times with the same result).</li> <li>You\u2019re using the database backend with transactional enqueue, which already provides strong delivery guarantees.</li> <li>Performance is critical and the extra <code>SELECT FOR UPDATE</code> round-trip is not acceptable.</li> </ul>"},{"location":"guides/getting-started.html","title":"Getting Started","text":"<p>This guide walks you through installing django-periodic-tasks, defining your first scheduled task, and running the scheduler.</p>"},{"location":"guides/getting-started.html#prerequisites","title":"Prerequisites","text":"<ul> <li>Python 3.13+</li> <li>Django 5.2+</li> <li>django-tasks installed and configured with at least one backend</li> </ul>"},{"location":"guides/getting-started.html#installation","title":"Installation","text":"<p>Install the package:</p> <pre><code>pip install django-periodic-tasks\n</code></pre> <p>Add both <code>django_tasks</code> and <code>django_periodic_tasks</code> to your <code>INSTALLED_APPS</code>:</p> <pre><code>INSTALLED_APPS = [\n # ...\n \"django_tasks\",\n \"django_tasks.backends.database\", # if using the database backend\n \"django_periodic_tasks\",\n # ...\n]\n</code></pre> <p>Run migrations to create the <code>ScheduledTask</code> table:</p> <pre><code>python manage.py migrate\n</code></pre>"},{"location":"guides/getting-started.html#define-your-first-scheduled-task","title":"Define Your First Scheduled Task","text":"<p>Create a task using django-tasks\u2019 <code>@task()</code> decorator, then register it for periodic execution with <code>@scheduled_task()</code>. Place your tasks in a <code>tasks.py</code> module inside any installed app \u2014 django-periodic-tasks automatically discovers these modules at startup (similar to how Django discovers <code>admin.py</code> files):</p> <pre><code># myapp/tasks.py\nfrom django_tasks import task\nfrom django_periodic_tasks.registry import scheduled_task\n\n\n@scheduled_task(cron=\"*/5 * * * *\") # Every 5 minutes\n@task()\ndef send_digest() -> None:\n \"\"\"Send the email digest.\"\"\"\n ...\n</code></pre> <p>The <code>@scheduled_task</code> decorator must be applied above <code>@task()</code>. The <code>cron</code> parameter accepts any standard 5-field cron expression.</p>"},{"location":"guides/getting-started.html#run-the-scheduler","title":"Run the Scheduler","text":"<p>Enable the scheduler by adding this to your settings:</p> <pre><code># settings.py\nPERIODIC_TASKS_AUTOSTART = True\n</code></pre> <p>The scheduler starts automatically as a daemon thread when Django starts. It checks for due tasks every 15 seconds (configurable via <code>PERIODIC_TASKS_SCHEDULER_INTERVAL</code>).</p> <p>Run your task worker as usual (e.g. <code>python manage.py db_worker</code> for the database backend). The scheduler thread enqueues tasks; the worker executes them.</p>"},{"location":"guides/getting-started.html#verify-it-works","title":"Verify It Works","text":"<ol> <li>Open the Django admin at <code>/admin/django_periodic_tasks/scheduledtask/</code>.</li> <li>You should see your task listed with <code>source = Code</code>.</li> <li>The <code>next_run_at</code> field shows when the task will next be enqueued.</li> <li>After the scheduled time passes, <code>last_run_at</code> and <code>total_run_count</code> will update.</li> </ol>"},{"location":"guides/running.html","title":"Running the Scheduler","text":"<p>django-periodic-tasks runs as a daemon thread inside your Django process. Enable it with a single setting and the scheduler starts automatically alongside your application.</p>"},{"location":"guides/running.html#autostart-recommended","title":"Autostart (Recommended)","text":"<p>Add the following to your Django settings:</p> <pre><code># settings.py\nPERIODIC_TASKS_AUTOSTART = True\n</code></pre> <p>When Django starts, <code>AppConfig.ready()</code> launches the scheduler as a daemon thread. It syncs code-defined schedules to the database, then enters a tick-sleep loop that checks for due tasks every <code>PERIODIC_TASKS_SCHEDULER_INTERVAL</code> seconds (default: 15).</p> <p>The daemon thread exits automatically when the main process shuts down \u2014 no signal handling or cleanup is needed on your part.</p>"},{"location":"guides/running.html#tuning-the-interval","title":"Tuning the Interval","text":"<pre><code>PERIODIC_TASKS_SCHEDULER_INTERVAL = 30 # check every 30 seconds\n</code></pre> <p>Lower values mean tasks are enqueued closer to their scheduled time but increase database load. The default of 15 seconds is a good balance for most applications.</p>"},{"location":"guides/running.html#standalone-scheduler","title":"Standalone Scheduler","text":"<p>For cases where you want the scheduler in its own dedicated process (e.g. separating scheduling from request serving), use the <code>run_scheduler</code> management command:</p> <pre><code>python manage.py run_scheduler\n</code></pre> <p>This runs the scheduler loop in the main thread (blocking). You\u2019ll need to run your task worker separately.</p>"},{"location":"guides/running.html#options","title":"Options","text":"Flag Default Description <code>--interval</code> <code>15</code> Seconds between scheduler ticks <code>-v 0</code> \u2014 Suppress all output <code>-v 2</code> \u2014 Enable debug logging"},{"location":"guides/running.html#multi-worker-deployment","title":"Multi-Worker Deployment","text":"<p>The scheduler is safe to run across multiple processes. Each scheduler tick uses <code>SELECT FOR UPDATE SKIP LOCKED</code> to claim due tasks, so even if multiple scheduler instances run simultaneously, each task is enqueued exactly once.</p> <p>A typical production setup with two web/worker processes:</p> <pre><code>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 gunicorn / daphne \u2502 \u2502 gunicorn / daphne \u2502\n\u2502 (autostart=True) \u2502 \u2502 (autostart=True) \u2502\n\u2502 scheduler + app \u2502 \u2502 scheduler + app \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25bc\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 PostgreSQL \u2502\n \u2502 (shared DB) \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n</code></pre> <p>Both instances run the scheduler, but <code>SKIP LOCKED</code> ensures no duplicate enqueues. Scale horizontally by adding more processes.</p>"},{"location":"guides/running.html#graceful-shutdown","title":"Graceful Shutdown","text":"<p>The <code>run_scheduler</code> command handles <code>SIGINT</code> and <code>SIGTERM</code> for graceful shutdown:</p> <ul> <li>The current scheduler tick completes.</li> <li>The stop event is set, and the scheduler thread exits.</li> <li>The process terminates cleanly.</li> </ul> <p>When using autostart, the scheduler thread is a daemon thread and exits automatically when the main process terminates. In container environments (Docker, Kubernetes), this means <code>docker stop</code> and Kubernetes pod termination work correctly out of the box.</p>"}]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/__init__.py
RENAMED
|
File without changes
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/apps.py
RENAMED
|
File without changes
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/cron.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/django_periodic_tasks/sync.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/guides/defining-schedules.md
RENAMED
|
File without changes
|
|
File without changes
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/docs/guides/getting-started.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/assets/images/favicon.png
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/guides/exactly-once.html
RENAMED
|
File without changes
|
{django_periodic_tasks-0.1.0a3 → django_periodic_tasks-0.1.0a5}/public/guides/getting-started.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_periodic_tasks-0.1.0a3/sandbox/testapp → django_periodic_tasks-0.1.0a5/sandbox}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_periodic_tasks-0.1.0a3/tests → django_periodic_tasks-0.1.0a5/sandbox/testapp}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|