From 1cf886113398670392c44c616905851c49081be2 Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Fri, 4 Jul 2025 15:49:31 +0100 Subject: [PATCH 1/4] Import checks in app config --- django_tasks/apps.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/django_tasks/apps.py b/django_tasks/apps.py index 04cf004..c6260df 100644 --- a/django_tasks/apps.py +++ b/django_tasks/apps.py @@ -1,13 +1,12 @@ from django.apps import AppConfig from django.core import checks -from django_tasks.checks import check_tasks - class TasksAppConfig(AppConfig): name = "django_tasks" def ready(self) -> None: from . import signal_handlers # noqa + from .checks import check_tasks checks.register(check_tasks) From 34a499be5cd3581d636e89b8e4e048f9fb6fbed8 Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Fri, 4 Jul 2025 15:52:42 +0100 Subject: [PATCH 2/4] Drop support for Python 3.9 --- .github/workflows/ci.yml | 10 +--------- pyproject.toml | 3 +-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4748f7f..466936f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,16 +15,8 @@ jobs: fail-fast: false matrix: os: [windows-latest, macos-latest, ubuntu-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] django-version: ["4.2", "5.0", "5.1", "5.2"] - exclude: - - django-version: "5.0" - python-version: "3.9" - - django-version: "5.1" - python-version: "3.9" - - django-version: "5.2" - python-version: "3.9" - steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/pyproject.toml b/pyproject.toml index 3e1e1c3..1c3103e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ license-files = ["LICENSE"] classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -36,7 +35,7 @@ classifiers = [ "Topic :: Internet :: WWW/HTTP", "Typing :: Typed" ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "Django>=4.2", "typing_extensions", From c1ce12c6f9006445512449fe40daa8e91e415b9b Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Fri, 4 Jul 2025 15:56:22 +0100 Subject: [PATCH 3/4] Reuse `get_random_string` --- django_tasks/utils.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/django_tasks/utils.py b/django_tasks/utils.py index e7a1c14..c04e2c8 100644 --- a/django_tasks/utils.py +++ b/django_tasks/utils.py @@ -1,12 +1,11 @@ import inspect import json -import random import time from functools import wraps from traceback import format_exception from typing import Any, Callable, TypeVar -from django.utils.crypto import RANDOM_STRING_CHARS +from django.utils.crypto import get_random_string from typing_extensions import ParamSpec T = TypeVar("T") @@ -69,8 +68,5 @@ def get_random_id() -> str: Return a random string for use as a task or worker id. Whilst 64 characters is the max, just use 32 as a sensible middle-ground. - - This should be much faster than Django's `get_random_string`, since - it's not cryptographically secure. """ - return "".join(random.choices(RANDOM_STRING_CHARS, k=32)) + return get_random_string(32) From 94c647942a05f07cc1c55d5e3e54718ecbf3a402 Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Fri, 11 Jul 2025 14:37:59 +0100 Subject: [PATCH 4/4] Update syntax for 3.10+ --- django_tasks/__init__.py | 3 +- django_tasks/backends/database/admin.py | 10 ++-- .../database/management/commands/db_worker.py | 7 ++- .../commands/prune_db_task_results.py | 3 +- django_tasks/backends/database/utils.py | 6 +-- django_tasks/backends/rq.py | 8 +-- django_tasks/task.py | 49 +++++++++---------- django_tasks/utils.py | 3 +- 8 files changed, 42 insertions(+), 47 deletions(-) diff --git a/django_tasks/__init__.py b/django_tasks/__init__.py index fe10dad..0d9696e 100644 --- a/django_tasks/__init__.py +++ b/django_tasks/__init__.py @@ -4,7 +4,6 @@ django_stubs_ext.monkeypatch() import importlib.metadata -from typing import Optional from django.utils.connection import BaseConnectionHandler, ConnectionProxy from django.utils.module_loading import import_string @@ -40,7 +39,7 @@ class TasksHandler(BaseConnectionHandler[BaseTaskBackend]): settings_name = "TASKS" exception_class = InvalidTaskBackendError - def configure_settings(self, settings: Optional[dict]) -> dict: + def configure_settings(self, settings: dict | None) -> dict: try: return super().configure_settings(settings) except AttributeError: diff --git a/django_tasks/backends/database/admin.py b/django_tasks/backends/database/admin.py index 0e05414..0e88362 100644 --- a/django_tasks/backends/database/admin.py +++ b/django_tasks/backends/database/admin.py @@ -1,5 +1,3 @@ -from typing import Optional - from django.contrib import admin from django.http import HttpRequest @@ -22,21 +20,21 @@ class DBTaskResultAdmin(admin.ModelAdmin): ordering = ["-enqueued_at"] def has_add_permission( - self, request: HttpRequest, obj: Optional[DBTaskResult] = None + self, request: HttpRequest, obj: DBTaskResult | None = None ) -> bool: return False def has_delete_permission( - self, request: HttpRequest, obj: Optional[DBTaskResult] = None + self, request: HttpRequest, obj: DBTaskResult | None = None ) -> bool: return False def has_change_permission( - self, request: HttpRequest, obj: Optional[DBTaskResult] = None + self, request: HttpRequest, obj: DBTaskResult | None = None ) -> bool: return False def get_readonly_fields( - self, request: HttpRequest, obj: Optional[DBTaskResult] = None + self, request: HttpRequest, obj: DBTaskResult | None = None ) -> list[str]: return [f.name for f in self.model._meta.fields] diff --git a/django_tasks/backends/database/management/commands/db_worker.py b/django_tasks/backends/database/management/commands/db_worker.py index c9476d8..90cd18b 100644 --- a/django_tasks/backends/database/management/commands/db_worker.py +++ b/django_tasks/backends/database/management/commands/db_worker.py @@ -7,7 +7,6 @@ import time from argparse import ArgumentParser, ArgumentTypeError, BooleanOptionalAction from types import FrameType -from typing import Optional from django.conf import settings from django.core.exceptions import SuspiciousOperation @@ -38,7 +37,7 @@ def __init__( batch: bool, backend_name: str, startup_delay: bool, - max_tasks: Optional[int], + max_tasks: int | None, worker_id: str, ): self.queue_names = queue_names @@ -55,7 +54,7 @@ def __init__( self.worker_id = worker_id - def shutdown(self, signum: int, frame: Optional[FrameType]) -> None: + def shutdown(self, signum: int, frame: FrameType | None) -> None: if not self.running: logger.warning( "Received %s - terminating current task.", signal.strsignal(signum) @@ -307,7 +306,7 @@ def handle( backend_name: str, startup_delay: bool, reload: bool, - max_tasks: Optional[int], + max_tasks: int | None, worker_id: str, **options: dict, ) -> None: diff --git a/django_tasks/backends/database/management/commands/prune_db_task_results.py b/django_tasks/backends/database/management/commands/prune_db_task_results.py index 0ce412a..4e37020 100644 --- a/django_tasks/backends/database/management/commands/prune_db_task_results.py +++ b/django_tasks/backends/database/management/commands/prune_db_task_results.py @@ -1,7 +1,6 @@ import logging from argparse import ArgumentParser, ArgumentTypeError from datetime import timedelta -from typing import Optional from django.core.management.base import BaseCommand from django.db.models import Q @@ -91,7 +90,7 @@ def handle( verbosity: int, backend: DatabaseBackend, min_age_days: int, - failed_min_age_days: Optional[int], + failed_min_age_days: int | None, queue_name: str, dry_run: bool, **options: dict, diff --git a/django_tasks/backends/database/utils.py b/django_tasks/backends/database/utils.py index cf586c8..6747c65 100644 --- a/django_tasks/backends/database/utils.py +++ b/django_tasks/backends/database/utils.py @@ -1,6 +1,6 @@ from collections.abc import Generator from contextlib import contextmanager -from typing import Any, Optional, Union +from typing import Any from uuid import UUID import django @@ -30,7 +30,7 @@ def connection_requires_manual_exclusive_transaction( @contextmanager -def exclusive_transaction(using: Optional[str] = None) -> Generator[Any, Any, Any]: +def exclusive_transaction(using: str | None = None) -> Generator[Any, Any, Any]: """ Wrapper around `transaction.atomic` which ensures transactions on SQLite are exclusive. @@ -50,7 +50,7 @@ def exclusive_transaction(using: Optional[str] = None) -> Generator[Any, Any, An yield -def normalize_uuid(val: Union[str, UUID]) -> str: +def normalize_uuid(val: str | UUID) -> str: """ Normalize a UUID into its dashed representation. diff --git a/django_tasks/backends/rq.py b/django_tasks/backends/rq.py index c2a1fdd..dcef9cc 100644 --- a/django_tasks/backends/rq.py +++ b/django_tasks/backends/rq.py @@ -1,6 +1,6 @@ from collections.abc import Iterable from types import TracebackType -from typing import Any, Optional, TypeVar +from typing import Any, TypeVar import django_rq from django.apps import apps @@ -140,7 +140,7 @@ def task_result(self) -> TaskResult: def failed_callback( job: Job, - connection: Optional[Redis], + connection: Redis | None, exception_class: type[Exception], exception_value: Exception, traceback: TracebackType, @@ -158,7 +158,7 @@ def failed_callback( task_finished.send(type(task_result.task.get_backend()), task_result=task_result) -def success_callback(job: Job, connection: Optional[Redis], result: Any) -> None: +def success_callback(job: Job, connection: Redis | None, result: Any) -> None: task_result = job.task_result object.__setattr__(task_result, "status", ResultStatus.SUCCEEDED) @@ -233,7 +233,7 @@ def save_result() -> None: def _get_queues(self) -> list[django_rq.queues.DjangoRQ]: return django_rq.queues.get_queues(*self.queues, job_class=Job) # type: ignore[no-any-return,no-untyped-call] - def _get_job(self, job_id: str) -> Optional[Job]: + def _get_job(self, job_id: str) -> Job | None: for queue in self._get_queues(): job = queue.fetch_job(job_id) if job is not None: diff --git a/django_tasks/task.py b/django_tasks/task.py index 3d89265..bd8a6c4 100644 --- a/django_tasks/task.py +++ b/django_tasks/task.py @@ -1,15 +1,14 @@ +from collections.abc import Callable from dataclasses import dataclass, field, replace from datetime import datetime from inspect import isclass, iscoroutinefunction from typing import ( TYPE_CHECKING, Any, - Callable, + Concatenate, Generic, Literal, - Optional, TypeVar, - Union, cast, overload, ) @@ -18,7 +17,7 @@ from django.db.models.enums import TextChoices from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ -from typing_extensions import Concatenate, ParamSpec, Self +from typing_extensions import ParamSpec, Self from .exceptions import ResultDoesNotExist from .utils import ( @@ -79,10 +78,10 @@ class Task(Generic[P, T]): queue_name: str = DEFAULT_QUEUE_NAME """The name of the queue the task will run on""" - run_after: Optional[datetime] = None + run_after: datetime | None = None """The earliest this task will run""" - enqueue_on_commit: Optional[bool] = None + enqueue_on_commit: bool | None = None """ Whether the task will be enqueued when the current transaction commits, immediately, or whatever the backend decides @@ -106,10 +105,10 @@ def name(self) -> str: def using( self, *, - priority: Optional[int] = None, - queue_name: Optional[str] = None, - run_after: Optional[datetime] = None, - backend: Optional[str] = None, + priority: int | None = None, + queue_name: str | None = None, + run_after: datetime | None = None, + backend: str | None = None, ) -> Self: """ Create a new task with modified defaults @@ -202,7 +201,7 @@ def task( priority: int = DEFAULT_PRIORITY, queue_name: str = DEFAULT_QUEUE_NAME, backend: str = DEFAULT_TASK_BACKEND_ALIAS, - enqueue_on_commit: Optional[bool] = None, + enqueue_on_commit: bool | None = None, takes_context: Literal[False] = False, ) -> Callable[[Callable[P, T]], Task[P, T]]: ... @@ -215,25 +214,25 @@ def task( priority: int = DEFAULT_PRIORITY, queue_name: str = DEFAULT_QUEUE_NAME, backend: str = DEFAULT_TASK_BACKEND_ALIAS, - enqueue_on_commit: Optional[bool] = None, + enqueue_on_commit: bool | None = None, takes_context: Literal[True], ) -> Callable[[Callable[Concatenate["TaskContext", P], T]], Task[P, T]]: ... # Implementation def task( # type: ignore[misc] - function: Optional[Callable[P, T]] = None, + function: Callable[P, T] | None = None, *, priority: int = DEFAULT_PRIORITY, queue_name: str = DEFAULT_QUEUE_NAME, backend: str = DEFAULT_TASK_BACKEND_ALIAS, - enqueue_on_commit: Optional[bool] = None, + enqueue_on_commit: bool | None = None, takes_context: bool = False, -) -> Union[ - Task[P, T], - Callable[[Callable[P, T]], Task[P, T]], - Callable[[Callable[Concatenate["TaskContext", P], T]], Task[P, T]], -]: +) -> ( + Task[P, T] + | Callable[[Callable[P, T]], Task[P, T]] + | Callable[[Callable[Concatenate["TaskContext", P], T]], Task[P, T]] +): """ A decorator used to create a task. """ @@ -286,16 +285,16 @@ class TaskResult(Generic[T]): status: ResultStatus """The status of the running task""" - enqueued_at: Optional[datetime] + enqueued_at: datetime | None """The time this task was enqueued""" - started_at: Optional[datetime] + started_at: datetime | None """The time this task was started""" - finished_at: Optional[datetime] + finished_at: datetime | None """The time this task was finished""" - last_attempted_at: Optional[datetime] + last_attempted_at: datetime | None """The time this task was last attempted to be run""" args: list @@ -313,10 +312,10 @@ class TaskResult(Generic[T]): worker_ids: list[str] """The workers which have processed the task""" - _return_value: Optional[T] = field(init=False, default=None) + _return_value: T | None = field(init=False, default=None) @property - def return_value(self) -> Optional[T]: + def return_value(self) -> T | None: """ The return value of the task. diff --git a/django_tasks/utils.py b/django_tasks/utils.py index c04e2c8..eeec1d9 100644 --- a/django_tasks/utils.py +++ b/django_tasks/utils.py @@ -1,9 +1,10 @@ import inspect import json import time +from collections.abc import Callable from functools import wraps from traceback import format_exception -from typing import Any, Callable, TypeVar +from typing import Any, TypeVar from django.utils.crypto import get_random_string from typing_extensions import ParamSpec