Skip to content

Commit bdf0dff

Browse files
committed
Initial commit
1 parent b42b581 commit bdf0dff

File tree

26 files changed

+1146
-0
lines changed

26 files changed

+1146
-0
lines changed

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on: [push, pull_request]
4+
5+
env:
6+
UV_SYSTEM_PYTHON: 1
7+
UV_PYTHON_DOWNLOADS: never
8+
UV_PYTHON_PREFERENCE: only-system
9+
10+
jobs:
11+
checks:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v6
15+
- uses: astral-sh/ruff-action@v3
16+
17+
test:
18+
runs-on: ubuntu-latest
19+
strategy:
20+
matrix:
21+
django-version: ["6.0"]
22+
python-version: ["3.12", "3.13", "3.13t", "3.14", "3.14t"]
23+
steps:
24+
- uses: actions/checkout@v6
25+
- name: Set up Python ${{ matrix.python-version }}
26+
uses: actions/setup-python@v6
27+
with:
28+
python-version: ${{ matrix.python-version }}
29+
- name: Setup uv
30+
uses: astral-sh/setup-uv@v7
31+
- name: Run Tests (SQLite)
32+
run: uv run --with "django~=${{ matrix.django-version }}" manage.py test

.gitignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Python-generated files
2+
__pycache__/
3+
*.py[oc]
4+
build/
5+
dist/
6+
wheels/
7+
*.egg-info
8+
9+
# Virtual environments
10+
.venv
11+
12+
# Editors
13+
.nova
14+
.vscode
15+
16+
# Everything else
17+
.env
18+
.DS_Store
19+
.python-version

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# django-dbtasks
2+
3+
Database backend and runner for [Django tasks](https://docs.djangoproject.com/en/dev/topics/tasks/) (new in 6.0).

manage.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env python
2+
import os
3+
import sys
4+
5+
if __name__ == "__main__":
6+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
7+
from django.core.management import execute_from_command_line
8+
9+
execute_from_command_line(sys.argv)

pyproject.toml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
[project]
2+
name = "django-dbtasks"
3+
version = "0.1.0"
4+
description = "Database task backend and runner for Django tasks."
5+
readme = "README.md"
6+
authors = [
7+
{ name = "Dan Watson", email = "watsond@imsweb.com" }
8+
]
9+
requires-python = ">=3.12"
10+
classifiers = [
11+
"Development Status :: 3 - Alpha",
12+
"Framework :: Django :: 6",
13+
"Intended Audience :: Developers",
14+
"License :: OSI Approved :: MIT License",
15+
"Programming Language :: Python",
16+
"Programming Language :: Python :: 3",
17+
]
18+
dependencies = []
19+
20+
[dependency-groups]
21+
dev = [
22+
"django>=6.0",
23+
"psycopg>=3.3.2",
24+
]
25+
26+
[project.urls]
27+
Homepage = "https://github.com/imsweb/django-dbtasks"
28+
29+
[build-system]
30+
requires = ["uv_build>=0.9.2,<0.10.0"]
31+
build-backend = "uv_build"
32+
33+
[tool.uv.build-backend]
34+
module-name = "dbtasks"
35+
36+
[tool.ruff.lint]
37+
extend-select = ["I"]

src/dbtasks/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .periodic import Periodic
2+
3+
__all__ = [
4+
"Periodic",
5+
]

src/dbtasks/admin.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from django.contrib import admin
2+
3+
from .models import ScheduledTask
4+
5+
6+
@admin.register(ScheduledTask)
7+
class ScheduledTaskAdmin(admin.ModelAdmin):
8+
list_display = [
9+
"id",
10+
"task_path",
11+
"status",
12+
"priority",
13+
"enqueued_at",
14+
"finished_at",
15+
]
16+
list_filter = [
17+
"task_path",
18+
"status",
19+
"periodic",
20+
"queue",
21+
"backend",
22+
]

src/dbtasks/backend.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import logging
2+
from typing import Any
3+
4+
from django.tasks import Task, TaskResult, TaskResultStatus
5+
from django.tasks.backends.base import BaseTaskBackend
6+
from django.tasks.exceptions import InvalidTask, TaskResultDoesNotExist
7+
from django.tasks.signals import task_enqueued, task_finished, task_started
8+
from django.utils import timezone
9+
from django.utils.json import normalize_json
10+
11+
from .models import ScheduledTask
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class DatabaseBackend(BaseTaskBackend):
17+
supports_defer = True
18+
supports_get_result = True
19+
supports_priority = True
20+
21+
@property
22+
def immediate(self):
23+
return bool(self.options.get("immediate", False))
24+
25+
def validate_task(self, task):
26+
super().validate_task(task)
27+
if self.immediate and task.run_after is not None:
28+
raise InvalidTask("Backend does not support run_after in immediate mode.")
29+
30+
def enqueue(self, task: Task, args: list[Any], kwargs: dict[str, Any]):
31+
self.validate_task(task)
32+
33+
scheduled = ScheduledTask.objects.create(
34+
task_path=task.module_path,
35+
priority=task.priority,
36+
queue=task.queue_name,
37+
backend=task.backend,
38+
run_after=task.run_after,
39+
args=normalize_json(args),
40+
kwargs=normalize_json(kwargs),
41+
)
42+
43+
logger.debug(f"Enqueued {scheduled}")
44+
task_enqueued.send(type(self), task_result=scheduled.result)
45+
46+
if self.immediate:
47+
logger.info(f"Running {scheduled} IMMEDIATELY")
48+
49+
scheduled.status = TaskResultStatus.RUNNING
50+
scheduled.started_at = timezone.now()
51+
scheduled.save(update_fields=["status", "started_at"])
52+
task_started.send(type(self), task_result=scheduled.result)
53+
54+
scheduled.run_and_update()
55+
task_finished.send(type(self), task_result=scheduled.result)
56+
57+
return scheduled.result
58+
59+
def get_result(self, result_id) -> TaskResult:
60+
try:
61+
return ScheduledTask.objects.get(pk=result_id).result
62+
except ScheduledTask.DoesNotExist:
63+
raise TaskResultDoesNotExist(result_id)

src/dbtasks/crontab.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import random
2+
from datetime import datetime, timedelta
3+
from typing import Iterator
4+
5+
WEEKDAYS = {
6+
"mon": 1,
7+
"tue": 2,
8+
"wed": 3,
9+
"thu": 4,
10+
"fri": 5,
11+
"sat": 6,
12+
"sun": 7,
13+
}
14+
15+
MONTHS = {
16+
"jan": 1,
17+
"feb": 2,
18+
"mar": 3,
19+
"apr": 4,
20+
"may": 5,
21+
"jun": 6,
22+
"jul": 7,
23+
"aug": 8,
24+
"sep": 9,
25+
"oct": 10,
26+
"nov": 11,
27+
"dec": 12,
28+
}
29+
30+
31+
class CrontabParseError(Exception):
32+
pass
33+
34+
35+
class CrontabExhausted(Exception):
36+
pass
37+
38+
39+
class CrontabParser:
40+
def __init__(
41+
self,
42+
min_value: int,
43+
max_value: int,
44+
names: dict[str, int] | None = None,
45+
):
46+
self.min_value = min_value
47+
self.max_value = max_value
48+
self.names = {
49+
key[:3].lower(): self._range_check(value)
50+
for key, value in (names or {}).items()
51+
}
52+
53+
def _range_check(self, num: int) -> int:
54+
if num < self.min_value or num > self.max_value:
55+
raise CrontabParseError(
56+
f"{num} is not in the range {self.min_value}-{self.max_value}"
57+
)
58+
return num
59+
60+
def _get_value(self, part: str) -> int:
61+
if part.isdigit():
62+
return self._range_check(int(part))
63+
elif value := self.names.get(part[:3].lower()):
64+
return value
65+
raise CrontabParseError(f"Could not parse value: {part}")
66+
67+
def parse_part(self, part: str) -> list[int]:
68+
if "/" in part:
69+
value, step = part.split("/", 1)
70+
step = self._range_check(int(step))
71+
values = self.parse_part(value)
72+
return values[::step]
73+
elif part == "*":
74+
return list(range(self.min_value, self.max_value + 1))
75+
elif part == "~":
76+
return [random.randint(self.min_value, self.max_value)]
77+
elif "-" in part:
78+
lo, hi = (self._get_value(p) for p in part.split("-", 1))
79+
if lo > hi:
80+
raise CrontabParseError(f"{lo}-{hi} is not a valid range ({lo} > {hi})")
81+
return list(range(lo, hi + 1))
82+
return [self._get_value(part)]
83+
84+
def parse(self, spec: str) -> list[int]:
85+
values: set[int] = set()
86+
for part in spec.split(","):
87+
values.update(self.parse_part(part))
88+
return list(sorted(values))
89+
90+
91+
minute = CrontabParser(0, 59)
92+
hour = CrontabParser(0, 23)
93+
day = CrontabParser(1, 31)
94+
month = CrontabParser(1, 12, names=MONTHS)
95+
weekday = CrontabParser(0, 7, names=WEEKDAYS)
96+
97+
98+
class Crontab:
99+
def __init__(self, spec: str):
100+
parts = spec.split(None)
101+
if len(parts) != 5:
102+
raise CrontabParseError("Crontab specs must have 5 parts")
103+
self.spec = spec
104+
self.minutes = minute.parse(parts[0])
105+
self.hours = hour.parse(parts[1])
106+
self.days = day.parse(parts[2])
107+
self.months = month.parse(parts[3])
108+
self.weekdays = weekday.parse(parts[4])
109+
self.specifies_day = parts[2] != "*"
110+
self.specifies_weekday = parts[4] != "*"
111+
112+
def __repr__(self):
113+
return f"crontab({self.spec!r})"
114+
115+
def match(self, dt: datetime) -> bool:
116+
"""
117+
Returns whether the specified datetime matches this crontab spec.
118+
"""
119+
if dt.minute not in self.minutes:
120+
return False
121+
if dt.hour not in self.hours:
122+
return False
123+
if dt.month not in self.months:
124+
return False
125+
if self.specifies_day and self.specifies_weekday:
126+
# Special case when both day and weekday are specified - by spec it matches
127+
# when *either* match.
128+
if (dt.day not in self.days) and (dt.isoweekday() not in self.weekdays):
129+
return False
130+
else:
131+
# Otherwise when one or none are specified, check them separately.
132+
if dt.day not in self.days:
133+
return False
134+
if dt.isoweekday() not in self.weekdays:
135+
return False
136+
return True
137+
138+
def next(
139+
self,
140+
after: datetime | None = None,
141+
until: datetime | None = None,
142+
) -> datetime:
143+
"""
144+
Returns the next matching date after the one specified (or after the current date if not specified), and before the specified `until` (or one year after the
145+
intial date if not specified).
146+
"""
147+
if after is None:
148+
after = datetime.now()
149+
if until is None:
150+
until = after.replace(year=after.year + 1)
151+
while after < until:
152+
after += timedelta(minutes=1)
153+
if self.match(after) and (after < until):
154+
return after.replace(second=0, microsecond=0)
155+
raise CrontabExhausted(f"Could not find matching date before {until}")
156+
157+
def dates(
158+
self,
159+
after: datetime | None = None,
160+
until: datetime | None = None,
161+
) -> Iterator[datetime]:
162+
"""
163+
Yields each date between `after` and `until`.
164+
"""
165+
d = after
166+
while True:
167+
try:
168+
d = self.next(after=d, until=until)
169+
yield d
170+
except CrontabExhausted:
171+
break

src/dbtasks/management/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)