From bd586fe7b27bfeeb272efaf9b39fa94c245c948f Mon Sep 17 00:00:00 2001 From: Joren Date: Fri, 14 Jul 2023 21:05:02 +0200 Subject: [PATCH 01/16] fixed undocumented `django-cron` requirement --- django_cron/cron.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/django_cron/cron.py b/django_cron/cron.py index 8d65f49..60d24b9 100644 --- a/django_cron/cron.py +++ b/django_cron/cron.py @@ -1,6 +1,5 @@ from django.conf import settings - -from django_common.helper import send_mail +from django.core.mail import send_mail from django_cron import CronJobBase, Schedule, get_class from django_cron.models import CronJobLog @@ -44,13 +43,12 @@ def do(self): if failures >= min_failures: send_mail( - '%s%s failed %s times in a row!' - % ( + '%s%s failed %s times in a row!' % ( failed_runs_cronjob_email_prefix, cron.code, min_failures, ), message, - settings.DEFAULT_FROM_EMAIL, + None, emails, ) From d30600fedb1ea986e8d7ce97d20a139248f0e024 Mon Sep 17 00:00:00 2001 From: jorenham Date: Sun, 28 Jul 2024 18:24:30 +0200 Subject: [PATCH 02/16] modernized project structure and test fixes --- .coveragerc | 2 - .github/workflows/django.yml | 42 ++--- .travis.yml | 46 ----- AUTHORS | 1 - MANIFEST.in | 4 - demo/requirements.txt | 2 - django_cron/core.py | 12 +- django_cron/test/__init__.py | 0 test_crons.py => django_cron/test/cron.py | 0 .../test/settings.py | 97 +++++++---- test_urls.py => django_cron/test/urls.py | 4 +- django_cron/tests.py | 145 ++++++++-------- flake8 | 4 - helpers.py | 32 ---- poetry.lock | 164 ++++++++++++++++++ pyproject.toml | 33 ++++ runtests.py | 27 --- settings_mysql.py | 11 -- settings_postgres.py | 11 -- settings_sqllite.py | 10 -- setup.py | 42 ----- test_requirements.txt | 4 - testmanage.py | 18 ++ 23 files changed, 376 insertions(+), 335 deletions(-) delete mode 100644 .coveragerc delete mode 100644 .travis.yml delete mode 100644 AUTHORS delete mode 100644 MANIFEST.in delete mode 100644 demo/requirements.txt create mode 100644 django_cron/test/__init__.py rename test_crons.py => django_cron/test/cron.py (100%) rename settings_base.py => django_cron/test/settings.py (57%) rename test_urls.py => django_cron/test/urls.py (53%) delete mode 100644 flake8 delete mode 100644 helpers.py create mode 100644 poetry.lock create mode 100644 pyproject.toml delete mode 100644 runtests.py delete mode 100644 settings_mysql.py delete mode 100644 settings_postgres.py delete mode 100644 settings_sqllite.py delete mode 100644 setup.py delete mode 100644 test_requirements.txt create mode 100755 testmanage.py diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 6bfd21b..0000000 --- a/.coveragerc +++ /dev/null @@ -1,2 +0,0 @@ -[report] -omit = */migrations/* diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 747f8cc..eaeab95 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -1,33 +1,35 @@ -name: Django CI +name: CI on: push: - branches: [ master ] + branches: + - master pull_request: - branches: [ master ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true jobs: build: - runs-on: ubuntu-latest strategy: max-parallel: 4 matrix: - python-version: [3.7, 3.8, 3.9] + python: [3.8, 3.12] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install Dependencies - run: | - python -m pip install --upgrade pip - pip install . - - name: Install Test Dependencies - run: | - pip install -r test_requirements.txt - - name: Run Tests - run: | - python runtests.py runtests + - uses: actions/checkout@v4 + - run: pipx install poetry + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: poetry check + run: poetry check + - name: poetry install + run: poetry install + - name: django-admin check + run: python run python testmanage.py check + - name: django-admin test + run: python run python testmanage.py test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 64f4ee6..0000000 --- a/.travis.yml +++ /dev/null @@ -1,46 +0,0 @@ -dist: bionic -os: linux -language: python -jobs: - include: - - python: "2.7" - env: DJANGO=1.11.* DJANGO_SETTINGS_MODULE='settings_sqllite' - - python: "2.7" - env: DJANGO=1.11.* DJANGO_SETTINGS_MODULE='settings_postgres' - services: - - postgresql - before_script: - - psql -c 'create database travis_test;' -U postgres - - python: "2.7" - env: DJANGO=1.11.* DJANGO_SETTINGS_MODULE='settings_mysql' - services: - - mysql - before_script: - - mysql -e 'create database travis_test;' - - python: "3.7" - env: DJANGO=1.11.* DJANGO_SETTINGS_MODULE='settings_sqllite' - - python: "3.7" - env: DJANGO=2.0.* DJANGO_SETTINGS_MODULE='settings_sqllite' - - python: "3.7" - env: DJANGO=3.0.* DJANGO_SETTINGS_MODULE='settings_sqllite' - - python: "3.7" - env: DJANGO=3.0.* DJANGO_SETTINGS_MODULE='settings_postgres' - services: - - postgresql - before_script: - - psql -c 'create database travis_test;' -U postgres - - python: "3.7" - env: DJANGO=3.0.* DJANGO_SETTINGS_MODULE='settings_mysql' - services: - - mysql - before_script: - - mysql -e 'create database travis_test;' -install: - - pip install -q Django==$DJANGO - - pip install coveralls - - pip install -r test_requirements.txt -script: - - flake8 . --config=flake8 - - coverage run --source=django_cron setup.py test -after_success: - - coveralls \ No newline at end of file diff --git a/AUTHORS b/AUTHORS deleted file mode 100644 index 63c1c49..0000000 --- a/AUTHORS +++ /dev/null @@ -1 +0,0 @@ -http://github.com/Tivix/django-cron/contributors diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 351752d..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include AUTHORS -include LICENSE -include MANIFEST.in -include README.rst \ No newline at end of file diff --git a/demo/requirements.txt b/demo/requirements.txt deleted file mode 100644 index af315c8..0000000 --- a/demo/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Django==4.0.4 --e ../ diff --git a/django_cron/core.py b/django_cron/core.py index 9d5a7e6..d7a945b 100644 --- a/django_cron/core.py +++ b/django_cron/core.py @@ -1,12 +1,12 @@ import logging -from datetime import datetime, timedelta -import traceback -import time import sys +import time +import traceback +from datetime import datetime, timedelta from django.conf import settings -from django.utils.timezone import now as utc_now from django.db.models import Q +from django.utils.timezone import now as utc_now from django_cron.helpers import get_class, get_current_time @@ -109,11 +109,11 @@ def should_run_now(self, force=False): return True if cron_job.schedule.run_monthly_on_days is not None: - if not datetime.today().day in cron_job.schedule.run_monthly_on_days: + if datetime.today().day not in cron_job.schedule.run_monthly_on_days: return False if cron_job.schedule.run_weekly_on_days is not None: - if not datetime.today().weekday() in cron_job.schedule.run_weekly_on_days: + if datetime.today().weekday() not in cron_job.schedule.run_weekly_on_days: return False if cron_job.schedule.retry_after_failure_mins: diff --git a/django_cron/test/__init__.py b/django_cron/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_crons.py b/django_cron/test/cron.py similarity index 100% rename from test_crons.py rename to django_cron/test/cron.py diff --git a/settings_base.py b/django_cron/test/settings.py similarity index 57% rename from settings_base.py rename to django_cron/test/settings.py index ecefc99..4d887f5 100644 --- a/settings_base.py +++ b/django_cron/test/settings.py @@ -1,3 +1,11 @@ +from pathlib import Path + +PROJECT_DIR = Path(__file__).resolve().parent.parent +BASE_DIR = PROJECT_DIR.parent + +SECRET_KEY = 'not-a-secure-key' +DEBUG = True + INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -5,30 +13,58 @@ 'django.contrib.contenttypes', 'django.contrib.messages', 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.sitemaps', 'django.contrib.staticfiles', 'django_cron', ] -SECRET_KEY = "wknfgl34qtnjo&Yk3jqfjtn2k3jtnk4wtnk" - - -CRON_CLASSES = [ - 'test_crons.TestSuccessCronJob', - 'test_crons.TestErrorCronJob', - 'test_crons.Test5minsCronJob', - 'test_crons.TestRunAtTimesCronJob', - 'test_crons.Wait3secCronJob', - 'django_cron.cron.FailedRunsNotificationCronJob', -] - MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'django_cron.test.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, ] +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + }, +} + +# since django is meant for developers with deadlines... +PASSWORD_HASHERS = ('django.contrib.auth.hashers.MD5PasswordHasher',) + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'USER': 'djangocron', + 'NAME': 'djangocron', + 'TEST_NAME': 'djangocron_test', + } +} + +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + LOGGING = { 'version': 1, 'disable_existing_loggers': False, @@ -47,29 +83,14 @@ }, } -CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - 'LOCATION': 'django_cache', - } -} +STATIC_URL = '/static/' -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, +_CRON_PATH = 'django_cron.test.cron.' +CRON_CLASSES = [ + f'{_CRON_PATH}TestSuccessCronJob', + f'{_CRON_PATH}TestErrorCronJob', + f'{_CRON_PATH}Test5minsCronJob', + f'{_CRON_PATH}TestRunAtTimesCronJob', + f'{_CRON_PATH}Wait3secCronJob', + 'django_cron.cron.FailedRunsNotificationCronJob', ] - -ROOT_URLCONF = 'test_urls' -SITE_ID = 1 -STATIC_URL = '/static/' diff --git a/test_urls.py b/django_cron/test/urls.py similarity index 53% rename from test_urls.py rename to django_cron/test/urls.py index def9be0..82a6152 100644 --- a/test_urls.py +++ b/django_cron/test/urls.py @@ -1,9 +1,9 @@ # urls.py from django.contrib import admin -from django.urls import re_path +from django.urls import path admin.autodiscover() urlpatterns = [ - re_path(r'^admin/', admin.site.urls), + path('admin/', admin.site.urls), ] diff --git a/django_cron/tests.py b/django_cron/tests.py index 9691b4f..7728515 100644 --- a/django_cron/tests.py +++ b/django_cron/tests.py @@ -1,23 +1,26 @@ import datetime import threading -from time import sleep from datetime import timedelta +from time import sleep from unittest import skip -from mock import patch -from freezegun import freeze_time - from django import db -from django.test import TransactionTestCase +from django.contrib.auth.models import User from django.core.management import call_command -from django.test.utils import override_settings +from django.test import TransactionTestCase from django.test.client import Client +from django.test.utils import override_settings from django.urls import reverse -from django.contrib.auth.models import User +from freezegun import freeze_time +from mock import patch from django_cron.helpers import humanize_duration -from django_cron.models import CronJobLog, CronJobLock -import test_crons +from django_cron.models import CronJobLock, CronJobLog + +from .test import cron + +_MAIN_CRON_MODULE = 'django_cron.cron' +_TEST_CRON_MODULE = 'django_cron.test.cron' class OutBuffer(object): @@ -48,20 +51,18 @@ def call(command, *args, **kwargs): class TestRunCrons(TransactionTestCase): - success_cron = 'test_crons.TestSuccessCronJob' - error_cron = 'test_crons.TestErrorCronJob' - five_mins_cron = 'test_crons.Test5minsCronJob' - five_mins_with_tolerance_cron = 'test_crons.Test5minsWithToleranceCronJob' - run_at_times_cron = 'test_crons.TestRunAtTimesCronJob' - wait_3sec_cron = 'test_crons.Wait3secCronJob' - run_on_wkend_cron = 'test_crons.RunOnWeekendCronJob' - does_not_exist_cron = 'ThisCronObviouslyDoesntExist' - no_code_cron = 'test_crons.NoCodeCronJob' - test_failed_runs_notification_cron = ( - 'django_cron.cron.FailedRunsNotificationCronJob' - ) - run_on_month_days = 'test_crons.RunOnMonthDaysCronJob' - run_and_remove_old_logs = 'test_crons.RunEveryMinuteAndRemoveOldLogs' + cron_success = f'{_TEST_CRON_MODULE}.TestSuccessCronJob' + cron_error = f'{_TEST_CRON_MODULE}.TestErrorCronJob' + cron_5mins = f'{_TEST_CRON_MODULE}.Test5minsCronJob' + cron_5mins_with_tolerance = f'{_TEST_CRON_MODULE}.Test5minsWithToleranceCronJob' + cron_run_at_times = f'{_TEST_CRON_MODULE}.TestRunAtTimesCronJob' + cron_wait_3sec = f'{_TEST_CRON_MODULE}.Wait3secCronJob' + cron_run_on_weekend = f'{_TEST_CRON_MODULE}.RunOnWeekendCronJob' + cron_obviously_doesnt_exist = 'ThisCronJobDoesntExost' + cron_no_code = f'{_TEST_CRON_MODULE}.NoCodeCronJob' + cron_failed_runs_notification = f'{_MAIN_CRON_MODULE}.FailedRunsNotificationCronJob' + run_on_month_days = f'{_TEST_CRON_MODULE}.RunOnMonthDaysCronJob' + run_and_remove_old_logs = f'{_TEST_CRON_MODULE}.RunEveryMinuteAndRemoveOldLogs' def _call(self, *args, **kwargs): return call('runcrons', *args, **kwargs) @@ -82,23 +83,23 @@ def assertReportedFail(self, job_cls, response): self.assertIn(expected_log, response) def test_success_cron(self): - self._call(self.success_cron, force=True) + self._call(self.cron_success, force=True) self.assertEqual(CronJobLog.objects.all().count(), 1) def test_failed_cron(self): - response = self._call(self.error_cron, force=True) - self.assertReportedFail(test_crons.TestErrorCronJob, response) + response = self._call(self.cron_error, force=True) + self.assertReportedFail(cron.TestErrorCronJob, response) self.assertEqual(CronJobLog.objects.all().count(), 1) def test_not_exists_cron(self): - response = self._call(self.does_not_exist_cron, force=True) + response = self._call(self.cron_obviously_doesnt_exist, force=True) self.assertIn('Make sure these are valid cron class names', response) - self.assertIn(self.does_not_exist_cron, response) + self.assertIn(self.cron_obviously_doesnt_exist, response) self.assertEqual(CronJobLog.objects.all().count(), 0) @patch('django_cron.core.logger') def test_requires_code(self, mock_logger): - response = self._call(self.no_code_cron, force=True) + response = self._call(self.cron_no_code, force=True) self.assertIn('does not have a code attribute', response) mock_logger.info.assert_called() @@ -106,7 +107,7 @@ def test_requires_code(self, mock_logger): DJANGO_CRON_LOCK_BACKEND='django_cron.backends.lock.file.FileLock' ) def test_file_locking_backend(self): - self._call(self.success_cron, force=True) + self._call(self.cron_success, force=True) self.assertEqual(CronJobLog.objects.all().count(), 1) @override_settings( @@ -116,23 +117,23 @@ def test_database_locking_backend(self): # TODO: to test it properly we would need to run multiple jobs at the same time cron_job_locks = CronJobLock.objects.all().count() for _ in range(3): - self._call(self.success_cron, force=True) + self._call(self.cron_success, force=True) self.assertEqual(CronJobLog.objects.all().count(), 3) self.assertEqual(CronJobLock.objects.all().count(), cron_job_locks + 1) self.assertEqual(CronJobLock.objects.first().locked, False) - @patch.object(test_crons.TestSuccessCronJob, 'do') + @patch.object(cron.TestSuccessCronJob, 'do') def test_dry_run_does_not_perform_task(self, mock_do): - response = self._call(self.success_cron, dry_run=True) - self.assertReportedRun(test_crons.TestSuccessCronJob, response) + response = self._call(self.cron_success, dry_run=True) + self.assertReportedRun(cron.TestSuccessCronJob, response) mock_do.assert_not_called() self.assertFalse(CronJobLog.objects.exists()) - @patch.object(test_crons.TestSuccessCronJob, 'do') + @patch.object(cron.TestSuccessCronJob, 'do') def test_non_dry_run_performs_task(self, mock_do): mock_do.return_value = 'message' - response = self._call(self.success_cron) - self.assertReportedRun(test_crons.TestSuccessCronJob, response) + response = self._call(self.cron_success) + self.assertReportedRun(cron.TestSuccessCronJob, response) mock_do.assert_called_once() self.assertEqual(1, CronJobLog.objects.count()) log = CronJobLog.objects.get() @@ -143,66 +144,66 @@ def test_non_dry_run_performs_task(self, mock_do): def test_runs_every_mins(self): with freeze_time("2014-01-01 00:00:00"): - response = self._call(self.five_mins_cron) - self.assertReportedRun(test_crons.Test5minsCronJob, response) + response = self._call(self.cron_5mins) + self.assertReportedRun(cron.Test5minsCronJob, response) self.assertEqual(CronJobLog.objects.all().count(), 1) with freeze_time("2014-01-01 00:04:59"): - response = self._call(self.five_mins_cron) - self.assertReportedNoRun(test_crons.Test5minsCronJob, response) + response = self._call(self.cron_5mins) + self.assertReportedNoRun(cron.Test5minsCronJob, response) self.assertEqual(CronJobLog.objects.all().count(), 1) with freeze_time("2014-01-01 00:05:01"): - response = self._call(self.five_mins_cron) - self.assertReportedRun(test_crons.Test5minsCronJob, response) + response = self._call(self.cron_5mins) + self.assertReportedRun(cron.Test5minsCronJob, response) self.assertEqual(CronJobLog.objects.all().count(), 2) def test_runs_every_mins_with_tolerance(self): with freeze_time("2014-01-01 00:00:00"): - call_command('runcrons', self.five_mins_with_tolerance_cron) + call_command('runcrons', self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 1) with freeze_time("2014-01-01 00:04:59"): - call_command('runcrons', self.five_mins_with_tolerance_cron) + call_command('runcrons', self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 2) with freeze_time("2014-01-01 00:05:01"): - call_command('runcrons', self.five_mins_with_tolerance_cron) + call_command('runcrons', self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 2) with freeze_time("2014-01-01 00:09:40"): - call_command('runcrons', self.five_mins_with_tolerance_cron) + call_command('runcrons', self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 2) with freeze_time("2014-01-01 00:09:54"): - call_command('runcrons', self.five_mins_with_tolerance_cron) + call_command('runcrons', self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 2) with freeze_time("2014-01-01 00:09:55"): - call_command('runcrons', self.five_mins_with_tolerance_cron) + call_command('runcrons', self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 3) def test_runs_at_time(self): with freeze_time("2014-01-01 00:00:01"): - response = self._call(self.run_at_times_cron) - self.assertReportedRun(test_crons.TestRunAtTimesCronJob, response) + response = self._call(self.cron_run_at_times) + self.assertReportedRun(cron.TestRunAtTimesCronJob, response) self.assertEqual(CronJobLog.objects.all().count(), 1) with freeze_time("2014-01-01 00:04:50"): - response = self._call(self.run_at_times_cron) - self.assertReportedNoRun(test_crons.TestRunAtTimesCronJob, response) + response = self._call(self.cron_run_at_times) + self.assertReportedNoRun(cron.TestRunAtTimesCronJob, response) self.assertEqual(CronJobLog.objects.all().count(), 1) with freeze_time("2014-01-01 00:05:01"): - response = self._call(self.run_at_times_cron) - self.assertReportedRun(test_crons.TestRunAtTimesCronJob, response) + response = self._call(self.cron_run_at_times) + self.assertReportedRun(cron.TestRunAtTimesCronJob, response) self.assertEqual(CronJobLog.objects.all().count(), 2) def test_run_on_weekend(self): for test_date in ("2017-06-17", "2017-06-18"): # Saturday and Sunday logs_count = CronJobLog.objects.all().count() with freeze_time(test_date): - call_command('runcrons', self.run_on_wkend_cron) + call_command('runcrons', self.cron_run_on_weekend) self.assertEqual(CronJobLog.objects.all().count(), logs_count + 1) for test_date in ( @@ -214,7 +215,7 @@ def test_run_on_weekend(self): ): # Mon-Fri logs_count = CronJobLog.objects.all().count() with freeze_time(test_date): - call_command('runcrons', self.run_on_wkend_cron) + call_command('runcrons', self.cron_run_on_weekend) self.assertEqual(CronJobLog.objects.all().count(), logs_count) def test_run_on_month_days(self): @@ -237,23 +238,23 @@ def test_run_on_month_days(self): self.assertEqual(CronJobLog.objects.all().count(), logs_count) def test_silent_produces_no_output_success(self): - response = self._call(self.success_cron, silent=True) + response = self._call(self.cron_success, silent=True) self.assertEqual(1, CronJobLog.objects.count()) self.assertEqual('', response) def test_silent_produces_no_output_no_run(self): with freeze_time("2014-01-01 00:00:00"): - response = self._call(self.run_at_times_cron, silent=True) + response = self._call(self.cron_run_at_times, silent=True) self.assertEqual(1, CronJobLog.objects.count()) self.assertEqual('', response) with freeze_time("2014-01-01 00:00:01"): - response = self._call(self.run_at_times_cron, silent=True) + response = self._call(self.cron_run_at_times, silent=True) self.assertEqual(1, CronJobLog.objects.count()) self.assertEqual('', response) def test_silent_produces_no_output_failure(self): - response = self._call(self.error_cron, silent=True) + response = self._call(self.cron_error, silent=True) self.assertEqual('', response) def test_admin(self): @@ -263,14 +264,14 @@ def test_admin(self): self.client.login(username=user.username, password=password) # edit CronJobLog object - self._call(self.success_cron, force=True) + self._call(self.cron_success, force=True) log = CronJobLog.objects.all()[0] url = reverse('admin:django_cron_cronjoblog_change', args=(log.id,)) response = self.client.get(url) self.assertIn('Cron job logs', str(response.content)) def run_cronjob_in_thread(self, logs_count): - self._call(self.wait_3sec_cron) + self._call(self.cron_wait_3sec) self.assertEqual(CronJobLog.objects.all().count(), logs_count + 1) db.close_old_connections() @@ -283,7 +284,7 @@ def test_cache_locking_backend(self): t.start() # this shouldn't get running sleep(0.1) # to avoid race condition - self._call(self.wait_3sec_cron) + self._call(self.cron_wait_3sec) t.join(10) self.assertEqual(CronJobLog.objects.all().count(), 1) @@ -312,8 +313,8 @@ def test_failed_runs_notification(self): CronJobLog.objects.all().delete() for i in range(10): - self._call(self.error_cron, force=True) - self._call(self.test_failed_runs_notification_cron) + self._call(self.cron_error, force=True) + self._call(self.cron_failed_runs_notification) self.assertEqual(CronJobLog.objects.all().count(), 11) @@ -342,31 +343,29 @@ def test_remove_old_succeeded_job_logs(self): def test_run_job_with_logs_in_future(self): mock_date_in_future = datetime.datetime(2222, 5, 1, 12, 0, 0) with freeze_time(mock_date_in_future): - call_command('runcrons', self.five_mins_cron) + call_command('runcrons', self.cron_5mins) self.assertEqual(CronJobLog.objects.all().count(), 1) self.assertEqual(CronJobLog.objects.all().first().end_time, mock_date_in_future) mock_date_in_past = mock_date_in_future - timedelta(days=1000) with freeze_time(mock_date_in_past): - call_command('runcrons', self.five_mins_cron) + call_command('runcrons', self.cron_5mins) self.assertEqual(CronJobLog.objects.all().count(), 2) self.assertEqual(CronJobLog.objects.all().earliest('start_time').end_time, mock_date_in_past) mock_date_in_past_plus_one_min = mock_date_in_future + timedelta(minutes=1) with freeze_time(mock_date_in_past_plus_one_min): - call_command('runcrons', self.five_mins_cron) + call_command('runcrons', self.cron_5mins) self.assertEqual(CronJobLog.objects.all().count(), 2) self.assertEqual(CronJobLog.objects.all().earliest('start_time').end_time, mock_date_in_past) class TestCronLoop(TransactionTestCase): - success_cron = 'test_crons.TestSuccessCronJob' + success_cron = f'{_TEST_CRON_MODULE}.TestSuccessCronJob' def _call(self, *args, **kwargs): return call('cronloop', *args, **kwargs) def test_repeat_twice(self): - self._call( - cron_classes=[self.success_cron, self.success_cron], repeat=2, sleep=1 - ) + self._call(cron_classes=[self.success_cron, self.success_cron], repeat=2, sleep=1) self.assertEqual(CronJobLog.objects.all().count(), 4) diff --git a/flake8 b/flake8 deleted file mode 100644 index dd1cc08..0000000 --- a/flake8 +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -max-line-length = 160 -exclude = docs/*,lib/* -ignore = F403 diff --git a/helpers.py b/helpers.py deleted file mode 100644 index 6e90e3c..0000000 --- a/helpers.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.utils.translation import gettext_lazy as _ - - -def humanize_duration(duration): - """ - Returns a humanized string representing time difference - - For example: 2 days 1 hour 25 minutes 10 seconds - """ - days = duration.days - hours = duration.seconds / 3600 - minutes = duration.seconds % 3600 / 60 - seconds = duration.seconds % 3600 % 60 - - parts = [] - if days > 0: - parts.append(u'%s %s' % (days, _('day') if days == 1 else _('days'))) - - if hours > 0: - parts.append(u'%s %s' % (hours, _('hour') if hours == 1 else _('hours'))) - - if minutes > 0: - parts.append( - u'%s %s' % (minutes, _('minute') if minutes == 1 else _('minutes')) - ) - - if seconds > 0: - parts.append( - u'%s %s' % (seconds, _('second') if seconds == 1 else _('seconds')) - ) - - return ' '.join(parts) if len(parts) != 0 else _('< 1 second') diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..8e177e2 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,164 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "asgiref" +version = "3.8.1" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.8" +files = [ + {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, + {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "backports-zoneinfo" +version = "0.2.1" +description = "Backport of the standard library zoneinfo module" +optional = false +python-versions = ">=3.6" +files = [ + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, + {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, +] + +[package.extras] +tzdata = ["tzdata"] + +[[package]] +name = "django" +version = "4.2.14" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Django-4.2.14-py3-none-any.whl", hash = "sha256:3ec32bc2c616ab02834b9cac93143a7dc1cdcd5b822d78ac95fc20a38c534240"}, + {file = "Django-4.2.14.tar.gz", hash = "sha256:fc6919875a6226c7ffcae1a7d51e0f2ceaf6f160393180818f6c95f51b1e7b96"}, +] + +[package.dependencies] +asgiref = ">=3.6.0,<4" +"backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "freezegun" +version = "1.5.1" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.7" +files = [ + {file = "freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1"}, + {file = "freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + +[[package]] +name = "mock" +version = "5.1.0" +description = "Rolling backport of unittest.mock for all Pythons" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, + {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, +] + +[package.extras] +build = ["blurb", "twine", "wheel"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sqlparse" +version = "0.5.1" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.8" +files = [ + {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"}, + {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"}, +] + +[package.extras] +dev = ["build", "hatch"] +doc = ["sphinx"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "ea0b5d905038e4a0d3853700b59ccdb32fa5dd5d1c5d5b959e56b45a5fcfb679" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e3138ff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "django-cron" +version = "0.6.0" +description = "Running python crons in a Django project" +authors = [ + "Sumit Chachra ", + "Joren Hammudoglu ", +] +readme = "README.rst" +classifiers = [ + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Framework :: Django", +] +repository = "https://github.com/jorenham/django-cron" + +[tool.poetry.dependencies] +python = "^3.8" +django = ">=4.2,<6.0" + +[tool.poetry.group.dev.dependencies] +freezegun = "^1.5.1" +mock = "^5.1.0" diff --git a/runtests.py b/runtests.py deleted file mode 100644 index 5ae04d6..0000000 --- a/runtests.py +++ /dev/null @@ -1,27 +0,0 @@ -# This file mainly exists to allow python setup.py test to work. -# flake8: noqa -import os -import sys - -if 'DJANGO_SETTINGS_MODULE' not in os.environ: - os.environ['DJANGO_SETTINGS_MODULE'] = 'settings_sqllite' - -test_dir = os.path.dirname(__file__) -sys.path.insert(0, test_dir) - -import django -from django.test.utils import get_runner -from django.conf import settings - - -def runtests(): - TestRunner = get_runner(settings) - test_runner = TestRunner(verbosity=1, interactive=False) - if hasattr(django, 'setup'): - django.setup() - failures = test_runner.run_tests(['django_cron']) - sys.exit(bool(failures)) - - -if __name__ == '__main__': - globals()[sys.argv[1]]() diff --git a/settings_mysql.py b/settings_mysql.py deleted file mode 100644 index f574741..0000000 --- a/settings_mysql.py +++ /dev/null @@ -1,11 +0,0 @@ -from settings_base import * - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'USER': 'travis', - 'PASSWORD': '', - 'NAME': 'travis', - 'TEST_NAME': 'travis_test', - } -} diff --git a/settings_postgres.py b/settings_postgres.py deleted file mode 100644 index 4e64bc4..0000000 --- a/settings_postgres.py +++ /dev/null @@ -1,11 +0,0 @@ -from settings_base import * - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'USER': 'postgres', - 'PASSWORD': '', - 'NAME': 'travis', - 'TEST_NAME': 'travis_test', - } -} diff --git a/settings_sqllite.py b/settings_sqllite.py deleted file mode 100644 index 65b6927..0000000 --- a/settings_sqllite.py +++ /dev/null @@ -1,10 +0,0 @@ -from settings_base import * - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'USER': 'travis', - 'NAME': 'djangocron', - 'TEST_NAME': 'djangocron_test', - } -} diff --git a/setup.py b/setup.py deleted file mode 100644 index 3d057dc..0000000 --- a/setup.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python -# flake8: noqa - -try: - from setuptools import setup, find_packages - from setuptools.command.test import test -except ImportError: - from ez_setup import use_setuptools - - use_setuptools() - from setuptools import setup, find_packages - from setuptools.command.test import test -import os - - -here = os.path.dirname(os.path.abspath(__file__)) -f = open(os.path.join(here, 'README.rst')) -long_description = f.read().strip() -f.close() - -setup( - name='django-cron', - version='0.6.0', - author='Sumit Chachra', - author_email='chachra@tivix.com', - url='http://github.com/tivix/django-cron', - description='Running python crons in a Django project', - packages=find_packages(), - long_description=long_description, - keywords='django cron', - zip_safe=False, - install_requires=['Django>=3.2'], - test_suite='runtests.runtests', - include_package_data=True, - classifiers=[ - 'Framework :: Django', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'Operating System :: OS Independent', - 'Topic :: Software Development', - ], -) diff --git a/test_requirements.txt b/test_requirements.txt deleted file mode 100644 index 5e6be87..0000000 --- a/test_requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -freezegun==0.2.8 -mock==2.0.0 -psycopg2==2.9.3 -flake8==2.4.0 diff --git a/testmanage.py b/testmanage.py new file mode 100755 index 0000000..c5fd447 --- /dev/null +++ b/testmanage.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python + +import os +import sys + +from django.core.management import execute_from_command_line + + +def main(): + os.environ.setdefault( + 'DJANGO_SETTINGS_MODULE', + 'django_cron.test.settings', + ) + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() From 7abdad48489be91dfc2669cdd530f618148c1d8f Mon Sep 17 00:00:00 2001 From: jorenham Date: Sun, 28 Jul 2024 18:46:11 +0200 Subject: [PATCH 03/16] replace deprecated `index_together` with `indexes` --- .../0004_alter_cronjoblog_options_and_more.py | 68 +++++++++++++++++++ django_cron/models.py | 37 +++++----- 2 files changed, 86 insertions(+), 19 deletions(-) create mode 100644 django_cron/migrations/0004_alter_cronjoblog_options_and_more.py diff --git a/django_cron/migrations/0004_alter_cronjoblog_options_and_more.py b/django_cron/migrations/0004_alter_cronjoblog_options_and_more.py new file mode 100644 index 0000000..99893fd --- /dev/null +++ b/django_cron/migrations/0004_alter_cronjoblog_options_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 4.2.14 on 2024-07-28 11:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_cron', '0003_cronjoblock'), + ] + + operations = [ + migrations.AlterModelOptions( + name='cronjoblog', + options={'get_latest_by': 'start_time'}, + ), + migrations.RenameIndex( + model_name='cronjoblog', + new_name='django_cron_code_966ed3_idx', + old_fields=('code', 'start_time'), + ), + migrations.RenameIndex( + model_name='cronjoblog', + new_name='django_cron_code_21f381_idx', + old_fields=('code', 'start_time', 'ran_at_time'), + ), + migrations.RenameIndex( + model_name='cronjoblog', + new_name='django_cron_code_89ad04_idx', + old_fields=('code', 'is_success', 'ran_at_time'), + ), + migrations.AlterField( + model_name='cronjoblog', + name='code', + field=models.CharField(max_length=64), + ), + migrations.AlterField( + model_name='cronjoblog', + name='end_time', + field=models.DateTimeField(), + ), + migrations.AlterField( + model_name='cronjoblog', + name='ran_at_time', + field=models.TimeField(blank=True, editable=False, null=True), + ), + migrations.AlterField( + model_name='cronjoblog', + name='start_time', + field=models.DateTimeField(), + ), + migrations.AddIndex( + model_name='cronjoblog', + index=models.Index(fields=['code'], name='django_cron_code_26aea9_idx'), + ), + migrations.AddIndex( + model_name='cronjoblog', + index=models.Index(fields=['start_time'], name='django_cron_start_t_9e0b8f_idx'), + ), + migrations.AddIndex( + model_name='cronjoblog', + index=models.Index(fields=['end_time'], name='django_cron_end_tim_c3cfdc_idx'), + ), + migrations.AddIndex( + model_name='cronjoblog', + index=models.Index(fields=['ran_at_time'], name='django_cron_ran_at__aa3be6_idx'), + ), + ] diff --git a/django_cron/models.py b/django_cron/models.py index c109e09..c7413ca 100644 --- a/django_cron/models.py +++ b/django_cron/models.py @@ -6,33 +6,32 @@ class CronJobLog(models.Model): Keeps track of the cron jobs that ran etc. and any error messages if they failed. """ - - code = models.CharField(max_length=64, db_index=True) - start_time = models.DateTimeField(db_index=True) - end_time = models.DateTimeField(db_index=True) + code = models.CharField(max_length=64) + start_time = models.DateTimeField() + end_time = models.DateTimeField() is_success = models.BooleanField(default=False) - message = models.TextField(default='', blank=True) # TODO: db_index=True + message = models.TextField(default="", blank=True) # This field is used to mark jobs executed in exact time. # Jobs that run every X minutes, have this field empty. - ran_at_time = models.TimeField(null=True, blank=True, db_index=True, editable=False) - - def __unicode__(self): - return '%s (%s)' % (self.code, 'Success' if self.is_success else 'Fail') + ran_at_time = models.TimeField(null=True, blank=True, editable=False) def __str__(self): - return "%s (%s)" % (self.code, "Success" if self.is_success else "Fail") + status = "Success" if self.is_success else "Fail" + return f"{self.code} ({status})" class Meta: - index_together = [ - ('code', 'is_success', 'ran_at_time'), - ('code', 'start_time', 'ran_at_time'), - ( - 'code', - 'start_time', - ), # useful when finding latest run (order by start_time) of cron - ] - app_label = 'django_cron' + app_label = "django_cron" + get_latest_by = "start_time" + indexes = ( + models.Index(fields=["code"]), + models.Index(fields=["start_time"]), + models.Index(fields=["end_time"]), + models.Index(fields=["ran_at_time"]), + models.Index(fields=["code", "start_time"]), + models.Index(fields=["code", "start_time", "ran_at_time"]), + models.Index(fields=["code", "is_success", "ran_at_time"]), + ) class CronJobLock(models.Model): From e31ddc129584fee0414eb5d322b75fab464969ab Mon Sep 17 00:00:00 2001 From: jorenham Date: Sun, 28 Jul 2024 18:54:36 +0200 Subject: [PATCH 04/16] ruff formatting --- django_cron/__init__.py | 19 ++- django_cron/admin.py | 54 ++++--- django_cron/backends/lock/base.py | 8 +- django_cron/backends/lock/cache.py | 6 +- django_cron/backends/lock/database.py | 8 +- django_cron/backends/lock/file.py | 10 +- django_cron/core.py | 111 ++++++++------ django_cron/cron.py | 5 +- django_cron/helpers.py | 12 +- django_cron/management/commands/cronloop.py | 4 +- django_cron/management/commands/runcrons.py | 33 ++-- django_cron/test/cron.py | 26 ++-- django_cron/test/settings.py | 108 +++++++------- django_cron/test/urls.py | 2 +- django_cron/tests.py | 157 +++++++++++--------- djangocron | 0 16 files changed, 306 insertions(+), 257 deletions(-) create mode 100644 djangocron diff --git a/django_cron/__init__.py b/django_cron/__init__.py index b5f77b8..f5b8374 100644 --- a/django_cron/__init__.py +++ b/django_cron/__init__.py @@ -1,2 +1,17 @@ -from django_cron.core import * -from django_cron.helpers import get_class, get_current_time +from .core import ( + DEFAULT_LOCK_BACKEND, + DJANGO_CRON_OUTPUT_ERRORS, + BadCronJobError, + CronJobBase, + CronJobManager, + Schedule, +) + +__all__ = ( + "DEFAULT_LOCK_BACKEND", + "DJANGO_CRON_OUTPUT_ERRORS", + "BadCronJobError", + "CronJobBase", + "CronJobManager", + "Schedule", +) diff --git a/django_cron/admin.py b/django_cron/admin.py index c43365d..5c1254a 100644 --- a/django_cron/admin.py +++ b/django_cron/admin.py @@ -4,50 +4,56 @@ from django.db.models import DurationField, ExpressionWrapper, F from django.utils.translation import gettext_lazy as _ -from django_cron.models import CronJobLog, CronJobLock -from django_cron.helpers import humanize_duration +from .helpers import humanize_duration +from .models import CronJobLock, CronJobLog class DurationFilter(admin.SimpleListFilter): - title = _('duration') - parameter_name = 'duration' + title = _("duration") + parameter_name = "duration" def lookups(self, request, model_admin): return ( - ('lte_minute', _('<= 1 minute')), - ('gt_minute', _('> 1 minute')), - ('gt_hour', _('> 1 hour')), - ('gt_day', _('> 1 day')), + ("lte_minute", _("<= 1 minute")), + ("gt_minute", _("> 1 minute")), + ("gt_hour", _("> 1 hour")), + ("gt_day", _("> 1 day")), ) def queryset(self, request, queryset): - if self.value() == 'lte_minute': - return queryset.filter(end_time__lte=F('start_time') + timedelta(minutes=1)) - if self.value() == 'gt_minute': - return queryset.filter(end_time__gt=F('start_time') + timedelta(minutes=1)) - if self.value() == 'gt_hour': - return queryset.filter(end_time__gt=F('start_time') + timedelta(hours=1)) - if self.value() == 'gt_day': - return queryset.filter(end_time__gt=F('start_time') + timedelta(days=1)) + if self.value() == "lte_minute": + return queryset.filter(end_time__lte=F("start_time") + timedelta(minutes=1)) + if self.value() == "gt_minute": + return queryset.filter(end_time__gt=F("start_time") + timedelta(minutes=1)) + if self.value() == "gt_hour": + return queryset.filter(end_time__gt=F("start_time") + timedelta(hours=1)) + if self.value() == "gt_day": + return queryset.filter(end_time__gt=F("start_time") + timedelta(days=1)) class CronJobLogAdmin(admin.ModelAdmin): class Meta: model = CronJobLog - search_fields = ('code', 'message') - ordering = ('-start_time',) - list_display = ('code', 'start_time', 'end_time', 'humanize_duration', 'is_success') - list_filter = ('code', 'start_time', 'is_success', DurationFilter) + search_fields = ("code", "message") + ordering = ("-start_time",) + list_display = ("code", "start_time", "end_time", "humanize_duration", "is_success") + list_filter = ("code", "start_time", "is_success", DurationFilter) def get_queryset(self, request): - return super().get_queryset(request).annotate( - duration=ExpressionWrapper(F('end_time') - F('start_time'), DurationField()), + return ( + super() + .get_queryset(request) + .annotate( + duration=ExpressionWrapper( + F("end_time") - F("start_time"), DurationField() + ), + ) ) def get_readonly_fields(self, request, obj=None): if not request.user.is_superuser and obj is not None: - names = [f.name for f in CronJobLog._meta.fields if f.name != 'id'] + names = [f.name for f in CronJobLog._meta.fields if f.name != "id"] return self.readonly_fields + tuple(names) return self.readonly_fields @@ -55,7 +61,7 @@ def humanize_duration(self, obj): return humanize_duration(obj.end_time - obj.start_time) humanize_duration.short_description = _("Duration") - humanize_duration.admin_order_field = 'duration' + humanize_duration.admin_order_field = "duration" admin.site.register(CronJobLog, CronJobLogAdmin) diff --git a/django_cron/backends/lock/base.py b/django_cron/backends/lock/base.py index a003b4a..7e62bc6 100644 --- a/django_cron/backends/lock/base.py +++ b/django_cron/backends/lock/base.py @@ -27,9 +27,9 @@ def __init__(self, cron_class, silent, *args, **kwargs): * self.silent for you. The rest is backend-specific. """ - self.job_name = '.'.join([cron_class.__module__, cron_class.__name__]) + self.job_name = ".".join([cron_class.__module__, cron_class.__name__]) self.job_code = cron_class.code - self.parallel = getattr(cron_class, 'ALLOW_PARALLEL_RUNS', False) + self.parallel = getattr(cron_class, "ALLOW_PARALLEL_RUNS", False) self.silent = silent def lock(self): @@ -41,7 +41,7 @@ def lock(self): Here you can optionally call self.notice_lock_failed(). """ raise NotImplementedError( - 'You have to implement lock(self) method for your class' + "You have to implement lock(self) method for your class" ) def release(self): @@ -51,7 +51,7 @@ def release(self): No need to return anything currently. """ raise NotImplementedError( - 'You have to implement release(self) method for your class' + "You have to implement release(self) method for your class" ) def lock_failed_message(self): diff --git a/django_cron/backends/lock/cache.py b/django_cron/backends/lock/cache.py index 00c9353..046dfec 100644 --- a/django_cron/backends/lock/cache.py +++ b/django_cron/backends/lock/cache.py @@ -2,7 +2,7 @@ from django.core.cache import caches from django.utils import timezone -from django_cron.backends.lock.base import DjangoCronJobLock +from .base import DjangoCronJobLock class CacheLock(DjangoCronJobLock): @@ -60,9 +60,9 @@ def get_lock_name(self): def get_cache_timeout(self, cron_class): try: timeout = getattr( - cron_class, 'DJANGO_CRON_LOCK_TIME', settings.DJANGO_CRON_LOCK_TIME + cron_class, "DJANGO_CRON_LOCK_TIME", settings.DJANGO_CRON_LOCK_TIME ) - except: + except Exception: timeout = self.DEFAULT_LOCK_TIME return timeout diff --git a/django_cron/backends/lock/database.py b/django_cron/backends/lock/database.py index 3bf78a1..f72c829 100644 --- a/django_cron/backends/lock/database.py +++ b/django_cron/backends/lock/database.py @@ -1,7 +1,9 @@ -from django_cron.backends.lock.base import DjangoCronJobLock -from django_cron.models import CronJobLock from django.db import transaction +from django_cron.models import CronJobLock + +from .base import DjangoCronJobLock + class DatabaseLock(DjangoCronJobLock): """ @@ -11,7 +13,7 @@ class DatabaseLock(DjangoCronJobLock): @transaction.atomic def lock(self): - lock, created = CronJobLock.objects.get_or_create(job_name=self.job_name) + lock, _ = CronJobLock.objects.get_or_create(job_name=self.job_name) if lock.locked: return False else: diff --git a/django_cron/backends/lock/file.py b/django_cron/backends/lock/file.py index 1d14d31..d0868b3 100644 --- a/django_cron/backends/lock/file.py +++ b/django_cron/backends/lock/file.py @@ -3,7 +3,7 @@ from django.conf import settings from django.core.files import locks -from django_cron.backends.lock.base import DjangoCronJobLock +from .base import DjangoCronJobLock class FileLock(DjangoCronJobLock): @@ -16,7 +16,7 @@ class FileLock(DjangoCronJobLock): def lock(self): lock_name = self.get_lock_name() try: - self.__lock_fd = open(lock_name, 'w+b', 1) + self.__lock_fd = open(lock_name, "w+b", 1) locks.lock(self.__lock_fd, locks.LOCK_EX | locks.LOCK_NB) except IOError: return False @@ -27,11 +27,11 @@ def release(self): self.__lock_fd.close() def get_lock_name(self): - default_path = '/tmp' - path = getattr(settings, 'DJANGO_CRON_LOCKFILE_PATH', default_path) + default_path = "/tmp" + path = getattr(settings, "DJANGO_CRON_LOCKFILE_PATH", default_path) if not os.path.isdir(path): # let it die if failed, can't run further anyway os.makedirs(path, exist_ok=True) - filename = self.job_name + '.lock' + filename = self.job_name + ".lock" return os.path.join(path, filename) diff --git a/django_cron/core.py b/django_cron/core.py index d7a945b..2ec3145 100644 --- a/django_cron/core.py +++ b/django_cron/core.py @@ -8,11 +8,20 @@ from django.db.models import Q from django.utils.timezone import now as utc_now -from django_cron.helpers import get_class, get_current_time - -DEFAULT_LOCK_BACKEND = 'django_cron.backends.lock.cache.CacheLock' +from .helpers import get_class, get_current_time + +__all__ = [ + "DEFAULT_LOCK_BACKEND", + "DJANGO_CRON_OUTPUT_ERRORS", + "BadCronJobError", + "CronJobBase", + "CronJobManager", + "Schedule", +] + +DEFAULT_LOCK_BACKEND = "django_cron.backends.lock.cache.CacheLock" DJANGO_CRON_OUTPUT_ERRORS = False -logger = logging.getLogger('django_cron') +logger = logging.getLogger("django_cron") class BadCronJobError(AssertionError): @@ -21,13 +30,13 @@ class BadCronJobError(AssertionError): class Schedule(object): def __init__( - self, - run_every_mins=None, - run_at_times=None, - retry_after_failure_mins=None, - run_weekly_on_days=None, - run_monthly_on_days=None, - run_tolerance_seconds=0, + self, + run_every_mins=None, + run_at_times=None, + retry_after_failure_mins=None, + run_weekly_on_days=None, + run_monthly_on_days=None, + run_tolerance_seconds=0, ): if run_at_times is None: run_at_times = [] @@ -65,13 +74,13 @@ def get_time_until_run(cls): from django_cron.models import CronJobLog try: - last_job = CronJobLog.objects.filter(code=cls.code).latest('start_time') + last_job = CronJobLog.objects.filter(code=cls.code).latest("start_time") except CronJobLog.DoesNotExist: return timedelta() return ( - last_job.start_time - + timedelta(minutes=cls.schedule.run_every_mins) - - utc_now() + last_job.start_time + + timedelta(minutes=cls.schedule.run_every_mins) + - utc_now() ) @@ -91,7 +100,7 @@ def __init__(self, cron_job_class, silent=False, dry_run=False, stdout=None): self.lock_class = self.get_lock_class() self.previously_ran_successful_cron = None self.write_log = getattr( - settings, 'DJANGO_CRON_OUTPUT_ERRORS', DJANGO_CRON_OUTPUT_ERRORS + settings, "DJANGO_CRON_OUTPUT_ERRORS", DJANGO_CRON_OUTPUT_ERRORS ) def should_run_now(self, force=False): @@ -120,32 +129,35 @@ def should_run_now(self, force=False): # We check last job - success or not last_job = ( CronJobLog.objects.filter(code=cron_job.code) - .order_by('-start_time') - .exclude(start_time__gt=datetime.today()) - .first() + .order_by("-start_time") + .exclude(start_time__gt=datetime.today()) + .first() ) if ( - last_job - and not last_job.is_success - and get_current_time() + timedelta(seconds=cron_job.schedule.run_tolerance_seconds) - <= last_job.start_time - + timedelta(minutes=cron_job.schedule.retry_after_failure_mins) + last_job + and not last_job.is_success + and get_current_time() + + timedelta(seconds=cron_job.schedule.run_tolerance_seconds) + <= last_job.start_time + + timedelta(minutes=cron_job.schedule.retry_after_failure_mins) ): return False if cron_job.schedule.run_every_mins is not None: try: - self.previously_ran_successful_cron = CronJobLog.objects.filter( - code=cron_job.code, is_success=True - ).exclude(start_time__gt=datetime.today()).latest('start_time') + self.previously_ran_successful_cron = ( + CronJobLog.objects.filter(code=cron_job.code, is_success=True) + .exclude(start_time__gt=datetime.today()) + .latest("start_time") + ) except CronJobLog.DoesNotExist: pass if self.previously_ran_successful_cron: - if ( - get_current_time() + timedelta(seconds=cron_job.schedule.run_tolerance_seconds) - > self.previously_ran_successful_cron.start_time - + timedelta(minutes=cron_job.schedule.run_every_mins) + if get_current_time() + timedelta( + seconds=cron_job.schedule.run_tolerance_seconds + ) > self.previously_ran_successful_cron.start_time + timedelta( + minutes=cron_job.schedule.run_every_mins ): return True else: @@ -176,12 +188,12 @@ def should_run_now(self, force=False): def make_log(self, *messages, **kwargs): cron_log = self.cron_log - cron_job = getattr(self, 'cron_job', self.cron_job_class) + cron_job = getattr(self, "cron_job", self.cron_job_class) cron_log.code = cron_job.code - cron_log.is_success = kwargs.get('success', True) + cron_log.is_success = kwargs.get("success", True) cron_log.message = self.make_log_msg(messages) - cron_log.ran_at_time = getattr(self, 'user_time', None) + cron_log.ran_at_time = getattr(self, "user_time", None) cron_log.end_time = get_current_time() cron_log.save() @@ -189,12 +201,12 @@ def make_log(self, *messages, **kwargs): logger.error("%s cronjob error:\n%s" % (cron_log.code, cron_log.message)) def make_log_msg(self, messages): - full_message = '' + full_message = "" if messages: for message in messages: if len(message): full_message += message - full_message += '\n' + full_message += "\n" return full_message @@ -218,7 +230,7 @@ def __exit__(self, ex_type, ex_value, ex_traceback): else: if not self.silent: self.stdout.write( - u"[\N{HEAVY BALLOT X}] {0}\n".format(self.cron_job_class.code) + "[\N{HEAVY BALLOT X}] {0}\n".format(self.cron_job_class.code) ) try: trace = "".join( @@ -242,10 +254,10 @@ def run(self, force=False): if not issubclass(cron_job_class, CronJobBase): raise BadCronJobError( - 'The cron_job to be run must be a subclass of %s' % CronJobBase.__name__ + "The cron_job to be run must be a subclass of %s" % CronJobBase.__name__ ) - if not hasattr(cron_job_class, 'code'): + if not hasattr(cron_job_class, "code"): raise BadCronJobError( "Cron class '{0}' does not have a code attribute".format( cron_job_class.__name__ @@ -262,7 +274,7 @@ def run(self, force=False): cron_job_class.__name__, self.cron_job.code, ) - self.make_log('Job in progress', success=True) + self.make_log("Job in progress", success=True) self.msg = self.cron_job.do() self.make_log(self.msg, success=True) self.cron_job.set_prev_success_cron( @@ -270,14 +282,14 @@ def run(self, force=False): ) if not self.silent: self.stdout.write( - u"[\N{HEAVY CHECK MARK}] {0}\n".format(self.cron_job.code) + "[\N{HEAVY CHECK MARK}] {0}\n".format(self.cron_job.code) ) self._remove_old_success_job_logs(cron_job_class) elif not self.silent: - self.stdout.write(u"[ ] {0}\n".format(self.cron_job.code)) + self.stdout.write("[ ] {0}\n".format(self.cron_job.code)) def get_lock_class(self): - name = getattr(settings, 'DJANGO_CRON_LOCK_BACKEND', DEFAULT_LOCK_BACKEND) + name = getattr(settings, "DJANGO_CRON_LOCK_BACKEND", DEFAULT_LOCK_BACKEND) try: return get_class(name) except Exception as err: @@ -285,15 +297,20 @@ def get_lock_class(self): @property def msg(self): - return getattr(self, '_msg', '') + return getattr(self, "_msg", "") @msg.setter def msg(self, msg): if msg is None: - msg = '' + msg = "" self._msg = msg def _remove_old_success_job_logs(self, job_class): - if job_class.remove_successful_cron_logs or getattr(settings, 'REMOVE_SUCCESSFUL_CRON_LOGS', False): + if job_class.remove_successful_cron_logs or getattr( + settings, "REMOVE_SUCCESSFUL_CRON_LOGS", False + ): from django_cron.models import CronJobLog - CronJobLog.objects.filter(code=job_class.code, is_success=True).exclude(pk=self.cron_log.pk).delete() + + CronJobLog.objects.filter(code=job_class.code, is_success=True).exclude( + pk=self.cron_log.pk + ).delete() diff --git a/django_cron/cron.py b/django_cron/cron.py index 60d24b9..762449c 100644 --- a/django_cron/cron.py +++ b/django_cron/cron.py @@ -1,8 +1,9 @@ from django.conf import settings from django.core.mail import send_mail -from django_cron import CronJobBase, Schedule, get_class -from django_cron.models import CronJobLog +from .core import CronJobBase, Schedule +from .helpers import get_class +from .models import CronJobLog class FailedRunsNotificationCronJob(CronJobBase): diff --git a/django_cron/helpers.py b/django_cron/helpers.py index 1c3ecdb..7b54dbe 100644 --- a/django_cron/helpers.py +++ b/django_cron/helpers.py @@ -16,18 +16,18 @@ def humanize_duration(duration): parts = [] if days > 0: - parts.append(u'%s %s' % (days, pluralize(days, _('day,days')))) + parts.append("%s %s" % (days, pluralize(days, _("day,days")))) if hours > 0: - parts.append(u'%s %s' % (hours, pluralize(hours, _('hour,hours')))) + parts.append("%s %s" % (hours, pluralize(hours, _("hour,hours")))) if minutes > 0: - parts.append(u'%s %s' % (minutes, pluralize(minutes, _('minute,minutes')))) + parts.append("%s %s" % (minutes, pluralize(minutes, _("minute,minutes")))) if seconds > 0: - parts.append(u'%s %s' % (seconds, pluralize(seconds, _('second,seconds')))) + parts.append("%s %s" % (seconds, pluralize(seconds, _("second,seconds")))) - return ', '.join(parts) if len(parts) != 0 else _('< 1 second') + return ", ".join(parts) if len(parts) != 0 else _("< 1 second") def get_class(kls): @@ -35,7 +35,7 @@ def get_class(kls): Converts a string to a class. Courtesy: http://stackoverflow.com/questions/452969/does-python-have-an-equivalent-to-java-class-forname/452981#452981 """ - parts = kls.split('.') + parts = kls.split(".") if len(parts) == 1: raise ImportError("'{0}'' is not a valid import path".format(kls)) diff --git a/django_cron/management/commands/cronloop.py b/django_cron/management/commands/cronloop.py index 6e88622..05a2679 100644 --- a/django_cron/management/commands/cronloop.py +++ b/django_cron/management/commands/cronloop.py @@ -1,4 +1,4 @@ -from time import sleep +import time from django.core.management import BaseCommand, call_command @@ -46,6 +46,6 @@ def handle(self, *args, **options): def _call_command_or_return_true(self, command, classes, s): try: call_command(command, *classes) - sleep(s) + time.sleep(s) except KeyboardInterrupt: return True diff --git a/django_cron/management/commands/runcrons.py b/django_cron/management/commands/runcrons.py index 07fa567..55224c0 100644 --- a/django_cron/management/commands/runcrons.py +++ b/django_cron/management/commands/runcrons.py @@ -1,28 +1,27 @@ -from __future__ import print_function import traceback from datetime import timedelta -from django.core.management.base import BaseCommand from django.conf import settings +from django.core.management.base import BaseCommand from django.db import close_old_connections -from django_cron import CronJobManager, get_class, get_current_time +from django_cron.core import CronJobManager +from django_cron.helpers import get_class, get_current_time from django_cron.models import CronJobLog - DEFAULT_LOCK_TIME = 24 * 60 * 60 # 24 hours class Command(BaseCommand): def add_arguments(self, parser): - parser.add_argument('cron_classes', nargs='*') - parser.add_argument('--force', action='store_true', help='Force cron runs') + parser.add_argument("cron_classes", nargs="*") + parser.add_argument("--force", action="store_true", help="Force cron runs") parser.add_argument( - '--silent', action='store_true', help='Do not push any message on console' + "--silent", action="store_true", help="Do not push any message on console" ) parser.add_argument( - '--dry-run', - action='store_true', + "--dry-run", + action="store_true", help="Just show what crons would be run; don't actually run them", ) @@ -31,22 +30,22 @@ def handle(self, *args, **options): Iterates over all the CRON_CLASSES (or if passed in as a commandline argument) and runs them. """ - if not options['silent']: + if not options["silent"]: self.stdout.write("Running Crons\n") self.stdout.write("{0}\n".format("=" * 40)) - cron_classes = options['cron_classes'] + cron_classes = options["cron_classes"] if cron_classes: cron_class_names = cron_classes else: - cron_class_names = getattr(settings, 'CRON_CLASSES', []) + cron_class_names = getattr(settings, "CRON_CLASSES", []) try: crons_to_run = [get_class(x) for x in cron_class_names] except ImportError: error = traceback.format_exc() self.stdout.write( - 'ERROR: Make sure these are valid cron class names: %s\n\n%s' + "ERROR: Make sure these are valid cron class names: %s\n\n%s" % (cron_class_names, error) ) return @@ -54,9 +53,9 @@ def handle(self, *args, **options): for cron_class in crons_to_run: run_cron_with_cache_check( cron_class, - force=options['force'], - silent=options['silent'], - dry_run=options['dry_run'], + force=options["force"], + silent=options["silent"], + dry_run=options["dry_run"], stdout=self.stdout, ) @@ -86,6 +85,6 @@ def clear_old_log_entries(): """ Removes older log entries, if the appropriate setting has been set """ - if hasattr(settings, 'DJANGO_CRON_DELETE_LOGS_OLDER_THAN'): + if hasattr(settings, "DJANGO_CRON_DELETE_LOGS_OLDER_THAN"): delta = timedelta(days=settings.DJANGO_CRON_DELETE_LOGS_OLDER_THAN) CronJobLog.objects.filter(end_time__lt=get_current_time() - delta).delete() diff --git a/django_cron/test/cron.py b/django_cron/test/cron.py index 87ebdb8..68bff4b 100644 --- a/django_cron/test/cron.py +++ b/django_cron/test/cron.py @@ -1,10 +1,10 @@ from time import sleep -from django_cron import CronJobBase, Schedule +from django_cron.core import CronJobBase, Schedule class TestSuccessCronJob(CronJobBase): - code = 'test_success_cron_job' + code = "test_success_cron_job" schedule = Schedule(run_every_mins=0) def do(self): @@ -12,7 +12,7 @@ def do(self): class TestErrorCronJob(CronJobBase): - code = 'test_error_cron_job' + code = "test_error_cron_job" schedule = Schedule(run_every_mins=0) def do(self): @@ -20,7 +20,7 @@ def do(self): class Test5minsCronJob(CronJobBase): - code = 'test_run_every_mins' + code = "test_run_every_mins" schedule = Schedule(run_every_mins=5) def do(self): @@ -28,7 +28,7 @@ def do(self): class Test5minsWithToleranceCronJob(CronJobBase): - code = 'test_run_every_mins' + code = "test_run_every_mins" schedule = Schedule(run_every_mins=5, run_tolerance_seconds=5) def do(self): @@ -36,15 +36,15 @@ def do(self): class TestRunAtTimesCronJob(CronJobBase): - code = 'test_run_at_times' - schedule = Schedule(run_at_times=['0:00', '0:05']) + code = "test_run_at_times" + schedule = Schedule(run_at_times=["0:00", "0:05"]) def do(self): pass class Wait3secCronJob(CronJobBase): - code = 'test_wait_3_seconds' + code = "test_wait_3_seconds" schedule = Schedule(run_every_mins=5) def do(self): @@ -52,11 +52,11 @@ def do(self): class RunOnWeekendCronJob(CronJobBase): - code = 'run_on_weekend' + code = "run_on_weekend" schedule = Schedule( run_weekly_on_days=[5, 6], run_at_times=[ - '0:00', + "0:00", ], ) @@ -70,11 +70,11 @@ def do(self): class RunOnMonthDaysCronJob(CronJobBase): - code = 'run_on_month_days' + code = "run_on_month_days" schedule = Schedule( run_monthly_on_days=[1, 10, 20], run_at_times=[ - '0:00', + "0:00", ], ) @@ -83,7 +83,7 @@ def do(self): class RunEveryMinuteAndRemoveOldLogs(CronJobBase): - code = 'run_and_remove_old_logs' + code = "run_and_remove_old_logs" schedule = Schedule(run_every_mins=1) remove_successful_cron_logs = True diff --git a/django_cron/test/settings.py b/django_cron/test/settings.py index 4d887f5..8028bb5 100644 --- a/django_cron/test/settings.py +++ b/django_cron/test/settings.py @@ -3,94 +3,94 @@ PROJECT_DIR = Path(__file__).resolve().parent.parent BASE_DIR = PROJECT_DIR.parent -SECRET_KEY = 'not-a-secure-key' +SECRET_KEY = "not-a-secure-key" DEBUG = True INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.humanize', - 'django.contrib.contenttypes', - 'django.contrib.messages', - 'django.contrib.sessions', - 'django.contrib.staticfiles', - 'django_cron', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.humanize", + "django.contrib.contenttypes", + "django.contrib.messages", + "django.contrib.sessions", + "django.contrib.staticfiles", + "django_cron", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'django_cron.test.urls' +ROOT_URLCONF = "django_cron.test.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", }, } # since django is meant for developers with deadlines... -PASSWORD_HASHERS = ('django.contrib.auth.hashers.MD5PasswordHasher',) +PASSWORD_HASHERS = ("django.contrib.auth.hashers.MD5PasswordHasher",) DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'USER': 'djangocron', - 'NAME': 'djangocron', - 'TEST_NAME': 'djangocron_test', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "USER": "djangocron", + "NAME": "djangocron", + "TEST_NAME": "djangocron_test", } } -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'null': { - 'level': 'DEBUG', - 'class': 'logging.NullHandler', + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "null": { + "level": "DEBUG", + "class": "logging.NullHandler", }, }, - 'loggers': { - 'django_cron': { - 'handlers': ['null'], - 'level': 'INFO', - 'propagate': True, + "loggers": { + "django_cron": { + "handlers": ["null"], + "level": "INFO", + "propagate": True, }, }, } -STATIC_URL = '/static/' +STATIC_URL = "/static/" -_CRON_PATH = 'django_cron.test.cron.' +_CRON_PATH = "django_cron.test.cron." CRON_CLASSES = [ - f'{_CRON_PATH}TestSuccessCronJob', - f'{_CRON_PATH}TestErrorCronJob', - f'{_CRON_PATH}Test5minsCronJob', - f'{_CRON_PATH}TestRunAtTimesCronJob', - f'{_CRON_PATH}Wait3secCronJob', - 'django_cron.cron.FailedRunsNotificationCronJob', + f"{_CRON_PATH}TestSuccessCronJob", + f"{_CRON_PATH}TestErrorCronJob", + f"{_CRON_PATH}Test5minsCronJob", + f"{_CRON_PATH}TestRunAtTimesCronJob", + f"{_CRON_PATH}Wait3secCronJob", + "django_cron.cron.FailedRunsNotificationCronJob", ] diff --git a/django_cron/test/urls.py b/django_cron/test/urls.py index 82a6152..ce109cd 100644 --- a/django_cron/test/urls.py +++ b/django_cron/test/urls.py @@ -5,5 +5,5 @@ admin.autodiscover() urlpatterns = [ - path('admin/', admin.site.urls), + path("admin/", admin.site.urls), ] diff --git a/django_cron/tests.py b/django_cron/tests.py index 7728515..55c7da0 100644 --- a/django_cron/tests.py +++ b/django_cron/tests.py @@ -14,18 +14,17 @@ from freezegun import freeze_time from mock import patch -from django_cron.helpers import humanize_duration -from django_cron.models import CronJobLock, CronJobLog - +from .helpers import humanize_duration +from .models import CronJobLock, CronJobLog from .test import cron -_MAIN_CRON_MODULE = 'django_cron.cron' -_TEST_CRON_MODULE = 'django_cron.test.cron' +_MAIN_CRON_MODULE = "django_cron.cron" +_TEST_CRON_MODULE = "django_cron.test.cron" class OutBuffer(object): def __init__(self): - self._str_cache = '' + self._str_cache = "" self.content = [] self.modified = False @@ -35,7 +34,7 @@ def write(self, *args): def str_content(self): if self.modified: - self._str_cache = ''.join((str(x) for x in self.content)) + self._str_cache = "".join((str(x) for x in self.content)) self.modified = False return self._str_cache @@ -51,35 +50,35 @@ def call(command, *args, **kwargs): class TestRunCrons(TransactionTestCase): - cron_success = f'{_TEST_CRON_MODULE}.TestSuccessCronJob' - cron_error = f'{_TEST_CRON_MODULE}.TestErrorCronJob' - cron_5mins = f'{_TEST_CRON_MODULE}.Test5minsCronJob' - cron_5mins_with_tolerance = f'{_TEST_CRON_MODULE}.Test5minsWithToleranceCronJob' - cron_run_at_times = f'{_TEST_CRON_MODULE}.TestRunAtTimesCronJob' - cron_wait_3sec = f'{_TEST_CRON_MODULE}.Wait3secCronJob' - cron_run_on_weekend = f'{_TEST_CRON_MODULE}.RunOnWeekendCronJob' - cron_obviously_doesnt_exist = 'ThisCronJobDoesntExost' - cron_no_code = f'{_TEST_CRON_MODULE}.NoCodeCronJob' - cron_failed_runs_notification = f'{_MAIN_CRON_MODULE}.FailedRunsNotificationCronJob' - run_on_month_days = f'{_TEST_CRON_MODULE}.RunOnMonthDaysCronJob' - run_and_remove_old_logs = f'{_TEST_CRON_MODULE}.RunEveryMinuteAndRemoveOldLogs' + cron_success = f"{_TEST_CRON_MODULE}.TestSuccessCronJob" + cron_error = f"{_TEST_CRON_MODULE}.TestErrorCronJob" + cron_5mins = f"{_TEST_CRON_MODULE}.Test5minsCronJob" + cron_5mins_with_tolerance = f"{_TEST_CRON_MODULE}.Test5minsWithToleranceCronJob" + cron_run_at_times = f"{_TEST_CRON_MODULE}.TestRunAtTimesCronJob" + cron_wait_3sec = f"{_TEST_CRON_MODULE}.Wait3secCronJob" + cron_run_on_weekend = f"{_TEST_CRON_MODULE}.RunOnWeekendCronJob" + cron_obviously_doesnt_exist = "ThisCronJobDoesntExost" + cron_no_code = f"{_TEST_CRON_MODULE}.NoCodeCronJob" + cron_failed_runs_notification = f"{_MAIN_CRON_MODULE}.FailedRunsNotificationCronJob" + run_on_month_days = f"{_TEST_CRON_MODULE}.RunOnMonthDaysCronJob" + run_and_remove_old_logs = f"{_TEST_CRON_MODULE}.RunEveryMinuteAndRemoveOldLogs" def _call(self, *args, **kwargs): - return call('runcrons', *args, **kwargs) + return call("runcrons", *args, **kwargs) def setUp(self): CronJobLog.objects.all().delete() def assertReportedRun(self, job_cls, response): - expected_log = u"[\N{HEAVY CHECK MARK}] {0}".format(job_cls.code) + expected_log = "[\N{HEAVY CHECK MARK}] {0}".format(job_cls.code) self.assertIn(expected_log, response) def assertReportedNoRun(self, job_cls, response): - expected_log = u"[ ] {0}".format(job_cls.code) + expected_log = "[ ] {0}".format(job_cls.code) self.assertIn(expected_log, response) def assertReportedFail(self, job_cls, response): - expected_log = u"[\N{HEAVY BALLOT X}] {0}".format(job_cls.code) + expected_log = "[\N{HEAVY BALLOT X}] {0}".format(job_cls.code) self.assertIn(expected_log, response) def test_success_cron(self): @@ -93,25 +92,25 @@ def test_failed_cron(self): def test_not_exists_cron(self): response = self._call(self.cron_obviously_doesnt_exist, force=True) - self.assertIn('Make sure these are valid cron class names', response) + self.assertIn("Make sure these are valid cron class names", response) self.assertIn(self.cron_obviously_doesnt_exist, response) self.assertEqual(CronJobLog.objects.all().count(), 0) - @patch('django_cron.core.logger') + @patch("django_cron.core.logger") def test_requires_code(self, mock_logger): response = self._call(self.cron_no_code, force=True) - self.assertIn('does not have a code attribute', response) + self.assertIn("does not have a code attribute", response) mock_logger.info.assert_called() @override_settings( - DJANGO_CRON_LOCK_BACKEND='django_cron.backends.lock.file.FileLock' + DJANGO_CRON_LOCK_BACKEND="django_cron.backends.lock.file.FileLock" ) def test_file_locking_backend(self): self._call(self.cron_success, force=True) self.assertEqual(CronJobLog.objects.all().count(), 1) @override_settings( - DJANGO_CRON_LOCK_BACKEND='django_cron.backends.lock.database.DatabaseLock' + DJANGO_CRON_LOCK_BACKEND="django_cron.backends.lock.database.DatabaseLock" ) def test_database_locking_backend(self): # TODO: to test it properly we would need to run multiple jobs at the same time @@ -122,23 +121,23 @@ def test_database_locking_backend(self): self.assertEqual(CronJobLock.objects.all().count(), cron_job_locks + 1) self.assertEqual(CronJobLock.objects.first().locked, False) - @patch.object(cron.TestSuccessCronJob, 'do') + @patch.object(cron.TestSuccessCronJob, "do") def test_dry_run_does_not_perform_task(self, mock_do): response = self._call(self.cron_success, dry_run=True) self.assertReportedRun(cron.TestSuccessCronJob, response) mock_do.assert_not_called() self.assertFalse(CronJobLog.objects.exists()) - @patch.object(cron.TestSuccessCronJob, 'do') + @patch.object(cron.TestSuccessCronJob, "do") def test_non_dry_run_performs_task(self, mock_do): - mock_do.return_value = 'message' + mock_do.return_value = "message" response = self._call(self.cron_success) self.assertReportedRun(cron.TestSuccessCronJob, response) mock_do.assert_called_once() self.assertEqual(1, CronJobLog.objects.count()) log = CronJobLog.objects.get() self.assertEqual( - 'message', log.message.strip() + "message", log.message.strip() ) # CronJobManager adds new line at the end of each message self.assertTrue(log.is_success) @@ -160,27 +159,27 @@ def test_runs_every_mins(self): def test_runs_every_mins_with_tolerance(self): with freeze_time("2014-01-01 00:00:00"): - call_command('runcrons', self.cron_5mins_with_tolerance) + call_command("runcrons", self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 1) with freeze_time("2014-01-01 00:04:59"): - call_command('runcrons', self.cron_5mins_with_tolerance) + call_command("runcrons", self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 2) with freeze_time("2014-01-01 00:05:01"): - call_command('runcrons', self.cron_5mins_with_tolerance) + call_command("runcrons", self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 2) with freeze_time("2014-01-01 00:09:40"): - call_command('runcrons', self.cron_5mins_with_tolerance) + call_command("runcrons", self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 2) with freeze_time("2014-01-01 00:09:54"): - call_command('runcrons', self.cron_5mins_with_tolerance) + call_command("runcrons", self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 2) with freeze_time("2014-01-01 00:09:55"): - call_command('runcrons', self.cron_5mins_with_tolerance) + call_command("runcrons", self.cron_5mins_with_tolerance) self.assertEqual(CronJobLog.objects.all().count(), 3) def test_runs_at_time(self): @@ -203,72 +202,72 @@ def test_run_on_weekend(self): for test_date in ("2017-06-17", "2017-06-18"): # Saturday and Sunday logs_count = CronJobLog.objects.all().count() with freeze_time(test_date): - call_command('runcrons', self.cron_run_on_weekend) + call_command("runcrons", self.cron_run_on_weekend) self.assertEqual(CronJobLog.objects.all().count(), logs_count + 1) for test_date in ( - "2017-06-19", - "2017-06-20", - "2017-06-21", - "2017-06-22", - "2017-06-23", + "2017-06-19", + "2017-06-20", + "2017-06-21", + "2017-06-22", + "2017-06-23", ): # Mon-Fri logs_count = CronJobLog.objects.all().count() with freeze_time(test_date): - call_command('runcrons', self.cron_run_on_weekend) + call_command("runcrons", self.cron_run_on_weekend) self.assertEqual(CronJobLog.objects.all().count(), logs_count) def test_run_on_month_days(self): for test_date in ("2010-10-1", "2010-10-10", "2010-10-20"): logs_count = CronJobLog.objects.all().count() with freeze_time(test_date): - call_command('runcrons', self.run_on_month_days) + call_command("runcrons", self.run_on_month_days) self.assertEqual(CronJobLog.objects.all().count(), logs_count + 1) for test_date in ( - "2010-10-2", - "2010-10-9", - "2010-10-11", - "2010-10-19", - "2010-10-21", + "2010-10-2", + "2010-10-9", + "2010-10-11", + "2010-10-19", + "2010-10-21", ): logs_count = CronJobLog.objects.all().count() with freeze_time(test_date): - call_command('runcrons', self.run_on_month_days) + call_command("runcrons", self.run_on_month_days) self.assertEqual(CronJobLog.objects.all().count(), logs_count) def test_silent_produces_no_output_success(self): response = self._call(self.cron_success, silent=True) self.assertEqual(1, CronJobLog.objects.count()) - self.assertEqual('', response) + self.assertEqual("", response) def test_silent_produces_no_output_no_run(self): with freeze_time("2014-01-01 00:00:00"): response = self._call(self.cron_run_at_times, silent=True) self.assertEqual(1, CronJobLog.objects.count()) - self.assertEqual('', response) + self.assertEqual("", response) with freeze_time("2014-01-01 00:00:01"): response = self._call(self.cron_run_at_times, silent=True) self.assertEqual(1, CronJobLog.objects.count()) - self.assertEqual('', response) + self.assertEqual("", response) def test_silent_produces_no_output_failure(self): response = self._call(self.cron_error, silent=True) - self.assertEqual('', response) + self.assertEqual("", response) def test_admin(self): - password = 'test' - user = User.objects.create_superuser('test', 'test@tivix.com', password) + password = "test" + user = User.objects.create_superuser("test", "test@tivix.com", password) self.client = Client() self.client.login(username=user.username, password=password) # edit CronJobLog object self._call(self.cron_success, force=True) log = CronJobLog.objects.all()[0] - url = reverse('admin:django_cron_cronjoblog_change', args=(log.id,)) + url = reverse("admin:django_cron_cronjoblog_change", args=(log.id,)) response = self.client.get(url) - self.assertIn('Cron job logs', str(response.content)) + self.assertIn("Cron job logs", str(response.content)) def run_cronjob_in_thread(self, logs_count): self._call(self.cron_wait_3sec) @@ -322,11 +321,11 @@ def test_humanize_duration(self): test_subjects = ( ( timedelta(days=1, hours=1, minutes=1, seconds=1), - '1 day, 1 hour, 1 minute, 1 second', + "1 day, 1 hour, 1 minute, 1 second", ), - (timedelta(days=2), '2 days'), - (timedelta(days=15, minutes=4), '15 days, 4 minutes'), - (timedelta(), '< 1 second'), + (timedelta(days=2), "2 days"), + (timedelta(days=15, minutes=4), "15 days, 4 minutes"), + (timedelta(), "< 1 second"), ) for duration, humanized in test_subjects: @@ -336,36 +335,46 @@ def test_remove_old_succeeded_job_logs(self): mock_date = datetime.datetime(2022, 5, 1, 12, 0, 0) for _ in range(5): with freeze_time(mock_date): - call_command('runcrons', self.run_and_remove_old_logs) + call_command("runcrons", self.run_and_remove_old_logs) self.assertEqual(CronJobLog.objects.all().count(), 1) self.assertEqual(CronJobLog.objects.all().first().end_time, mock_date) def test_run_job_with_logs_in_future(self): mock_date_in_future = datetime.datetime(2222, 5, 1, 12, 0, 0) with freeze_time(mock_date_in_future): - call_command('runcrons', self.cron_5mins) + call_command("runcrons", self.cron_5mins) self.assertEqual(CronJobLog.objects.all().count(), 1) - self.assertEqual(CronJobLog.objects.all().first().end_time, mock_date_in_future) + self.assertEqual( + CronJobLog.objects.all().first().end_time, mock_date_in_future + ) mock_date_in_past = mock_date_in_future - timedelta(days=1000) with freeze_time(mock_date_in_past): - call_command('runcrons', self.cron_5mins) + call_command("runcrons", self.cron_5mins) self.assertEqual(CronJobLog.objects.all().count(), 2) - self.assertEqual(CronJobLog.objects.all().earliest('start_time').end_time, mock_date_in_past) + self.assertEqual( + CronJobLog.objects.all().earliest("start_time").end_time, + mock_date_in_past, + ) mock_date_in_past_plus_one_min = mock_date_in_future + timedelta(minutes=1) with freeze_time(mock_date_in_past_plus_one_min): - call_command('runcrons', self.cron_5mins) + call_command("runcrons", self.cron_5mins) self.assertEqual(CronJobLog.objects.all().count(), 2) - self.assertEqual(CronJobLog.objects.all().earliest('start_time').end_time, mock_date_in_past) + self.assertEqual( + CronJobLog.objects.all().earliest("start_time").end_time, + mock_date_in_past, + ) class TestCronLoop(TransactionTestCase): - success_cron = f'{_TEST_CRON_MODULE}.TestSuccessCronJob' + success_cron = f"{_TEST_CRON_MODULE}.TestSuccessCronJob" def _call(self, *args, **kwargs): - return call('cronloop', *args, **kwargs) + return call("cronloop", *args, **kwargs) def test_repeat_twice(self): - self._call(cron_classes=[self.success_cron, self.success_cron], repeat=2, sleep=1) + self._call( + cron_classes=[self.success_cron, self.success_cron], repeat=2, sleep=1 + ) self.assertEqual(CronJobLog.objects.all().count(), 4) diff --git a/djangocron b/djangocron new file mode 100644 index 0000000..e69de29 From 0575c2fe2d291f3982b87fcd48e028f59ad3a158 Mon Sep 17 00:00:00 2001 From: jorenham Date: Sun, 28 Jul 2024 19:03:18 +0200 Subject: [PATCH 05/16] remove empty file --- djangocron | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 djangocron diff --git a/djangocron b/djangocron deleted file mode 100644 index e69de29..0000000 From 2017e512b42183a28ee94a3bc84566e3e93f5142 Mon Sep 17 00:00:00 2001 From: jorenham Date: Wed, 7 Aug 2024 16:16:43 +0200 Subject: [PATCH 06/16] fix migrations for django 5.1 support --- django_cron/migrations/0001_initial.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/django_cron/migrations/0001_initial.py b/django_cron/migrations/0001_initial.py index 5cf8d85..bfbd456 100644 --- a/django_cron/migrations/0001_initial.py +++ b/django_cron/migrations/0001_initial.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations +from django.db import migrations, models class Migration(migrations.Migration): @@ -34,14 +34,14 @@ class Migration(migrations.Migration): ), ], ), - migrations.AlterIndexTogether( - name='cronjoblog', - index_together=set( - [ - ('code', 'is_success', 'ran_at_time'), - ('code', 'start_time', 'ran_at_time'), - ('code', 'start_time'), - ] - ), - ), + # migrations.AlterIndexTogether( + # name='cronjoblog', + # index_together=set( + # [ + # ('code', 'is_success', 'ran_at_time'), + # ('code', 'start_time', 'ran_at_time'), + # ('code', 'start_time'), + # ] + # ), + # ), ] From 5f4c82307608a03547eebb821ec6aa2c6e3a2b70 Mon Sep 17 00:00:00 2001 From: jorenham Date: Wed, 14 Aug 2024 14:19:19 +0200 Subject: [PATCH 07/16] restore original initial migration --- django_cron/migrations/0001_initial.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/django_cron/migrations/0001_initial.py b/django_cron/migrations/0001_initial.py index bfbd456..26ef67b 100644 --- a/django_cron/migrations/0001_initial.py +++ b/django_cron/migrations/0001_initial.py @@ -34,14 +34,14 @@ class Migration(migrations.Migration): ), ], ), - # migrations.AlterIndexTogether( - # name='cronjoblog', - # index_together=set( - # [ - # ('code', 'is_success', 'ran_at_time'), - # ('code', 'start_time', 'ran_at_time'), - # ('code', 'start_time'), - # ] - # ), - # ), + migrations.AlterIndexTogether( + name='cronjoblog', + index_together=set( + [ + ('code', 'is_success', 'ran_at_time'), + ('code', 'start_time', 'ran_at_time'), + ('code', 'start_time'), + ] + ), + ), ] From b4aafd801d35f31429471fc6b98572a43e6e2e0c Mon Sep 17 00:00:00 2001 From: jorenham Date: Wed, 14 Aug 2024 14:19:31 +0200 Subject: [PATCH 08/16] squash migrations on django 5.0.8 --- ..._0004_alter_cronjoblog_options_and_more.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 django_cron/migrations/0001_squashed_0004_alter_cronjoblog_options_and_more.py diff --git a/django_cron/migrations/0001_squashed_0004_alter_cronjoblog_options_and_more.py b/django_cron/migrations/0001_squashed_0004_alter_cronjoblog_options_and_more.py new file mode 100644 index 0000000..95ee318 --- /dev/null +++ b/django_cron/migrations/0001_squashed_0004_alter_cronjoblog_options_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.0.8 on 2024-08-14 14:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('django_cron', '0001_initial'), ('django_cron', '0002_remove_max_length_from_CronJobLog_message'), ('django_cron', '0003_cronjoblock'), ('django_cron', '0004_alter_cronjoblog_options_and_more')] + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CronJobLock', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('job_name', models.CharField(max_length=200, unique=True)), + ('locked', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='CronJobLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=64)), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField()), + ('is_success', models.BooleanField(default=False)), + ('message', models.TextField(blank=True, default='')), + ('ran_at_time', models.TimeField(blank=True, editable=False, null=True)), + ], + options={ + 'get_latest_by': 'start_time', + 'indexes': [models.Index(fields=['code', 'start_time'], name='django_cron_code_966ed3_idx'), models.Index(fields=['code', 'start_time', 'ran_at_time'], name='django_cron_code_21f381_idx'), models.Index(fields=['code', 'is_success', 'ran_at_time'], name='django_cron_code_89ad04_idx'), models.Index(fields=['code'], name='django_cron_code_26aea9_idx'), models.Index(fields=['start_time'], name='django_cron_start_t_9e0b8f_idx'), models.Index(fields=['end_time'], name='django_cron_end_tim_c3cfdc_idx'), models.Index(fields=['ran_at_time'], name='django_cron_ran_at__aa3be6_idx')], + }, + ), + ] From 5b210e7a8df65358fd3c546e71ea30c0547e6d0a Mon Sep 17 00:00:00 2001 From: jorenham Date: Fri, 5 Dec 2025 15:20:06 +0100 Subject: [PATCH 09/16] drop py <3.10, migrate to uv, remove django upper bound --- .github/workflows/django.yml | 28 +++--- poetry.lock | 164 ----------------------------------- pyproject.toml | 41 +++++---- uv.lock | 146 +++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 192 deletions(-) delete mode 100644 poetry.lock create mode 100644 uv.lock diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index eaeab95..676c301 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -1,4 +1,5 @@ name: CI +permissions: read-all on: push: @@ -8,28 +9,31 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +env: + UV_LOCKED: 1 + jobs: build: runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: - max-parallel: 4 + fail-fast: false matrix: - python: [3.8, 3.12] + python: ["3.10", "3,11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 - - run: pipx install poetry - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6.0.1 + + - uses: astral-sh/setup-uv@v7.1.4 with: python-version: ${{ matrix.python }} - - name: poetry check - run: poetry check - - name: poetry install - run: poetry install + - name: django-admin check - run: python run python testmanage.py check + run: uv run testmanage.py check + - name: django-admin test - run: python run python testmanage.py test + run: uv run testmanage.py test diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 8e177e2..0000000 --- a/poetry.lock +++ /dev/null @@ -1,164 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "asgiref" -version = "3.8.1" -description = "ASGI specs, helper code, and adapters" -optional = false -python-versions = ">=3.8" -files = [ - {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, - {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} - -[package.extras] -tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] - -[[package]] -name = "backports-zoneinfo" -version = "0.2.1" -description = "Backport of the standard library zoneinfo module" -optional = false -python-versions = ">=3.6" -files = [ - {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, - {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, - {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"}, - {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"}, - {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"}, - {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"}, - {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"}, - {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"}, - {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"}, - {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"}, - {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, - {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, - {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, - {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, - {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, - {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, -] - -[package.extras] -tzdata = ["tzdata"] - -[[package]] -name = "django" -version = "4.2.14" -description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." -optional = false -python-versions = ">=3.8" -files = [ - {file = "Django-4.2.14-py3-none-any.whl", hash = "sha256:3ec32bc2c616ab02834b9cac93143a7dc1cdcd5b822d78ac95fc20a38c534240"}, - {file = "Django-4.2.14.tar.gz", hash = "sha256:fc6919875a6226c7ffcae1a7d51e0f2ceaf6f160393180818f6c95f51b1e7b96"}, -] - -[package.dependencies] -asgiref = ">=3.6.0,<4" -"backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} -sqlparse = ">=0.3.1" -tzdata = {version = "*", markers = "sys_platform == \"win32\""} - -[package.extras] -argon2 = ["argon2-cffi (>=19.1.0)"] -bcrypt = ["bcrypt"] - -[[package]] -name = "freezegun" -version = "1.5.1" -description = "Let your Python tests travel through time" -optional = false -python-versions = ">=3.7" -files = [ - {file = "freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1"}, - {file = "freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9"}, -] - -[package.dependencies] -python-dateutil = ">=2.7" - -[[package]] -name = "mock" -version = "5.1.0" -description = "Rolling backport of unittest.mock for all Pythons" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, - {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, -] - -[package.extras] -build = ["blurb", "twine", "wheel"] -docs = ["sphinx"] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "sqlparse" -version = "0.5.1" -description = "A non-validating SQL parser." -optional = false -python-versions = ">=3.8" -files = [ - {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"}, - {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"}, -] - -[package.extras] -dev = ["build", "hatch"] -doc = ["sphinx"] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "tzdata" -version = "2024.1" -description = "Provider of IANA time zone data" -optional = false -python-versions = ">=2" -files = [ - {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, - {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, -] - -[metadata] -lock-version = "2.0" -python-versions = "^3.8" -content-hash = "ea0b5d905038e4a0d3853700b59ccdb32fa5dd5d1c5d5b959e56b45a5fcfb679" diff --git a/pyproject.toml b/pyproject.toml index e3138ff..7cec295 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,42 @@ [build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +requires = ["uv_build>=0.9,<0.10"] +build-backend = "uv_build" -[tool.poetry] +[tool.uv.build-backend] +module-root = "" + +[project] name = "django-cron" -version = "0.6.0" description = "Running python crons in a Django project" +version = "0.7.0" authors = [ - "Sumit Chachra ", - "Joren Hammudoglu ", + {name = "Sumit Chachra", email = "chachra@tivix.com"}, + {name = "Joren Hammudoglu", email = "jhammudoglu@gmail.com"}, ] +license = "MIT" +license-files = ["LICENSE"] readme = "README.rst" classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "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", "Framework :: Django", ] -repository = "https://github.com/jorenham/django-cron" +requires-python = ">=3.10" +dependencies = [ + "django>=4.2", +] -[tool.poetry.dependencies] -python = "^3.8" -django = ">=4.2,<6.0" +[project.urls] +Repository = "https://github.com/jorenham/django-cron" +Issues = "https://github.com/jorenham/django-cron/issues" -[tool.poetry.group.dev.dependencies] -freezegun = "^1.5.1" -mock = "^5.1.0" +[dependency-groups] +dev = [ + "freezegun==1.5.5", + "mock==5.2.0", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b4a9bd3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,146 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "asgiref" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, +] + +[[package]] +name = "django" +version = "5.2.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" }, +] + +[[package]] +name = "django" +version = "6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/75/19762bfc4ea556c303d9af8e36f0cd910ab17dff6c8774644314427a2120/django-6.0.tar.gz", hash = "sha256:7b0c1f50c0759bbe6331c6a39c89ae022a84672674aeda908784617ef47d8e26", size = 10932418, upload-time = "2025-12-03T16:26:21.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ae/f19e24789a5ad852670d6885f5480f5e5895576945fcc01817dfd9bc002a/django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d", size = 8339181, upload-time = "2025-12-03T16:26:16.231Z" }, +] + +[[package]] +name = "django-cron" +version = "0.7.0" +source = { editable = "." } +dependencies = [ + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "freezegun" }, + { name = "mock" }, +] + +[package.metadata] +requires-dist = [{ name = "django", specifier = ">=4.2" }] + +[package.metadata.requires-dev] +dev = [ + { name = "freezegun", specifier = "==1.5.5" }, + { name = "mock", specifier = "==5.2.0" }, +] + +[[package]] +name = "freezegun" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, +] + +[[package]] +name = "mock" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/8c/14c2ae915e5f9dca5a22edd68b35be94400719ccfa068a03e0fb63d0f6f6/mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0", size = 92796, upload-time = "2025-03-03T12:31:42.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/d9/617e6af809bf3a1d468e0d58c3997b1dc219a9a9202e650d30c2fc85d481/mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f", size = 31617, upload-time = "2025-03-03T12:31:41.518Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/67/701f86b28d63b2086de47c942eccf8ca2208b3be69715a1119a4e384415a/sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e", size = 120112, upload-time = "2025-11-28T07:10:18.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] From 3d535ba42bf2f369c8bddcce2625cc89dd2b94a8 Mon Sep 17 00:00:00 2001 From: jorenham Date: Fri, 5 Dec 2025 16:21:34 +0100 Subject: [PATCH 10/16] add django-stubs as dev dependency --- pyproject.toml | 1 + uv.lock | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 7cec295..99b77df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,4 +39,5 @@ Issues = "https://github.com/jorenham/django-cron/issues" dev = [ "freezegun==1.5.5", "mock==5.2.0", + "django-stubs", ] diff --git a/uv.lock b/uv.lock index b4a9bd3..fdd50a7 100644 --- a/uv.lock +++ b/uv.lock @@ -63,6 +63,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "django-stubs" }, { name = "freezegun" }, { name = "mock" }, ] @@ -72,10 +73,42 @@ requires-dist = [{ name = "django", specifier = ">=4.2" }] [package.metadata.requires-dev] dev = [ + { name = "django-stubs" }, { name = "freezegun", specifier = "==1.5.5" }, { name = "mock", specifier = "==5.2.0" }, ] +[[package]] +name = "django-stubs" +version = "5.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "django-stubs-ext" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/75/97626224fd8f1787bb6f7f06944efcfddd5da7764bf741cf7f59d102f4a0/django_stubs-5.2.8.tar.gz", hash = "sha256:9bba597c9a8ed8c025cae4696803d5c8be1cf55bfc7648a084cbf864187e2f8b", size = 257709, upload-time = "2025-12-01T08:13:09.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/3f/7c9543ad5ade5ce1d33d187a3abd82164570314ebee72c6206ab5c044ebf/django_stubs-5.2.8-py3-none-any.whl", hash = "sha256:a3c63119fd7062ac63d58869698d07c9e5ec0561295c4e700317c54e8d26716c", size = 508136, upload-time = "2025-12-01T08:13:07.963Z" }, +] + +[[package]] +name = "django-stubs-ext" +version = "5.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/a2/d67f4a5200ff7626b104eddceaf529761cba4ed318a73ffdb0677551be73/django_stubs_ext-5.2.8.tar.gz", hash = "sha256:b39938c46d7a547cd84e4a6378dbe51a3dd64d70300459087229e5fee27e5c6b", size = 6487, upload-time = "2025-12-01T08:12:37.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/2d/cb0151b780c3730cf0f2c0fcb1b065a5e88f877cf7a9217483c375353af1/django_stubs_ext-5.2.8-py3-none-any.whl", hash = "sha256:1dd5470c9675591362c78a157a3cf8aec45d0e7a7f0cf32f227a1363e54e0652", size = 9949, upload-time = "2025-12-01T08:12:36.397Z" }, +] + [[package]] name = "freezegun" version = "1.5.5" @@ -127,6 +160,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" }, ] +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 5fb4f3debb94eaf6acdd0c8bb4ed485e3d550a3f Mon Sep 17 00:00:00 2001 From: jorenham Date: Fri, 5 Dec 2025 16:22:24 +0100 Subject: [PATCH 11/16] fixed tests, timezone stuff, and some annotations --- demo/demo/cron.py | 5 +- django_cron/core.py | 66 +++++++++++++++------ django_cron/helpers.py | 14 ++--- django_cron/management/commands/runcrons.py | 5 +- django_cron/models.py | 3 + django_cron/test/cron.py | 4 +- django_cron/test/settings.py | 2 + django_cron/tests.py | 19 +++--- 8 files changed, 70 insertions(+), 48 deletions(-) diff --git a/demo/demo/cron.py b/demo/demo/cron.py index f026950..a251107 100644 --- a/demo/demo/cron.py +++ b/demo/demo/cron.py @@ -1,8 +1,7 @@ -import datetime - from django.conf import settings from django.contrib.auth.models import User from django.core.mail import send_mail +from django.utils import timezone from django_cron import CronJobBase, Schedule @@ -37,6 +36,6 @@ class WriteDateToFileCronJob(CronJobBase): code = 'cron.WriteDateToFileCronJob' def do(self): - message = f"Current date: {datetime.datetime.now()} \n" + message = f"Current date: {timezone.now()} \n" with open("cron-demo.txt", "w") as myfile: myfile.write(message) diff --git a/django_cron/core.py b/django_cron/core.py index 2ec3145..9ac12ef 100644 --- a/django_cron/core.py +++ b/django_cron/core.py @@ -2,13 +2,15 @@ import sys import time import traceback +from collections.abc import Container, Sequence from datetime import datetime, timedelta +from typing import ClassVar, Literal, TypeAlias from django.conf import settings from django.db.models import Q -from django.utils.timezone import now as utc_now +from django.utils import timezone -from .helpers import get_class, get_current_time +from .helpers import get_class __all__ = [ "DEFAULT_LOCK_BACKEND", @@ -19,6 +21,12 @@ "Schedule", ] +_Weekday: TypeAlias = Literal[0, 1, 2, 3, 4, 5, 6] +_Monthday: TypeAlias = Literal[ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, +] # fmt: skip + DEFAULT_LOCK_BACKEND = "django_cron.backends.lock.cache.CacheLock" DJANGO_CRON_OUTPUT_ERRORS = False logger = logging.getLogger("django_cron") @@ -28,15 +36,22 @@ class BadCronJobError(AssertionError): pass -class Schedule(object): +class Schedule: + run_every_mins: float | None + run_at_times: Sequence[str] + retry_after_failure_mins: float | None + run_weekly_on_days: Container[_Weekday] | None + run_monthly_on_days: Container[_Monthday] | None + run_tolerance_seconds: float + def __init__( self, - run_every_mins=None, - run_at_times=None, - retry_after_failure_mins=None, - run_weekly_on_days=None, - run_monthly_on_days=None, - run_tolerance_seconds=0, + run_every_mins: float | None = None, + run_at_times: Sequence[str] | None = None, + retry_after_failure_mins: float | None = None, + run_weekly_on_days: Container[_Weekday] | None = None, + run_monthly_on_days: Container[_Monthday] | None = None, + run_tolerance_seconds: float = 0, ): if run_at_times is None: run_at_times = [] @@ -48,7 +63,7 @@ def __init__( self.run_tolerance_seconds = run_tolerance_seconds -class CronJobBase(object): +class CronJobBase: """ Sub-classes should have the following properties: + code - This should be a code specific to the cron being run. Eg. 'general.stats' etc. @@ -60,6 +75,9 @@ class CronJobBase(object): remove_successful_cron_logs = False + code: ClassVar[str] + schedule: ClassVar[Schedule] + def __init__(self): self.prev_success_cron = None @@ -77,14 +95,18 @@ def get_time_until_run(cls): last_job = CronJobLog.objects.filter(code=cls.code).latest("start_time") except CronJobLog.DoesNotExist: return timedelta() + return ( last_job.start_time - + timedelta(minutes=cls.schedule.run_every_mins) - - utc_now() + + timedelta(minutes=cls.schedule.run_every_mins or 0) + - timezone.now() ) + def do(self) -> None: + raise NotImplementedError -class CronJobManager(object): + +class CronJobManager: """ A manager instance should be created per cron job to be run. Does all the logger tracking etc. for it. @@ -92,7 +114,13 @@ class CronJobManager(object): proper logger in cases of job failure. """ - def __init__(self, cron_job_class, silent=False, dry_run=False, stdout=None): + def __init__( + self, + cron_job_class: type, + silent: bool = False, + dry_run: bool = False, + stdout=None, + ): self.cron_job_class = cron_job_class self.silent = silent self.dry_run = dry_run @@ -136,7 +164,7 @@ def should_run_now(self, force=False): if ( last_job and not last_job.is_success - and get_current_time() + and timezone.now() + timedelta(seconds=cron_job.schedule.run_tolerance_seconds) <= last_job.start_time + timedelta(minutes=cron_job.schedule.retry_after_failure_mins) @@ -154,7 +182,7 @@ def should_run_now(self, force=False): pass if self.previously_ran_successful_cron: - if get_current_time() + timedelta( + if timezone.now() + timedelta( seconds=cron_job.schedule.run_tolerance_seconds ) > self.previously_ran_successful_cron.start_time + timedelta( minutes=cron_job.schedule.run_every_mins @@ -166,7 +194,7 @@ def should_run_now(self, force=False): if cron_job.schedule.run_at_times: for time_data in cron_job.schedule.run_at_times: user_time = time.strptime(time_data, "%H:%M") - now = get_current_time() + now = timezone.now() actual_time = time.strptime("%s:%s" % (now.hour, now.minute), "%H:%M") if actual_time >= user_time: qset = CronJobLog.objects.filter( @@ -194,7 +222,7 @@ def make_log(self, *messages, **kwargs): cron_log.is_success = kwargs.get("success", True) cron_log.message = self.make_log_msg(messages) cron_log.ran_at_time = getattr(self, "user_time", None) - cron_log.end_time = get_current_time() + cron_log.end_time = timezone.now() cron_log.save() if not cron_log.is_success and self.write_log: @@ -213,7 +241,7 @@ def make_log_msg(self, messages): def __enter__(self): from django_cron.models import CronJobLog - self.cron_log = CronJobLog(start_time=get_current_time()) + self.cron_log = CronJobLog(start_time=timezone.now()) return self diff --git a/django_cron/helpers.py b/django_cron/helpers.py index 7b54dbe..61fed96 100644 --- a/django_cron/helpers.py +++ b/django_cron/helpers.py @@ -1,9 +1,10 @@ -from django.utils.timezone import now as utc_now, localtime, is_naive -from django.utils.translation import gettext as _ +from datetime import timedelta + from django.template.defaultfilters import pluralize +from django.utils.translation import gettext as _ -def humanize_duration(duration): +def humanize_duration(duration: timedelta) -> str: """ Returns a humanized string representing time difference @@ -30,7 +31,7 @@ def humanize_duration(duration): return ", ".join(parts) if len(parts) != 0 else _("< 1 second") -def get_class(kls): +def get_class(kls: str) -> type: """ Converts a string to a class. Courtesy: http://stackoverflow.com/questions/452969/does-python-have-an-equivalent-to-java-class-forname/452981#452981 @@ -45,8 +46,3 @@ def get_class(kls): for comp in parts[1:]: m = getattr(m, comp) return m - - -def get_current_time(): - now = utc_now() - return now if is_naive(now) else localtime(now) diff --git a/django_cron/management/commands/runcrons.py b/django_cron/management/commands/runcrons.py index 55224c0..4ea1395 100644 --- a/django_cron/management/commands/runcrons.py +++ b/django_cron/management/commands/runcrons.py @@ -4,9 +4,10 @@ from django.conf import settings from django.core.management.base import BaseCommand from django.db import close_old_connections +from django.utils import timezone from django_cron.core import CronJobManager -from django_cron.helpers import get_class, get_current_time +from django_cron.helpers import get_class from django_cron.models import CronJobLog DEFAULT_LOCK_TIME = 24 * 60 * 60 # 24 hours @@ -87,4 +88,4 @@ def clear_old_log_entries(): """ if hasattr(settings, "DJANGO_CRON_DELETE_LOGS_OLDER_THAN"): delta = timedelta(days=settings.DJANGO_CRON_DELETE_LOGS_OLDER_THAN) - CronJobLog.objects.filter(end_time__lt=get_current_time() - delta).delete() + CronJobLog.objects.filter(end_time__lt=timezone.now() - delta).delete() diff --git a/django_cron/models.py b/django_cron/models.py index c7413ca..8336358 100644 --- a/django_cron/models.py +++ b/django_cron/models.py @@ -6,6 +6,9 @@ class CronJobLog(models.Model): Keeps track of the cron jobs that ran etc. and any error messages if they failed. """ + + id: int + code = models.CharField(max_length=64) start_time = models.DateTimeField() end_time = models.DateTimeField() diff --git a/django_cron/test/cron.py b/django_cron/test/cron.py index 68bff4b..daf54d3 100644 --- a/django_cron/test/cron.py +++ b/django_cron/test/cron.py @@ -73,9 +73,7 @@ class RunOnMonthDaysCronJob(CronJobBase): code = "run_on_month_days" schedule = Schedule( run_monthly_on_days=[1, 10, 20], - run_at_times=[ - "0:00", - ], + run_at_times=["0:00"], ) def do(self): diff --git a/django_cron/test/settings.py b/django_cron/test/settings.py index 8028bb5..1d7e4de 100644 --- a/django_cron/test/settings.py +++ b/django_cron/test/settings.py @@ -94,3 +94,5 @@ f"{_CRON_PATH}Wait3secCronJob", "django_cron.cron.FailedRunsNotificationCronJob", ] + +USE_TZ = False diff --git a/django_cron/tests.py b/django_cron/tests.py index 55c7da0..519f476 100644 --- a/django_cron/tests.py +++ b/django_cron/tests.py @@ -1,8 +1,6 @@ -import datetime import threading -from datetime import timedelta +from datetime import datetime, timedelta from time import sleep -from unittest import skip from django import db from django.contrib.auth.models import User @@ -40,7 +38,7 @@ def str_content(self): return self._str_cache -def call(command, *args, **kwargs): +def call(command: str, *args, **kwargs) -> str: """ Run the runcrons management command with a supressed output. """ @@ -119,7 +117,7 @@ def test_database_locking_backend(self): self._call(self.cron_success, force=True) self.assertEqual(CronJobLog.objects.all().count(), 3) self.assertEqual(CronJobLock.objects.all().count(), cron_job_locks + 1) - self.assertEqual(CronJobLock.objects.first().locked, False) + self.assertEqual(CronJobLock.objects.all()[0].locked, False) @patch.object(cron.TestSuccessCronJob, "do") def test_dry_run_does_not_perform_task(self, mock_do): @@ -307,7 +305,6 @@ def test_cache_locking_backend(self): # t.join(10) # self.assertEqual(CronJobLog.objects.all().count(), logs_count + 1) - @skip # TODO check why the test is failing def test_failed_runs_notification(self): CronJobLog.objects.all().delete() @@ -332,21 +329,19 @@ def test_humanize_duration(self): self.assertEqual(humanize_duration(duration), humanized) def test_remove_old_succeeded_job_logs(self): - mock_date = datetime.datetime(2022, 5, 1, 12, 0, 0) + mock_date = datetime(2022, 5, 1, 12, 0, 0) for _ in range(5): with freeze_time(mock_date): call_command("runcrons", self.run_and_remove_old_logs) self.assertEqual(CronJobLog.objects.all().count(), 1) - self.assertEqual(CronJobLog.objects.all().first().end_time, mock_date) + self.assertEqual(CronJobLog.objects.all()[0].end_time, mock_date) def test_run_job_with_logs_in_future(self): - mock_date_in_future = datetime.datetime(2222, 5, 1, 12, 0, 0) + mock_date_in_future = datetime(2222, 5, 1, 12, 0, 0) with freeze_time(mock_date_in_future): call_command("runcrons", self.cron_5mins) self.assertEqual(CronJobLog.objects.all().count(), 1) - self.assertEqual( - CronJobLog.objects.all().first().end_time, mock_date_in_future - ) + self.assertEqual(CronJobLog.objects.all()[0].end_time, mock_date_in_future) mock_date_in_past = mock_date_in_future - timedelta(days=1000) with freeze_time(mock_date_in_past): From 6c1cb917706f261770e1e81c7406ac67a1133231 Mon Sep 17 00:00:00 2001 From: jorenham Date: Fri, 5 Dec 2025 16:25:27 +0100 Subject: [PATCH 12/16] typo --- .github/workflows/django.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 676c301..e8e5053 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -23,7 +23,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.10", "3,11", "3.12", "3.13", "3.14"] + python: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6.0.1 From c22d653273dfb342776da83a5372c66787076c75 Mon Sep 17 00:00:00 2001 From: jorenham Date: Sat, 6 Dec 2025 17:04:24 +0100 Subject: [PATCH 13/16] partial reversion of #4 --- demo/demo/cron.py | 5 ++-- django_cron/core.py | 33 ++++++++------------- django_cron/helpers.py | 6 ++++ django_cron/management/commands/runcrons.py | 5 ++-- django_cron/tests.py | 19 +++++++----- 5 files changed, 36 insertions(+), 32 deletions(-) diff --git a/demo/demo/cron.py b/demo/demo/cron.py index a251107..f026950 100644 --- a/demo/demo/cron.py +++ b/demo/demo/cron.py @@ -1,7 +1,8 @@ +import datetime + from django.conf import settings from django.contrib.auth.models import User from django.core.mail import send_mail -from django.utils import timezone from django_cron import CronJobBase, Schedule @@ -36,6 +37,6 @@ class WriteDateToFileCronJob(CronJobBase): code = 'cron.WriteDateToFileCronJob' def do(self): - message = f"Current date: {timezone.now()} \n" + message = f"Current date: {datetime.datetime.now()} \n" with open("cron-demo.txt", "w") as myfile: myfile.write(message) diff --git a/django_cron/core.py b/django_cron/core.py index 9ac12ef..5d5f078 100644 --- a/django_cron/core.py +++ b/django_cron/core.py @@ -4,13 +4,13 @@ import traceback from collections.abc import Container, Sequence from datetime import datetime, timedelta -from typing import ClassVar, Literal, TypeAlias +from typing import Any, ClassVar from django.conf import settings from django.db.models import Q from django.utils import timezone -from .helpers import get_class +from .helpers import get_class, get_current_time __all__ = [ "DEFAULT_LOCK_BACKEND", @@ -21,12 +21,6 @@ "Schedule", ] -_Weekday: TypeAlias = Literal[0, 1, 2, 3, 4, 5, 6] -_Monthday: TypeAlias = Literal[ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, - 23, 24, 25, 26, 27, 28, 29, 30, 31, -] # fmt: skip - DEFAULT_LOCK_BACKEND = "django_cron.backends.lock.cache.CacheLock" DJANGO_CRON_OUTPUT_ERRORS = False logger = logging.getLogger("django_cron") @@ -40,8 +34,8 @@ class Schedule: run_every_mins: float | None run_at_times: Sequence[str] retry_after_failure_mins: float | None - run_weekly_on_days: Container[_Weekday] | None - run_monthly_on_days: Container[_Monthday] | None + run_weekly_on_days: Container[int] | None + run_monthly_on_days: Container[int] | None run_tolerance_seconds: float def __init__( @@ -49,8 +43,8 @@ def __init__( run_every_mins: float | None = None, run_at_times: Sequence[str] | None = None, retry_after_failure_mins: float | None = None, - run_weekly_on_days: Container[_Weekday] | None = None, - run_monthly_on_days: Container[_Monthday] | None = None, + run_weekly_on_days: Container[int] | None = None, + run_monthly_on_days: Container[int] | None = None, run_tolerance_seconds: float = 0, ): if run_at_times is None: @@ -95,14 +89,13 @@ def get_time_until_run(cls): last_job = CronJobLog.objects.filter(code=cls.code).latest("start_time") except CronJobLog.DoesNotExist: return timedelta() - return ( last_job.start_time - + timedelta(minutes=cls.schedule.run_every_mins or 0) + + timedelta(minutes=cls.schedule.run_every_mins) - timezone.now() ) - def do(self) -> None: + def do(self) -> Any: raise NotImplementedError @@ -164,7 +157,7 @@ def should_run_now(self, force=False): if ( last_job and not last_job.is_success - and timezone.now() + and get_current_time() + timedelta(seconds=cron_job.schedule.run_tolerance_seconds) <= last_job.start_time + timedelta(minutes=cron_job.schedule.retry_after_failure_mins) @@ -182,7 +175,7 @@ def should_run_now(self, force=False): pass if self.previously_ran_successful_cron: - if timezone.now() + timedelta( + if get_current_time() + timedelta( seconds=cron_job.schedule.run_tolerance_seconds ) > self.previously_ran_successful_cron.start_time + timedelta( minutes=cron_job.schedule.run_every_mins @@ -194,7 +187,7 @@ def should_run_now(self, force=False): if cron_job.schedule.run_at_times: for time_data in cron_job.schedule.run_at_times: user_time = time.strptime(time_data, "%H:%M") - now = timezone.now() + now = get_current_time() actual_time = time.strptime("%s:%s" % (now.hour, now.minute), "%H:%M") if actual_time >= user_time: qset = CronJobLog.objects.filter( @@ -222,7 +215,7 @@ def make_log(self, *messages, **kwargs): cron_log.is_success = kwargs.get("success", True) cron_log.message = self.make_log_msg(messages) cron_log.ran_at_time = getattr(self, "user_time", None) - cron_log.end_time = timezone.now() + cron_log.end_time = get_current_time() cron_log.save() if not cron_log.is_success and self.write_log: @@ -241,7 +234,7 @@ def make_log_msg(self, messages): def __enter__(self): from django_cron.models import CronJobLog - self.cron_log = CronJobLog(start_time=timezone.now()) + self.cron_log = CronJobLog(start_time=get_current_time()) return self diff --git a/django_cron/helpers.py b/django_cron/helpers.py index 61fed96..ebef82a 100644 --- a/django_cron/helpers.py +++ b/django_cron/helpers.py @@ -1,6 +1,7 @@ from datetime import timedelta from django.template.defaultfilters import pluralize +from django.utils import timezone from django.utils.translation import gettext as _ @@ -46,3 +47,8 @@ def get_class(kls: str) -> type: for comp in parts[1:]: m = getattr(m, comp) return m + + +def get_current_time(): + now = timezone.now() + return now if timezone.is_naive(now) else timezone.localtime(now) diff --git a/django_cron/management/commands/runcrons.py b/django_cron/management/commands/runcrons.py index 4ea1395..55224c0 100644 --- a/django_cron/management/commands/runcrons.py +++ b/django_cron/management/commands/runcrons.py @@ -4,10 +4,9 @@ from django.conf import settings from django.core.management.base import BaseCommand from django.db import close_old_connections -from django.utils import timezone from django_cron.core import CronJobManager -from django_cron.helpers import get_class +from django_cron.helpers import get_class, get_current_time from django_cron.models import CronJobLog DEFAULT_LOCK_TIME = 24 * 60 * 60 # 24 hours @@ -88,4 +87,4 @@ def clear_old_log_entries(): """ if hasattr(settings, "DJANGO_CRON_DELETE_LOGS_OLDER_THAN"): delta = timedelta(days=settings.DJANGO_CRON_DELETE_LOGS_OLDER_THAN) - CronJobLog.objects.filter(end_time__lt=timezone.now() - delta).delete() + CronJobLog.objects.filter(end_time__lt=get_current_time() - delta).delete() diff --git a/django_cron/tests.py b/django_cron/tests.py index 519f476..55c7da0 100644 --- a/django_cron/tests.py +++ b/django_cron/tests.py @@ -1,6 +1,8 @@ +import datetime import threading -from datetime import datetime, timedelta +from datetime import timedelta from time import sleep +from unittest import skip from django import db from django.contrib.auth.models import User @@ -38,7 +40,7 @@ def str_content(self): return self._str_cache -def call(command: str, *args, **kwargs) -> str: +def call(command, *args, **kwargs): """ Run the runcrons management command with a supressed output. """ @@ -117,7 +119,7 @@ def test_database_locking_backend(self): self._call(self.cron_success, force=True) self.assertEqual(CronJobLog.objects.all().count(), 3) self.assertEqual(CronJobLock.objects.all().count(), cron_job_locks + 1) - self.assertEqual(CronJobLock.objects.all()[0].locked, False) + self.assertEqual(CronJobLock.objects.first().locked, False) @patch.object(cron.TestSuccessCronJob, "do") def test_dry_run_does_not_perform_task(self, mock_do): @@ -305,6 +307,7 @@ def test_cache_locking_backend(self): # t.join(10) # self.assertEqual(CronJobLog.objects.all().count(), logs_count + 1) + @skip # TODO check why the test is failing def test_failed_runs_notification(self): CronJobLog.objects.all().delete() @@ -329,19 +332,21 @@ def test_humanize_duration(self): self.assertEqual(humanize_duration(duration), humanized) def test_remove_old_succeeded_job_logs(self): - mock_date = datetime(2022, 5, 1, 12, 0, 0) + mock_date = datetime.datetime(2022, 5, 1, 12, 0, 0) for _ in range(5): with freeze_time(mock_date): call_command("runcrons", self.run_and_remove_old_logs) self.assertEqual(CronJobLog.objects.all().count(), 1) - self.assertEqual(CronJobLog.objects.all()[0].end_time, mock_date) + self.assertEqual(CronJobLog.objects.all().first().end_time, mock_date) def test_run_job_with_logs_in_future(self): - mock_date_in_future = datetime(2222, 5, 1, 12, 0, 0) + mock_date_in_future = datetime.datetime(2222, 5, 1, 12, 0, 0) with freeze_time(mock_date_in_future): call_command("runcrons", self.cron_5mins) self.assertEqual(CronJobLog.objects.all().count(), 1) - self.assertEqual(CronJobLog.objects.all()[0].end_time, mock_date_in_future) + self.assertEqual( + CronJobLog.objects.all().first().end_time, mock_date_in_future + ) mock_date_in_past = mock_date_in_future - timedelta(days=1000) with freeze_time(mock_date_in_past): From 0d41327d9fbe4d2028f7779059ed0ea807664b25 Mon Sep 17 00:00:00 2001 From: jorenham Date: Sat, 6 Dec 2025 17:06:58 +0100 Subject: [PATCH 14/16] restore static typing fixes in the tests --- django_cron/tests.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/django_cron/tests.py b/django_cron/tests.py index 55c7da0..8ae48ab 100644 --- a/django_cron/tests.py +++ b/django_cron/tests.py @@ -2,7 +2,6 @@ import threading from datetime import timedelta from time import sleep -from unittest import skip from django import db from django.contrib.auth.models import User @@ -119,7 +118,7 @@ def test_database_locking_backend(self): self._call(self.cron_success, force=True) self.assertEqual(CronJobLog.objects.all().count(), 3) self.assertEqual(CronJobLock.objects.all().count(), cron_job_locks + 1) - self.assertEqual(CronJobLock.objects.first().locked, False) + self.assertEqual(CronJobLock.objects.all()[0].locked, False) @patch.object(cron.TestSuccessCronJob, "do") def test_dry_run_does_not_perform_task(self, mock_do): @@ -307,7 +306,6 @@ def test_cache_locking_backend(self): # t.join(10) # self.assertEqual(CronJobLog.objects.all().count(), logs_count + 1) - @skip # TODO check why the test is failing def test_failed_runs_notification(self): CronJobLog.objects.all().delete() @@ -337,16 +335,14 @@ def test_remove_old_succeeded_job_logs(self): with freeze_time(mock_date): call_command("runcrons", self.run_and_remove_old_logs) self.assertEqual(CronJobLog.objects.all().count(), 1) - self.assertEqual(CronJobLog.objects.all().first().end_time, mock_date) + self.assertEqual(CronJobLog.objects.get().end_time, mock_date) def test_run_job_with_logs_in_future(self): mock_date_in_future = datetime.datetime(2222, 5, 1, 12, 0, 0) with freeze_time(mock_date_in_future): call_command("runcrons", self.cron_5mins) self.assertEqual(CronJobLog.objects.all().count(), 1) - self.assertEqual( - CronJobLog.objects.all().first().end_time, mock_date_in_future - ) + self.assertEqual(CronJobLog.objects.get().end_time, mock_date_in_future) mock_date_in_past = mock_date_in_future - timedelta(days=1000) with freeze_time(mock_date_in_past): From b994a555b588408d75bba946e5377aa00d30373d Mon Sep 17 00:00:00 2001 From: jorenham Date: Sat, 6 Dec 2025 17:32:26 +0100 Subject: [PATCH 15/16] some neurotic static typing fixes --- django_cron/admin.py | 11 ++++------- django_cron/backends/lock/base.py | 21 +++++++++++++-------- django_cron/backends/lock/cache.py | 10 +++++++--- django_cron/backends/lock/database.py | 14 +++++++------- django_cron/backends/lock/file.py | 14 +++++++++++--- django_cron/core.py | 2 +- django_cron/management/commands/cronloop.py | 4 ++-- django_cron/management/commands/runcrons.py | 6 +++++- 8 files changed, 50 insertions(+), 32 deletions(-) diff --git a/django_cron/admin.py b/django_cron/admin.py index 5c1254a..ad27001 100644 --- a/django_cron/admin.py +++ b/django_cron/admin.py @@ -30,7 +30,7 @@ def queryset(self, request, queryset): if self.value() == "gt_day": return queryset.filter(end_time__gt=F("start_time") + timedelta(days=1)) - +@admin.register(CronJobLog) class CronJobLogAdmin(admin.ModelAdmin): class Meta: model = CronJobLog @@ -52,17 +52,14 @@ def get_queryset(self, request): ) def get_readonly_fields(self, request, obj=None): - if not request.user.is_superuser and obj is not None: + if obj is not None and not getattr(request.user, "is_superuser", False): names = [f.name for f in CronJobLog._meta.fields if f.name != "id"] - return self.readonly_fields + tuple(names) + return *self.readonly_fields, *names return self.readonly_fields + @admin.display(description=_("Duration"), ordering="duration") def humanize_duration(self, obj): return humanize_duration(obj.end_time - obj.start_time) - humanize_duration.short_description = _("Duration") - humanize_duration.admin_order_field = "duration" - -admin.site.register(CronJobLog, CronJobLogAdmin) admin.site.register(CronJobLock) diff --git a/django_cron/backends/lock/base.py b/django_cron/backends/lock/base.py index 7e62bc6..61c31c2 100644 --- a/django_cron/backends/lock/base.py +++ b/django_cron/backends/lock/base.py @@ -1,4 +1,4 @@ -class DjangoCronJobLock(object): +class DjangoCronJobLock: """ The lock class to use in runcrons management command. Intendent usage is @@ -12,10 +12,15 @@ class DjangoCronJobLock(object): pass """ + job_name: str + job_code: str + parallel: bool + silent: bool + class LockFailedException(Exception): pass - def __init__(self, cron_class, silent, *args, **kwargs): + def __init__(self, cron_class: type, silent: bool, *args, **kwargs): """ This method inits the class. You should take care of getting all @@ -27,12 +32,12 @@ def __init__(self, cron_class, silent, *args, **kwargs): * self.silent for you. The rest is backend-specific. """ - self.job_name = ".".join([cron_class.__module__, cron_class.__name__]) + self.job_name = f"{cron_class.__module__}.{cron_class.__name__}" self.job_code = cron_class.code self.parallel = getattr(cron_class, "ALLOW_PARALLEL_RUNS", False) self.silent = silent - def lock(self): + def lock(self) -> bool: """ This method called to acquire lock. Typically. it will be called from __enter__ method. @@ -44,7 +49,7 @@ def lock(self): "You have to implement lock(self) method for your class" ) - def release(self): + def release(self) -> None: """ This method called to release lock. Tipically called from __exit__ method. @@ -54,13 +59,13 @@ def release(self): "You have to implement release(self) method for your class" ) - def lock_failed_message(self): + def lock_failed_message(self) -> str | list[str]: return "%s: lock found. Will try later." % self.job_name - def __enter__(self): + def __enter__(self) -> None: if not self.parallel and not self.lock(): raise self.LockFailedException(self.lock_failed_message()) - def __exit__(self, type, value, traceback): + def __exit__(self, type, value, traceback) -> None: if not self.parallel: self.release() diff --git a/django_cron/backends/lock/cache.py b/django_cron/backends/lock/cache.py index 046dfec..7ae4c02 100644 --- a/django_cron/backends/lock/cache.py +++ b/django_cron/backends/lock/cache.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.core.cache import caches +from django.core.cache import BaseCache, caches from django.utils import timezone from .base import DjangoCronJobLock @@ -13,7 +13,11 @@ class CacheLock(DjangoCronJobLock): DEFAULT_LOCK_TIME = 24 * 60 * 60 # 24 hours - def __init__(self, cron_class, *args, **kwargs): + cache: BaseCache + lock_name: str + timeout: float + + def __init__(self, cron_class: type, *args, **kwargs): super().__init__(cron_class, *args, **kwargs) self.cache = self.get_cache_by_name() @@ -57,7 +61,7 @@ def get_cache_by_name(self): def get_lock_name(self): return self.job_name - def get_cache_timeout(self, cron_class): + def get_cache_timeout(self, cron_class: type) -> float: try: timeout = getattr( cron_class, "DJANGO_CRON_LOCK_TIME", settings.DJANGO_CRON_LOCK_TIME diff --git a/django_cron/backends/lock/database.py b/django_cron/backends/lock/database.py index f72c829..9872873 100644 --- a/django_cron/backends/lock/database.py +++ b/django_cron/backends/lock/database.py @@ -12,17 +12,17 @@ class DatabaseLock(DjangoCronJobLock): """ @transaction.atomic - def lock(self): + def lock(self) -> bool: lock, _ = CronJobLock.objects.get_or_create(job_name=self.job_name) if lock.locked: return False - else: - lock.locked = True - lock.save() - return True + + lock.locked = True + lock.save(update_fields=["locked"]) + return True @transaction.atomic def release(self): - lock = CronJobLock.objects.filter(job_name=self.job_name, locked=True).first() + lock = CronJobLock.objects.filter(job_name=self.job_name, locked=True)[0] lock.locked = False - lock.save() + lock.save(update_fields=["locked"]) diff --git a/django_cron/backends/lock/file.py b/django_cron/backends/lock/file.py index d0868b3..610c22d 100644 --- a/django_cron/backends/lock/file.py +++ b/django_cron/backends/lock/file.py @@ -1,4 +1,8 @@ import os +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from io import BufferedRandom from django.conf import settings from django.core.files import locks @@ -11,7 +15,7 @@ class FileLock(DjangoCronJobLock): Quite a simple lock backend that uses kernel based locking """ - __lock_fd = None + __lock_fd: "BufferedRandom | None" = None def lock(self): lock_name = self.get_lock_name() @@ -23,8 +27,12 @@ def lock(self): return True def release(self): - locks.unlock(self.__lock_fd) - self.__lock_fd.close() + lock = self.__lock_fd + if lock is None: + raise RuntimeError("cannot release an unacquired lock") + + locks.unlock(lock) + lock.close() def get_lock_name(self): default_path = "/tmp" diff --git a/django_cron/core.py b/django_cron/core.py index 5d5f078..d58af25 100644 --- a/django_cron/core.py +++ b/django_cron/core.py @@ -91,7 +91,7 @@ def get_time_until_run(cls): return timedelta() return ( last_job.start_time - + timedelta(minutes=cls.schedule.run_every_mins) + + timedelta(minutes=cls.schedule.run_every_mins or 0) - timezone.now() ) diff --git a/django_cron/management/commands/cronloop.py b/django_cron/management/commands/cronloop.py index 05a2679..4f8feb9 100644 --- a/django_cron/management/commands/cronloop.py +++ b/django_cron/management/commands/cronloop.py @@ -29,7 +29,7 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - s = options['sleep'] + s: float = options["sleep"] classes = options['cron_classes'] if not classes: classes = [] @@ -43,7 +43,7 @@ def handle(self, *args, **options): if self._call_command_or_return_true('runcrons', classes, s): break - def _call_command_or_return_true(self, command, classes, s): + def _call_command_or_return_true(self, command, classes, s: float): try: call_command(command, *classes) time.sleep(s) diff --git a/django_cron/management/commands/runcrons.py b/django_cron/management/commands/runcrons.py index 55224c0..ffb933b 100644 --- a/django_cron/management/commands/runcrons.py +++ b/django_cron/management/commands/runcrons.py @@ -64,7 +64,11 @@ def handle(self, *args, **options): def run_cron_with_cache_check( - cron_class, force=False, silent=False, dry_run=False, stdout=None + cron_class: type, + force: bool = False, + silent: bool = False, + dry_run: bool = False, + stdout=None, ): """ Checks the cache and runs the cron or not. From 109b49f6986483fb1590535ed34a9cc2fdc6bfa9 Mon Sep 17 00:00:00 2001 From: jorenham Date: Sat, 6 Dec 2025 17:32:49 +0100 Subject: [PATCH 16/16] bump to 0.7.1 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 99b77df..aa91e2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ module-root = "" [project] name = "django-cron" description = "Running python crons in a Django project" -version = "0.7.0" +version = "0.7.1" authors = [ {name = "Sumit Chachra", email = "chachra@tivix.com"}, {name = "Joren Hammudoglu", email = "jhammudoglu@gmail.com"}, diff --git a/uv.lock b/uv.lock index fdd50a7..4b19652 100644 --- a/uv.lock +++ b/uv.lock @@ -54,7 +54,7 @@ wheels = [ [[package]] name = "django-cron" -version = "0.7.0" +version = "0.7.1" source = { editable = "." } dependencies = [ { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },