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..e8e5053 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -1,33 +1,39 @@ -name: Django CI +name: CI +permissions: read-all on: push: - branches: [ master ] + branches: + - master pull_request: - branches: [ master ] + workflow_dispatch: + +concurrency: + 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-version: [3.7, 3.8, 3.9] + python: ["3.10", "3.11", "3.12", "3.13", "3.14"] 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@v6.0.1 + + - uses: astral-sh/setup-uv@v7.1.4 + with: + python-version: ${{ matrix.python }} + + - name: django-admin check + run: uv run testmanage.py check + + - name: django-admin test + run: uv run 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/__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..ad27001 100644 --- a/django_cron/admin.py +++ b/django_cron/admin.py @@ -4,59 +4,62 @@ 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)) +@admin.register(CronJobLog) 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'] - return self.readonly_fields + tuple(names) + 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, *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 a003b4a..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.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. @@ -41,26 +46,26 @@ 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): + def release(self) -> None: """ This method called to release lock. Tipically called from __exit__ method. 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): + 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 00c9353..7ae4c02 100644 --- a/django_cron/backends/lock/cache.py +++ b/django_cron/backends/lock/cache.py @@ -1,8 +1,8 @@ from django.conf import settings -from django.core.cache import caches +from django.core.cache import BaseCache, caches from django.utils import timezone -from django_cron.backends.lock.base import DjangoCronJobLock +from .base import DjangoCronJobLock class CacheLock(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,12 +61,12 @@ 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 + 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..9872873 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): """ @@ -10,17 +12,17 @@ class DatabaseLock(DjangoCronJobLock): """ @transaction.atomic - def lock(self): - lock, created = CronJobLock.objects.get_or_create(job_name=self.job_name) + 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 1d14d31..610c22d 100644 --- a/django_cron/backends/lock/file.py +++ b/django_cron/backends/lock/file.py @@ -1,9 +1,13 @@ 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 -from django_cron.backends.lock.base import DjangoCronJobLock +from .base import DjangoCronJobLock class FileLock(DjangoCronJobLock): @@ -11,27 +15,31 @@ 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() 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 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' - 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 9d5a7e6..d58af25 100644 --- a/django_cron/core.py +++ b/django_cron/core.py @@ -1,33 +1,51 @@ import logging -from datetime import datetime, timedelta -import traceback -import time import sys +import time +import traceback +from collections.abc import Container, Sequence +from datetime import datetime, timedelta +from typing import Any, ClassVar from django.conf import settings -from django.utils.timezone import now as utc_now from django.db.models import Q +from django.utils import timezone -from django_cron.helpers import get_class, get_current_time +from .helpers import get_class, get_current_time -DEFAULT_LOCK_BACKEND = 'django_cron.backends.lock.cache.CacheLock' +__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): 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[int] | None + run_monthly_on_days: Container[int] | 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, + self, + 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[int] | None = None, + run_monthly_on_days: Container[int] | None = None, + run_tolerance_seconds: float = 0, ): if run_at_times is None: run_at_times = [] @@ -39,7 +57,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. @@ -51,6 +69,9 @@ class CronJobBase(object): remove_successful_cron_logs = False + code: ClassVar[str] + schedule: ClassVar[Schedule] + def __init__(self): self.prev_success_cron = None @@ -65,17 +86,20 @@ 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 or 0) + - timezone.now() ) + def do(self) -> Any: + 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. @@ -83,7 +107,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 @@ -91,7 +121,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): @@ -109,43 +139,46 @@ 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: # 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 +209,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 +222,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 +251,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 +275,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 +295,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 +303,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 +318,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 8d65f49..762449c 100644 --- a/django_cron/cron.py +++ b/django_cron/cron.py @@ -1,9 +1,9 @@ from django.conf import settings +from django.core.mail import send_mail -from django_common.helper 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): @@ -44,13 +44,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, ) diff --git a/django_cron/helpers.py b/django_cron/helpers.py index 1c3ecdb..ebef82a 100644 --- a/django_cron/helpers.py +++ b/django_cron/helpers.py @@ -1,9 +1,11 @@ -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 import timezone +from django.utils.translation import gettext as _ -def humanize_duration(duration): +def humanize_duration(duration: timedelta) -> str: """ Returns a humanized string representing time difference @@ -16,26 +18,26 @@ 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): +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 """ - parts = kls.split('.') + parts = kls.split(".") if len(parts) == 1: raise ImportError("'{0}'' is not a valid import path".format(kls)) @@ -48,5 +50,5 @@ def get_class(kls): def get_current_time(): - now = utc_now() - return now if is_naive(now) else localtime(now) + now = timezone.now() + return now if timezone.is_naive(now) else timezone.localtime(now) diff --git a/django_cron/management/commands/cronloop.py b/django_cron/management/commands/cronloop.py index 6e88622..4f8feb9 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 @@ -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,9 +43,9 @@ 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) - 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..ffb933b 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, ) @@ -65,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. @@ -86,6 +89,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/migrations/0001_initial.py b/django_cron/migrations/0001_initial.py index 5cf8d85..26ef67b 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): 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')], + }, + ), + ] 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..8336358 100644 --- a/django_cron/models.py +++ b/django_cron/models.py @@ -7,32 +7,34 @@ class CronJobLog(models.Model): 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) + id: int + + 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): 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 73% rename from test_crons.py rename to django_cron/test/cron.py index 87ebdb8..daf54d3 100644 --- a/test_crons.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,12 +70,10 @@ 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', - ], + run_at_times=["0:00"], ) def do(self): @@ -83,7 +81,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 new file mode 100644 index 0000000..1d7e4de --- /dev/null +++ b/django_cron/test/settings.py @@ -0,0 +1,98 @@ +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", + "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", +] + +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, + "handlers": { + "null": { + "level": "DEBUG", + "class": "logging.NullHandler", + }, + }, + "loggers": { + "django_cron": { + "handlers": ["null"], + "level": "INFO", + "propagate": True, + }, + }, +} + +STATIC_URL = "/static/" + +_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", +] + +USE_TZ = False 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..ce109cd 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..8ae48ab 100644 --- a/django_cron/tests.py +++ b/django_cron/tests.py @@ -1,28 +1,29 @@ import datetime import threading -from time import sleep from datetime import timedelta -from unittest import skip - -from mock import patch -from freezegun import freeze_time +from time import sleep 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 .helpers import humanize_duration +from .models import CronJobLock, CronJobLog +from .test import cron -from django_cron.helpers import humanize_duration -from django_cron.models import CronJobLog, CronJobLock -import test_crons +_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 @@ -32,7 +33,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 @@ -48,229 +49,227 @@ 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) + 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): - 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) - self.assertIn('Make sure these are valid cron class names', response) - self.assertIn(self.does_not_exist_cron, response) + response = self._call(self.cron_obviously_doesnt_exist, force=True) + 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.no_code_cron, force=True) - self.assertIn('does not have a code attribute', response) + response = self._call(self.cron_no_code, force=True) + 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.success_cron, force=True) + 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 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) + self.assertEqual(CronJobLock.objects.all()[0].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) + 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) 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 ( - "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.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): 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.success_cron, silent=True) + 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.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) + 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) + self.assertEqual("", response) def test_silent_produces_no_output_failure(self): - response = self._call(self.error_cron, silent=True) - self.assertEqual('', response) + response = self._call(self.cron_error, silent=True) + 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.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,)) + 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.wait_3sec_cron) + self._call(self.cron_wait_3sec) self.assertEqual(CronJobLog.objects.all().count(), logs_count + 1) db.close_old_connections() @@ -283,7 +282,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) @@ -307,13 +306,12 @@ 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() 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) @@ -321,11 +319,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: @@ -335,35 +333,41 @@ 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) + 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.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) + 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): - 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) + 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) + 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) + return call("cronloop", *args, **kwargs) def test_repeat_twice(self): self._call( 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aa91e2f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["uv_build>=0.9,<0.10"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-root = "" + +[project] +name = "django-cron" +description = "Running python crons in a Django project" +version = "0.7.1" +authors = [ + {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.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Framework :: Django", +] +requires-python = ">=3.10" +dependencies = [ + "django>=4.2", +] + +[project.urls] +Repository = "https://github.com/jorenham/django-cron" +Issues = "https://github.com/jorenham/django-cron/issues" + +[dependency-groups] +dev = [ + "freezegun==1.5.5", + "mock==5.2.0", + "django-stubs", +] 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_base.py b/settings_base.py deleted file mode 100644 index ecefc99..0000000 --- a/settings_base.py +++ /dev/null @@ -1,75 +0,0 @@ -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.humanize', - '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.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', -] - -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'null': { - 'level': 'DEBUG', - 'class': 'logging.NullHandler', - }, - }, - 'loggers': { - 'django_cron': { - 'handlers': ['null'], - 'level': 'INFO', - 'propagate': True, - }, - }, -} - -CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - 'LOCATION': 'django_cache', - } -} - -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', - ], - }, - }, -] - -ROOT_URLCONF = 'test_urls' -SITE_ID = 1 -STATIC_URL = '/static/' 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() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4b19652 --- /dev/null +++ b/uv.lock @@ -0,0 +1,237 @@ +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.1" +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 = "django-stubs" }, + { name = "freezegun" }, + { name = "mock" }, +] + +[package.metadata] +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" +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 = "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" +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" }, +]