From aa09daf7738daed9eaadd0cf4526be623c89cb29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20B=C3=BCrmann?= Date: Fri, 10 Oct 2025 14:14:31 +0200 Subject: [PATCH 1/3] Migrate to pytest + migrate to pyproject.toml --- .github/workflows/publish.yml | 14 ++--- .github/workflows/test.yml | 13 ++--- .gitignore | 3 ++ .pre-commit-config.yaml | 4 +- CHANGELOG.md | 13 +++++ pyproject.toml | 54 +++++++++++++++++++ requirements.txt | 16 ------ setup.py | 2 +- tests/testapp/apps.py | 2 +- tests/testapp/handlers.py | 2 +- tests/testapp/mixins.py | 24 ++++----- tests/testapp/tests/test_future_tasks.py | 4 +- .../tests/test_periodic_future_tasks.py | 4 +- 13 files changed, 101 insertions(+), 54 deletions(-) create mode 100644 pyproject.toml delete mode 100644 requirements.txt diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1324a4d..17353b5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,22 +8,22 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - + - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: - python-version: '3.9' - architecture: 'x64' + python-version: "3.x" - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install build twine - name: Build source and binary distribution package run: | - python setup.py sdist bdist_wheel + python -m build env: PACKAGE_VERSION: ${{ github.ref }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3884162..85aed16 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: django-version: '5.1' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 @@ -34,7 +34,7 @@ jobs: - name: Install dependencies and package run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -e ".[dev]" pip install django~=${{ matrix.django-version }}.0 - name: Run lint and code review @@ -42,15 +42,8 @@ jobs: pre-commit run --all-files - name: Run tests with coverage run: | - # prepare Django project: link all necessary data from the test project into the root directory - # Hint: Simply changing the directory does not work (leads to missing files in coverage report) - ln -s ./tests/core core - ln -s ./tests/testapp testapp - ln -s ./tests/manage.py manage.py # run tests with coverage - coverage run \ - --source='./django_future_tasks' \ - manage.py test + pytest --cov=django_future_tasks tests coverage xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/.gitignore b/.gitignore index 7b46ce0..89a5a55 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ __pycache__ db.sqlite3 build/* tests/static/* +.python-version +.coverage +uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4ce655d..da05865 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,8 +20,8 @@ repos: - id: add-trailing-comma - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.12.8 hooks: - - id: ruff + - id: ruff-check args: [ --fix ] - id: ruff-format diff --git a/CHANGELOG.md b/CHANGELOG.md index ae73a6c..f0baf31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.2] + +### Changed + +- Migrate to `pytest`. +- Migrate to `pyproject.toml`. + +## [1.3.1] + +### Fixed + +- Fix infinite loop in populate_periodic_future_tasks on IntegrityError + ## [1.3.0] ### Added diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0935a47 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[project] +name = "django-future-tasks" +description = "A library to create periodic, cron-like tasks or single tasks with a specified execution/start time and schedule it to run in the future." +readme = "README.md" +requires-python = ">=3.9" +license = {text = "MIT"} +authors = [ + {name = "Armin Ster", email = "aster@anexia-it.com"}, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Framework :: Django", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dynamic = ["version"] +dependencies = [ + "croniter>=3.0.3,<3.1", + "django-cronfield>=0.2.0,<0.3", +] + +[project.optional-dependencies] +dev = [ + "django>=4.2,<5.2", + "pre-commit>=4.3,<4.4", + "pytest>=8.4,<8.5", + "pytest-cov>=7.0,<7.1", + "pytest-django>=4.11,<4.12", + "time-machine>=2.15.0,<2.17.0", +] + +[project.urls] +Homepage = "https://github.com/anexia/django-future-tasks" +Documentation = "https://github.com/anexia/django-future-tasks/blob/main/README.md" +Repository = "https://github.com/anexia/django-future-tasks" +Issues = "https://github.com/anexia/django-future-tasks/issues" +Changelog = "https://github.com/anexia/django-future-tasks/blob/main/CHANGELOG.md" + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "core.settings" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 927d84f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -# Package and package dependencies --e . - -# Development dependencies -coverage>=7.6.2,<7.7 - -# Cron -croniter>=3.0.3,<3.1 - -# TestApp dependencies -django>=4.2,<5.2 -django-cronfield>=0.2.0,<0.3 - -# Linters and formatters -pre-commit>=4.0.1,<4.1 -time-machine>=2.15.0,<2.17.0 diff --git a/setup.py b/setup.py index fbadce3..12053cd 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name="django-future-tasks", - version=os.getenv("PACKAGE_VERSION", "1.3.0").replace("refs/tags/", ""), + version=os.getenv("PACKAGE_VERSION", "1.3.2").replace("refs/tags/", ""), packages=find_packages(), include_package_data=True, install_requires=[ diff --git a/tests/testapp/apps.py b/tests/testapp/apps.py index 24105e1..7e9b7dd 100644 --- a/tests/testapp/apps.py +++ b/tests/testapp/apps.py @@ -5,4 +5,4 @@ class TestappConfig(AppConfig): name = "testapp" # import signal handlers - import tests.testapp.handlers + import testapp.handlers diff --git a/tests/testapp/handlers.py b/tests/testapp/handlers.py index 926bca6..11b91c2 100644 --- a/tests/testapp/handlers.py +++ b/tests/testapp/handlers.py @@ -5,7 +5,7 @@ from django.dispatch import receiver from django_future_tasks.handlers import future_task_signal -from tests.core import settings +from core import settings @receiver(future_task_signal, sender=intern(settings.FUTURE_TASK_TYPE_ONE)) diff --git a/tests/testapp/mixins.py b/tests/testapp/mixins.py index 2f3ed51..68cd35a 100644 --- a/tests/testapp/mixins.py +++ b/tests/testapp/mixins.py @@ -20,9 +20,9 @@ def run(self): class ProcessTasksCommandMixin: @classmethod def setUpClass(cls): - assert ( - not hasattr(cls, "command_instance") or cls.command_instance is None - ), "process_future_tasks has already been started" + assert not hasattr(cls, "command_instance") or cls.command_instance is None, ( + "process_future_tasks has already been started" + ) print("Starting process_future_tasks...") cls.command_instance = ProcessTasksCommand() @@ -32,9 +32,9 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): - assert ( - cls.command_instance is not None - ), "process_future_tasks has not been started and can therefore not be stopped" + assert cls.command_instance is not None, ( + "process_future_tasks has not been started and can therefore not be stopped" + ) print("Stopping process_future_tasks...") super().tearDownClass() @@ -45,9 +45,9 @@ def tearDownClass(cls): class PopulatePeriodicTaskCommandMixin: @classmethod def setUpClass(cls): - assert ( - not hasattr(cls, "command_instance") or cls.command_instance is None - ), "populate_periodic_future_tasks has already been started" + assert not hasattr(cls, "command_instance") or cls.command_instance is None, ( + "populate_periodic_future_tasks has already been started" + ) print("Starting populate_periodic_future_tasks...") cls.command_instance = PopulatePeriodicTasksCommand() @@ -57,9 +57,9 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): - assert ( - cls.command_instance is not None - ), "populate_periodic_future_tasks has not been started and can therefore not be stopped" + assert cls.command_instance is not None, ( + "populate_periodic_future_tasks has not been started and can therefore not be stopped" + ) print("Stopping populate_periodic_future_tasks...") super().tearDownClass() diff --git a/tests/testapp/tests/test_future_tasks.py b/tests/testapp/tests/test_future_tasks.py index 78b3039..2a025e0 100644 --- a/tests/testapp/tests/test_future_tasks.py +++ b/tests/testapp/tests/test_future_tasks.py @@ -10,8 +10,8 @@ from django.utils import timezone from django_future_tasks.models import FutureTask -from tests.core import settings -from tests.testapp.mixins import ProcessTasksCommandMixin +from core import settings +from testapp.mixins import ProcessTasksCommandMixin class WaitForTaskStatusTimeout(Exception): diff --git a/tests/testapp/tests/test_periodic_future_tasks.py b/tests/testapp/tests/test_periodic_future_tasks.py index f2e7d51..b09ee5b 100644 --- a/tests/testapp/tests/test_periodic_future_tasks.py +++ b/tests/testapp/tests/test_periodic_future_tasks.py @@ -7,8 +7,8 @@ from django.utils import timezone from django_future_tasks.models import FutureTask, PeriodicFutureTask -from tests.core import settings -from tests.testapp.mixins import PopulatePeriodicTaskCommandMixin +from core import settings +from testapp.mixins import PopulatePeriodicTaskCommandMixin SLEEP_TIME = 1.8 From f564ec4900ab2d94f94cdbedf42f8b67663813aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20B=C3=BCrmann?= Date: Fri, 10 Oct 2025 16:52:21 +0200 Subject: [PATCH 2/3] Add support for Python 3.14 + Django 5.2 + drop end-of-life versions --- .github/workflows/test.yml | 8 +---- CHANGELOG.md | 21 +++++++++++++- README.md | 13 +++++---- .../populate_periodic_future_tasks.py | 3 +- .../commands/process_future_tasks.py | 4 +-- .../0006_periodicfuturetask_end_time.py | 10 +++++++ django_future_tasks/models.py | 24 +++++++-------- pyproject.toml | 29 +++++++++++++++---- setup.py | 4 +-- tests/core/settings.py | 4 +-- tests/manage.py | 4 +-- tests/testapp/handlers.py | 2 +- tests/testapp/mixins.py | 11 ++++--- tests/testapp/tests/test_future_tasks.py | 7 +++-- .../tests/test_periodic_future_tasks.py | 2 +- 15 files changed, 95 insertions(+), 51 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 85aed16..9207579 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,20 +8,14 @@ jobs: fail-fast: false matrix: python-version: - - '3.9' - '3.10' - '3.11' - '3.12' - '3.13' django-version: - '4.2' - - '5.0' - '5.1' - exclude: - - python-version: '3.9' - django-version: '5.0' - - python-version: '3.9' - django-version: '5.1' + - '5.2' steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index f0baf31..3687fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Use `timezone.now()` instead of `datetime.now()` in `PeriodicFutureTask.save()`. +- Use `condition` instead of `check` attribute in `CheckConstraint` with `django>=5.1`. + +### Added + +- Support for Python 3.14. +- Support for Django 5.2. + +### Removed + +- Support for Python 3.9. +- Support for Django 5.0. + ## [1.3.2] ### Changed @@ -70,7 +87,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial setup. -[Unreleased]: https://github.com/anexia/django-future-tasks/compare/1.3.0...HEAD +[Unreleased]: https://github.com/anexia/django-future-tasks/compare/1.3.2...HEAD +[1.3.2]: https://github.com/anexia/django-future-tasks/releases/tag/1.3.2 +[1.3.1]: https://github.com/anexia/django-future-tasks/releases/tag/1.3.1 [1.3.0]: https://github.com/anexia/django-future-tasks/releases/tag/1.3.0 [1.2.1]: https://github.com/anexia/django-future-tasks/releases/tag/1.2.1 [1.2.0]: https://github.com/anexia/django-future-tasks/releases/tag/1.2.0 diff --git a/README.md b/README.md index 1639de5..f8169b6 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,10 @@ python manage.py populate_periodic_future_tasks If your project uses an older version of Django or Django Rest Framework, you can choose an older version of this project. -| This Project | Python Version | Django Version | -|--------------|-----------------------------|----------------| -| 1.3.* | 3.9, 3.10, 3.11, 3.12, 3.13 | 4.2, 5.0, 5.1 | -| 1.2.* | 3.8, 3.9, 3.10, 3.11 | 3.2, 4.1, 4.2 | -| 1.1.* | 3.8, 3.9, 3.10, 3.11 | 3.2, 4.1, 4.2 | -| 1.0.* | 3.8, 3.9, 3.10, 3.11 | 3.2, 4.0, 4.1 | +| This Project | Python Version | Django Version | +|--------------|------------------------------|----------------| +| 1.4.* | 3.10, 3.11, 3.12, 3.13, 3.14 | 4.2, 5.1, 5.2 | +| 1.3.* | 3.9, 3.10, 3.11, 3.12, 3.13 | 4.2, 5.0, 5.1 | +| 1.2.* | 3.8, 3.9, 3.10, 3.11 | 3.2, 4.1, 4.2 | +| 1.1.* | 3.8, 3.9, 3.10, 3.11 | 3.2, 4.1, 4.2 | +| 1.0.* | 3.8, 3.9, 3.10, 3.11 | 3.2, 4.0, 4.1 | diff --git a/django_future_tasks/management/commands/populate_periodic_future_tasks.py b/django_future_tasks/management/commands/populate_periodic_future_tasks.py index 8f1435a..e0dfd7a 100644 --- a/django_future_tasks/management/commands/populate_periodic_future_tasks.py +++ b/django_future_tasks/management/commands/populate_periodic_future_tasks.py @@ -55,8 +55,7 @@ def handle_tick(self): for dt in relevant_dts: if ( p_task.max_number_of_executions is not None - and self.number_of_corresponding_single_tasks(p_task) - >= p_task.max_number_of_executions + and self.number_of_corresponding_single_tasks(p_task) >= p_task.max_number_of_executions ) or (p_task.end_time is not None and p_task.end_time < dt): p_task.is_active = False break diff --git a/django_future_tasks/management/commands/process_future_tasks.py b/django_future_tasks/management/commands/process_future_tasks.py index 217a96e..40a43e1 100644 --- a/django_future_tasks/management/commands/process_future_tasks.py +++ b/django_future_tasks/management/commands/process_future_tasks.py @@ -70,9 +70,7 @@ def handle_tick(self): except Exception as exc: task.status = FutureTask.FUTURE_TASK_STATUS_ERROR task.result = { - "exception": "An exception of type {} occurred.".format( - type(exc).__name__, - ), + "exception": f"An exception of type {type(exc).__name__} occurred.", "args": self._convert_exception_args(exc.args), "traceback": traceback.format_exception( *sys.exc_info(), diff --git a/django_future_tasks/migrations/0006_periodicfuturetask_end_time.py b/django_future_tasks/migrations/0006_periodicfuturetask_end_time.py index b6c6b9c..b9393b8 100644 --- a/django_future_tasks/migrations/0006_periodicfuturetask_end_time.py +++ b/django_future_tasks/migrations/0006_periodicfuturetask_end_time.py @@ -1,5 +1,6 @@ # Generated by Django 3.2.22 on 2023-10-20 15:42 +import django from django.db import migrations, models @@ -27,6 +28,15 @@ class Migration(migrations.Migration): _connector="OR", ), name="not_both_not_null", + ) + if django.VERSION < (5, 1) + else models.CheckConstraint( + condition=models.Q( + ("end_time__isnull", True), + ("max_number_of_executions__isnull", True), + _connector="OR", + ), + name="not_both_not_null", ), ), ] diff --git a/django_future_tasks/models.py b/django_future_tasks/models.py index e092869..75ecd76 100644 --- a/django_future_tasks/models.py +++ b/django_future_tasks/models.py @@ -1,12 +1,10 @@ -import datetime - import croniter +import django from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.db.models import JSONField, Q -from django.utils import timezone -from django.utils.dateformat import format +from django.utils import dateformat, timezone from django.utils.translation import gettext_lazy as _ from .fields import FutureTaskCronField @@ -106,18 +104,16 @@ def next_planned_execution(self): not self.is_active or ( self.max_number_of_executions is not None - and FutureTask.objects.filter(periodic_parent_task=self.pk).count() - >= self.max_number_of_executions + and FutureTask.objects.filter(periodic_parent_task=self.pk).count() >= self.max_number_of_executions ) or ( self.end_time is not None - and self.end_time - < croniter.croniter(self.cron_string, now).get_next(timezone.datetime) + and self.end_time < croniter.croniter(self.cron_string, now).get_next(timezone.datetime) ) ): return None - return format( + return dateformat.format( timezone.template_localtime(next_planned_execution), settings.DATETIME_FORMAT, ) @@ -134,7 +130,7 @@ def save( update_fields=None, ): if self.is_active and not self.__original_is_active: - self.last_task_creation = datetime.datetime.now() + self.last_task_creation = timezone.now() self.clean() super().save() @@ -159,8 +155,12 @@ def clean(self): class Meta: constraints = [ models.CheckConstraint( - check=Q(end_time__isnull=True) - | Q(max_number_of_executions__isnull=True), + check=Q(end_time__isnull=True) | Q(max_number_of_executions__isnull=True), + name="not_both_not_null", + ) + if django.VERSION < (5, 1) + else models.CheckConstraint( + condition=Q(end_time__isnull=True) | Q(max_number_of_executions__isnull=True), name="not_both_not_null", ), ] diff --git a/pyproject.toml b/pyproject.toml index 0935a47..a0b6273 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "django-future-tasks" description = "A library to create periodic, cron-like tasks or single tasks with a specified execution/start time and schedule it to run in the future." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = {text = "MIT"} authors = [ {name = "Armin Ster", email = "aster@anexia-it.com"}, @@ -11,32 +11,32 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Django", "Framework :: Django :: 4.2", - "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", + "Framework :: Django :: 5.2", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dynamic = ["version"] dependencies = [ - "croniter>=3.0.3,<3.1", + "croniter>=3.0.3,<6.0", "django-cronfield>=0.2.0,<0.3", ] [project.optional-dependencies] dev = [ - "django>=4.2,<5.2", + "django>=4.2,<6.0", "pre-commit>=4.3,<4.4", "pytest>=8.4,<8.5", "pytest-cov>=7.0,<7.1", "pytest-django>=4.11,<4.12", - "time-machine>=2.15.0,<2.17.0", + "time-machine>=2.19.0,<2.20.0", ] [project.urls] @@ -48,7 +48,24 @@ Changelog = "https://github.com/anexia/django-future-tasks/blob/main/CHANGELOG.m [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "core.settings" +filterwarnings = ["error", "ignore::ResourceWarning"] [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" + +[tool.ruff] +line-length = 120 +respect-gitignore = true +extend-exclude = [".venv", "__pycache__"] +show-fixes = true + +[tool.ruff.format] +quote-style = "double" + +[tool.ruff.lint] +select = ["A", "B", "C", "E", "F", "W", "T20", "LOG", "I", "UP", "RUF010", "RUF019"] +ignore = ["E203", "E266", "E501", "F403", "F405"] + +[tool.ruff.lint.isort] +known-first-party = ["django_future_tasks", "core"] diff --git a/setup.py b/setup.py index 12053cd..e01e2a9 100644 --- a/setup.py +++ b/setup.py @@ -28,17 +28,17 @@ "Development Status :: 5 - Production/Stable", "Framework :: Django", "Framework :: Django :: 4.2", - "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", + "Framework :: Django :: 5.2", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ], ) diff --git a/tests/core/settings.py b/tests/core/settings.py index c3d3e52..6943d37 100644 --- a/tests/core/settings.py +++ b/tests/core/settings.py @@ -95,8 +95,6 @@ USE_I18N = True -USE_L10N = True - USE_TZ = True FUTURE_TASK_TYPE_ONE = "task_one" @@ -116,3 +114,5 @@ STATIC_URL = "/static/" ROOT_URLCONF = "core.urls" STATIC_ROOT = os.path.join(BASE_DIR, "static") + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/tests/manage.py b/tests/manage.py index 9de3ae6..217f50c 100644 --- a/tests/manage.py +++ b/tests/manage.py @@ -12,11 +12,11 @@ # exceptions on Python 2. try: import django # noqa: F401 - except ImportError: + except ImportError as exception: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?", - ) + ) from exception raise execute_from_command_line(sys.argv) diff --git a/tests/testapp/handlers.py b/tests/testapp/handlers.py index 11b91c2..ae2676a 100644 --- a/tests/testapp/handlers.py +++ b/tests/testapp/handlers.py @@ -4,8 +4,8 @@ from django.dispatch import receiver -from django_future_tasks.handlers import future_task_signal from core import settings +from django_future_tasks.handlers import future_task_signal @receiver(future_task_signal, sender=intern(settings.FUTURE_TASK_TYPE_ONE)) diff --git a/tests/testapp/mixins.py b/tests/testapp/mixins.py index 68cd35a..b19506c 100644 --- a/tests/testapp/mixins.py +++ b/tests/testapp/mixins.py @@ -1,3 +1,4 @@ +import logging from threading import Thread from django.core.management import call_command @@ -10,6 +11,8 @@ Command as ProcessTasksCommand, ) +logger = logging.getLogger(__name__) + class TestThread(Thread): def run(self): @@ -23,7 +26,7 @@ def setUpClass(cls): assert not hasattr(cls, "command_instance") or cls.command_instance is None, ( "process_future_tasks has already been started" ) - print("Starting process_future_tasks...") + logger.info("Starting process_future_tasks...") cls.command_instance = ProcessTasksCommand() cls.thread = TestThread(target=call_command, args=(cls.command_instance,)) @@ -35,7 +38,7 @@ def tearDownClass(cls): assert cls.command_instance is not None, ( "process_future_tasks has not been started and can therefore not be stopped" ) - print("Stopping process_future_tasks...") + logger.info("Stopping process_future_tasks...") super().tearDownClass() cls.command_instance._handle_termination() @@ -48,7 +51,7 @@ def setUpClass(cls): assert not hasattr(cls, "command_instance") or cls.command_instance is None, ( "populate_periodic_future_tasks has already been started" ) - print("Starting populate_periodic_future_tasks...") + logger.info("Starting populate_periodic_future_tasks...") cls.command_instance = PopulatePeriodicTasksCommand() cls.thread = TestThread(target=call_command, args=(cls.command_instance,)) @@ -60,7 +63,7 @@ def tearDownClass(cls): assert cls.command_instance is not None, ( "populate_periodic_future_tasks has not been started and can therefore not be stopped" ) - print("Stopping populate_periodic_future_tasks...") + logger.info("Stopping populate_periodic_future_tasks...") super().tearDownClass() cls.command_instance._handle_termination() diff --git a/tests/testapp/tests/test_future_tasks.py b/tests/testapp/tests/test_future_tasks.py index 2a025e0..3e44f19 100644 --- a/tests/testapp/tests/test_future_tasks.py +++ b/tests/testapp/tests/test_future_tasks.py @@ -1,3 +1,4 @@ +import logging import os import signal import time @@ -9,10 +10,12 @@ from django.test import TestCase, TransactionTestCase from django.utils import timezone -from django_future_tasks.models import FutureTask from core import settings +from django_future_tasks.models import FutureTask from testapp.mixins import ProcessTasksCommandMixin +logger = logging.getLogger(__name__) + class WaitForTaskStatusTimeout(Exception): pass @@ -67,7 +70,7 @@ def test_process_future_tasks_error(self): eta=timezone.now(), type=settings.FUTURE_TASK_TYPE_ERROR, ) - print(FutureTask.objects.all()) + logger.info(FutureTask.objects.all()) self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_OPEN) _wait_for_task_status(task, FutureTask.FUTURE_TASK_STATUS_ERROR) self.assertEqual(task.result["args"], ["task error"]) diff --git a/tests/testapp/tests/test_periodic_future_tasks.py b/tests/testapp/tests/test_periodic_future_tasks.py index b09ee5b..8ca1db1 100644 --- a/tests/testapp/tests/test_periodic_future_tasks.py +++ b/tests/testapp/tests/test_periodic_future_tasks.py @@ -6,8 +6,8 @@ from django.test import TransactionTestCase from django.utils import timezone -from django_future_tasks.models import FutureTask, PeriodicFutureTask from core import settings +from django_future_tasks.models import FutureTask, PeriodicFutureTask from testapp.mixins import PopulatePeriodicTaskCommandMixin SLEEP_TIME = 1.8 From 376878ad4a6e707e5b9636fcd992fd73d3bc493b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20B=C3=BCrmann?= Date: Fri, 17 Oct 2025 16:48:19 +0200 Subject: [PATCH 3/3] Improve process_future_tasks command --- CHANGELOG.md | 7 + .../commands/process_future_tasks.py | 126 +++++----- tests/testapp/handlers.py | 2 +- tests/testapp/mixins.py | 28 --- tests/testapp/tests/test_future_tasks.py | 233 +++++++++++------- tests/testapp/tests/utils.py | 19 ++ 6 files changed, 232 insertions(+), 183 deletions(-) create mode 100644 tests/testapp/tests/utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3687fe6..ae62fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use `timezone.now()` instead of `datetime.now()` in `PeriodicFutureTask.save()`. - Use `condition` instead of `check` attribute in `CheckConstraint` with `django>=5.1`. +### Changed + +- Improve `process_future_tasks` command. + - Terminate after processing the current task instead of the current task batch if SIGINT/SIGTERM is received. + - Remove unnecessary waiting for new tasks when there are already tasks that can be processed. + - Configurable waiting duration for new tasks. + ### Added - Support for Python 3.14. diff --git a/django_future_tasks/management/commands/process_future_tasks.py b/django_future_tasks/management/commands/process_future_tasks.py index 40a43e1..792dd07 100644 --- a/django_future_tasks/management/commands/process_future_tasks.py +++ b/django_future_tasks/management/commands/process_future_tasks.py @@ -21,99 +21,103 @@ class Command(BaseCommand): current_task_pk = None - def add_arguments(self, parser): - parser.add_argument( - "--onetimerun", - action="append", - type=bool, - default=False, - help="Run command only one times", - ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # The command will run as long as the `_running` attribute is + # set to `True`. To safely quit the command, just set this attribute to `False` and the + # command will finish a running tick and quit afterwards. + self._running = True + + # Register system signal handler to gracefully quit the service when + # getting a `SIGINT` or `SIGTERM` signal (e.g. by CTRL+C). + signal.signal(signal.SIGINT, self._handle_termination) + signal.signal(signal.SIGTERM, self._handle_termination) def _handle_termination(self, *args, **kwargs): + # Mark the task as interrupted in case the command will receive a SIGKILL before the task was completed. + # If the command terminates graciously instead, the task will be finished and marked as done again by the + # main loop. try: current_task = FutureTask.objects.get(pk=self.current_task_pk) current_task.status = FutureTask.FUTURE_TASK_STATUS_INTERRUPTED current_task.save() except FutureTask.DoesNotExist: pass + self._running = False def _handle_options(self, options): - self.tick = 1 - self.one_time_run = options["onetimerun"] + self.one_time_run = options["one_time_run"] + self.wait_for_tasks_duration_seconds = options["wait_for_tasks_duration_seconds"] - @staticmethod - def tasks_for_processing(): + def _get_open_tasks(self): return FutureTask.objects.filter( eta__lte=timezone.now(), status=FutureTask.FUTURE_TASK_STATUS_OPEN, ).order_by("eta") + def _endless_task_iterator(self): + while self._running: + tasks = self._get_open_tasks() + yield from tasks + if not tasks: + time.sleep(self.wait_for_tasks_duration_seconds) + @staticmethod def _convert_exception_args(args): return [str(arg) for arg in args] - def handle_tick(self): - task_list = self.tasks_for_processing() - logger.debug(f"Got {len(task_list)} tasks for processing") + def _handle_task(self, task): + task.status = FutureTask.FUTURE_TASK_STATUS_IN_PROGRESS + task.save() + self.current_task_pk = task.pk + try: + start_time = timeit.default_timer() + future_task_signal.send(sender=intern(task.type), instance=task) + task.execution_time = timeit.default_timer() - start_time + task.status = FutureTask.FUTURE_TASK_STATUS_DONE + except Exception as exception: + task.status = FutureTask.FUTURE_TASK_STATUS_ERROR + task.result = { + "exception": f"An exception of type {type(exception).__name__} occurred.", + "args": self._convert_exception_args(exception.args), + "traceback": traceback.format_exception( + *sys.exc_info(), + limit=None, + chain=None, + ), + } + logger.exception(exception) + self.current_task_pk = None + task.save() - for task in task_list: - task.status = FutureTask.FUTURE_TASK_STATUS_IN_PROGRESS - task.save() - self.current_task_pk = task.pk - try: - start_time = timeit.default_timer() - future_task_signal.send(sender=intern(task.type), instance=task) - task.execution_time = timeit.default_timer() - start_time - task.status = FutureTask.FUTURE_TASK_STATUS_DONE - except Exception as exc: - task.status = FutureTask.FUTURE_TASK_STATUS_ERROR - task.result = { - "exception": f"An exception of type {type(exc).__name__} occurred.", - "args": self._convert_exception_args(exc.args), - "traceback": traceback.format_exception( - *sys.exc_info(), - limit=None, - chain=None, - ), - } - logger.exception(exc) - self.current_task_pk = None - task.save() - - time.sleep(self.tick) + def add_arguments(self, parser): + parser.add_argument( + "--one-time-run", + action="store_true", + help="Process tasks that are open at the time of running the command and exit.", + ) + parser.add_argument( + "--wait-for-tasks-duration-seconds", + type=float, + default=1.0, + help="If there are no open tasks the command waits this amount of time until it checks for open tasks again.", + ) def handle(self, *args, **options): # Load given options. self._handle_options(options) - + tasks = iter(self._get_open_tasks()) if self.one_time_run else self._endless_task_iterator() while self._running: - time.sleep(self.tick) - try: - self.handle_tick() - if self.one_time_run: - break - + self._handle_task(next(tasks)) + except StopIteration: + break except Exception as exc: logger.exception( f"{exc.__class__.__name__} exception occurred...", ) - # As the database connection might have failed, we discard it here, so django will # create a new one on the next database access. db.close_old_connections() - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # The command will run as long as the `_running` attribute is - # set to `True`. To safely quit the command, just set this attribute to `False` and the - # command will finish a running tick and quit afterwards. - self._running = True - - # Register system signal handler to gracefully quit the service when - # getting a `SIGINT` or `SIGTERM` signal (e.g. by CTRL+C). - signal.signal(signal.SIGINT, self._handle_termination) - signal.signal(signal.SIGTERM, self._handle_termination) diff --git a/tests/testapp/handlers.py b/tests/testapp/handlers.py index ae2676a..a2a39de 100644 --- a/tests/testapp/handlers.py +++ b/tests/testapp/handlers.py @@ -25,7 +25,7 @@ def my_task_function_error(sender, instance, **kwargs): @receiver(future_task_signal, sender=intern(settings.FUTURE_TASK_TYPE_INTERRUPTION)) def my_task_function_interruption(sender, instance, **kwargs): - time.sleep(10) + time.sleep(2) @receiver(future_task_signal, sender=intern(settings.FUTURE_TASK_TYPE_ETA_ORDERING)) diff --git a/tests/testapp/mixins.py b/tests/testapp/mixins.py index b19506c..13e955f 100644 --- a/tests/testapp/mixins.py +++ b/tests/testapp/mixins.py @@ -7,9 +7,6 @@ from django_future_tasks.management.commands.populate_periodic_future_tasks import ( Command as PopulatePeriodicTasksCommand, ) -from django_future_tasks.management.commands.process_future_tasks import ( - Command as ProcessTasksCommand, -) logger = logging.getLogger(__name__) @@ -20,31 +17,6 @@ def run(self): connection.close() -class ProcessTasksCommandMixin: - @classmethod - def setUpClass(cls): - assert not hasattr(cls, "command_instance") or cls.command_instance is None, ( - "process_future_tasks has already been started" - ) - logger.info("Starting process_future_tasks...") - - cls.command_instance = ProcessTasksCommand() - cls.thread = TestThread(target=call_command, args=(cls.command_instance,)) - cls.thread.start() - super().setUpClass() - - @classmethod - def tearDownClass(cls): - assert cls.command_instance is not None, ( - "process_future_tasks has not been started and can therefore not be stopped" - ) - logger.info("Stopping process_future_tasks...") - - super().tearDownClass() - cls.command_instance._handle_termination() - cls.thread.join() - - class PopulatePeriodicTaskCommandMixin: @classmethod def setUpClass(cls): diff --git a/tests/testapp/tests/test_future_tasks.py b/tests/testapp/tests/test_future_tasks.py index 3e44f19..9c46073 100644 --- a/tests/testapp/tests/test_future_tasks.py +++ b/tests/testapp/tests/test_future_tasks.py @@ -5,14 +5,14 @@ from datetime import timedelta from timeit import default_timer +import pytest import time_machine from django.core.management import call_command -from django.test import TestCase, TransactionTestCase from django.utils import timezone from core import settings from django_future_tasks.models import FutureTask -from testapp.mixins import ProcessTasksCommandMixin +from testapp.tests.utils import ProcessFutureTasksWorker logger = logging.getLogger(__name__) @@ -21,7 +21,7 @@ class WaitForTaskStatusTimeout(Exception): pass -def _wait_for_task_status(task, status, tick_seconds=0.1, timeout_seconds=3): +def _wait_for_task_status(task, status, tick_seconds: float = 0.15, timeout_seconds: float = 3.0): start_time = default_timer() while task.status != status: if default_timer() - start_time >= timeout_seconds: @@ -32,136 +32,183 @@ def _wait_for_task_status(task, status, tick_seconds=0.1, timeout_seconds=3): time.sleep(tick_seconds) -class TestProcessFutureTasks(ProcessTasksCommandMixin, TransactionTestCase): +@pytest.mark.django_db(transaction=True) +class TestWorker: @time_machine.travel("2024-01-01 00:00 +0000", tick=False) - def test_process_future_tasks_eta_now(self): - start_time = default_timer() - task = FutureTask.objects.create( - task_id="task", - eta=timezone.now(), - type=settings.FUTURE_TASK_TYPE_ONE, - ) - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_OPEN) - _wait_for_task_status(task, FutureTask.FUTURE_TASK_STATUS_DONE) - end_time = default_timer() - self.assertIsNotNone(task.execution_time) - self.assertGreater(task.execution_time, 0.0) - self.assertLess(task.execution_time, end_time - start_time) + def test_eta_now(self): + with ProcessFutureTasksWorker(): + start_time = default_timer() + task = FutureTask.objects.create( + task_id="task", + eta=timezone.now(), + type=settings.FUTURE_TASK_TYPE_ONE, + ) + assert task.status == FutureTask.FUTURE_TASK_STATUS_OPEN + _wait_for_task_status(task, FutureTask.FUTURE_TASK_STATUS_DONE) + end_time = default_timer() + assert task.execution_time is not None + assert task.execution_time > 0.0 + assert task.execution_time < end_time - start_time @time_machine.travel("2024-01-01 00:00 +0000", tick=False) - def test_process_future_tasks_eta_future(self): - task = FutureTask.objects.create( - task_id="task", - eta=timezone.now() + timedelta(microseconds=1), - type=settings.FUTURE_TASK_TYPE_TWO, - ) - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_OPEN) - try: - _wait_for_task_status(task, FutureTask.FUTURE_TASK_STATUS_DONE) - except WaitForTaskStatusTimeout: - pass - task.refresh_from_db() - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_OPEN) + def test_eta_future(self): + with ProcessFutureTasksWorker(): + task = FutureTask.objects.create( + task_id="task", + eta=timezone.now() + timedelta(microseconds=1), + type=settings.FUTURE_TASK_TYPE_TWO, + ) + assert task.status == FutureTask.FUTURE_TASK_STATUS_OPEN + try: + _wait_for_task_status(task, FutureTask.FUTURE_TASK_STATUS_DONE) + except WaitForTaskStatusTimeout: + pass + task.refresh_from_db() + assert task.status == FutureTask.FUTURE_TASK_STATUS_OPEN @time_machine.travel("2024-01-01 00:00 +0000", tick=False) - def test_process_future_tasks_error(self): - task = FutureTask.objects.create( - task_id="task", - eta=timezone.now(), - type=settings.FUTURE_TASK_TYPE_ERROR, - ) - logger.info(FutureTask.objects.all()) - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_OPEN) - _wait_for_task_status(task, FutureTask.FUTURE_TASK_STATUS_ERROR) - self.assertEqual(task.result["args"], ["task error"]) + def test_error(self): + with ProcessFutureTasksWorker(): + task = FutureTask.objects.create( + task_id="task", + eta=timezone.now(), + type=settings.FUTURE_TASK_TYPE_ERROR, + ) + logger.info(FutureTask.objects.all()) + assert task.status == FutureTask.FUTURE_TASK_STATUS_OPEN + _wait_for_task_status(task, FutureTask.FUTURE_TASK_STATUS_ERROR) + assert task.result["args"] == ["task error"] @time_machine.travel("2024-01-01 00:00 +0000", tick=True) - def test_process_future_tasks_eta_ordering(self): - _now = timezone.now() - task_late = FutureTask.objects.create( - task_id="task_late", - eta=_now, - type=settings.FUTURE_TASK_TYPE_ETA_ORDERING, - ) - task_early = FutureTask.objects.create( - task_id="task_early", - eta=_now - timedelta(microseconds=1), - type=settings.FUTURE_TASK_TYPE_ETA_ORDERING, - ) - self.assertEqual(task_late.status, FutureTask.FUTURE_TASK_STATUS_OPEN) - self.assertEqual(task_early.status, FutureTask.FUTURE_TASK_STATUS_OPEN) - _wait_for_task_status(task_late, FutureTask.FUTURE_TASK_STATUS_DONE) - _wait_for_task_status(task_early, FutureTask.FUTURE_TASK_STATUS_DONE) - self.assertGreater(task_late.result, task_early.result) + def test_eta_ordering(self): + with ProcessFutureTasksWorker(): + _now = timezone.now() + task_late = FutureTask.objects.create( + task_id="task_late", + eta=_now, + type=settings.FUTURE_TASK_TYPE_ETA_ORDERING, + ) + task_early = FutureTask.objects.create( + task_id="task_early", + eta=_now - timedelta(microseconds=1), + type=settings.FUTURE_TASK_TYPE_ETA_ORDERING, + ) + assert task_late.status == FutureTask.FUTURE_TASK_STATUS_OPEN + assert task_early.status == FutureTask.FUTURE_TASK_STATUS_OPEN + _wait_for_task_status(task_late, FutureTask.FUTURE_TASK_STATUS_DONE) + _wait_for_task_status(task_early, FutureTask.FUTURE_TASK_STATUS_DONE) + assert task_late.result > task_early.result + @time_machine.travel("2024-01-01 00:00 +0000", tick=False) + def test_interruption(self): + with ProcessFutureTasksWorker(): + task_1 = FutureTask.objects.create( + task_id="task-1", + eta=timezone.now() - timedelta(microseconds=1), + type=settings.FUTURE_TASK_TYPE_INTERRUPTION, + ) + task_2 = FutureTask.objects.create( + task_id="task-2", + eta=timezone.now(), + type=settings.FUTURE_TASK_TYPE_ONE, + ) + assert task_1.status == FutureTask.FUTURE_TASK_STATUS_OPEN + _wait_for_task_status(task_1, FutureTask.FUTURE_TASK_STATUS_IN_PROGRESS) + pid = os.getpid() + os.kill(pid, signal.SIGINT) + # The command prepares for a potential SIGKILL and sets the status to interrupted. + _wait_for_task_status(task_1, FutureTask.FUTURE_TASK_STATUS_INTERRUPTED) + # The command finishes graciously and therefore also the task finishes graciously. + _wait_for_task_status(task_1, FutureTask.FUTURE_TASK_STATUS_DONE) + # The command does not start any tasks after finishing the current task. + with pytest.raises(WaitForTaskStatusTimeout): + _wait_for_task_status(task_2, FutureTask.FUTURE_TASK_STATUS_DONE, timeout_seconds=1.0) + task_2.refresh_from_db() + assert task_2.status == FutureTask.FUTURE_TASK_STATUS_OPEN -class TestProcessFutureTasksInterruption(ProcessTasksCommandMixin, TransactionTestCase): @time_machine.travel("2024-01-01 00:00 +0000", tick=False) - def test_future_task_process_interruption(self): - task = FutureTask.objects.create( - task_id="task", - eta=timezone.now(), - type=settings.FUTURE_TASK_TYPE_INTERRUPTION, - ) - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_OPEN) - _wait_for_task_status(task, FutureTask.FUTURE_TASK_STATUS_IN_PROGRESS) - pid = os.getpid() - os.kill(pid, signal.SIGINT) + def test_short_wait_for_tasks_duration(self): + with ProcessFutureTasksWorker("--wait-for-tasks-duration=0.5"): + # Small delay to let the command run into the waiting mechanism. + time.sleep(0.1) + task = FutureTask.objects.create( + task_id="task", + eta=timezone.now(), + type=settings.FUTURE_TASK_TYPE_ONE, + ) + # Wait for the task to process. + time.sleep(0.6) + task.refresh_from_db() + assert task.status == FutureTask.FUTURE_TASK_STATUS_DONE + + @time_machine.travel("2024-01-01 00:00 +0000", tick=False) + def test_long_wait_for_tasks_duration(self): + with ProcessFutureTasksWorker("--wait-for-tasks-duration=3.0"): + # Small delay to let the command run into the waiting mechanism. + time.sleep(0.1) + task = FutureTask.objects.create( + task_id="task", + eta=timezone.now(), + type=settings.FUTURE_TASK_TYPE_ONE, + ) + time.sleep(0.2) + # Worker has been terminated while waiting for tasks. The task is still open. task.refresh_from_db() - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_INTERRUPTED) + assert task.status == FutureTask.FUTURE_TASK_STATUS_OPEN -class TestFutureTasksOnetimeRun(TestCase): +@pytest.mark.django_db(transaction=True) +class TestOnetimeRun: @time_machine.travel("2024-01-01 00:00 +0000", tick=False) - def test_process_future_tasks_onetimerun_no_task(self): - call_command("process_future_tasks", onetimerun=True) + def test_no_task(self): + call_command("process_future_tasks", one_time_run=True) @time_machine.travel("2024-01-01 00:00 +0000", tick=False) - def test_process_future_tasks_onetimerun_eta_now(self): + def test_eta_now(self): start_time = default_timer() task = FutureTask.objects.create( task_id="task", eta=timezone.now(), type=settings.FUTURE_TASK_TYPE_ONE, ) - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_OPEN) - call_command("process_future_tasks", onetimerun=True) + assert task.status == FutureTask.FUTURE_TASK_STATUS_OPEN + call_command("process_future_tasks", one_time_run=True) end_time = default_timer() task.refresh_from_db() - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_DONE) - self.assertIsNotNone(task.execution_time) - self.assertGreater(task.execution_time, 0.0) - self.assertLess(task.execution_time, end_time - start_time) + assert task.status == FutureTask.FUTURE_TASK_STATUS_DONE + assert task.execution_time is not None + assert task.execution_time > 0.0 + assert task.execution_time < end_time - start_time @time_machine.travel("2024-01-01 00:00 +0000", tick=False) - def test_process_future_tasks_onetimerun_eta_future(self): + def test_eta_future(self): _now = timezone.now() task = FutureTask.objects.create( task_id="task", eta=_now + timedelta(microseconds=1), type=settings.FUTURE_TASK_TYPE_TWO, ) - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_OPEN) - call_command("process_future_tasks", onetimerun=True) + assert task.status == FutureTask.FUTURE_TASK_STATUS_OPEN + call_command("process_future_tasks", one_time_run=True) task.refresh_from_db() - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_OPEN) + assert task.status == FutureTask.FUTURE_TASK_STATUS_OPEN @time_machine.travel("2024-01-01 00:00 +0000", tick=False) - def test_process_future_tasks_onetimerun_error(self): + def test_error(self): _now = timezone.now() task = FutureTask.objects.create( task_id="task", eta=_now, type=settings.FUTURE_TASK_TYPE_ERROR, ) - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_OPEN) - call_command("process_future_tasks", onetimerun=True) + assert task.status == FutureTask.FUTURE_TASK_STATUS_OPEN + call_command("process_future_tasks", one_time_run=True) task.refresh_from_db() - self.assertEqual(task.status, FutureTask.FUTURE_TASK_STATUS_ERROR) - self.assertEqual(task.result["args"], ["task error"]) + assert task.status == FutureTask.FUTURE_TASK_STATUS_ERROR + assert task.result["args"] == ["task error"] @time_machine.travel("2024-01-01 00:00 +0000", tick=True) - def test_process_future_tasks_onetimerun_eta_ordering(self): + def test_eta_ordering(self): _now = timezone.now() task_late = FutureTask.objects.create( task_id="task_late", @@ -173,9 +220,9 @@ def test_process_future_tasks_onetimerun_eta_ordering(self): eta=_now - timedelta(microseconds=1), type=settings.FUTURE_TASK_TYPE_ETA_ORDERING, ) - self.assertEqual(task_late.status, FutureTask.FUTURE_TASK_STATUS_OPEN) - self.assertEqual(task_early.status, FutureTask.FUTURE_TASK_STATUS_OPEN) - call_command("process_future_tasks", onetimerun=True) + assert task_late.status == FutureTask.FUTURE_TASK_STATUS_OPEN + assert task_early.status == FutureTask.FUTURE_TASK_STATUS_OPEN + call_command("process_future_tasks", one_time_run=True) task_late.refresh_from_db() task_early.refresh_from_db() - self.assertGreater(task_late.result, task_early.result) + assert task_late.result > task_early.result diff --git a/tests/testapp/tests/utils.py b/tests/testapp/tests/utils.py new file mode 100644 index 0000000..1eb0764 --- /dev/null +++ b/tests/testapp/tests/utils.py @@ -0,0 +1,19 @@ +from django.core.management import call_command + +from django_future_tasks.management.commands.process_future_tasks import ( + Command as ProcessTasksCommand, +) +from testapp.mixins import TestThread + + +class ProcessFutureTasksWorker: + def __init__(self, *args: str): + self.command_instance = ProcessTasksCommand() + self.thread = TestThread(target=call_command, args=(self.command_instance, *args)) + + def __enter__(self): + self.thread.start() + + def __exit__(self, exc_type, exc_val, exc_tb): + self.command_instance._handle_termination() + self.thread.join()