From 6cff6fddc829900b7282f10a3329c831c0d88cc4 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 11 Jun 2025 11:17:58 -0400 Subject: [PATCH 01/12] mypy --- pyproject.toml | 11 ++ scheduler/admin/ephemeral_models.py | 15 +- scheduler/helpers/queues/queue_logic.py | 4 +- scheduler/helpers/sentry_integration.py | 148 ++++++++++++++++++ .../management/commands/scheduler_stats.py | 17 +- .../management/commands/scheduler_worker.py | 30 ++-- scheduler/redis_models/worker.py | 2 +- scheduler/tests/test_settings.py | 12 +- scheduler/views/job_views.py | 4 +- scheduler/worker/commands/worker_commands.py | 4 +- scheduler/worker/scheduler.py | 8 +- scheduler/worker/worker.py | 123 +++++++-------- uv.lock | 98 ++++++++++++ 13 files changed, 374 insertions(+), 102 deletions(-) create mode 100644 scheduler/helpers/sentry_integration.py diff --git a/pyproject.toml b/pyproject.toml index d2e8c2c..7494ff3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dev = [ "coverage~=7.6", "fakeredis~=2.28", "pyyaml>=6,<7", + "mypy>=1.16.0", ] [tool.hatch.build.targets.sdist] @@ -84,3 +85,13 @@ quote-style = "double" indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" + + +[tool.mypy] +packages = ['scheduler', ] +exclude = "scheduler/tests/.*\\.py" +strict = true +follow_imports = "silent" +ignore_missing_imports = true +scripts_are_modules = true +check_untyped_defs = true diff --git a/scheduler/admin/ephemeral_models.py b/scheduler/admin/ephemeral_models.py index 15fddd1..2f7be7a 100644 --- a/scheduler/admin/ephemeral_models.py +++ b/scheduler/admin/ephemeral_models.py @@ -1,17 +1,20 @@ +from typing import Any + from django.contrib import admin +from django.http import HttpResponse, HttpRequest from scheduler import views from scheduler.models.ephemeral_models import Queue, Worker class ImmutableAdmin(admin.ModelAdmin): - def has_add_permission(self, request): + def has_add_permission(self, request: HttpRequest) -> bool: return False # Hide the admin "+ Add" link for Queues - def has_change_permission(self, request, obj=None): + def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool: return True - def has_module_permission(self, request): + def has_module_permission(self, request: HttpRequest) -> bool: """Returns True if the given request has any permission in the given app label. Can be overridden by the user in subclasses. In such case, it should return True if the given request has @@ -19,14 +22,14 @@ def has_module_permission(self, request): not restrict access to the add, change or delete views. Use `ModelAdmin.has_(add|change|delete)_permission` for that. """ - return request.user.has_module_perms("django-tasks-scheduler") + return request.user.has_module_perms("django-tasks-scheduler") # type: ignore @admin.register(Queue) class QueueAdmin(ImmutableAdmin): """Admin View for queues""" - def changelist_view(self, request, extra_context=None): + def changelist_view(self, request: HttpRequest, extra_context: Any = None) -> HttpResponse: """The 'change list' admin view for this model.""" return views.stats(request) @@ -35,6 +38,6 @@ def changelist_view(self, request, extra_context=None): class WorkerAdmin(ImmutableAdmin): """Admin View for workers""" - def changelist_view(self, request, extra_context=None): + def changelist_view(self, request: HttpRequest, extra_context: Any = None) -> HttpResponse: """The 'change list' admin view for this model.""" return views.workers_list(request) diff --git a/scheduler/helpers/queues/queue_logic.py b/scheduler/helpers/queues/queue_logic.py index d18c3d8..498a70c 100644 --- a/scheduler/helpers/queues/queue_logic.py +++ b/scheduler/helpers/queues/queue_logic.py @@ -68,14 +68,14 @@ class Queue: queued="queued_job_registry", ) - def __init__(self, connection: Optional[ConnectionType], name: str, is_async: bool = True) -> None: + def __init__(self, connection: ConnectionType, name: str, is_async: bool = True) -> None: """Initializes a Queue object. :param name: The queue name :param connection: Broker connection :param is_async: Whether jobs should run "async" (using the worker). """ - self.connection = connection + self.connection: ConnectionType = connection self.name = name self._is_async = is_async self.queued_job_registry = QueuedJobRegistry(connection=self.connection, name=self.name) diff --git a/scheduler/helpers/sentry_integration.py b/scheduler/helpers/sentry_integration.py new file mode 100644 index 0000000..366d5a9 --- /dev/null +++ b/scheduler/helpers/sentry_integration.py @@ -0,0 +1,148 @@ +import weakref + +import sentry_sdk +from sentry_sdk.consts import OP +from sentry_sdk.api import continue_trace +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration +from sentry_sdk.integrations.logging import ignore_logger +from sentry_sdk.tracing import TransactionSource +from sentry_sdk.utils import ( + capture_internal_exceptions, + ensure_integration_enabled, + event_from_exception, + format_timestamp, + parse_version, +) + +import scheduler +from scheduler.helpers.queues import Queue +from scheduler.redis_models import JobStatus +from scheduler.timeouts import JobTimeoutException +from scheduler.worker import Worker + + +class SentryIntegration(Integration): + identifier = "rq" + origin = f"auto.queue.{identifier}" + + @staticmethod + def setup_once(): + # type: () -> None + version = parse_version(scheduler.__version__) + _check_minimum_version(SentryIntegration, version) + + old_perform_job = Worker.perform_job + + @ensure_integration_enabled(SentryIntegration, old_perform_job) + def sentry_patched_perform_job(self, job, *args, **kwargs): + # type: (Any, Job, *Queue, **Any) -> bool + with sentry_sdk.new_scope() as scope: + scope.clear_breadcrumbs() + scope.add_event_processor(_make_event_processor(weakref.ref(job))) + + transaction = continue_trace( + job.meta.get("_sentry_trace_headers") or {}, + op=OP.QUEUE_TASK_RQ, + name="unknown RQ task", + source=TransactionSource.TASK, + origin=SentryIntegration.origin, + ) + + with capture_internal_exceptions(): + transaction.name = job.func_name + + with sentry_sdk.start_transaction( + transaction, + custom_sampling_context={"rq_job": job}, + ): + rv = old_perform_job(self, job, *args, **kwargs) + + if self.is_horse: + # We're inside of a forked process and RQ is + # about to call `os._exit`. Make sure that our + # events get sent out. + sentry_sdk.get_client().flush() + + return rv + + Worker.perform_job = sentry_patched_perform_job + + old_handle_exception = Worker.handle_exception + + def sentry_patched_handle_exception(self, job, *exc_info, **kwargs): + # type: (Worker, Any, *Any, **Any) -> Any + retry = ( + hasattr(job, "retries_left") + and job.retries_left + and job.retries_left > 0 + ) + failed = job._status == JobStatus.FAILED or job.is_failed + if failed and not retry: + _capture_exception(exc_info) + + return old_handle_exception(self, job, *exc_info, **kwargs) + + Worker.handle_exception = sentry_patched_handle_exception + + old_enqueue_job = Queue.enqueue_job + + @ensure_integration_enabled(SentryIntegration, old_enqueue_job) + def sentry_patched_enqueue_job(self, job, **kwargs): + # type: (Queue, Any, **Any) -> Any + scope = sentry_sdk.get_current_scope() + if scope.span is not None: + job.meta["_sentry_trace_headers"] = dict( + scope.iter_trace_propagation_headers() + ) + + return old_enqueue_job(self, job, **kwargs) + + Queue.enqueue_job = sentry_patched_enqueue_job + + ignore_logger("rq.worker") + + +def _make_event_processor(weak_job): + # type: (Callable[[], Job]) -> EventProcessor + def event_processor(event, hint): + # type: (Event, dict[str, Any]) -> Event + job = weak_job() + if job is not None: + with capture_internal_exceptions(): + extra = event.setdefault("extra", {}) + rq_job = { + "job_id": job.id, + "func": job.func_name, + "args": job.args, + "kwargs": job.kwargs, + "description": job.description, + } + + if job.enqueued_at: + rq_job["enqueued_at"] = format_timestamp(job.enqueued_at) + if job.started_at: + rq_job["started_at"] = format_timestamp(job.started_at) + + extra["rq-job"] = rq_job + + if "exc_info" in hint: + with capture_internal_exceptions(): + if issubclass(hint["exc_info"][0], JobTimeoutException): + event["fingerprint"] = ["rq", "JobTimeoutException", job.func_name] + + return event + + return event_processor + + +def _capture_exception(exc_info, **kwargs): + # type: (ExcInfo, **Any) -> None + client = sentry_sdk.get_client() + + event, hint = event_from_exception( + exc_info, + client_options=client.options, + mechanism={"type": "rq", "handled": False}, + ) + + sentry_sdk.capture_event(event, hint=hint) diff --git a/scheduler/management/commands/scheduler_stats.py b/scheduler/management/commands/scheduler_stats.py index a52e646..24cd258 100644 --- a/scheduler/management/commands/scheduler_stats.py +++ b/scheduler/management/commands/scheduler_stats.py @@ -1,4 +1,6 @@ import time +from argparse import ArgumentParser +from typing import Any, Dict, List, Optional import click from django.core.management.base import BaseCommand @@ -17,12 +19,12 @@ class Command(BaseCommand): help = __doc__ - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super(Command, self).__init__(*args, **kwargs) self.table_width = 80 self.interval = None - def add_arguments(self, parser): + def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument( "-j", "--json", @@ -47,10 +49,15 @@ def add_arguments(self, parser): help="Poll statistics every N seconds", ) - def _print_separator(self): + def _print_separator(self) -> None: click.echo("-" * self.table_width) - def _print_stats_dashboard(self, statistics, prev_stats=None, with_color: bool = True): + def _print_stats_dashboard( + self, + statistics: Dict[str, List[Dict[str, Any]]], + prev_stats: Optional[Dict[str, List[Dict[str, Any]]]] = None, + with_color: bool = True, + ) -> None: if self.interval: click.clear() click.echo() @@ -81,7 +88,7 @@ def _print_stats_dashboard(self, statistics, prev_stats=None, with_color: bool = click.echo() click.echo("Press 'Ctrl+c' to quit") - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: if options.get("json") and options.get("yaml"): click.secho("Aborting. Cannot output as both json and yaml", err=True, fg="red") exit(1) diff --git a/scheduler/management/commands/scheduler_worker.py b/scheduler/management/commands/scheduler_worker.py index ab122d6..c193363 100644 --- a/scheduler/management/commands/scheduler_worker.py +++ b/scheduler/management/commands/scheduler_worker.py @@ -1,14 +1,16 @@ import logging import os import sys +from argparse import ArgumentParser +from typing import Any, Optional import click from django.core.management.base import BaseCommand from django.db import connections +from scheduler.settings import logger from scheduler.types import ConnectionErrorTypes from scheduler.worker import create_worker -from scheduler.settings import logger VERBOSITY_TO_LOG_LEVEL = { 0: logging.CRITICAL, @@ -31,20 +33,20 @@ } -def reset_db_connections(): +def reset_db_connections() -> None: for c in connections.all(): c.close() -def register_sentry(sentry_dsn, **opts): +def register_sentry(sentry_dsn: str, **opts: Any) -> None: try: import sentry_sdk - from sentry_sdk.integrations.rq import RqIntegration + from scheduler.helpers.sentry_integration import SentryIntegration except ImportError: logger.error("Sentry SDK not installed. Skipping Sentry Integration") return - sentry_sdk.init(sentry_dsn, integrations=[RqIntegration()], **opts) + sentry_sdk.init(sentry_dsn, integrations=[SentryIntegration()], **opts) class Command(BaseCommand): @@ -57,12 +59,12 @@ class Command(BaseCommand): args = "" - def _add_sentry_args(self, parser): + def _add_sentry_args(self, parser: ArgumentParser) -> None: parser.add_argument("--sentry-dsn", action="store", dest="sentry_dsn", help="Sentry DSN to use") parser.add_argument("--sentry-debug", action="store_true", dest="sentry_debug", help="Enable Sentry debug mode") parser.add_argument("--sentry-ca-certs", action="store", dest="sentry_ca_certs", help="Path to CA certs file") - def _add_work_args(self, parser): + def _add_work_args(self, parser: ArgumentParser) -> None: parser.add_argument( "--burst", action="store_true", dest="burst", default=False, help="Run worker in burst mode" ) @@ -90,7 +92,7 @@ def _add_work_args(self, parser): help="Run worker without scheduler, default to with scheduler", ) - def add_arguments(self, parser): + def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument( "--pid", action="store", dest="pidfile", default=None, help="file to write the worker`s pid into" ) @@ -120,7 +122,7 @@ def add_arguments(self, parser): self._add_sentry_args(parser) self._add_work_args(parser) - def handle(self, **options): + def handle(self, **options: Any) -> None: queues = options.pop("queues", []) if not queues: queues = [ @@ -149,12 +151,12 @@ def handle(self, **options): # Check whether sentry is enabled if options.get("sentry_dsn") is not None: sentry_opts = dict(ca_certs=options.get("sentry_ca_certs"), debug=options.get("sentry_debug")) - register_sentry(options.get("sentry_dsn"), **sentry_opts) + dsn: str = options.get("sentry_dsn") # type: ignore + register_sentry(dsn, **sentry_opts) - w.work( - max_jobs=options["max_jobs"], - max_idle_time=options.get("max_idle_time", None), - ) + max_jobs: Optional[int] = options.get("max_jobs", None) + max_idle_time: Optional[int] = options.get("max_idle_time", None) + w.work(max_jobs=max_jobs, max_idle_time=max_idle_time) except ConnectionErrorTypes as e: click.echo(str(e), err=True) sys.exit(1) diff --git a/scheduler/redis_models/worker.py b/scheduler/redis_models/worker.py index 5d31600..97bf9b4 100644 --- a/scheduler/redis_models/worker.py +++ b/scheduler/redis_models/worker.py @@ -79,7 +79,7 @@ def __hash__(self): """The hash does not take the database/connection into account""" return hash((self._key, ",".join(self.queue_names))) - def set_current_job_working_time(self, job_execution_time: int, connection: ConnectionType) -> None: + def set_current_job_working_time(self, job_execution_time: float, connection: ConnectionType) -> None: self.set_field("current_job_working_time", job_execution_time, connection=connection) def heartbeat(self, connection: ConnectionType, timeout: Optional[int] = None) -> None: diff --git a/scheduler/tests/test_settings.py b/scheduler/tests/test_settings.py index 1028231..4d0e957 100644 --- a/scheduler/tests/test_settings.py +++ b/scheduler/tests/test_settings.py @@ -9,23 +9,24 @@ class TestWorkerAdmin(SchedulerBaseCase): - def setUp(self): from scheduler.settings import SCHEDULER_CONFIG + self.old_settings = SCHEDULER_CONFIG def tearDown(self): from scheduler import settings as scheduler_settings + scheduler_settings.SCHEDULER_CONFIG = self.old_settings def test_scheduler_config_as_dict(self): from scheduler.settings import SCHEDULER_CONFIG + settings.SCHEDULER_CONFIG = dict( EXECUTIONS_IN_PAGE=SCHEDULER_CONFIG.EXECUTIONS_IN_PAGE + 1, SCHEDULER_INTERVAL=SCHEDULER_CONFIG.SCHEDULER_INTERVAL + 1, BROKER=Broker.REDIS, CALLBACK_TIMEOUT=SCHEDULER_CONFIG.SCHEDULER_INTERVAL + 1, - DEFAULT_SUCCESS_TTL=SCHEDULER_CONFIG.DEFAULT_SUCCESS_TTL + 1, DEFAULT_FAILURE_TTL=SCHEDULER_CONFIG.DEFAULT_FAILURE_TTL + 1, DEFAULT_JOB_TTL=SCHEDULER_CONFIG.DEFAULT_JOB_TTL + 1, @@ -38,18 +39,19 @@ def test_scheduler_config_as_dict(self): ) conf_settings() from scheduler.settings import SCHEDULER_CONFIG + for key, value in settings.SCHEDULER_CONFIG.items(): self.assertEqual(getattr(SCHEDULER_CONFIG, key), value) def test_scheduler_config_as_data_class(self): from scheduler.settings import SCHEDULER_CONFIG + self.assertEqual(SCHEDULER_CONFIG.EXECUTIONS_IN_PAGE, 20) settings.SCHEDULER_CONFIG = SchedulerConfiguration( EXECUTIONS_IN_PAGE=1, SCHEDULER_INTERVAL=60, BROKER=Broker.REDIS, CALLBACK_TIMEOUT=1111, - DEFAULT_SUCCESS_TTL=1111, DEFAULT_FAILURE_TTL=111111, DEFAULT_JOB_TTL=1111, @@ -62,6 +64,7 @@ def test_scheduler_config_as_data_class(self): ) conf_settings() from scheduler.settings import SCHEDULER_CONFIG + for key, value in dataclasses.asdict(settings.SCHEDULER_CONFIG).items(): self.assertEqual(getattr(SCHEDULER_CONFIG, key), value) @@ -71,7 +74,6 @@ def test_scheduler_config_as_dict_bad_param(self): SCHEDULER_INTERVAL=60, BROKER=Broker.REDIS, CALLBACK_TIMEOUT=1111, - DEFAULT_SUCCESS_TTL=1111, DEFAULT_FAILURE_TTL=111111, DEFAULT_JOB_TTL=1111, @@ -81,6 +83,6 @@ def test_scheduler_config_as_dict_bad_param(self): DEFAULT_MAINTENANCE_TASK_INTERVAL=111, DEFAULT_JOB_MONITORING_INTERVAL=1111, SCHEDULER_FALLBACK_PERIOD_SECS=1111, - BAD_PARAM='bad_value', # This should raise an error + BAD_PARAM="bad_value", # This should raise an error ) self.assertRaises(ImproperlyConfigured, conf_settings) diff --git a/scheduler/views/job_views.py b/scheduler/views/job_views.py index c57468c..127ba28 100644 --- a/scheduler/views/job_views.py +++ b/scheduler/views/job_views.py @@ -25,7 +25,7 @@ class JobDetailAction(str, Enum): @staff_member_required def job_detail(request: HttpRequest, job_name: str) -> HttpResponse: queue, job = _find_job(job_name) - if job is None: + if job is None or queue is None: messages.warning(request, f"Job {escape(job_name)} does not exist, maybe its TTL has passed") return redirect("queues_home") try: @@ -54,7 +54,7 @@ def job_detail(request: HttpRequest, job_name: str) -> HttpResponse: @staff_member_required def job_action(request: HttpRequest, job_name: str, action: str) -> HttpResponse: queue, job = _find_job(job_name) - if job is None: + if job is None or queue is None: messages.warning(request, f"Job {escape(job_name)} does not exist, maybe its TTL has passed") return redirect("queues_home") if action not in [item.value for item in JobDetailAction]: diff --git a/scheduler/worker/commands/worker_commands.py b/scheduler/worker/commands/worker_commands.py index b2d9f25..c7045e7 100644 --- a/scheduler/worker/commands/worker_commands.py +++ b/scheduler/worker/commands/worker_commands.py @@ -72,14 +72,14 @@ def __init__(self, connection: ConnectionType, worker_name: str) -> None: def _commands_channel(worker_name: str) -> str: return _PUBSUB_CHANNEL_TEMPLATE.format(worker_name) - def start(self): + def start(self) -> None: """Subscribe to this worker's channel""" logger.info(f"Subscribing to channel {self.pubsub_channel_name}") self.pubsub = self.connection.pubsub() self.pubsub.subscribe(**{self.pubsub_channel_name: self.handle_payload}) self.pubsub_thread = self.pubsub.run_in_thread(sleep_time=0.2, daemon=True) - def stop(self): + def stop(self) -> None: """Unsubscribe from pubsub channel""" if self.pubsub_thread: logger.info(f"Unsubscribing from channel {self.pubsub_channel_name}") diff --git a/scheduler/worker/scheduler.py b/scheduler/worker/scheduler.py index af7c85b..c8bc9d6 100644 --- a/scheduler/worker/scheduler.py +++ b/scheduler/worker/scheduler.py @@ -96,14 +96,14 @@ def start(self) -> None: self._thread = Thread(target=run_scheduler, args=(self,), name="scheduler-thread") self._thread.start() - def request_stop_and_wait(self): + def request_stop_and_wait(self) -> None: """Toggle self._stop_requested that's checked on every loop""" logger.debug(f"[Scheduler {self.worker_name}/{self.pid}] Stop Scheduler requested") self._stop_requested = True if self._thread is not None: self._thread.join() - def heartbeat(self): + def heartbeat(self) -> None: """Updates the TTL on scheduler keys and the locks""" lock_keys = ", ".join(self._locks.keys()) logger.debug(f"[Scheduler {self.worker_name}/{self.pid}] Scheduler updating lock for queue {lock_keys}") @@ -112,14 +112,14 @@ def heartbeat(self): lock.expire(self.connection, expire=self.interval + 60) pipeline.execute() - def stop(self): + def stop(self) -> None: logger.info( f"[Scheduler {self.worker_name}/{self.pid}] Stopping scheduler, releasing locks for {', '.join(self._locks.keys())}..." ) self.release_locks() self.status = SchedulerStatus.STOPPED - def release_locks(self): + def release_locks(self) -> None: """Release acquired locks""" with self.connection.pipeline() as pipeline: for lock in self._locks.values(): diff --git a/scheduler/worker/worker.py b/scheduler/worker/worker.py index a760771..3938ca6 100644 --- a/scheduler/worker/worker.py +++ b/scheduler/worker/worker.py @@ -15,7 +15,7 @@ from random import shuffle from resource import struct_rusage from types import FrameType -from typing import List, Optional, Tuple, Any, Iterable +from typing import List, Optional, Tuple, Any, Iterable, Collection import scheduler from scheduler.helpers.queues import get_queue @@ -31,7 +31,7 @@ try: from signal import SIGKILL except ImportError: - from signal import SIGTERM as SIGKILL + from signal import SIGTERM as SIGKILL # type:ignore from contextlib import suppress @@ -43,7 +43,7 @@ from setproctitle import setproctitle as setprocname except ImportError: - def setprocname(*args, **kwargs): # noqa + def setprocname(*args: Any, **kwargs: Any) -> None: # noqa pass @@ -64,7 +64,7 @@ class QueueConnectionDiscrepancyError(Exception): ) -def signal_name(signum): +def signal_name(signum) -> str: try: return signal.Signals(signum).name except KeyError: @@ -100,21 +100,21 @@ def from_model(cls, model: WorkerModel) -> Self: return res def __init__( - self, - queues, - name: str, - connection: Optional[ConnectionType] = None, - maintenance_interval: int = SCHEDULER_CONFIG.DEFAULT_MAINTENANCE_TASK_INTERVAL, - job_monitoring_interval=SCHEDULER_CONFIG.DEFAULT_JOB_MONITORING_INTERVAL, - dequeue_strategy: DequeueStrategy = DequeueStrategy.DEFAULT, - disable_default_exception_handler: bool = False, - fork_job_execution: bool = True, - with_scheduler: bool = True, - burst: bool = False, - model: Optional[WorkerModel] = None, + self, + queues, + name: str, + connection: ConnectionType, + maintenance_interval: int = SCHEDULER_CONFIG.DEFAULT_MAINTENANCE_TASK_INTERVAL, + job_monitoring_interval=SCHEDULER_CONFIG.DEFAULT_JOB_MONITORING_INTERVAL, + dequeue_strategy: DequeueStrategy = DequeueStrategy.DEFAULT, + disable_default_exception_handler: bool = False, + fork_job_execution: bool = True, + with_scheduler: bool = True, + burst: bool = False, + model: Optional[WorkerModel] = None, ): # noqa self.fork_job_execution = fork_job_execution - self.job_monitoring_interval = job_monitoring_interval + self.job_monitoring_interval: int = job_monitoring_interval self.maintenance_interval = maintenance_interval connection = self._set_connection(connection) @@ -136,7 +136,7 @@ def __init__( self.disable_default_exception_handler = disable_default_exception_handler self.with_scheduler = with_scheduler self.burst = burst - self._model = ( + self._model: WorkerModel = ( model if model is not None else WorkerModel( @@ -158,7 +158,7 @@ def __init__( def _pid(self) -> int: return self._model.pid - def should_run_maintenance_tasks(self): + def should_run_maintenance_tasks(self) -> bool: """Maintenance tasks should run on first startup or every 10 minutes.""" if self._model.last_cleaned_at is None: return True @@ -179,14 +179,14 @@ def _set_connection(self, connection: ConnectionType) -> ConnectionType: connection.connection_pool.connection_kwargs.update(timeout_config) return connection - def clean_registries(self): + def clean_registries(self) -> None: """Runs maintenance jobs on each Queue's registries.""" for queue in self.queues: # If there are multiple workers running, we only want 1 worker # to run clean_registries(). queue_lock = QueueLock(self.name) if queue_lock.acquire(1, expire=899, connection=self.connection): - logger.info(f"[Worker {self.name}/{self._pid}]: Cleaning registries for queue: {queue.name}") + logger.debug(f"[Worker {self.name}/{self._pid}]: Cleaning registries for queue: {queue.name}") queue.clean_registries() WorkerModel.cleanup(queue.connection, queue.name) queue_lock.release(self.connection) @@ -202,11 +202,7 @@ def _install_signal_handlers(self) -> None: signal.signal(signal.SIGINT, self.request_stop) signal.signal(signal.SIGTERM, self.request_stop) - def work( - self, - max_jobs: Optional[int] = None, - max_idle_time: Optional[int] = None, - ) -> bool: + def work(self, max_jobs: Optional[int] = None, max_idle_time: Optional[int] = None) -> bool: """Starts the work loop. Pops and performs all jobs on the current list of queues. When all @@ -264,15 +260,15 @@ def work( logger.error(f"[Worker {self.name}/{self._pid}]: Redis connection timeout, quitting...") except StopRequested: logger.info(f"[Worker {self.name}/{self._pid}]: Worker was requested to stop, quitting") - pass except SystemExit: # Cold shutdown detected raise except Exception: logger.error(f"[Worker {self.name}/{self._pid}]: found an unhandled exception, quitting...", exc_info=True) finally: self.teardown() + return False - def handle_job_failure(self, job: JobModel, queue: Queue, exc_string=""): + def handle_job_failure(self, job: JobModel, queue: Queue, exc_string="") -> None: """ Handles the failure or an executing job by: 1. Setting the job status to failed @@ -360,7 +356,7 @@ def _check_for_suspension(self, burst: bool) -> None: if before_state: self._model.set_field("state", before_state, connection=self.connection) - def run_maintenance_tasks(self): + def run_maintenance_tasks(self) -> None: """Runs periodic maintenance tasks, these include: 1. Check if scheduler should be started. 2. Cleaning registries @@ -380,7 +376,7 @@ def run_maintenance_tasks(self): self._model.save(connection=self.connection) def dequeue_job_and_maintain_ttl( - self, timeout: Optional[int], max_idle_time: Optional[int] = None + self, timeout: Optional[int], max_idle_time: Optional[int] = None ) -> Tuple[JobModel, Queue]: """Dequeues a job while maintaining the TTL. :param timeout: The timeout for the dequeue operation. @@ -410,7 +406,7 @@ def dequeue_job_and_maintain_ttl( f"[Worker {self.name}/{self._pid}]: Fetching jobs on queues {qnames} and timeout {timeout}" ) job, queue = Queue.dequeue_any(self._ordered_queues, timeout, connection=self.connection) - if job is not None: + if job is not None and queue is not None: self.reorder_queues(reference_queue=queue) logger.info(f"[Worker {self.name}/{self._pid}]: Popped job `{job.name}` from `{queue.name}`") break @@ -435,20 +431,20 @@ def dequeue_job_and_maintain_ttl( def connection_timeout(self) -> int: return SCHEDULER_CONFIG.DEFAULT_WORKER_TTL - 5 - def procline(self, message): + def procline(self, message: str) -> None: """Changes the current procname for the process. This can be used to make `ps -ef` output more readable. """ setprocname(f"{self._model._key}: {message}") - def _validate_name_uniqueness(self): + def _validate_name_uniqueness(self) -> None: """Validates that the worker name is unique.""" worker_model = WorkerModel.get(self.name, connection=self.connection) if worker_model is not None and worker_model.death is None: raise ValueError(f"There exists an active worker named {self.name!r} already") - def worker_start(self): + def worker_start(self) -> None: """Registers its own birth.""" logger.debug(f"[Worker {self.name}/{self._pid}]: Registering birth") now = utcnow() @@ -457,7 +453,7 @@ def worker_start(self): self._model.state = WorkerStatus.STARTED self._model.save(self.connection) - def kill_job_execution_process(self, sig: signal.Signals = SIGKILL): + def kill_job_execution_process(self, sig: signal.Signals = SIGKILL) -> None: """Kill the job execution process but catch "No such process" error has the job execution process could already be dead. @@ -484,7 +480,7 @@ def wait_for_job_execution_process(self) -> Tuple[Optional[int], Optional[int], pid, stat, rusage = os.wait4(self._model.job_execution_process_pid, 0) return pid, stat, rusage - def request_force_stop(self, signum: int, frame: Optional[FrameType]): + def request_force_stop(self, signum: int, frame: Optional[FrameType]) -> None: """Terminates the application (cold shutdown). :param signum: Signal number @@ -537,7 +533,7 @@ def request_stop(self, signum: int, frame: Optional[FrameType]) -> None: else: raise StopRequested() - def reorder_queues(self, reference_queue: Queue): + def reorder_queues(self, reference_queue: Queue) -> None: """Reorder the queues according to the strategy. As this can be defined both in the `Worker` initialization or in the `work` method, it doesn't take the strategy directly, but rather uses the private `_dequeue_strategy` attribute. @@ -555,7 +551,7 @@ def reorder_queues(self, reference_queue: Queue): return if self._dequeue_strategy == DequeueStrategy.ROUND_ROBIN: pos = self._ordered_queues.index(reference_queue) - self._ordered_queues = self._ordered_queues[pos + 1 :] + self._ordered_queues[: pos + 1] + self._ordered_queues = self._ordered_queues[pos + 1:] + self._ordered_queues[: pos + 1] return if self._dequeue_strategy == DequeueStrategy.RANDOM: shuffle(self._ordered_queues) @@ -568,7 +564,7 @@ def teardown(self) -> None: self._command_listener.stop() self._model.delete(self.connection) - def stop_scheduler(self): + def stop_scheduler(self) -> None: """Stop the scheduler thread. Will send the kill signal to the scheduler process, if there's an OSError, just passes and `join()`'s the scheduler process, waiting for the process to finish. @@ -580,11 +576,11 @@ def stop_scheduler(self): logger.debug(f"[Worker {self.name}/{self._pid}]: Scheduler thread stopped") self.scheduler = None - def refresh(self, update_queues: bool = False): + def refresh(self, update_queues: bool = False) -> None: """Refreshes the worker data. It will get the data from the datastore and update the Worker's attributes """ - self._model = WorkerModel.get(self.name, connection=self.connection) + self._model = WorkerModel.get(self.name, connection=self.connection) # type:ignore if self._model is None: msg = f"[Worker {self.name}/{self._pid}]: Worker broker record for {self.name} not found, quitting..." logger.error(msg) @@ -639,13 +635,14 @@ def monitor_job_execution_process(self, job: JobModel, queue: Queue) -> None: while True: try: with SCHEDULER_CONFIG.DEATH_PENALTY_CLASS( - self.job_monitoring_interval, JobExecutionMonitorTimeoutException + self.job_monitoring_interval, JobExecutionMonitorTimeoutException ): retpid, ret_val, rusage = self.wait_for_job_execution_process() break except JobExecutionMonitorTimeoutException: # job execution process has not exited yet and is still running. Send a heartbeat to keep the worker alive. - self._model.set_current_job_working_time((utcnow() - job.started_at).total_seconds(), self.connection) + working_time = (utcnow() - job.started_at).total_seconds() + self._model.set_current_job_working_time(working_time, self.connection) # Kill the job from this side if something is really wrong (interpreter lock/etc). if job.timeout != -1 and self._model.current_job_working_time > (job.timeout + 60): @@ -668,7 +665,7 @@ def monitor_job_execution_process(self, job: JobModel, queue: Queue) -> None: # Send a heartbeat to keep the worker alive. self._model.heartbeat(self.connection) - self._model = WorkerModel.get(self.name, connection=self.connection) + self.refresh() self._model.current_job_working_time = 0 self._model.save(connection=self.connection) if ret_val == os.EX_OK: # The process exited normally. @@ -700,7 +697,7 @@ def monitor_job_execution_process(self, job: JobModel, queue: Queue) -> None: self.handle_job_failure(job, queue=queue, exc_string=exc_string) - def execute_job(self, job: JobModel, queue: Queue): + def execute_job(self, job: JobModel, queue: Queue) -> None: """Spawns a job execution process to perform the actual work and passes it a job. The worker will wait for the job execution process and make sure it executes within the given timeout bounds, or will end the job execution process with SIGALRM. @@ -715,7 +712,7 @@ def execute_job(self, job: JobModel, queue: Queue): self.perform_job(job, queue) self._model.set_field("state", WorkerStatus.IDLE, connection=self.connection) - def maintain_heartbeats(self, job: JobModel, queue: Queue): + def maintain_heartbeats(self, job: JobModel, queue: Queue) -> None: """Updates worker and job's last heartbeat field.""" with self.connection.pipeline() as pipeline: self._model.heartbeat(pipeline, self.job_monitoring_interval + 60) @@ -726,7 +723,7 @@ def maintain_heartbeats(self, job: JobModel, queue: Queue): if results[2] == 1: job.delete(self.connection) - def execute_in_separate_process(self, job: JobModel, queue: Queue): + def execute_in_separate_process(self, job: JobModel, queue: Queue) -> None: """This is the entry point of the newly spawned job execution process. After fork()'ing, assure we are generating random sequences that are different from the worker. @@ -741,7 +738,7 @@ def execute_in_separate_process(self, job: JobModel, queue: Queue): os._exit(1) os._exit(0) - def setup_job_execution_process_signals(self): + def setup_job_execution_process_signals(self) -> None: """Setup signal handing for the newly spawned job execution process Always ignore Ctrl+C in the job execution process, as it might abort the currently running job. @@ -765,7 +762,7 @@ def worker_before_execution(self, job: JobModel, connection: ConnectionType) -> ) self._model.save(connection=connection) - def handle_job_success(self, job: JobModel, return_value: Any, queue: Queue): + def handle_job_success(self, job: JobModel, return_value: Any, queue: Queue) -> None: """Handles the successful execution of certain job. It will remove the job from the `active_job_registry`, adding it to the `SuccessfulJobRegistry`, and run a few maintenance tasks including: @@ -793,7 +790,8 @@ def handle_job_success(self, job: JobModel, return_value: Any, queue: Queue): self._model.current_job_name = None self._model.successful_job_count += 1 self._model.completed_jobs += 1 - self._model.total_working_time_ms += (job.ended_at - job.started_at).microseconds / 1000.0 + if job.started_at is not None and job.ended_at is not None: + self._model.total_working_time_ms += (job.ended_at - job.started_at).microseconds / 1000.0 self._model.save(connection=self.connection) job.expire(job.success_ttl, connection=pipeline) @@ -842,7 +840,7 @@ def perform_job(self, job: JobModel, queue: Queue) -> bool: return True - def handle_exception(self, job: JobModel, *exc_info): + def handle_exception(self, job: JobModel, *exc_info: Any) -> None: """Walks the exception handler stack to delegate exception handling. If the job cannot be deserialized, it will raise when func_name or the other properties are accessed, which will stop exceptions from @@ -863,12 +861,12 @@ def handle_exception(self, job: JobModel, *exc_info): # func_name logger.error( f"[Worker {self.name}/{self._pid}]: exception raised while executing ({func_name})\n{exc_string}", - extra=extra, + extra=extra, # type:ignore ) class SimpleWorker(Worker): - def execute_job(self, job: JobModel, queue: Queue): + def execute_job(self, job: JobModel, queue: Queue) -> None: """Execute job in same thread/process, do not fork()""" self._model.set_field("state", WorkerStatus.BUSY, connection=self.connection) self.perform_job(job, queue) @@ -878,15 +876,15 @@ def execute_job(self, job: JobModel, queue: Queue): class RoundRobinWorker(Worker): """Modified version of Worker that dequeues jobs from the queues using a round-robin strategy.""" - def reorder_queues(self, reference_queue): + def reorder_queues(self, reference_queue: Queue) -> None: pos = self._ordered_queues.index(reference_queue) - self._ordered_queues = self._ordered_queues[pos + 1 :] + self._ordered_queues[: pos + 1] + self._ordered_queues = self._ordered_queues[pos + 1:] + self._ordered_queues[: pos + 1] class RandomWorker(Worker): """Modified version of Worker that dequeues jobs from the queues using a random strategy.""" - def reorder_queues(self, reference_queue): + def reorder_queues(self, reference_queue: Queue) -> None: shuffle(self._ordered_queues) @@ -896,7 +894,10 @@ def _get_ip_address_from_connection(connection: ConnectionType, client_name: str except ResponseErrorTypes: warnings.warn("CLIENT SETNAME command not supported, setting ip_address to unknown", Warning) return "unknown" - client_adresses = [client["addr"] for client in connection.client_list() if client["name"] == client_name] + client_list = connection.client_list() + client_adresses: List[str] = [ + client["addr"] for client in client_list if client["name"] == client_name + ] if len(client_adresses) > 0: return client_adresses[0] else: @@ -904,7 +905,7 @@ def _get_ip_address_from_connection(connection: ConnectionType, client_name: str return "unknown" -def _ensure_list(obj: Any) -> List: +def _ensure_list(obj: Any) -> List[Any]: """When passed an iterable of objects, does nothing, otherwise, it returns a list with just that object in it. :param obj: The object to ensure is a list @@ -914,7 +915,7 @@ def _ensure_list(obj: Any) -> List: return obj if is_nonstring_iterable else [obj] -def _calc_worker_name(existing_worker_names) -> str: +def _calc_worker_name(existing_worker_names: Collection[str]) -> str: hostname = os.uname()[1] c = 1 worker_name = f"{hostname}-worker.{c}" @@ -942,10 +943,10 @@ def get_queues(*queue_names: str) -> List[Queue]: return queues -def create_worker(*queue_names: str, **kwargs) -> Worker: +def create_worker(*queue_names: str, **kwargs: Any) -> Worker: """Returns a Django worker for all queues or specified ones.""" queues = get_queues(*queue_names) - existing_worker_names = WorkerModel.all_names(connection=queues[0].connection) + existing_worker_names: Collection[str] = WorkerModel.all_names(connection=queues[0].connection) kwargs.setdefault("fork_job_execution", SCHEDULER_CONFIG.BROKER != Broker.FAKEREDIS) if kwargs.get("name", None) is None: kwargs["name"] = _calc_worker_name(existing_worker_names) diff --git a/uv.lock b/uv.lock index fa95800..fb848b8 100644 --- a/uv.lock +++ b/uv.lock @@ -169,6 +169,7 @@ yaml = [ dev = [ { name = "coverage" }, { name = "fakeredis" }, + { name = "mypy" }, { name = "pyyaml" }, { name = "ruff" }, { name = "time-machine" }, @@ -189,6 +190,7 @@ provides-extras = ["yaml", "valkey", "sentry"] dev = [ { name = "coverage", specifier = "~=7.6" }, { name = "fakeredis", specifier = "~=2.28" }, + { name = "mypy", specifier = ">=1.16.0" }, { name = "pyyaml", specifier = ">=6,<7" }, { name = "ruff", specifier = ">=0.11" }, { name = "time-machine", specifier = ">=2.16.0,<3" }, @@ -208,6 +210,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/fd/9af8a9c6d7a4233ee292f6ae0d142fcb22b1173940596089c85a9f5bffce/fakeredis-2.29.0-py3-none-any.whl", hash = "sha256:f644c0a69dc088455d75a9b259d101e28a1c5659381aa6d9ee6c2b31eb5a909f", size = 114198, upload-time = "2025-05-06T18:42:03.82Z" }, ] +[[package]] +name = "mypy" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/38/13c2f1abae94d5ea0354e146b95a1be9b2137a0d506728e0da037c4276f6/mypy-1.16.0.tar.gz", hash = "sha256:84b94283f817e2aa6350a14b4a8fb2a35a53c286f97c9d30f53b63620e7af8ab", size = 3323139, upload-time = "2025-05-29T13:46:12.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/5e/a0485f0608a3d67029d3d73cec209278b025e3493a3acfda3ef3a88540fd/mypy-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7909541fef256527e5ee9c0a7e2aeed78b6cda72ba44298d1334fe7881b05c5c", size = 10967416, upload-time = "2025-05-29T13:34:17.783Z" }, + { url = "https://files.pythonhosted.org/packages/4b/53/5837c221f74c0d53a4bfc3003296f8179c3a2a7f336d7de7bbafbe96b688/mypy-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e71d6f0090c2256c713ed3d52711d01859c82608b5d68d4fa01a3fe30df95571", size = 10087654, upload-time = "2025-05-29T13:32:37.878Z" }, + { url = "https://files.pythonhosted.org/packages/29/59/5fd2400352c3093bed4c09017fe671d26bc5bb7e6ef2d4bf85f2a2488104/mypy-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:936ccfdd749af4766be824268bfe22d1db9eb2f34a3ea1d00ffbe5b5265f5491", size = 11875192, upload-time = "2025-05-29T13:34:54.281Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3e/4bfec74663a64c2012f3e278dbc29ffe82b121bc551758590d1b6449ec0c/mypy-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4086883a73166631307fdd330c4a9080ce24913d4f4c5ec596c601b3a4bdd777", size = 12612939, upload-time = "2025-05-29T13:33:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/88/1f/fecbe3dcba4bf2ca34c26ca016383a9676711907f8db4da8354925cbb08f/mypy-1.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:feec38097f71797da0231997e0de3a58108c51845399669ebc532c815f93866b", size = 12874719, upload-time = "2025-05-29T13:21:52.09Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/c2d280601cd816c43dfa512a759270d5a5ef638d7ac9bea9134c8305a12f/mypy-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:09a8da6a0ee9a9770b8ff61b39c0bb07971cda90e7297f4213741b48a0cc8d93", size = 9487053, upload-time = "2025-05-29T13:33:29.797Z" }, + { url = "https://files.pythonhosted.org/packages/24/c4/ff2f79db7075c274fe85b5fff8797d29c6b61b8854c39e3b7feb556aa377/mypy-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9f826aaa7ff8443bac6a494cf743f591488ea940dd360e7dd330e30dd772a5ab", size = 10884498, upload-time = "2025-05-29T13:18:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/02/07/12198e83006235f10f6a7808917376b5d6240a2fd5dce740fe5d2ebf3247/mypy-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82d056e6faa508501af333a6af192c700b33e15865bda49611e3d7d8358ebea2", size = 10011755, upload-time = "2025-05-29T13:34:00.851Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9b/5fd5801a72b5d6fb6ec0105ea1d0e01ab2d4971893076e558d4b6d6b5f80/mypy-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:089bedc02307c2548eb51f426e085546db1fa7dd87fbb7c9fa561575cf6eb1ff", size = 11800138, upload-time = "2025-05-29T13:32:55.082Z" }, + { url = "https://files.pythonhosted.org/packages/2e/81/a117441ea5dfc3746431e51d78a4aca569c677aa225bca2cc05a7c239b61/mypy-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a2322896003ba66bbd1318c10d3afdfe24e78ef12ea10e2acd985e9d684a666", size = 12533156, upload-time = "2025-05-29T13:19:12.963Z" }, + { url = "https://files.pythonhosted.org/packages/3f/38/88ec57c6c86014d3f06251e00f397b5a7daa6888884d0abf187e4f5f587f/mypy-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:021a68568082c5b36e977d54e8f1de978baf401a33884ffcea09bd8e88a98f4c", size = 12742426, upload-time = "2025-05-29T13:20:22.72Z" }, + { url = "https://files.pythonhosted.org/packages/bd/53/7e9d528433d56e6f6f77ccf24af6ce570986c2d98a5839e4c2009ef47283/mypy-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:54066fed302d83bf5128632d05b4ec68412e1f03ef2c300434057d66866cea4b", size = 9478319, upload-time = "2025-05-29T13:21:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/70/cf/158e5055e60ca2be23aec54a3010f89dcffd788732634b344fc9cb1e85a0/mypy-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5436d11e89a3ad16ce8afe752f0f373ae9620841c50883dc96f8b8805620b13", size = 11062927, upload-time = "2025-05-29T13:35:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/94/34/cfff7a56be1609f5d10ef386342ce3494158e4d506516890142007e6472c/mypy-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2622af30bf01d8fc36466231bdd203d120d7a599a6d88fb22bdcb9dbff84090", size = 10083082, upload-time = "2025-05-29T13:35:33.378Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7f/7242062ec6288c33d8ad89574df87c3903d394870e5e6ba1699317a65075/mypy-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d045d33c284e10a038f5e29faca055b90eee87da3fc63b8889085744ebabb5a1", size = 11828306, upload-time = "2025-05-29T13:21:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b392f7b4f659f5b619ce5994c5c43caab3d80df2296ae54fa888b3d17f5a/mypy-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4968f14f44c62e2ec4a038c8797a87315be8df7740dc3ee8d3bfe1c6bf5dba8", size = 12702764, upload-time = "2025-05-29T13:20:42.826Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c0/7646ef3a00fa39ac9bc0938626d9ff29d19d733011be929cfea59d82d136/mypy-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb14a4a871bb8efb1e4a50360d4e3c8d6c601e7a31028a2c79f9bb659b63d730", size = 12896233, upload-time = "2025-05-29T13:18:37.446Z" }, + { url = "https://files.pythonhosted.org/packages/6d/38/52f4b808b3fef7f0ef840ee8ff6ce5b5d77381e65425758d515cdd4f5bb5/mypy-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd4e1ebe126152a7bbaa4daedd781c90c8f9643c79b9748caa270ad542f12bec", size = 9565547, upload-time = "2025-05-29T13:20:02.836Z" }, + { url = "https://files.pythonhosted.org/packages/97/9c/ca03bdbefbaa03b264b9318a98950a9c683e06472226b55472f96ebbc53d/mypy-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e056237c89f1587a3be1a3a70a06a698d25e2479b9a2f57325ddaaffc3567b", size = 11059753, upload-time = "2025-05-29T13:18:18.167Z" }, + { url = "https://files.pythonhosted.org/packages/36/92/79a969b8302cfe316027c88f7dc6fee70129490a370b3f6eb11d777749d0/mypy-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b07e107affb9ee6ce1f342c07f51552d126c32cd62955f59a7db94a51ad12c0", size = 10073338, upload-time = "2025-05-29T13:19:48.079Z" }, + { url = "https://files.pythonhosted.org/packages/14/9b/a943f09319167da0552d5cd722104096a9c99270719b1afeea60d11610aa/mypy-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fb60cbd85dc65d4d63d37cb5c86f4e3a301ec605f606ae3a9173e5cf34997b", size = 11827764, upload-time = "2025-05-29T13:46:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/ff75e71c65a0cb6ee737287c7913ea155845a556c64144c65b811afdb9c7/mypy-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7e32297a437cc915599e0578fa6bc68ae6a8dc059c9e009c628e1c47f91495d", size = 12701356, upload-time = "2025-05-29T13:35:13.553Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ad/0e93c18987a1182c350f7a5fab70550852f9fabe30ecb63bfbe51b602074/mypy-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:afe420c9380ccec31e744e8baff0d406c846683681025db3531b32db56962d52", size = 12900745, upload-time = "2025-05-29T13:17:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/28/5d/036c278d7a013e97e33f08c047fe5583ab4f1fc47c9a49f985f1cdd2a2d7/mypy-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:55f9076c6ce55dd3f8cd0c6fff26a008ca8e5131b89d5ba6d86bd3f47e736eeb", size = 9572200, upload-time = "2025-05-29T13:33:44.92Z" }, + { url = "https://files.pythonhosted.org/packages/99/a3/6ed10530dec8e0fdc890d81361260c9ef1f5e5c217ad8c9b21ecb2b8366b/mypy-1.16.0-py3-none-any.whl", hash = "sha256:29e1499864a3888bca5c1542f2d7232c6e586295183320caa95758fc84034031", size = 2265773, upload-time = "2025-05-29T13:35:18.762Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -405,6 +464,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/75/c4d8b2f0fe7dac22854d88a9c509d428e78ac4bf284bc54cfe83f75cc13b/time_machine-2.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:4d3843143c46dddca6491a954bbd0abfd435681512ac343169560e9bab504129", size = 18047, upload-time = "2024-10-08T14:21:46.261Z" }, ] +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.0" From 4d622c73e7c79888782655feb16d24a458d18660 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Tue, 17 Jun 2025 13:21:13 -0400 Subject: [PATCH 02/12] fix:repeatable task without start date #276 --- pyproject.toml | 4 + scheduler/models/task.py | 15 +- .../test_task_types/test_repeatable_task.py | 41 ++- uv.lock | 271 ++++++++++-------- 4 files changed, 193 insertions(+), 138 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7494ff3..5a168d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "django>=5", "croniter>=2.0", "click~=8.2", + "fakeredis", ] [project.optional-dependencies] @@ -95,3 +96,6 @@ follow_imports = "silent" ignore_missing_imports = true scripts_are_modules = true check_untyped_defs = true + +[tool.uv.sources] +fakeredis = { path = "../fakeredis" } diff --git a/scheduler/models/task.py b/scheduler/models/task.py index 2119e28..1d5d47d 100644 --- a/scheduler/models/task.py +++ b/scheduler/models/task.py @@ -187,9 +187,9 @@ def is_scheduled(self) -> bool: return False # check whether job_id is in scheduled/queued/active jobs res = ( - (self.job_name in self.rqueue.scheduled_job_registry.all()) - or (self.job_name in self.rqueue.queued_job_registry.all()) - or (self.job_name in self.rqueue.active_job_registry.all()) + (self.job_name in self.rqueue.scheduled_job_registry.all()) + or (self.job_name in self.rqueue.queued_job_registry.all()) + or (self.job_name in self.rqueue.active_job_registry.all()) ) # If the job_id is not scheduled/queued/started, # update the job_id to None. (The job_id belongs to a previous run which is completed) @@ -360,6 +360,7 @@ def _schedule(self) -> bool: return True def save(self, **kwargs): + self.clean() schedule_job = kwargs.pop("schedule_job", True) update_fields = kwargs.get("update_fields", None) if update_fields is not None: @@ -406,7 +407,7 @@ def clean_interval_unit(self): code="invalid", params={"queue": self.queue, "interval": config.SCHEDULER_INTERVAL}, ) - if self.interval_seconds() % config.SCHEDULER_INTERVAL: + if self.interval_seconds() <= config.SCHEDULER_INTERVAL: raise ValidationError( _("Job interval is not a multiple of rq_scheduler's interval frequency: %(interval)ss"), code="invalid", @@ -434,6 +435,10 @@ def clean_cron_string(self): raise ValidationError({"cron_string": ValidationError(_(str(e)), code="invalid")}) def clean(self): + if self.task_type not in TaskType.values: + raise ValidationError( + {"task_type": ValidationError(_("Invalid task type"), code="invalid")}, + ) self.clean_queue() self.clean_callable() if self.task_type == TaskType.CRON: @@ -441,6 +446,8 @@ def clean(self): if self.task_type == TaskType.REPEATABLE: self.clean_interval_unit() self.clean_result_ttl() + if self.task_type == TaskType.REPEATABLE and self.scheduled_time is None: + self.scheduled_time = timezone.now() + timedelta(seconds=2) if self.task_type == TaskType.ONCE and self.scheduled_time is None: raise ValidationError({"scheduled_time": ValidationError(_("Scheduled time is required"), code="invalid")}) if self.task_type == TaskType.ONCE and self.scheduled_time < timezone.now(): diff --git a/scheduler/tests/test_task_types/test_repeatable_task.py b/scheduler/tests/test_task_types/test_repeatable_task.py index 8f231b8..463b8e5 100644 --- a/scheduler/tests/test_task_types/test_repeatable_task.py +++ b/scheduler/tests/test_task_types/test_repeatable_task.py @@ -18,17 +18,38 @@ class TestRepeatableTask(BaseTestCases.TestSchedulableTask): def test_create_task_error(self): scheduled_time = timezone.now() - - Task.objects.create( + task = Task.objects.create( name="konichiva_every_2s", - callable="chat.task_scheduler.konichiva_func", - task_type="REPEATABLE", - interval=2, + callable="scheduler.tests.jobs.test_args_kwargs", + task_type=TaskType.REPEATABLE, + interval=333, interval_unit="seconds", queue="default", enabled=True, scheduled_time=scheduled_time, ) + self.assertEqual(task.name, "konichiva_every_2s") + self.assertEqual(task.callable, "scheduler.tests.jobs.test_args_kwargs") + self.assertEqual(task.task_type, TaskType.REPEATABLE) + self.assertEqual(task.interval, 333) + self.assertEqual(task.interval_unit, "seconds") + + def test_create_task_without_scheduled_time(self): + task = Task.objects.create( + name="konichiva_every_2s", + callable="scheduler.tests.jobs.test_args_kwargs", + task_type=TaskType.REPEATABLE, + interval=33, + interval_unit="seconds", + queue="default", + enabled=True, + ) + self.assertAlmostEqual(task.scheduled_time.timestamp(), timezone.now().timestamp(), delta=2) + self.assertEqual(task.name, "konichiva_every_2s") + self.assertEqual(task.callable, "scheduler.tests.jobs.test_args_kwargs") + self.assertEqual(task.task_type, TaskType.REPEATABLE) + self.assertEqual(task.interval, 33) + self.assertEqual(task.interval_unit, "seconds") def test_unschedulable_old_job(self): job = task_factory(self.task_type, scheduled_time=timezone.now() - timedelta(hours=1), repeat=0) @@ -67,16 +88,6 @@ def test_clean_too_frequent(self): with self.assertRaises(ValidationError): job.clean_interval_unit() - @override_settings(SCHEDULER_CONFIG=SchedulerConfiguration(SCHEDULER_INTERVAL=10)) - def test_clean_not_multiple(self): - job = task_factory(self.task_type) - job.queue = self.queue_name - job.callable = "scheduler.tests.jobs.test_job" - job.interval = 121 - job.interval_unit = "seconds" - with self.assertRaises(ValidationError): - job.clean_interval_unit() - def test_clean_short_result_ttl(self): task = task_factory(self.task_type) task.queue = self.queue_name diff --git a/uv.lock b/uv.lock index fb848b8..9da67b6 100644 --- a/uv.lock +++ b/uv.lock @@ -25,11 +25,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.4.26" +version = "2025.6.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, + { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, ] [[package]] @@ -55,66 +55,66 @@ wheels = [ [[package]] name = "coverage" -version = "7.8.2" +version = "7.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/07/998afa4a0ecdf9b1981ae05415dad2d4e7716e1b1f00abbd91691ac09ac9/coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27", size = 812759, upload-time = "2025-05-23T11:39:57.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/98670a80884f64578f0c22cd70c5e81a6e07b08167721c7487b4d70a7ca0/coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec", size = 813650, upload-time = "2025-06-13T13:02:28.627Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/6b/7dd06399a5c0b81007e3a6af0395cd60e6a30f959f8d407d3ee04642e896/coverage-7.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a", size = 211573, upload-time = "2025-05-23T11:37:47.207Z" }, - { url = "https://files.pythonhosted.org/packages/f0/df/2b24090820a0bac1412955fb1a4dade6bc3b8dcef7b899c277ffaf16916d/coverage-7.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be", size = 212006, upload-time = "2025-05-23T11:37:50.289Z" }, - { url = "https://files.pythonhosted.org/packages/c5/c4/e4e3b998e116625562a872a342419652fa6ca73f464d9faf9f52f1aff427/coverage-7.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3", size = 241128, upload-time = "2025-05-23T11:37:52.229Z" }, - { url = "https://files.pythonhosted.org/packages/b1/67/b28904afea3e87a895da850ba587439a61699bf4b73d04d0dfd99bbd33b4/coverage-7.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6", size = 239026, upload-time = "2025-05-23T11:37:53.846Z" }, - { url = "https://files.pythonhosted.org/packages/8c/0f/47bf7c5630d81bc2cd52b9e13043685dbb7c79372a7f5857279cc442b37c/coverage-7.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622", size = 240172, upload-time = "2025-05-23T11:37:55.711Z" }, - { url = "https://files.pythonhosted.org/packages/ba/38/af3eb9d36d85abc881f5aaecf8209383dbe0fa4cac2d804c55d05c51cb04/coverage-7.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c", size = 240086, upload-time = "2025-05-23T11:37:57.724Z" }, - { url = "https://files.pythonhosted.org/packages/9e/64/c40c27c2573adeba0fe16faf39a8aa57368a1f2148865d6bb24c67eadb41/coverage-7.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3", size = 238792, upload-time = "2025-05-23T11:37:59.737Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ab/b7c85146f15457671c1412afca7c25a5696d7625e7158002aa017e2d7e3c/coverage-7.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404", size = 239096, upload-time = "2025-05-23T11:38:01.693Z" }, - { url = "https://files.pythonhosted.org/packages/d3/50/9446dad1310905fb1dc284d60d4320a5b25d4e3e33f9ea08b8d36e244e23/coverage-7.8.2-cp310-cp310-win32.whl", hash = "sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7", size = 214144, upload-time = "2025-05-23T11:38:03.68Z" }, - { url = "https://files.pythonhosted.org/packages/23/ed/792e66ad7b8b0df757db8d47af0c23659cdb5a65ef7ace8b111cacdbee89/coverage-7.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347", size = 215043, upload-time = "2025-05-23T11:38:05.217Z" }, - { url = "https://files.pythonhosted.org/packages/6a/4d/1ff618ee9f134d0de5cc1661582c21a65e06823f41caf801aadf18811a8e/coverage-7.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9", size = 211692, upload-time = "2025-05-23T11:38:08.485Z" }, - { url = "https://files.pythonhosted.org/packages/96/fa/c3c1b476de96f2bc7a8ca01a9f1fcb51c01c6b60a9d2c3e66194b2bdb4af/coverage-7.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879", size = 212115, upload-time = "2025-05-23T11:38:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c2/5414c5a1b286c0f3881ae5adb49be1854ac5b7e99011501f81c8c1453065/coverage-7.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a", size = 244740, upload-time = "2025-05-23T11:38:11.947Z" }, - { url = "https://files.pythonhosted.org/packages/cd/46/1ae01912dfb06a642ef3dd9cf38ed4996fda8fe884dab8952da616f81a2b/coverage-7.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5", size = 242429, upload-time = "2025-05-23T11:38:13.955Z" }, - { url = "https://files.pythonhosted.org/packages/06/58/38c676aec594bfe2a87c7683942e5a30224791d8df99bcc8439fde140377/coverage-7.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11", size = 244218, upload-time = "2025-05-23T11:38:15.631Z" }, - { url = "https://files.pythonhosted.org/packages/80/0c/95b1023e881ce45006d9abc250f76c6cdab7134a1c182d9713878dfefcb2/coverage-7.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a", size = 243865, upload-time = "2025-05-23T11:38:17.622Z" }, - { url = "https://files.pythonhosted.org/packages/57/37/0ae95989285a39e0839c959fe854a3ae46c06610439350d1ab860bf020ac/coverage-7.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb", size = 242038, upload-time = "2025-05-23T11:38:19.966Z" }, - { url = "https://files.pythonhosted.org/packages/4d/82/40e55f7c0eb5e97cc62cbd9d0746fd24e8caf57be5a408b87529416e0c70/coverage-7.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54", size = 242567, upload-time = "2025-05-23T11:38:21.912Z" }, - { url = "https://files.pythonhosted.org/packages/f9/35/66a51adc273433a253989f0d9cc7aa6bcdb4855382cf0858200afe578861/coverage-7.8.2-cp311-cp311-win32.whl", hash = "sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a", size = 214194, upload-time = "2025-05-23T11:38:23.571Z" }, - { url = "https://files.pythonhosted.org/packages/f6/8f/a543121f9f5f150eae092b08428cb4e6b6d2d134152c3357b77659d2a605/coverage-7.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975", size = 215109, upload-time = "2025-05-23T11:38:25.137Z" }, - { url = "https://files.pythonhosted.org/packages/77/65/6cc84b68d4f35186463cd7ab1da1169e9abb59870c0f6a57ea6aba95f861/coverage-7.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53", size = 213521, upload-time = "2025-05-23T11:38:27.123Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2a/1da1ada2e3044fcd4a3254fb3576e160b8fe5b36d705c8a31f793423f763/coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c", size = 211876, upload-time = "2025-05-23T11:38:29.01Z" }, - { url = "https://files.pythonhosted.org/packages/70/e9/3d715ffd5b6b17a8be80cd14a8917a002530a99943cc1939ad5bb2aa74b9/coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1", size = 212130, upload-time = "2025-05-23T11:38:30.675Z" }, - { url = "https://files.pythonhosted.org/packages/a0/02/fdce62bb3c21649abfd91fbdcf041fb99be0d728ff00f3f9d54d97ed683e/coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279", size = 246176, upload-time = "2025-05-23T11:38:32.395Z" }, - { url = "https://files.pythonhosted.org/packages/a7/52/decbbed61e03b6ffe85cd0fea360a5e04a5a98a7423f292aae62423b8557/coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99", size = 243068, upload-time = "2025-05-23T11:38:33.989Z" }, - { url = "https://files.pythonhosted.org/packages/38/6c/d0e9c0cce18faef79a52778219a3c6ee8e336437da8eddd4ab3dbd8fadff/coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20", size = 245328, upload-time = "2025-05-23T11:38:35.568Z" }, - { url = "https://files.pythonhosted.org/packages/f0/70/f703b553a2f6b6c70568c7e398ed0789d47f953d67fbba36a327714a7bca/coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2", size = 245099, upload-time = "2025-05-23T11:38:37.627Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fb/4cbb370dedae78460c3aacbdad9d249e853f3bc4ce5ff0e02b1983d03044/coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57", size = 243314, upload-time = "2025-05-23T11:38:39.238Z" }, - { url = "https://files.pythonhosted.org/packages/39/9f/1afbb2cb9c8699b8bc38afdce00a3b4644904e6a38c7bf9005386c9305ec/coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f", size = 244489, upload-time = "2025-05-23T11:38:40.845Z" }, - { url = "https://files.pythonhosted.org/packages/79/fa/f3e7ec7d220bff14aba7a4786ae47043770cbdceeea1803083059c878837/coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8", size = 214366, upload-time = "2025-05-23T11:38:43.551Z" }, - { url = "https://files.pythonhosted.org/packages/54/aa/9cbeade19b7e8e853e7ffc261df885d66bf3a782c71cba06c17df271f9e6/coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223", size = 215165, upload-time = "2025-05-23T11:38:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/c4/73/e2528bf1237d2448f882bbebaec5c3500ef07301816c5c63464b9da4d88a/coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f", size = 213548, upload-time = "2025-05-23T11:38:46.74Z" }, - { url = "https://files.pythonhosted.org/packages/1a/93/eb6400a745ad3b265bac36e8077fdffcf0268bdbbb6c02b7220b624c9b31/coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca", size = 211898, upload-time = "2025-05-23T11:38:49.066Z" }, - { url = "https://files.pythonhosted.org/packages/1b/7c/bdbf113f92683024406a1cd226a199e4200a2001fc85d6a6e7e299e60253/coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d", size = 212171, upload-time = "2025-05-23T11:38:51.207Z" }, - { url = "https://files.pythonhosted.org/packages/91/22/594513f9541a6b88eb0dba4d5da7d71596dadef6b17a12dc2c0e859818a9/coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85", size = 245564, upload-time = "2025-05-23T11:38:52.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f4/2860fd6abeebd9f2efcfe0fd376226938f22afc80c1943f363cd3c28421f/coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257", size = 242719, upload-time = "2025-05-23T11:38:54.529Z" }, - { url = "https://files.pythonhosted.org/packages/89/60/f5f50f61b6332451520e6cdc2401700c48310c64bc2dd34027a47d6ab4ca/coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108", size = 244634, upload-time = "2025-05-23T11:38:57.326Z" }, - { url = "https://files.pythonhosted.org/packages/3b/70/7f4e919039ab7d944276c446b603eea84da29ebcf20984fb1fdf6e602028/coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0", size = 244824, upload-time = "2025-05-23T11:38:59.421Z" }, - { url = "https://files.pythonhosted.org/packages/26/45/36297a4c0cea4de2b2c442fe32f60c3991056c59cdc3cdd5346fbb995c97/coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050", size = 242872, upload-time = "2025-05-23T11:39:01.049Z" }, - { url = "https://files.pythonhosted.org/packages/a4/71/e041f1b9420f7b786b1367fa2a375703889ef376e0d48de9f5723fb35f11/coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48", size = 244179, upload-time = "2025-05-23T11:39:02.709Z" }, - { url = "https://files.pythonhosted.org/packages/bd/db/3c2bf49bdc9de76acf2491fc03130c4ffc51469ce2f6889d2640eb563d77/coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7", size = 214393, upload-time = "2025-05-23T11:39:05.457Z" }, - { url = "https://files.pythonhosted.org/packages/c6/dc/947e75d47ebbb4b02d8babb1fad4ad381410d5bc9da7cfca80b7565ef401/coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3", size = 215194, upload-time = "2025-05-23T11:39:07.171Z" }, - { url = "https://files.pythonhosted.org/packages/90/31/a980f7df8a37eaf0dc60f932507fda9656b3a03f0abf188474a0ea188d6d/coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7", size = 213580, upload-time = "2025-05-23T11:39:08.862Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6a/25a37dd90f6c95f59355629417ebcb74e1c34e38bb1eddf6ca9b38b0fc53/coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008", size = 212734, upload-time = "2025-05-23T11:39:11.109Z" }, - { url = "https://files.pythonhosted.org/packages/36/8b/3a728b3118988725f40950931abb09cd7f43b3c740f4640a59f1db60e372/coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36", size = 212959, upload-time = "2025-05-23T11:39:12.751Z" }, - { url = "https://files.pythonhosted.org/packages/53/3c/212d94e6add3a3c3f412d664aee452045ca17a066def8b9421673e9482c4/coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46", size = 257024, upload-time = "2025-05-23T11:39:15.569Z" }, - { url = "https://files.pythonhosted.org/packages/a4/40/afc03f0883b1e51bbe804707aae62e29c4e8c8bbc365c75e3e4ddeee9ead/coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be", size = 252867, upload-time = "2025-05-23T11:39:17.64Z" }, - { url = "https://files.pythonhosted.org/packages/18/a2/3699190e927b9439c6ded4998941a3c1d6fa99e14cb28d8536729537e307/coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740", size = 255096, upload-time = "2025-05-23T11:39:19.328Z" }, - { url = "https://files.pythonhosted.org/packages/b4/06/16e3598b9466456b718eb3e789457d1a5b8bfb22e23b6e8bbc307df5daf0/coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625", size = 256276, upload-time = "2025-05-23T11:39:21.077Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d5/4b5a120d5d0223050a53d2783c049c311eea1709fa9de12d1c358e18b707/coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b", size = 254478, upload-time = "2025-05-23T11:39:22.838Z" }, - { url = "https://files.pythonhosted.org/packages/ba/85/f9ecdb910ecdb282b121bfcaa32fa8ee8cbd7699f83330ee13ff9bbf1a85/coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199", size = 255255, upload-time = "2025-05-23T11:39:24.644Z" }, - { url = "https://files.pythonhosted.org/packages/50/63/2d624ac7d7ccd4ebbd3c6a9eba9d7fc4491a1226071360d59dd84928ccb2/coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8", size = 215109, upload-time = "2025-05-23T11:39:26.722Z" }, - { url = "https://files.pythonhosted.org/packages/22/5e/7053b71462e970e869111c1853afd642212568a350eba796deefdfbd0770/coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d", size = 216268, upload-time = "2025-05-23T11:39:28.429Z" }, - { url = "https://files.pythonhosted.org/packages/07/69/afa41aa34147655543dbe96994f8a246daf94b361ccf5edfd5df62ce066a/coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b", size = 214071, upload-time = "2025-05-23T11:39:30.55Z" }, - { url = "https://files.pythonhosted.org/packages/69/2f/572b29496d8234e4a7773200dd835a0d32d9e171f2d974f3fe04a9dbc271/coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837", size = 203636, upload-time = "2025-05-23T11:39:52.002Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1a/0b9c32220ad694d66062f571cc5cedfa9997b64a591e8a500bb63de1bd40/coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32", size = 203623, upload-time = "2025-05-23T11:39:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/1c1c5ec58f16817c09cbacb39783c3655d54a221b6552f47ff5ac9297603/coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca", size = 212028, upload-time = "2025-06-13T13:00:29.293Z" }, + { url = "https://files.pythonhosted.org/packages/98/db/e91b9076f3a888e3b4ad7972ea3842297a52cc52e73fd1e529856e473510/coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509", size = 212420, upload-time = "2025-06-13T13:00:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d0/2b3733412954576b0aea0a16c3b6b8fbe95eb975d8bfa10b07359ead4252/coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b", size = 241529, upload-time = "2025-06-13T13:00:35.786Z" }, + { url = "https://files.pythonhosted.org/packages/b3/00/5e2e5ae2e750a872226a68e984d4d3f3563cb01d1afb449a17aa819bc2c4/coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3", size = 239403, upload-time = "2025-06-13T13:00:37.399Z" }, + { url = "https://files.pythonhosted.org/packages/37/3b/a2c27736035156b0a7c20683afe7df498480c0dfdf503b8c878a21b6d7fb/coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3", size = 240548, upload-time = "2025-06-13T13:00:39.647Z" }, + { url = "https://files.pythonhosted.org/packages/98/f5/13d5fc074c3c0e0dc80422d9535814abf190f1254d7c3451590dc4f8b18c/coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5", size = 240459, upload-time = "2025-06-13T13:00:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/36/24/24b9676ea06102df824c4a56ffd13dc9da7904478db519efa877d16527d5/coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187", size = 239128, upload-time = "2025-06-13T13:00:42.343Z" }, + { url = "https://files.pythonhosted.org/packages/be/05/242b7a7d491b369ac5fee7908a6e5ba42b3030450f3ad62c645b40c23e0e/coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce", size = 239402, upload-time = "2025-06-13T13:00:43.634Z" }, + { url = "https://files.pythonhosted.org/packages/73/e0/4de7f87192fa65c9c8fbaeb75507e124f82396b71de1797da5602898be32/coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70", size = 214518, upload-time = "2025-06-13T13:00:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/5e4e2fe458907d2a65fab62c773671cfc5ac704f1e7a9ddd91996f66e3c2/coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe", size = 215436, upload-time = "2025-06-13T13:00:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/60/34/fa69372a07d0903a78ac103422ad34db72281c9fc625eba94ac1185da66f/coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582", size = 212146, upload-time = "2025-06-13T13:00:48.496Z" }, + { url = "https://files.pythonhosted.org/packages/27/f0/da1894915d2767f093f081c42afeba18e760f12fdd7a2f4acbe00564d767/coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86", size = 212536, upload-time = "2025-06-13T13:00:51.535Z" }, + { url = "https://files.pythonhosted.org/packages/10/d5/3fc33b06e41e390f88eef111226a24e4504d216ab8e5d1a7089aa5a3c87a/coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed", size = 245092, upload-time = "2025-06-13T13:00:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/0a/39/7aa901c14977aba637b78e95800edf77f29f5a380d29768c5b66f258305b/coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d", size = 242806, upload-time = "2025-06-13T13:00:54.571Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/30e5cfeaf560b1fc1989227adedc11019ce4bb7cce59d65db34fe0c2d963/coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338", size = 244610, upload-time = "2025-06-13T13:00:56.932Z" }, + { url = "https://files.pythonhosted.org/packages/bf/15/cca62b13f39650bc87b2b92bb03bce7f0e79dd0bf2c7529e9fc7393e4d60/coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875", size = 244257, upload-time = "2025-06-13T13:00:58.545Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1a/c0f2abe92c29e1464dbd0ff9d56cb6c88ae2b9e21becdb38bea31fcb2f6c/coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250", size = 242309, upload-time = "2025-06-13T13:00:59.836Z" }, + { url = "https://files.pythonhosted.org/packages/57/8d/c6fd70848bd9bf88fa90df2af5636589a8126d2170f3aade21ed53f2b67a/coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c", size = 242898, upload-time = "2025-06-13T13:01:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/6ca46c7bff4675f09a66fe2797cd1ad6a24f14c9c7c3b3ebe0470a6e30b8/coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32", size = 214561, upload-time = "2025-06-13T13:01:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/a1/30/166978c6302010742dabcdc425fa0f938fa5a800908e39aff37a7a876a13/coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125", size = 215493, upload-time = "2025-06-13T13:01:05.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/a6d2342cd80a5be9f0eeab115bc5ebb3917b4a64c2953534273cf9bc7ae6/coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e", size = 213869, upload-time = "2025-06-13T13:01:09.345Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/7f66eb0a8f2fce222de7bdc2046ec41cb31fe33fb55a330037833fb88afc/coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626", size = 212336, upload-time = "2025-06-13T13:01:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/e07cb920ef3addf20f052ee3d54906e57407b6aeee3227a9c91eea38a665/coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb", size = 212571, upload-time = "2025-06-13T13:01:12.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/f8/96f155de7e9e248ca9c8ff1a40a521d944ba48bec65352da9be2463745bf/coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300", size = 246377, upload-time = "2025-06-13T13:01:14.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cf/1d783bd05b7bca5c10ded5f946068909372e94615a4416afadfe3f63492d/coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8", size = 243394, upload-time = "2025-06-13T13:01:16.23Z" }, + { url = "https://files.pythonhosted.org/packages/02/dd/e7b20afd35b0a1abea09fb3998e1abc9f9bd953bee548f235aebd2b11401/coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5", size = 245586, upload-time = "2025-06-13T13:01:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/4e/38/b30b0006fea9d617d1cb8e43b1bc9a96af11eff42b87eb8c716cf4d37469/coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd", size = 245396, upload-time = "2025-06-13T13:01:19.164Z" }, + { url = "https://files.pythonhosted.org/packages/31/e4/4d8ec1dc826e16791f3daf1b50943e8e7e1eb70e8efa7abb03936ff48418/coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898", size = 243577, upload-time = "2025-06-13T13:01:22.433Z" }, + { url = "https://files.pythonhosted.org/packages/25/f4/b0e96c5c38e6e40ef465c4bc7f138863e2909c00e54a331da335faf0d81a/coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d", size = 244809, upload-time = "2025-06-13T13:01:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/8a/65/27e0a1fa5e2e5079bdca4521be2f5dabf516f94e29a0defed35ac2382eb2/coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74", size = 214724, upload-time = "2025-06-13T13:01:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a8/d5b128633fd1a5e0401a4160d02fa15986209a9e47717174f99dc2f7166d/coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e", size = 215535, upload-time = "2025-06-13T13:01:27.861Z" }, + { url = "https://files.pythonhosted.org/packages/a3/37/84bba9d2afabc3611f3e4325ee2c6a47cd449b580d4a606b240ce5a6f9bf/coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342", size = 213904, upload-time = "2025-06-13T13:01:29.202Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a7/a027970c991ca90f24e968999f7d509332daf6b8c3533d68633930aaebac/coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631", size = 212358, upload-time = "2025-06-13T13:01:30.909Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/6aaed3651ae83b231556750280682528fea8ac7f1232834573472d83e459/coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f", size = 212620, upload-time = "2025-06-13T13:01:32.256Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/f4b613f3b44d8b9f144847c89151992b2b6b79cbc506dee89ad0c35f209d/coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd", size = 245788, upload-time = "2025-06-13T13:01:33.948Z" }, + { url = "https://files.pythonhosted.org/packages/04/d2/de4fdc03af5e4e035ef420ed26a703c6ad3d7a07aff2e959eb84e3b19ca8/coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86", size = 243001, upload-time = "2025-06-13T13:01:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e8/eed18aa5583b0423ab7f04e34659e51101135c41cd1dcb33ac1d7013a6d6/coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43", size = 244985, upload-time = "2025-06-13T13:01:36.712Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/ae9e5cce8885728c934eaa58ebfa8281d488ef2afa81c3dbc8ee9e6d80db/coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1", size = 245152, upload-time = "2025-06-13T13:01:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c8/272c01ae792bb3af9b30fac14d71d63371db227980682836ec388e2c57c0/coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751", size = 243123, upload-time = "2025-06-13T13:01:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/2819a1e3086143c094ab446e3bdf07138527a7b88cb235c488e78150ba7a/coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67", size = 244506, upload-time = "2025-06-13T13:01:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/9f6117b89152df7b6112f65c7a4ed1f2f5ec8e60c4be8f351d91e7acc848/coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643", size = 214766, upload-time = "2025-06-13T13:01:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/27/0f/4b59f7c93b52c2c4ce7387c5a4e135e49891bb3b7408dcc98fe44033bbe0/coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a", size = 215568, upload-time = "2025-06-13T13:01:45.772Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/9679826336f8c67b9c39a359352882b24a8a7aee48d4c9cad08d38d7510f/coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d", size = 213939, upload-time = "2025-06-13T13:01:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5b/5c6b4e7a407359a2e3b27bf9c8a7b658127975def62077d441b93a30dbe8/coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0", size = 213079, upload-time = "2025-06-13T13:01:48.554Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/1e2e07279fd2fd97ae26c01cc2186e2258850e9ec125ae87184225662e89/coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d", size = 213299, upload-time = "2025-06-13T13:01:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/4c5125a4b69d66b8c85986d3321520f628756cf524af810baab0790c7647/coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f", size = 256535, upload-time = "2025-06-13T13:01:51.314Z" }, + { url = "https://files.pythonhosted.org/packages/81/8b/e36a04889dda9960be4263e95e777e7b46f1bb4fc32202612c130a20c4da/coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029", size = 252756, upload-time = "2025-06-13T13:01:54.403Z" }, + { url = "https://files.pythonhosted.org/packages/98/82/be04eff8083a09a4622ecd0e1f31a2c563dbea3ed848069e7b0445043a70/coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece", size = 254912, upload-time = "2025-06-13T13:01:56.769Z" }, + { url = "https://files.pythonhosted.org/packages/0f/25/c26610a2c7f018508a5ab958e5b3202d900422cf7cdca7670b6b8ca4e8df/coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683", size = 256144, upload-time = "2025-06-13T13:01:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/fb9425c4684066c79e863f1e6e7ecebb49e3a64d9f7f7860ef1688c56f4a/coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f", size = 254257, upload-time = "2025-06-13T13:01:59.645Z" }, + { url = "https://files.pythonhosted.org/packages/93/df/27b882f54157fc1131e0e215b0da3b8d608d9b8ef79a045280118a8f98fe/coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10", size = 255094, upload-time = "2025-06-13T13:02:01.37Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437, upload-time = "2025-06-13T13:02:02.905Z" }, + { url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605, upload-time = "2025-06-13T13:02:05.638Z" }, + { url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392, upload-time = "2025-06-13T13:02:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/c723545c3fd3204ebde3b4cc4b927dce709d3b6dc577754bb57f63ca4a4a/coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514", size = 204009, upload-time = "2025-06-13T13:02:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000, upload-time = "2025-06-13T13:02:27.173Z" }, ] [[package]] @@ -132,16 +132,16 @@ wheels = [ [[package]] name = "django" -version = "5.2.2" +version = "5.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/17/4567ee12bb84114c544d5c4a792e7226db517ac78f552111e9dc62d1de14/django-5.2.2.tar.gz", hash = "sha256:85852e517f84435e9b13421379cd6c43ef5b48a9c8b391d29a26f7900967e952", size = 10827542, upload-time = "2025-06-04T13:52:40.879Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/af/77b403926025dc6f7fd7b31256394d643469418965eb528eab45d0505358/django-5.2.3.tar.gz", hash = "sha256:335213277666ab2c5cac44a792a6d2f3d58eb79a80c14b6b160cd4afc3b75684", size = 10850303, upload-time = "2025-06-10T10:14:05.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/5c/5d00acab6c062b154e5a0f092938ae5a0c698dbc4362b68e23200960f32c/django-5.2.2-py3-none-any.whl", hash = "sha256:997ef2162d04ead6869551b22cde4e06da1f94cf595f4af3f3d3afeae1f3f6fe", size = 8302562, upload-time = "2025-06-04T13:52:33.14Z" }, + { url = "https://files.pythonhosted.org/packages/1b/11/7aff961db37e1ea501a2bb663d27a8ce97f3683b9e5b83d3bfead8b86fa4/django-5.2.3-py3-none-any.whl", hash = "sha256:c517a6334e0fd940066aa9467b29401b93c37cec2e61365d663b80922542069d", size = 8301935, upload-time = "2025-06-10T10:13:58.993Z" }, ] [[package]] @@ -152,6 +152,7 @@ dependencies = [ { name = "click" }, { name = "croniter" }, { name = "django" }, + { name = "fakeredis" }, ] [package.optional-dependencies] @@ -180,6 +181,7 @@ requires-dist = [ { name = "click", specifier = "~=8.2" }, { name = "croniter", specifier = ">=2.0" }, { name = "django", specifier = ">=5" }, + { name = "fakeredis", directory = "../fakeredis" }, { name = "pyyaml", marker = "extra == 'yaml'", specifier = "~=6.0" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = "~=2.19" }, { name = "valkey", marker = "extra == 'valkey'", specifier = ">=6.0.2,<7" }, @@ -189,7 +191,7 @@ provides-extras = ["yaml", "valkey", "sentry"] [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = "~=7.6" }, - { name = "fakeredis", specifier = "~=2.28" }, + { name = "fakeredis", directory = "../fakeredis" }, { name = "mypy", specifier = ">=1.16.0" }, { name = "pyyaml", specifier = ">=6,<7" }, { name = "ruff", specifier = ">=0.11" }, @@ -198,21 +200,52 @@ dev = [ [[package]] name = "fakeredis" -version = "2.29.0" -source = { registry = "https://pypi.org/simple" } +version = "2.30.1" +source = { directory = "../fakeredis" } dependencies = [ { name = "redis" }, { name = "sortedcontainers" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/cc/f97894dacf214148df6538f696d49e8ee9943108d052e1195721319972f6/fakeredis-2.29.0.tar.gz", hash = "sha256:159cebf2c53e2c2bd7d18220fa93aa5f1d7152f6b6dd7896c46234d674342398", size = 162915, upload-time = "2025-05-06T18:42:05.558Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/fd/9af8a9c6d7a4233ee292f6ae0d142fcb22b1173940596089c85a9f5bffce/fakeredis-2.29.0-py3-none-any.whl", hash = "sha256:f644c0a69dc088455d75a9b259d101e28a1c5659381aa6d9ee6c2b31eb5a909f", size = 114198, upload-time = "2025-05-06T18:42:03.82Z" }, + +[package.metadata] +requires-dist = [ + { name = "jsonpath-ng", marker = "extra == 'json'", specifier = "~=1.6" }, + { name = "lupa", marker = "extra == 'lua'", specifier = ">=2.1,<3.0" }, + { name = "pyprobables", marker = "extra == 'bf'", specifier = ">=0.6" }, + { name = "pyprobables", marker = "extra == 'cf'", specifier = ">=0.6" }, + { name = "pyprobables", marker = "extra == 'probabilistic'", specifier = ">=0.6" }, + { name = "redis", marker = "python_full_version < '3.8'", specifier = ">=4" }, + { name = "redis", marker = "python_full_version >= '3.9'", specifier = ">=4.3" }, + { name = "sortedcontainers", specifier = ">=2,<3" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = "~=4.7" }, +] +provides-extras = ["lua", "json", "bf", "cf", "probabilistic"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", marker = "python_full_version >= '3.10'", specifier = ">=1.15" }, + { name = "pre-commit", marker = "python_full_version >= '3.10'", specifier = "~=4.2" }, + { name = "ruff", marker = "python_full_version >= '3.10'", specifier = ">=0.11" }, +] +docs = [ + { name = "pygithub", marker = "python_full_version >= '3.10'", specifier = "~=2.3" }, + { name = "python-dotenv", marker = "python_full_version >= '3.10'", specifier = ">=1,<2" }, +] +test = [ + { name = "coverage", marker = "python_full_version >= '3.9'", specifier = "~=7.6" }, + { name = "hypothesis", marker = "python_full_version >= '3.9'", specifier = "~=6.111" }, + { name = "pytest", marker = "python_full_version >= '3.9'", specifier = "~=8.3" }, + { name = "pytest-asyncio", marker = "python_full_version >= '3.9'", specifier = ">=0.24,<0.25" }, + { name = "pytest-cov", marker = "python_full_version >= '3.9'", specifier = "~=6.0" }, + { name = "pytest-html", marker = "python_full_version >= '3.9'", specifier = "~=4.1" }, + { name = "pytest-mock", marker = "python_full_version >= '3.9'", specifier = "~=3.14" }, + { name = "pytest-timeout", marker = "python_full_version >= '3.9'", specifier = ">=2.3.1,<3" }, ] [[package]] name = "mypy" -version = "1.16.0" +version = "1.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, @@ -220,33 +253,33 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/38/13c2f1abae94d5ea0354e146b95a1be9b2137a0d506728e0da037c4276f6/mypy-1.16.0.tar.gz", hash = "sha256:84b94283f817e2aa6350a14b4a8fb2a35a53c286f97c9d30f53b63620e7af8ab", size = 3323139, upload-time = "2025-05-29T13:46:12.532Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/5e/a0485f0608a3d67029d3d73cec209278b025e3493a3acfda3ef3a88540fd/mypy-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7909541fef256527e5ee9c0a7e2aeed78b6cda72ba44298d1334fe7881b05c5c", size = 10967416, upload-time = "2025-05-29T13:34:17.783Z" }, - { url = "https://files.pythonhosted.org/packages/4b/53/5837c221f74c0d53a4bfc3003296f8179c3a2a7f336d7de7bbafbe96b688/mypy-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e71d6f0090c2256c713ed3d52711d01859c82608b5d68d4fa01a3fe30df95571", size = 10087654, upload-time = "2025-05-29T13:32:37.878Z" }, - { url = "https://files.pythonhosted.org/packages/29/59/5fd2400352c3093bed4c09017fe671d26bc5bb7e6ef2d4bf85f2a2488104/mypy-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:936ccfdd749af4766be824268bfe22d1db9eb2f34a3ea1d00ffbe5b5265f5491", size = 11875192, upload-time = "2025-05-29T13:34:54.281Z" }, - { url = "https://files.pythonhosted.org/packages/ad/3e/4bfec74663a64c2012f3e278dbc29ffe82b121bc551758590d1b6449ec0c/mypy-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4086883a73166631307fdd330c4a9080ce24913d4f4c5ec596c601b3a4bdd777", size = 12612939, upload-time = "2025-05-29T13:33:14.766Z" }, - { url = "https://files.pythonhosted.org/packages/88/1f/fecbe3dcba4bf2ca34c26ca016383a9676711907f8db4da8354925cbb08f/mypy-1.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:feec38097f71797da0231997e0de3a58108c51845399669ebc532c815f93866b", size = 12874719, upload-time = "2025-05-29T13:21:52.09Z" }, - { url = "https://files.pythonhosted.org/packages/f3/51/c2d280601cd816c43dfa512a759270d5a5ef638d7ac9bea9134c8305a12f/mypy-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:09a8da6a0ee9a9770b8ff61b39c0bb07971cda90e7297f4213741b48a0cc8d93", size = 9487053, upload-time = "2025-05-29T13:33:29.797Z" }, - { url = "https://files.pythonhosted.org/packages/24/c4/ff2f79db7075c274fe85b5fff8797d29c6b61b8854c39e3b7feb556aa377/mypy-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9f826aaa7ff8443bac6a494cf743f591488ea940dd360e7dd330e30dd772a5ab", size = 10884498, upload-time = "2025-05-29T13:18:54.066Z" }, - { url = "https://files.pythonhosted.org/packages/02/07/12198e83006235f10f6a7808917376b5d6240a2fd5dce740fe5d2ebf3247/mypy-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82d056e6faa508501af333a6af192c700b33e15865bda49611e3d7d8358ebea2", size = 10011755, upload-time = "2025-05-29T13:34:00.851Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9b/5fd5801a72b5d6fb6ec0105ea1d0e01ab2d4971893076e558d4b6d6b5f80/mypy-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:089bedc02307c2548eb51f426e085546db1fa7dd87fbb7c9fa561575cf6eb1ff", size = 11800138, upload-time = "2025-05-29T13:32:55.082Z" }, - { url = "https://files.pythonhosted.org/packages/2e/81/a117441ea5dfc3746431e51d78a4aca569c677aa225bca2cc05a7c239b61/mypy-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a2322896003ba66bbd1318c10d3afdfe24e78ef12ea10e2acd985e9d684a666", size = 12533156, upload-time = "2025-05-29T13:19:12.963Z" }, - { url = "https://files.pythonhosted.org/packages/3f/38/88ec57c6c86014d3f06251e00f397b5a7daa6888884d0abf187e4f5f587f/mypy-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:021a68568082c5b36e977d54e8f1de978baf401a33884ffcea09bd8e88a98f4c", size = 12742426, upload-time = "2025-05-29T13:20:22.72Z" }, - { url = "https://files.pythonhosted.org/packages/bd/53/7e9d528433d56e6f6f77ccf24af6ce570986c2d98a5839e4c2009ef47283/mypy-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:54066fed302d83bf5128632d05b4ec68412e1f03ef2c300434057d66866cea4b", size = 9478319, upload-time = "2025-05-29T13:21:17.582Z" }, - { url = "https://files.pythonhosted.org/packages/70/cf/158e5055e60ca2be23aec54a3010f89dcffd788732634b344fc9cb1e85a0/mypy-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5436d11e89a3ad16ce8afe752f0f373ae9620841c50883dc96f8b8805620b13", size = 11062927, upload-time = "2025-05-29T13:35:52.328Z" }, - { url = "https://files.pythonhosted.org/packages/94/34/cfff7a56be1609f5d10ef386342ce3494158e4d506516890142007e6472c/mypy-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2622af30bf01d8fc36466231bdd203d120d7a599a6d88fb22bdcb9dbff84090", size = 10083082, upload-time = "2025-05-29T13:35:33.378Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7f/7242062ec6288c33d8ad89574df87c3903d394870e5e6ba1699317a65075/mypy-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d045d33c284e10a038f5e29faca055b90eee87da3fc63b8889085744ebabb5a1", size = 11828306, upload-time = "2025-05-29T13:21:02.164Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5f/b392f7b4f659f5b619ce5994c5c43caab3d80df2296ae54fa888b3d17f5a/mypy-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4968f14f44c62e2ec4a038c8797a87315be8df7740dc3ee8d3bfe1c6bf5dba8", size = 12702764, upload-time = "2025-05-29T13:20:42.826Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c0/7646ef3a00fa39ac9bc0938626d9ff29d19d733011be929cfea59d82d136/mypy-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb14a4a871bb8efb1e4a50360d4e3c8d6c601e7a31028a2c79f9bb659b63d730", size = 12896233, upload-time = "2025-05-29T13:18:37.446Z" }, - { url = "https://files.pythonhosted.org/packages/6d/38/52f4b808b3fef7f0ef840ee8ff6ce5b5d77381e65425758d515cdd4f5bb5/mypy-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd4e1ebe126152a7bbaa4daedd781c90c8f9643c79b9748caa270ad542f12bec", size = 9565547, upload-time = "2025-05-29T13:20:02.836Z" }, - { url = "https://files.pythonhosted.org/packages/97/9c/ca03bdbefbaa03b264b9318a98950a9c683e06472226b55472f96ebbc53d/mypy-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e056237c89f1587a3be1a3a70a06a698d25e2479b9a2f57325ddaaffc3567b", size = 11059753, upload-time = "2025-05-29T13:18:18.167Z" }, - { url = "https://files.pythonhosted.org/packages/36/92/79a969b8302cfe316027c88f7dc6fee70129490a370b3f6eb11d777749d0/mypy-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b07e107affb9ee6ce1f342c07f51552d126c32cd62955f59a7db94a51ad12c0", size = 10073338, upload-time = "2025-05-29T13:19:48.079Z" }, - { url = "https://files.pythonhosted.org/packages/14/9b/a943f09319167da0552d5cd722104096a9c99270719b1afeea60d11610aa/mypy-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fb60cbd85dc65d4d63d37cb5c86f4e3a301ec605f606ae3a9173e5cf34997b", size = 11827764, upload-time = "2025-05-29T13:46:04.47Z" }, - { url = "https://files.pythonhosted.org/packages/ec/64/ff75e71c65a0cb6ee737287c7913ea155845a556c64144c65b811afdb9c7/mypy-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7e32297a437cc915599e0578fa6bc68ae6a8dc059c9e009c628e1c47f91495d", size = 12701356, upload-time = "2025-05-29T13:35:13.553Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ad/0e93c18987a1182c350f7a5fab70550852f9fabe30ecb63bfbe51b602074/mypy-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:afe420c9380ccec31e744e8baff0d406c846683681025db3531b32db56962d52", size = 12900745, upload-time = "2025-05-29T13:17:24.409Z" }, - { url = "https://files.pythonhosted.org/packages/28/5d/036c278d7a013e97e33f08c047fe5583ab4f1fc47c9a49f985f1cdd2a2d7/mypy-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:55f9076c6ce55dd3f8cd0c6fff26a008ca8e5131b89d5ba6d86bd3f47e736eeb", size = 9572200, upload-time = "2025-05-29T13:33:44.92Z" }, - { url = "https://files.pythonhosted.org/packages/99/a3/6ed10530dec8e0fdc890d81361260c9ef1f5e5c217ad8c9b21ecb2b8366b/mypy-1.16.0-py3-none-any.whl", hash = "sha256:29e1499864a3888bca5c1542f2d7232c6e586295183320caa95758fc84034031", size = 2265773, upload-time = "2025-05-29T13:35:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/8e/12/2bf23a80fcef5edb75de9a1e295d778e0f46ea89eb8b115818b663eff42b/mypy-1.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4f0fed1022a63c6fec38f28b7fc77fca47fd490445c69d0a66266c59dd0b88a", size = 10958644, upload-time = "2025-06-16T16:51:11.649Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/bfe47b3b278eacf348291742fd5e6613bbc4b3434b72ce9361896417cfe5/mypy-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86042bbf9f5a05ea000d3203cf87aa9d0ccf9a01f73f71c58979eb9249f46d72", size = 10087033, upload-time = "2025-06-16T16:35:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/40307c12fe25675a0776aaa2cdd2879cf30d99eec91b898de00228dc3ab5/mypy-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7469ee5902c95542bea7ee545f7006508c65c8c54b06dc2c92676ce526f3ea", size = 11875645, upload-time = "2025-06-16T16:35:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d8/85bdb59e4a98b7a31495bd8f1a4445d8ffc86cde4ab1f8c11d247c11aedc/mypy-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352025753ef6a83cb9e7f2427319bb7875d1fdda8439d1e23de12ab164179574", size = 12616986, upload-time = "2025-06-16T16:48:39.526Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d0/bb25731158fa8f8ee9e068d3e94fcceb4971fedf1424248496292512afe9/mypy-1.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff9fa5b16e4c1364eb89a4d16bcda9987f05d39604e1e6c35378a2987c1aac2d", size = 12878632, upload-time = "2025-06-16T16:36:08.195Z" }, + { url = "https://files.pythonhosted.org/packages/2d/11/822a9beb7a2b825c0cb06132ca0a5183f8327a5e23ef89717c9474ba0bc6/mypy-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:1256688e284632382f8f3b9e2123df7d279f603c561f099758e66dd6ed4e8bd6", size = 9484391, upload-time = "2025-06-16T16:37:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" }, + { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" }, + { url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" }, + { url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" }, ] [[package]] @@ -346,40 +379,40 @@ wheels = [ [[package]] name = "ruff" -version = "0.11.12" +version = "0.11.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/0a/92416b159ec00cdf11e5882a9d80d29bf84bba3dbebc51c4898bfbca1da6/ruff-0.11.12.tar.gz", hash = "sha256:43cf7f69c7d7c7d7513b9d59c5d8cafd704e05944f978614aa9faff6ac202603", size = 4202289, upload-time = "2025-05-29T13:31:40.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/cc/53eb79f012d15e136d40a8e8fc519ba8f55a057f60b29c2df34efd47c6e3/ruff-0.11.12-py3-none-linux_armv6l.whl", hash = "sha256:c7680aa2f0d4c4f43353d1e72123955c7a2159b8646cd43402de6d4a3a25d7cc", size = 10285597, upload-time = "2025-05-29T13:30:57.539Z" }, - { url = "https://files.pythonhosted.org/packages/e7/d7/73386e9fb0232b015a23f62fea7503f96e29c29e6c45461d4a73bac74df9/ruff-0.11.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2cad64843da9f134565c20bcc430642de897b8ea02e2e79e6e02a76b8dcad7c3", size = 11053154, upload-time = "2025-05-29T13:31:00.865Z" }, - { url = "https://files.pythonhosted.org/packages/4e/eb/3eae144c5114e92deb65a0cb2c72326c8469e14991e9bc3ec0349da1331c/ruff-0.11.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9b6886b524a1c659cee1758140138455d3c029783d1b9e643f3624a5ee0cb0aa", size = 10403048, upload-time = "2025-05-29T13:31:03.413Z" }, - { url = "https://files.pythonhosted.org/packages/29/64/20c54b20e58b1058db6689e94731f2a22e9f7abab74e1a758dfba058b6ca/ruff-0.11.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc3a3690aad6e86c1958d3ec3c38c4594b6ecec75c1f531e84160bd827b2012", size = 10597062, upload-time = "2025-05-29T13:31:05.539Z" }, - { url = "https://files.pythonhosted.org/packages/29/3a/79fa6a9a39422a400564ca7233a689a151f1039110f0bbbabcb38106883a/ruff-0.11.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f97fdbc2549f456c65b3b0048560d44ddd540db1f27c778a938371424b49fe4a", size = 10155152, upload-time = "2025-05-29T13:31:07.986Z" }, - { url = "https://files.pythonhosted.org/packages/e5/a4/22c2c97b2340aa968af3a39bc38045e78d36abd4ed3fa2bde91c31e712e3/ruff-0.11.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74adf84960236961090e2d1348c1a67d940fd12e811a33fb3d107df61eef8fc7", size = 11723067, upload-time = "2025-05-29T13:31:10.57Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cf/3e452fbd9597bcd8058856ecd42b22751749d07935793a1856d988154151/ruff-0.11.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b56697e5b8bcf1d61293ccfe63873aba08fdbcbbba839fc046ec5926bdb25a3a", size = 12460807, upload-time = "2025-05-29T13:31:12.88Z" }, - { url = "https://files.pythonhosted.org/packages/2f/ec/8f170381a15e1eb7d93cb4feef8d17334d5a1eb33fee273aee5d1f8241a3/ruff-0.11.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d47afa45e7b0eaf5e5969c6b39cbd108be83910b5c74626247e366fd7a36a13", size = 12063261, upload-time = "2025-05-29T13:31:15.236Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/57208f8c0a8153a14652a85f4116c0002148e83770d7a41f2e90b52d2b4e/ruff-0.11.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bf9603fe1bf949de8b09a2da896f05c01ed7a187f4a386cdba6760e7f61be", size = 11329601, upload-time = "2025-05-29T13:31:18.68Z" }, - { url = "https://files.pythonhosted.org/packages/c3/56/edf942f7fdac5888094d9ffa303f12096f1a93eb46570bcf5f14c0c70880/ruff-0.11.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08033320e979df3b20dba567c62f69c45e01df708b0f9c83912d7abd3e0801cd", size = 11522186, upload-time = "2025-05-29T13:31:21.216Z" }, - { url = "https://files.pythonhosted.org/packages/ed/63/79ffef65246911ed7e2290aeece48739d9603b3a35f9529fec0fc6c26400/ruff-0.11.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:929b7706584f5bfd61d67d5070f399057d07c70585fa8c4491d78ada452d3bef", size = 10449032, upload-time = "2025-05-29T13:31:23.417Z" }, - { url = "https://files.pythonhosted.org/packages/88/19/8c9d4d8a1c2a3f5a1ea45a64b42593d50e28b8e038f1aafd65d6b43647f3/ruff-0.11.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7de4a73205dc5756b8e09ee3ed67c38312dce1aa28972b93150f5751199981b5", size = 10129370, upload-time = "2025-05-29T13:31:25.777Z" }, - { url = "https://files.pythonhosted.org/packages/bc/0f/2d15533eaa18f460530a857e1778900cd867ded67f16c85723569d54e410/ruff-0.11.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2635c2a90ac1b8ca9e93b70af59dfd1dd2026a40e2d6eebaa3efb0465dd9cf02", size = 11123529, upload-time = "2025-05-29T13:31:28.396Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e2/4c2ac669534bdded835356813f48ea33cfb3a947dc47f270038364587088/ruff-0.11.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d05d6a78a89166f03f03a198ecc9d18779076ad0eec476819467acb401028c0c", size = 11577642, upload-time = "2025-05-29T13:31:30.647Z" }, - { url = "https://files.pythonhosted.org/packages/a7/9b/c9ddf7f924d5617a1c94a93ba595f4b24cb5bc50e98b94433ab3f7ad27e5/ruff-0.11.12-py3-none-win32.whl", hash = "sha256:f5a07f49767c4be4772d161bfc049c1f242db0cfe1bd976e0f0886732a4765d6", size = 10475511, upload-time = "2025-05-29T13:31:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d6/74fb6d3470c1aada019ffff33c0f9210af746cca0a4de19a1f10ce54968a/ruff-0.11.12-py3-none-win_amd64.whl", hash = "sha256:5a4d9f8030d8c3a45df201d7fb3ed38d0219bccd7955268e863ee4a115fa0832", size = 11523573, upload-time = "2025-05-29T13:31:35.782Z" }, - { url = "https://files.pythonhosted.org/packages/44/42/d58086ec20f52d2b0140752ae54b355ea2be2ed46f914231136dd1effcc7/ruff-0.11.12-py3-none-win_arm64.whl", hash = "sha256:65194e37853158d368e333ba282217941029a28ea90913c67e558c611d04daa5", size = 10697770, upload-time = "2025-05-29T13:31:38.009Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, ] [[package]] name = "sentry-sdk" -version = "2.29.1" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/67/d552a5f8e5a6a56b2feea6529e2d8ccd54349084c84176d5a1f7295044bc/sentry_sdk-2.29.1.tar.gz", hash = "sha256:8d4a0206b95fa5fe85e5e7517ed662e3888374bdc342c00e435e10e6d831aa6d", size = 325518, upload-time = "2025-05-19T14:27:38.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/4c/af31e0201b48469786ddeb1bf6fd3dfa3a291cc613a0fe6a60163a7535f9/sentry_sdk-2.30.0.tar.gz", hash = "sha256:436369b02afef7430efb10300a344fb61a11fe6db41c2b11f41ee037d2dd7f45", size = 326767, upload-time = "2025-06-12T10:34:34.733Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/e5/da07b0bd832cefd52d16f2b9bbbe31624d57552602c06631686b93ccb1bd/sentry_sdk-2.29.1-py2.py3-none-any.whl", hash = "sha256:90862fe0616ded4572da6c9dadb363121a1ae49a49e21c418f0634e9d10b4c19", size = 341553, upload-time = "2025-05-19T14:27:36.882Z" }, + { url = "https://files.pythonhosted.org/packages/5a/99/31ac6faaae33ea698086692638f58d14f121162a8db0039e68e94135e7f1/sentry_sdk-2.30.0-py2.py3-none-any.whl", hash = "sha256:59391db1550662f746ea09b483806a631c3ae38d6340804a1a4c0605044f6877", size = 343149, upload-time = "2025-06-12T10:34:32.896Z" }, ] [[package]] From 87fc53bf0f704f9eae304775b1117ab8bea0be02 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Tue, 24 Jun 2025 16:15:52 -0400 Subject: [PATCH 03/12] wip --- pyproject.toml | 7 +- scheduler/helpers/sentry_integration.py | 70 +++----- .../commands/delete_failed_executions.py | 2 +- scheduler/management/commands/export.py | 7 +- scheduler/management/commands/import.py | 33 ++-- scheduler/management/commands/run_job.py | 8 +- .../management/commands/scheduler_stats.py | 4 +- scheduler/views/job_views.py | 11 +- scheduler/views/queue_job_actions.py | 13 +- uv.lock | 156 +++++++++++++++++- 10 files changed, 225 insertions(+), 86 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 14e31bb..99a341a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ dependencies = [ ] [project.optional-dependencies] -yaml = ["pyyaml~=6.0"] +yaml = ["pyyaml~=6.0", "types-PyYAML>=6.0.12.20250516"] valkey = ["valkey>=6.0.2,<7"] sentry = ["sentry-sdk~=2.19"] @@ -90,7 +90,10 @@ line-ending = "auto" [tool.mypy] packages = ['scheduler', ] -exclude = "scheduler/tests/.*\\.py" +exclude = ["scheduler/tests/.*\\.py", + "scheduler/migrations/.*\\.py", + "testproject/.*\\.py", + "testproject/tests/.*\\.py"] strict = true follow_imports = "silent" ignore_missing_imports = true diff --git a/scheduler/helpers/sentry_integration.py b/scheduler/helpers/sentry_integration.py index 366d5a9..9ef2b4a 100644 --- a/scheduler/helpers/sentry_integration.py +++ b/scheduler/helpers/sentry_integration.py @@ -1,21 +1,23 @@ import weakref +from typing import Any, Callable import sentry_sdk -from sentry_sdk.consts import OP +from sentry_sdk._types import EventProcessor, Event, ExcInfo from sentry_sdk.api import continue_trace -from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration +from sentry_sdk.consts import OP +from sentry_sdk.integrations import _check_minimum_version, Integration from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.tracing import TransactionSource from sentry_sdk.utils import ( capture_internal_exceptions, ensure_integration_enabled, event_from_exception, - format_timestamp, parse_version, ) import scheduler from scheduler.helpers.queues import Queue +from scheduler.redis_models import JobModel from scheduler.redis_models import JobStatus from scheduler.timeouts import JobTimeoutException from scheduler.worker import Worker @@ -26,22 +28,20 @@ class SentryIntegration(Integration): origin = f"auto.queue.{identifier}" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: version = parse_version(scheduler.__version__) _check_minimum_version(SentryIntegration, version) old_perform_job = Worker.perform_job @ensure_integration_enabled(SentryIntegration, old_perform_job) - def sentry_patched_perform_job(self, job, *args, **kwargs): - # type: (Any, Job, *Queue, **Any) -> bool + def sentry_patched_perform_job(self: Any, job_model: JobModel, *args: Queue, **kwargs: Any) -> bool: with sentry_sdk.new_scope() as scope: scope.clear_breadcrumbs() - scope.add_event_processor(_make_event_processor(weakref.ref(job))) + scope.add_event_processor(_make_event_processor(weakref.ref(job_model))) transaction = continue_trace( - job.meta.get("_sentry_trace_headers") or {}, + job_model.meta.get("_sentry_trace_headers") or {}, op=OP.QUEUE_TASK_RQ, name="unknown RQ task", source=TransactionSource.TASK, @@ -49,13 +49,13 @@ def sentry_patched_perform_job(self, job, *args, **kwargs): ) with capture_internal_exceptions(): - transaction.name = job.func_name + transaction.name = job_model.func_name with sentry_sdk.start_transaction( - transaction, - custom_sampling_context={"rq_job": job}, + transaction, + custom_sampling_context={"rq_job": job_model}, ): - rv = old_perform_job(self, job, *args, **kwargs) + rv = old_perform_job(self, job_model, *args, **kwargs) if self.is_horse: # We're inside of a forked process and RQ is @@ -65,16 +65,15 @@ def sentry_patched_perform_job(self, job, *args, **kwargs): return rv - Worker.perform_job = sentry_patched_perform_job + Worker.perform_job = sentry_patched_perform_job # type: ignore[method-assign] old_handle_exception = Worker.handle_exception - def sentry_patched_handle_exception(self, job, *exc_info, **kwargs): - # type: (Worker, Any, *Any, **Any) -> Any + def sentry_patched_handle_exception(self: Worker, job: Any, *exc_info: Any, **kwargs: Any) -> Any: retry = ( - hasattr(job, "retries_left") - and job.retries_left - and job.retries_left > 0 + hasattr(job, "retries_left") + and job.retries_left + and job.retries_left > 0 ) failed = job._status == JobStatus.FAILED or job.is_failed if failed and not retry: @@ -82,13 +81,12 @@ def sentry_patched_handle_exception(self, job, *exc_info, **kwargs): return old_handle_exception(self, job, *exc_info, **kwargs) - Worker.handle_exception = sentry_patched_handle_exception + Worker.handle_exception = sentry_patched_handle_exception # type: ignore[method-assign] old_enqueue_job = Queue.enqueue_job @ensure_integration_enabled(SentryIntegration, old_enqueue_job) - def sentry_patched_enqueue_job(self, job, **kwargs): - # type: (Queue, Any, **Any) -> Any + def sentry_patched_enqueue_job(self: Queue, job: Any, **kwargs: Any) -> Any: scope = sentry_sdk.get_current_scope() if scope.span is not None: job.meta["_sentry_trace_headers"] = dict( @@ -97,46 +95,30 @@ def sentry_patched_enqueue_job(self, job, **kwargs): return old_enqueue_job(self, job, **kwargs) - Queue.enqueue_job = sentry_patched_enqueue_job + Queue.enqueue_job = sentry_patched_enqueue_job # type: ignore[method-assign] ignore_logger("rq.worker") -def _make_event_processor(weak_job): - # type: (Callable[[], Job]) -> EventProcessor - def event_processor(event, hint): - # type: (Event, dict[str, Any]) -> Event +def _make_event_processor(weak_job: Callable[[], JobModel]) -> EventProcessor: + def event_processor(event: Event, hint: dict[str, Any]) -> Event: job = weak_job() if job is not None: with capture_internal_exceptions(): extra = event.setdefault("extra", {}) - rq_job = { - "job_id": job.id, - "func": job.func_name, - "args": job.args, - "kwargs": job.kwargs, - "description": job.description, - } - - if job.enqueued_at: - rq_job["enqueued_at"] = format_timestamp(job.enqueued_at) - if job.started_at: - rq_job["started_at"] = format_timestamp(job.started_at) - - extra["rq-job"] = rq_job + extra["job"] = job.serialize() if "exc_info" in hint: with capture_internal_exceptions(): if issubclass(hint["exc_info"][0], JobTimeoutException): - event["fingerprint"] = ["rq", "JobTimeoutException", job.func_name] + event["fingerprint"] = ["django-tasks-scheduler", "JobTimeoutException", job.func_name] return event return event_processor -def _capture_exception(exc_info, **kwargs): - # type: (ExcInfo, **Any) -> None +def _capture_exception(exc_info: ExcInfo, **kwargs: Any) -> None: client = sentry_sdk.get_client() event, hint = event_from_exception( diff --git a/scheduler/management/commands/delete_failed_executions.py b/scheduler/management/commands/delete_failed_executions.py index 6f41980..0e38fc6 100644 --- a/scheduler/management/commands/delete_failed_executions.py +++ b/scheduler/management/commands/delete_failed_executions.py @@ -8,7 +8,7 @@ class Command(BaseCommand): help = "Delete failed jobs from Django queue." - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument("--queue", "-q", dest="queue", default="default", help="Specify the queue [default]") parser.add_argument("-f", "--func", help='optional job function name, e.g. "app.tasks.func"') parser.add_argument("--dry-run", action="store_true", help="Do not actually delete failed jobs") diff --git a/scheduler/management/commands/export.py b/scheduler/management/commands/export.py index 85c3c9d..3571686 100644 --- a/scheduler/management/commands/export.py +++ b/scheduler/management/commands/export.py @@ -1,7 +1,8 @@ import sys +from typing import Any import click -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandParser from scheduler.models import Task @@ -11,7 +12,7 @@ class Command(BaseCommand): help = __doc__ - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "-o", "--output", @@ -37,7 +38,7 @@ def add_arguments(self, parser): help="File name to load (otherwise writes to standard output)", ) - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: file = open(options.get("filename"), "w") if options.get("filename") else sys.stdout res = list() diff --git a/scheduler/management/commands/import.py b/scheduler/management/commands/import.py index 28007a2..4a8bb88 100644 --- a/scheduler/management/commands/import.py +++ b/scheduler/management/commands/import.py @@ -4,7 +4,7 @@ import click from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandParser from django.utils import timezone from scheduler.models import TaskArg, TaskKwarg, Task @@ -24,11 +24,11 @@ def get_task_type(model_str: str) -> TaskType: except ValueError: pass if model_str == "CronTask": - return TaskType.CRON + return TaskType(TaskType.CRON) elif model_str == "RepeatableTask": - return TaskType.REPEATABLE + return TaskType(TaskType.REPEATABLE) elif model_str in {"ScheduledTask", "OnceTask"}: - return TaskType.ONCE + return TaskType(TaskType.ONCE) raise ValueError(f"Invalid model {model_str}") @@ -52,12 +52,17 @@ def create_task_from_dict(task_dict: Dict[str, Any], update: bool) -> Optional[T if not settings.USE_TZ and not timezone.is_naive(target): target = timezone.make_naive(target) kwargs["scheduled_time"] = target - model_fields = filter(lambda field: hasattr(field, "attname"), Task._meta.get_fields()) - model_fields = set(map(lambda field: field.attname, model_fields)) + model_fields = set(map( + lambda field: field.attname, + filter( + lambda field: hasattr(field, "attname"), + Task._meta.get_fields() + ) + )) keys_to_ignore = list(filter(lambda _k: _k not in model_fields, kwargs.keys())) for k in keys_to_ignore: del kwargs[k] - task = Task.objects.create(**kwargs) + task: Task = Task.objects.create(**kwargs) click.echo(f"Created task {task}") content_type = ContentType.objects.get_for_model(task) @@ -77,13 +82,11 @@ def create_task_from_dict(task_dict: Dict[str, Any], update: bool) -> Optional[T class Command(BaseCommand): - """ - Import scheduled jobs - """ + """Import scheduled jobs""" help = __doc__ - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "-f", "--format", @@ -115,8 +118,8 @@ def add_arguments(self, parser): help="Update existing records", ) - def handle(self, *args, **options): - file = open(options.get("filename")) if options.get("filename") else sys.stdin + def handle(self, *args: Any, **options: Any) -> None: + file = open(options.get("filename")) if options.get("filename") else sys.stdin # type: ignore[arg-type] jobs = list() if options.get("format") == "json": import json @@ -133,11 +136,11 @@ def handle(self, *args, **options): click.echo("Aborting. LibYAML is not installed.") exit(1) # Disable YAML alias - yaml.Dumper.ignore_aliases = lambda *x: True + yaml.Dumper.ignore_aliases = lambda *x: True # type: ignore[method-assign] jobs = yaml.load(file, yaml.SafeLoader) if options.get("reset"): Task.objects.all().delete() for job in jobs: - create_task_from_dict(job, update=options.get("update")) + create_task_from_dict(job, update=options.get("update")) # type: ignore[arg-type] diff --git a/scheduler/management/commands/run_job.py b/scheduler/management/commands/run_job.py index 2420c87..4c01a22 100644 --- a/scheduler/management/commands/run_job.py +++ b/scheduler/management/commands/run_job.py @@ -1,5 +1,7 @@ +from typing import Any + import click -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandParser from scheduler.helpers.queues import get_queue @@ -13,7 +15,7 @@ class Command(BaseCommand): help = __doc__ args = "" - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument("--queue", "-q", dest="queue", default="default", help="Specify the queue [default]") parser.add_argument("--timeout", "-t", type=int, dest="timeout", help="A timeout in seconds") parser.add_argument( @@ -25,7 +27,7 @@ def add_arguments(self, parser): ) parser.add_argument("args", nargs="*", help="Args for callable") - def handle(self, **options): + def handle(self, **options:Any)-> None: verbosity = int(options.get("verbosity", 1)) timeout = options.get("timeout") result_ttl = options.get("result_ttl") diff --git a/scheduler/management/commands/scheduler_stats.py b/scheduler/management/commands/scheduler_stats.py index 24cd258..a8f072e 100644 --- a/scheduler/management/commands/scheduler_stats.py +++ b/scheduler/management/commands/scheduler_stats.py @@ -3,7 +3,7 @@ from typing import Any, Dict, List, Optional import click -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandParser from scheduler.views import get_statistics @@ -24,7 +24,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.table_width = 80 self.interval = None - def add_arguments(self, parser: ArgumentParser) -> None: + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "-j", "--json", diff --git a/scheduler/views/job_views.py b/scheduler/views/job_views.py index 127ba28..c4fb44c 100644 --- a/scheduler/views/job_views.py +++ b/scheduler/views/job_views.py @@ -21,8 +21,8 @@ class JobDetailAction(str, Enum): CANCEL = "cancel" -@never_cache -@staff_member_required +@never_cache # type: ignore +@staff_member_required # type: ignore def job_detail(request: HttpRequest, job_name: str) -> HttpResponse: queue, job = _find_job(job_name) if job is None or queue is None: @@ -50,8 +50,8 @@ def job_detail(request: HttpRequest, job_name: str) -> HttpResponse: return render(request, "admin/scheduler/job_detail.html", context_data) -@never_cache -@staff_member_required +@never_cache # type: ignore +@staff_member_required # type: ignore def job_action(request: HttpRequest, job_name: str, action: str) -> HttpResponse: queue, job = _find_job(job_name) if job is None or queue is None: @@ -80,6 +80,9 @@ def job_action(request: HttpRequest, job_name: str, action: str) -> HttpResponse messages.info(request, f"You have successfully enqueued {job.name}") return redirect("job_details", job_name) elif action == JobDetailAction.CANCEL: + if job.worker_name is None: + messages.warning(request, "You cannot cancel a job that has no worker assigned") + return redirect("job_details", job_name) send_command( connection=queue.connection, command=StopJobCommand(job_name=job.name, worker_name=job.worker_name) ) diff --git a/scheduler/views/queue_job_actions.py b/scheduler/views/queue_job_actions.py index d7956cc..208a3fd 100644 --- a/scheduler/views/queue_job_actions.py +++ b/scheduler/views/queue_job_actions.py @@ -20,12 +20,12 @@ class QueueJobAction(Enum): REQUEUE = "requeue" STOP = "stop" - def __contains__(self, item) -> bool: + def __contains__(self, item: str) -> bool: return item in [a.value for a in self.__class__] -@never_cache -@staff_member_required +@never_cache # type: ignore +@staff_member_required # type: ignore def queue_job_actions(request: HttpRequest, queue_name: str) -> HttpResponse: queue = get_queue(queue_name) next_url = _check_next_url(request, reverse("queue_registry_jobs", args=[queue_name, "queued"])) @@ -51,6 +51,9 @@ def queue_job_actions(request: HttpRequest, queue_name: str) -> HttpResponse: if job is None: continue try: + if job.worker_name is None: + logger.warning(f"Job {job.name} has no worker assigned, cannot stop it") + continue command = StopJobCommand(job_name=job.name, worker_name=job.worker_name) send_command(connection=queue.connection, command=command) queue.cancel_job(job.name) @@ -62,8 +65,8 @@ def queue_job_actions(request: HttpRequest, queue_name: str) -> HttpResponse: return redirect(next_url) -@never_cache -@staff_member_required +@never_cache # type: ignore +@staff_member_required # type: ignore def queue_confirm_job_action(request: HttpRequest, queue_name: str) -> HttpResponse: queue = get_queue(queue_name) next_url = _check_next_url(request, reverse("queue_registry_jobs", args=[queue_name, "queued"])) diff --git a/uv.lock b/uv.lock index 95cc2e4..b6b82b1 100644 --- a/uv.lock +++ b/uv.lock @@ -152,6 +152,7 @@ dependencies = [ { name = "click" }, { name = "croniter" }, { name = "django" }, + { name = "fakeredis" }, ] [package.optional-dependencies] @@ -163,12 +164,14 @@ valkey = [ ] yaml = [ { name = "pyyaml" }, + { name = "types-pyyaml" }, ] [package.dev-dependencies] dev = [ { name = "coverage" }, { name = "fakeredis" }, + { name = "mypy" }, { name = "pyyaml" }, { name = "ruff" }, { name = "time-machine" }, @@ -179,8 +182,10 @@ requires-dist = [ { name = "click", specifier = "~=8.2" }, { name = "croniter", specifier = ">=2.0" }, { name = "django", specifier = ">=5" }, + { name = "fakeredis", directory = "../fakeredis" }, { name = "pyyaml", marker = "extra == 'yaml'", specifier = "~=6.0" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = "~=2.19" }, + { name = "types-pyyaml", marker = "extra == 'yaml'", specifier = ">=6.0.12.20250516" }, { name = "valkey", marker = "extra == 'valkey'", specifier = ">=6.0.2,<7" }, ] provides-extras = ["yaml", "valkey", "sentry"] @@ -188,7 +193,8 @@ provides-extras = ["yaml", "valkey", "sentry"] [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = "~=7.6" }, - { name = "fakeredis", specifier = "~=2.28" }, + { name = "fakeredis", directory = "../fakeredis" }, + { name = "mypy", specifier = ">=1.16.0" }, { name = "pyyaml", specifier = ">=6,<7" }, { name = "ruff", specifier = ">=0.11" }, { name = "time-machine", specifier = ">=2.16.0,<3" }, @@ -197,15 +203,103 @@ dev = [ [[package]] name = "fakeredis" version = "2.30.1" -source = { registry = "https://pypi.org/simple" } +source = { directory = "../fakeredis" } dependencies = [ { name = "redis" }, { name = "sortedcontainers" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/3b/eb1d4d0fdc138df1d8e625dfa6b500189030e6c1c265b8dd22b783b2f9ec/fakeredis-2.30.1.tar.gz", hash = "sha256:6489f2926e39815c9bf0fce80751635e0898e333c43a767825adf101180dbc45", size = 167724, upload-time = "2025-06-19T17:55:45.549Z" } + +[package.metadata] +requires-dist = [ + { name = "jsonpath-ng", marker = "extra == 'json'", specifier = "~=1.6" }, + { name = "lupa", marker = "extra == 'lua'", specifier = ">=2.1,<3.0" }, + { name = "pyprobables", marker = "extra == 'bf'", specifier = ">=0.6" }, + { name = "pyprobables", marker = "extra == 'cf'", specifier = ">=0.6" }, + { name = "pyprobables", marker = "extra == 'probabilistic'", specifier = ">=0.6" }, + { name = "redis", marker = "python_full_version < '3.8'", specifier = ">=4" }, + { name = "redis", marker = "python_full_version >= '3.9'", specifier = ">=4.3" }, + { name = "sortedcontainers", specifier = ">=2,<3" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = "~=4.7" }, +] +provides-extras = ["lua", "json", "bf", "cf", "probabilistic"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", marker = "python_full_version >= '3.10'", specifier = ">=1.15" }, + { name = "pre-commit", marker = "python_full_version >= '3.10'", specifier = "~=4.2" }, + { name = "ruff", marker = "python_full_version >= '3.10'", specifier = ">=0.12" }, +] +docs = [ + { name = "pygithub", marker = "python_full_version >= '3.10'", specifier = "~=2.3" }, + { name = "python-dotenv", marker = "python_full_version >= '3.10'", specifier = ">=1,<2" }, +] +test = [ + { name = "coverage", marker = "python_full_version >= '3.9'", specifier = "~=7.6" }, + { name = "hypothesis", marker = "python_full_version >= '3.9'", specifier = "~=6.111" }, + { name = "pytest", marker = "python_full_version >= '3.9'", specifier = "~=8.3" }, + { name = "pytest-asyncio", marker = "python_full_version >= '3.9'", specifier = ">=0.24,<0.25" }, + { name = "pytest-cov", marker = "python_full_version >= '3.9'", specifier = "~=6.0" }, + { name = "pytest-html", marker = "python_full_version >= '3.9'", specifier = "~=4.1" }, + { name = "pytest-mock", marker = "python_full_version >= '3.9'", specifier = "~=3.14" }, + { name = "pytest-timeout", marker = "python_full_version >= '3.9'", specifier = ">=2.3.1,<3" }, +] + +[[package]] +name = "mypy" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/12/2bf23a80fcef5edb75de9a1e295d778e0f46ea89eb8b115818b663eff42b/mypy-1.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4f0fed1022a63c6fec38f28b7fc77fca47fd490445c69d0a66266c59dd0b88a", size = 10958644, upload-time = "2025-06-16T16:51:11.649Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/bfe47b3b278eacf348291742fd5e6613bbc4b3434b72ce9361896417cfe5/mypy-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86042bbf9f5a05ea000d3203cf87aa9d0ccf9a01f73f71c58979eb9249f46d72", size = 10087033, upload-time = "2025-06-16T16:35:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/40307c12fe25675a0776aaa2cdd2879cf30d99eec91b898de00228dc3ab5/mypy-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7469ee5902c95542bea7ee545f7006508c65c8c54b06dc2c92676ce526f3ea", size = 11875645, upload-time = "2025-06-16T16:35:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d8/85bdb59e4a98b7a31495bd8f1a4445d8ffc86cde4ab1f8c11d247c11aedc/mypy-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352025753ef6a83cb9e7f2427319bb7875d1fdda8439d1e23de12ab164179574", size = 12616986, upload-time = "2025-06-16T16:48:39.526Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d0/bb25731158fa8f8ee9e068d3e94fcceb4971fedf1424248496292512afe9/mypy-1.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff9fa5b16e4c1364eb89a4d16bcda9987f05d39604e1e6c35378a2987c1aac2d", size = 12878632, upload-time = "2025-06-16T16:36:08.195Z" }, + { url = "https://files.pythonhosted.org/packages/2d/11/822a9beb7a2b825c0cb06132ca0a5183f8327a5e23ef89717c9474ba0bc6/mypy-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:1256688e284632382f8f3b9e2123df7d279f603c561f099758e66dd6ed4e8bd6", size = 9484391, upload-time = "2025-06-16T16:37:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" }, + { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" }, + { url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" }, + { url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/ee/acc3de71b8c66029ea4567d83e9c736d79836b2d97aa2cacf1b83f96c678/fakeredis-2.30.1-py3-none-any.whl", hash = "sha256:b594a9c20aef8b94c4d923f489210ef443e4001e62ad3cd73b9a01298dcef743", size = 116215, upload-time = "2025-06-19T17:55:43.893Z" }, + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] [[package]] @@ -312,15 +406,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.30.0" +version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/4c/af31e0201b48469786ddeb1bf6fd3dfa3a291cc613a0fe6a60163a7535f9/sentry_sdk-2.30.0.tar.gz", hash = "sha256:436369b02afef7430efb10300a344fb61a11fe6db41c2b11f41ee037d2dd7f45", size = 326767, upload-time = "2025-06-12T10:34:34.733Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/45/c7ef7e12d8434fda8b61cdab432d8af64fb832480c93cdaf4bdcab7f5597/sentry_sdk-2.31.0.tar.gz", hash = "sha256:fed6d847f15105849cdf5dfdc64dcec356f936d41abb8c9d66adae45e60959ec", size = 334167, upload-time = "2025-06-24T16:36:26.066Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/99/31ac6faaae33ea698086692638f58d14f121162a8db0039e68e94135e7f1/sentry_sdk-2.30.0-py2.py3-none-any.whl", hash = "sha256:59391db1550662f746ea09b483806a631c3ae38d6340804a1a4c0605044f6877", size = 343149, upload-time = "2025-06-12T10:34:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a2/9b6d8cc59f03251c583b3fec9d2f075dc09c0f6e030e0e0a3b223c6e64b2/sentry_sdk-2.31.0-py2.py3-none-any.whl", hash = "sha256:e953f5ab083e6599bab255b75d6829b33b3ddf9931a27ca00b4ab0081287e84f", size = 355638, upload-time = "2025-06-24T16:36:24.306Z" }, ] [[package]] @@ -405,6 +499,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/75/c4d8b2f0fe7dac22854d88a9c509d428e78ac4bf284bc54cfe83f75cc13b/time_machine-2.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:4d3843143c46dddca6491a954bbd0abfd435681512ac343169560e9bab504129", size = 18047, upload-time = "2024-10-08T14:21:46.261Z" }, ] +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250516" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/22/59e2aeb48ceeee1f7cd4537db9568df80d62bdb44a7f9e743502ea8aab9c/types_pyyaml-6.0.12.20250516.tar.gz", hash = "sha256:9f21a70216fc0fa1b216a8176db5f9e0af6eb35d2f2932acb87689d03a5bf6ba", size = 17378, upload-time = "2025-05-16T03:08:04.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5f/e0af6f7f6a260d9af67e1db4f54d732abad514252a7a378a6c4d17dd1036/types_pyyaml-6.0.12.20250516-py3-none-any.whl", hash = "sha256:8478208feaeb53a34cb5d970c56a7cd76b72659442e733e268a94dc72b2d0530", size = 20312, upload-time = "2025-05-16T03:08:04.019Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.0" From cb9b344217d14a9f5ef9b7633bfc4fe8a150ee68 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Tue, 24 Jun 2025 16:42:25 -0400 Subject: [PATCH 04/12] wip --- pyproject.toml | 1 + scheduler/helpers/queues/queue_logic.py | 8 ++- scheduler/helpers/sentry_integration.py | 14 ++--- .../commands/delete_failed_executions.py | 1 + scheduler/management/commands/import.py | 14 ++--- scheduler/management/commands/run_job.py | 2 +- scheduler/models/args.py | 18 +++--- scheduler/models/task.py | 63 +++++++++---------- scheduler/redis_models/base.py | 6 +- scheduler/views/helpers.py | 2 +- scheduler/views/job_views.py | 7 +-- scheduler/views/queue_job_actions.py | 11 ++-- scheduler/views/queue_registry_actions.py | 24 ++++--- scheduler/views/queue_views.py | 27 ++++---- scheduler/views/worker_views.py | 8 +-- scheduler/worker/commands/stop_job.py | 4 +- scheduler/worker/commands/worker_commands.py | 6 +- scheduler/worker/worker.py | 36 +++++------ uv.lock | 11 ++++ 19 files changed, 132 insertions(+), 131 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 99a341a..79832dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ dev = [ "fakeredis~=2.28", "pyyaml>=6,<7", "mypy>=1.16.0", + "types-croniter>=6.0.0.20250411", ] [tool.hatch.build.targets.sdist] diff --git a/scheduler/helpers/queues/queue_logic.py b/scheduler/helpers/queues/queue_logic.py index 498a70c..6caa26f 100644 --- a/scheduler/helpers/queues/queue_logic.py +++ b/scheduler/helpers/queues/queue_logic.py @@ -30,6 +30,10 @@ class NoSuchJobError(Exception): pass +class NoSuchRegistryError(Exception): + pass + + def perform_job(job_model: JobModel, connection: ConnectionType) -> Any: # noqa """The main execution method. Invokes the job function with the job arguments. @@ -155,11 +159,11 @@ def count(self) -> int: res += getattr(self, registry).count(connection=self.connection) return res - def get_registry(self, name: str) -> Union[None, JobNamesRegistry]: + def get_registry(self, name: str) -> JobNamesRegistry: name = name.lower() if name in Queue.REGISTRIES: return getattr(self, Queue.REGISTRIES[name]) - return None + raise NoSuchRegistryError(f"Unknown registry name {name}") def get_all_job_names(self) -> List[str]: res = list() diff --git a/scheduler/helpers/sentry_integration.py b/scheduler/helpers/sentry_integration.py index 9ef2b4a..5e6f91c 100644 --- a/scheduler/helpers/sentry_integration.py +++ b/scheduler/helpers/sentry_integration.py @@ -52,8 +52,8 @@ def sentry_patched_perform_job(self: Any, job_model: JobModel, *args: Queue, **k transaction.name = job_model.func_name with sentry_sdk.start_transaction( - transaction, - custom_sampling_context={"rq_job": job_model}, + transaction, + custom_sampling_context={"rq_job": job_model}, ): rv = old_perform_job(self, job_model, *args, **kwargs) @@ -70,11 +70,7 @@ def sentry_patched_perform_job(self: Any, job_model: JobModel, *args: Queue, **k old_handle_exception = Worker.handle_exception def sentry_patched_handle_exception(self: Worker, job: Any, *exc_info: Any, **kwargs: Any) -> Any: - retry = ( - hasattr(job, "retries_left") - and job.retries_left - and job.retries_left > 0 - ) + retry = hasattr(job, "retries_left") and job.retries_left and job.retries_left > 0 failed = job._status == JobStatus.FAILED or job.is_failed if failed and not retry: _capture_exception(exc_info) @@ -89,9 +85,7 @@ def sentry_patched_handle_exception(self: Worker, job: Any, *exc_info: Any, **kw def sentry_patched_enqueue_job(self: Queue, job: Any, **kwargs: Any) -> Any: scope = sentry_sdk.get_current_scope() if scope.span is not None: - job.meta["_sentry_trace_headers"] = dict( - scope.iter_trace_propagation_headers() - ) + job.meta["_sentry_trace_headers"] = dict(scope.iter_trace_propagation_headers()) return old_enqueue_job(self, job, **kwargs) diff --git a/scheduler/management/commands/delete_failed_executions.py b/scheduler/management/commands/delete_failed_executions.py index 0e38fc6..ac4552c 100644 --- a/scheduler/management/commands/delete_failed_executions.py +++ b/scheduler/management/commands/delete_failed_executions.py @@ -1,4 +1,5 @@ import click +from django.core.management import CommandParser from django.core.management.base import BaseCommand from scheduler.helpers.queues import get_queue diff --git a/scheduler/management/commands/import.py b/scheduler/management/commands/import.py index 4a8bb88..448913e 100644 --- a/scheduler/management/commands/import.py +++ b/scheduler/management/commands/import.py @@ -52,13 +52,9 @@ def create_task_from_dict(task_dict: Dict[str, Any], update: bool) -> Optional[T if not settings.USE_TZ and not timezone.is_naive(target): target = timezone.make_naive(target) kwargs["scheduled_time"] = target - model_fields = set(map( - lambda field: field.attname, - filter( - lambda field: hasattr(field, "attname"), - Task._meta.get_fields() - ) - )) + model_fields = set( + map(lambda field: field.attname, filter(lambda field: hasattr(field, "attname"), Task._meta.get_fields())) + ) keys_to_ignore = list(filter(lambda _k: _k not in model_fields, kwargs.keys())) for k in keys_to_ignore: del kwargs[k] @@ -119,7 +115,7 @@ def add_arguments(self, parser: CommandParser) -> None: ) def handle(self, *args: Any, **options: Any) -> None: - file = open(options.get("filename")) if options.get("filename") else sys.stdin # type: ignore[arg-type] + file = open(options.get("filename")) if options.get("filename") else sys.stdin # type: ignore[arg-type] jobs = list() if options.get("format") == "json": import json @@ -143,4 +139,4 @@ def handle(self, *args: Any, **options: Any) -> None: Task.objects.all().delete() for job in jobs: - create_task_from_dict(job, update=options.get("update")) # type: ignore[arg-type] + create_task_from_dict(job, update=options.get("update")) # type: ignore[arg-type] diff --git a/scheduler/management/commands/run_job.py b/scheduler/management/commands/run_job.py index 4c01a22..88d1828 100644 --- a/scheduler/management/commands/run_job.py +++ b/scheduler/management/commands/run_job.py @@ -27,7 +27,7 @@ def add_arguments(self, parser: CommandParser) -> None: ) parser.add_argument("args", nargs="*", help="Args for callable") - def handle(self, **options:Any)-> None: + def handle(self, **options: Any) -> None: verbosity = int(options.get("verbosity", 1)) timeout = options.get("timeout") result_ttl = options.get("result_ttl") diff --git a/scheduler/models/args.py b/scheduler/models/args.py index ac2d700..a7d02c7 100644 --- a/scheduler/models/args.py +++ b/scheduler/models/args.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Callable +from typing import Callable, Any, Tuple, Dict, Type from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType @@ -9,7 +9,7 @@ from scheduler.helpers import utils -ARG_TYPE_TYPES_DICT = { +ARG_TYPE_TYPES_DICT:Dict[str,Type] = { "str": str, "int": int, "bool": bool, @@ -37,7 +37,7 @@ class ArgType(models.TextChoices): object_id = models.PositiveIntegerField() content_object = GenericForeignKey() - def clean(self): + def clean(self) -> None: if self.arg_type not in ARG_TYPE_TYPES_DICT: raise ValidationError( { @@ -61,15 +61,15 @@ def clean(self): {"arg_type": ValidationError(_(f"Could not parse {self.val} as {self.arg_type}"), code="invalid")} ) - def save(self, **kwargs): + def save(self, **kwargs: Any) -> None: super(BaseTaskArg, self).save(**kwargs) self.content_object.save() - def delete(self, **kwargs): + def delete(self, **kwargs: Any) -> None: super(BaseTaskArg, self).delete(**kwargs) self.content_object.save() - def value(self): + def value(self) -> Any: if self.arg_type == "callable": res = utils.callable_func(self.val)() elif self.arg_type == "datetime": @@ -86,16 +86,16 @@ class Meta: class TaskArg(BaseTaskArg): - def __str__(self): + def __str__(self) -> str: return f"TaskArg[arg_type={self.arg_type},value={self.value()}]" class TaskKwarg(BaseTaskArg): key = models.CharField(max_length=255) - def __str__(self): + def __str__(self) -> str: key, value = self.value() return f"TaskKwarg[key={key},arg_type={self.arg_type},value={self.val}]" - def value(self): + def value(self) -> Tuple[str, Any]: return self.key, super(TaskKwarg, self).value() diff --git a/scheduler/models/task.py b/scheduler/models/task.py index 352f1f0..01efe45 100644 --- a/scheduler/models/task.py +++ b/scheduler/models/task.py @@ -1,6 +1,6 @@ import math from datetime import timedelta, datetime -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List, Tuple, Callable import croniter from django.conf import settings as django_settings @@ -29,11 +29,11 @@ def _get_task_for_job(job: JobModel) -> Optional["Task"]: if job.task_type is None or job.scheduled_task_id is None: return None - task = Task.objects.filter(id=job.scheduled_task_id).first() + task: Task = Task.objects.filter(id=job.scheduled_task_id).first() return task -def failure_callback(job: JobModel, connection, result, *args, **kwargs): +def failure_callback(job: JobModel, connection:ConnectionType, result: Any, *args: Any, **kwargs: Any) -> None: task = _get_task_for_job(job) if task is None: logger.warn(f"Could not find task for job {job.name}") @@ -48,7 +48,7 @@ def failure_callback(job: JobModel, connection, result, *args, **kwargs): task.save(schedule_job=True, clean=False) -def success_callback(job: JobModel, connection: ConnectionType, result: Any, *args, **kwargs): +def success_callback(job: JobModel, connection: ConnectionType, result: Any, *args: Any, **kwargs: Any) -> None: task = _get_task_for_job(job) if task is None: logger.warn(f"Could not find task for job {job.name}") @@ -59,7 +59,7 @@ def success_callback(job: JobModel, connection: ConnectionType, result: Any, *ar task.save(schedule_job=True, clean=False) -def get_queue_choices(): +def get_queue_choices() -> List[Tuple[str, str]]: queue_names = get_queue_names() return [(queue, queue) for queue in queue_names] @@ -176,20 +176,20 @@ class TimeUnits(models.TextChoices): ), ) - def callable_func(self): + def callable_func(self)->Callable: """Translate callable string to callable""" return utils.callable_func(self.callable) - @admin.display(boolean=True, description=_("is scheduled?")) + @admin.display(boolean=True, description=_("is scheduled?")) # type: ignore[misc] def is_scheduled(self) -> bool: """Check whether a next job for this task is queued/scheduled to be executed""" if self.job_name is None: # no job_id => is not scheduled return False # check whether job_id is in scheduled/queued/active jobs res = ( - (self.job_name in self.rqueue.scheduled_job_registry.all()) - or (self.job_name in self.rqueue.queued_job_registry.all()) - or (self.job_name in self.rqueue.active_job_registry.all()) + (self.job_name in self.rqueue.scheduled_job_registry.all()) + or (self.job_name in self.rqueue.queued_job_registry.all()) + or (self.job_name in self.rqueue.active_job_registry.all()) ) # If the job_id is not scheduled/queued/started, # update the job_id to None. (The job_id belongs to a previous run which is completed) @@ -198,7 +198,7 @@ def is_scheduled(self) -> bool: super(Task, self).save() return res - @admin.display(description="Callable") + @admin.display(description="Callable") # type: ignore[misc] def function_string(self) -> str: args = self.parse_args() args_list = [repr(arg) for arg in args] @@ -206,21 +206,21 @@ def function_string(self) -> str: kwargs_list = [k + "=" + repr(v) for (k, v) in kwargs.items()] return self.callable + f"({', '.join(args_list + kwargs_list)})" - def parse_args(self): + def parse_args(self) -> List[Any]: """Parse args for running the job""" args = self.callable_args.all() return [arg.value() for arg in args] - def parse_kwargs(self): + def parse_kwargs(self) -> Dict[str, Any]: """Parse kwargs for running the job""" kwargs = self.callable_kwargs.all() return dict([kwarg.value() for kwarg in kwargs]) - def _next_job_id(self): + def _next_job_id(self) -> str: addition = timezone.now().strftime("%Y%m%d%H%M%S%f") return f"{self.queue}:{self.id}:{addition}" - def _enqueue_args(self) -> Dict: + def _enqueue_args(self) -> Dict[str, Any]: """Args for Queue.enqueue_call. Set all arguments for Queue.enqueue. Particularly: - set job timeout and ttl @@ -282,7 +282,7 @@ def _schedule_time(self) -> datetime: self.repeat = (self.repeat - gap) if self.repeat is not None else None return utc(self.scheduled_time) if django_settings.USE_TZ else self.scheduled_time - def to_dict(self) -> Dict: + def to_dict(self) -> Dict[str, Any]: """Export model to dictionary, so it can be saved as external file backup""" interval_unit = str(self.interval_unit) if self.interval_unit else None res = dict( @@ -321,16 +321,11 @@ def to_dict(self) -> Dict: ) return res - def get_absolute_url(self): + def get_absolute_url(self) -> str: model = self._meta.model.__name__.lower() - return reverse( - f"admin:scheduler_{model}_change", - args=[ - self.id, - ], - ) + return reverse(f"admin:scheduler_{model}_change", args=[self.id]) - def __str__(self): + def __str__(self) -> str: func = self.function_string() return f"{self.task_type}[{self.name}={func}]" @@ -359,7 +354,7 @@ def _schedule(self) -> bool: self.job_name = job.name return True - def save(self, **kwargs): + def save(self, **kwargs: Any) -> None: should_clean = kwargs.pop("clean", True) if should_clean: self.clean() @@ -372,17 +367,17 @@ def save(self, **kwargs): self._schedule() super(Task, self).save() - def delete(self, **kwargs): + def delete(self, **kwargs: Any) -> None: self.unschedule() super(Task, self).delete(**kwargs) - def interval_seconds(self): + def interval_seconds(self) -> float: kwargs = { self.interval_unit: self.interval, } return timedelta(**kwargs).total_seconds() - def clean_callable(self): + def clean_callable(self) -> None: try: utils.callable_func(self.callable) except Exception: @@ -390,7 +385,7 @@ def clean_callable(self): {"callable": ValidationError(_("Invalid callable, must be importable"), code="invalid")} ) - def clean_queue(self): + def clean_queue(self) -> None: queue_names = get_queue_names() if self.queue not in queue_names: raise ValidationError( @@ -401,7 +396,7 @@ def clean_queue(self): } ) - def clean_interval_unit(self): + def clean_interval_unit(self) -> None: config = settings.SCHEDULER_CONFIG if config.SCHEDULER_INTERVAL > self.interval_seconds(): raise ValidationError( @@ -424,13 +419,13 @@ def clean_result_ttl(self) -> None: params={"interval": self.interval_seconds()}, ) - def clean_cron_string(self): + def clean_cron_string(self) -> None: try: croniter.croniter(self.cron_string) except ValueError as e: raise ValidationError({"cron_string": ValidationError(_(str(e)), code="invalid")}) - def clean(self): + def clean(self) -> None: if self.task_type not in TaskType.values: raise ValidationError( {"task_type": ValidationError(_("Invalid task type"), code="invalid")}, @@ -470,7 +465,7 @@ def get_scheduled_task(task_type_str: str, task_id: int) -> Task: task = Task.objects.filter(task_type=task_type, id=task_id).first() if task is None: raise ValueError(f"Job {task_type}:{task_id} does not exit") - return task + return task # type: ignore[no-any-return] except ValueError: raise ValueError(f"Invalid task type {task_type_str}") raise ValueError(f"Job Model {task_type_str} does not exist, choices are {TASK_TYPES}") @@ -484,5 +479,5 @@ def run_task(task_model: str, task_id: int) -> Any: logger.debug(f"Running task {str(scheduled_task)}") args = scheduled_task.parse_args() kwargs = scheduled_task.parse_kwargs() - res = scheduled_task.callable_func()(*args, **kwargs) + res = scheduled_task.callable_func()(*args, **kwargs) # type: ignore[no-untyped-call] return res diff --git a/scheduler/redis_models/base.py b/scheduler/redis_models/base.py index 9ff0f39..0c63796 100644 --- a/scheduler/redis_models/base.py +++ b/scheduler/redis_models/base.py @@ -5,8 +5,6 @@ from enum import Enum from typing import List, Optional, Union, Dict, Collection, Any, ClassVar, Set, Type -from redis import Redis - from scheduler.settings import logger from scheduler.types import ConnectionType, Self @@ -138,13 +136,13 @@ def _parent_key(self) -> Optional[str]: return self._children_key_template.format(self.parent) @classmethod - def all_names(cls, connection: Redis, parent: Optional[str] = None) -> Collection[str]: + def all_names(cls, connection: ConnectionType, parent: Optional[str] = None) -> Collection[str]: collection_key = cls._children_key_template.format(parent) if parent else cls._list_key collection_members = connection.smembers(collection_key) return [r.decode() for r in collection_members] @classmethod - def all(cls, connection: Redis, parent: Optional[str] = None) -> List[Self]: + def all(cls, connection: ConnectionType, parent: Optional[str] = None) -> List[Self]: keys = cls.all_names(connection, parent) items = [cls.get(k, connection) for k in keys] return [w for w in items if w is not None] diff --git a/scheduler/views/helpers.py b/scheduler/views/helpers.py index 713b20c..335f540 100644 --- a/scheduler/views/helpers.py +++ b/scheduler/views/helpers.py @@ -43,7 +43,7 @@ def _find_job(job_name: str) -> Tuple[Optional[Queue], Optional[JobModel]]: def _check_next_url(request: HttpRequest, default_next_url: str) -> str: - next_url = request.POST.get("next_url", default_next_url) + next_url: str = request.POST.get("next_url", default_next_url) next_url = next_url.replace("\\", "") if ( not url_has_allowed_host_and_scheme(next_url, allowed_hosts=None) diff --git a/scheduler/views/job_views.py b/scheduler/views/job_views.py index c4fb44c..93b44e9 100644 --- a/scheduler/views/job_views.py +++ b/scheduler/views/job_views.py @@ -50,8 +50,8 @@ def job_detail(request: HttpRequest, job_name: str) -> HttpResponse: return render(request, "admin/scheduler/job_detail.html", context_data) -@never_cache # type: ignore -@staff_member_required # type: ignore +@never_cache # type: ignore +@staff_member_required # type: ignore def job_action(request: HttpRequest, job_name: str, action: str) -> HttpResponse: queue, job = _find_job(job_name) if job is None or queue is None: @@ -80,9 +80,6 @@ def job_action(request: HttpRequest, job_name: str, action: str) -> HttpResponse messages.info(request, f"You have successfully enqueued {job.name}") return redirect("job_details", job_name) elif action == JobDetailAction.CANCEL: - if job.worker_name is None: - messages.warning(request, "You cannot cancel a job that has no worker assigned") - return redirect("job_details", job_name) send_command( connection=queue.connection, command=StopJobCommand(job_name=job.name, worker_name=job.worker_name) ) diff --git a/scheduler/views/queue_job_actions.py b/scheduler/views/queue_job_actions.py index 208a3fd..1d51232 100644 --- a/scheduler/views/queue_job_actions.py +++ b/scheduler/views/queue_job_actions.py @@ -24,8 +24,8 @@ def __contains__(self, item: str) -> bool: return item in [a.value for a in self.__class__] -@never_cache # type: ignore -@staff_member_required # type: ignore +@never_cache # type: ignore +@staff_member_required # type: ignore def queue_job_actions(request: HttpRequest, queue_name: str) -> HttpResponse: queue = get_queue(queue_name) next_url = _check_next_url(request, reverse("queue_registry_jobs", args=[queue_name, "queued"])) @@ -51,9 +51,6 @@ def queue_job_actions(request: HttpRequest, queue_name: str) -> HttpResponse: if job is None: continue try: - if job.worker_name is None: - logger.warning(f"Job {job.name} has no worker assigned, cannot stop it") - continue command = StopJobCommand(job_name=job.name, worker_name=job.worker_name) send_command(connection=queue.connection, command=command) queue.cancel_job(job.name) @@ -65,8 +62,8 @@ def queue_job_actions(request: HttpRequest, queue_name: str) -> HttpResponse: return redirect(next_url) -@never_cache # type: ignore -@staff_member_required # type: ignore +@never_cache # type: ignore +@staff_member_required # type: ignore def queue_confirm_job_action(request: HttpRequest, queue_name: str) -> HttpResponse: queue = get_queue(queue_name) next_url = _check_next_url(request, reverse("queue_registry_jobs", args=[queue_name, "queued"])) diff --git a/scheduler/views/queue_registry_actions.py b/scheduler/views/queue_registry_actions.py index 2c9db5b..719ed8e 100644 --- a/scheduler/views/queue_registry_actions.py +++ b/scheduler/views/queue_registry_actions.py @@ -10,7 +10,9 @@ from django.views.decorators.cache import never_cache from scheduler.helpers.queues import Queue +from scheduler.helpers.queues.queue_logic import NoSuchRegistryError from scheduler.redis_models import JobModel, JobNamesRegistry +from scheduler.settings import logger from scheduler.types import ResponseErrorTypes from scheduler.views.helpers import get_queue, _check_next_url, _enqueue_multiple_jobs @@ -20,32 +22,38 @@ class QueueRegistryActions(Enum): REQUEUE = "requeue" -def _clear_registry(request: HttpRequest, queue: Queue, registry_name: str, registry: JobNamesRegistry): +def _clear_registry(request: HttpRequest, queue: Queue, registry_name: str, registry: JobNamesRegistry) -> None: try: job_names = registry.all() for job_name in job_names: registry.delete(registry.connection, job_name) job_model = JobModel.get(job_name, connection=registry.connection) - job_model.delete(connection=registry.connection) + if job_model is not None: + job_model.delete(connection=registry.connection) messages.info(request, f"You have successfully cleared the {registry_name} jobs in queue {queue.name}") except ResponseErrorTypes as e: messages.error(request, f"error: {e}") raise e -def _requeue_job_names(request: HttpRequest, queue: Queue, registry_name: str): - registry = queue.get_registry(registry_name) +def _requeue_job_names(request: HttpRequest, queue: Queue, registry_name: str) -> None: + try: + registry = queue.get_registry(registry_name) + except NoSuchRegistryError: + logger.error(f"No registry named {registry_name}") + return job_names = registry.all() jobs_requeued_count = _enqueue_multiple_jobs(queue, job_names) messages.info(request, f"You have successfully re-queued {jobs_requeued_count} jobs!") -@never_cache -@staff_member_required +@never_cache # type: ignore +@staff_member_required # type: ignore def queue_registry_actions(request: HttpRequest, queue_name: str, registry_name: str, action: str) -> HttpResponse: queue = get_queue(queue_name) - registry = queue.get_registry(registry_name) - if registry is None: + try: + registry = queue.get_registry(registry_name) + except NoSuchRegistryError: return HttpResponseNotFound() next_url = _check_next_url(request, reverse("queue_registry_jobs", args=[queue_name, registry_name])) if action not in [item.value for item in QueueRegistryActions]: diff --git a/scheduler/views/queue_views.py b/scheduler/views/queue_views.py index 7800886..04896f5 100644 --- a/scheduler/views/queue_views.py +++ b/scheduler/views/queue_views.py @@ -10,6 +10,7 @@ from scheduler import settings from scheduler.helpers.queues import Queue, get_all_workers +from scheduler.helpers.queues.queue_logic import NoSuchRegistryError from scheduler.redis_models import JobModel, JobNamesRegistry, WorkerModel from scheduler.settings import get_queue_names, logger from scheduler.types import ConnectionErrorTypes @@ -19,7 +20,7 @@ def _get_registry_job_list(queue: Queue, registry: JobNamesRegistry, page: int) -> Tuple[List[JobModel], int, range]: items_per_page = settings.SCHEDULER_CONFIG.EXECUTIONS_IN_PAGE num_jobs = registry.count(queue.connection) - job_list = list() + job_list: List[JobModel] = list() if num_jobs == 0: return job_list, num_jobs, range(1, 1) @@ -31,19 +32,19 @@ def _get_registry_job_list(queue: Queue, registry: JobNamesRegistry, page: int) job_list = JobModel.get_many(job_names, connection=queue.connection) remove_job_names = [job_name for i, job_name in enumerate(job_names) if job_list[i] is None] valid_jobs = [job for job in job_list if job is not None] - if registry is not queue: - for job_name in remove_job_names: - registry.delete(queue.connection, job_name) + for job_name in remove_job_names: + registry.delete(queue.connection, job_name) return valid_jobs, num_jobs, page_range -@never_cache -@staff_member_required +@never_cache # type: ignore +@staff_member_required # type: ignore def list_registry_jobs(request: HttpRequest, queue_name: str, registry_name: str) -> HttpResponse: queue = get_queue(queue_name) - registry = queue.get_registry(registry_name) - if registry is None: + try: + registry = queue.get_registry(registry_name) + except NoSuchRegistryError: return HttpResponseNotFound() title = registry_name.capitalize() page = int(request.GET.get("page", 1)) @@ -63,8 +64,8 @@ def list_registry_jobs(request: HttpRequest, queue_name: str, registry_name: str return render(request, "admin/scheduler/jobs.html", context_data) -@never_cache -@staff_member_required +@never_cache # type: ignore +@staff_member_required # type: ignore def queue_workers(request: HttpRequest, queue_name: str) -> HttpResponse: queue = get_queue(queue_name) queue.clean_registries() @@ -83,14 +84,14 @@ def queue_workers(request: HttpRequest, queue_name: str) -> HttpResponse: def stats_json(request: HttpRequest) -> Union[JsonResponse, HttpResponseNotFound]: auth_token = request.headers.get("Authorization") token_validation_func = settings.SCHEDULER_CONFIG.TOKEN_VALIDATION_METHOD - if request.user.is_staff or (token_validation_func and auth_token and token_validation_func(auth_token)): + if request.user.is_staff or (token_validation_func and auth_token and token_validation_func(auth_token)): # type: ignore return JsonResponse(get_statistics()) return HttpResponseNotFound() -@never_cache -@staff_member_required +@never_cache # type: ignore +@staff_member_required # type: ignore def stats(request: HttpRequest) -> HttpResponse: context_data = {**admin.site.each_context(request), **get_statistics(run_maintenance_tasks=True)} return render(request, "admin/scheduler/stats.html", context_data) diff --git a/scheduler/views/worker_views.py b/scheduler/views/worker_views.py index 3f133e4..2736730 100644 --- a/scheduler/views/worker_views.py +++ b/scheduler/views/worker_views.py @@ -23,8 +23,8 @@ def get_worker_executions(worker: WorkerModel) -> List[JobModel]: return res -@never_cache -@staff_member_required +@never_cache # type: ignore +@staff_member_required # type: ignore def worker_details(request: HttpRequest, name: str) -> HttpResponse: workers = get_all_workers() worker = next((w for w in workers if w.name == name), None) @@ -53,8 +53,8 @@ def worker_details(request: HttpRequest, name: str) -> HttpResponse: return render(request, "admin/scheduler/worker_details.html", context_data) -@never_cache -@staff_member_required +@never_cache # type: ignore +@staff_member_required # type: ignore def workers_list(request: HttpRequest) -> HttpResponse: all_workers = get_all_workers() worker_list = [worker for worker in all_workers] diff --git a/scheduler/worker/commands/stop_job.py b/scheduler/worker/commands/stop_job.py index 5f1dc41..e60813d 100644 --- a/scheduler/worker/commands/stop_job.py +++ b/scheduler/worker/commands/stop_job.py @@ -1,6 +1,6 @@ import os import signal -from typing import Dict, Any +from typing import Dict, Any, Optional from scheduler.types import ConnectionType from scheduler.redis_models import WorkerModel, JobModel @@ -13,7 +13,7 @@ class StopJobCommand(WorkerCommand): command_name = "stop-job" - def __init__(self, *args, job_name: str, worker_name: str, **kwargs) -> None: + def __init__(self, *args, job_name: str, worker_name: Optional[str], **kwargs) -> None: super().__init__(*args, worker_name=worker_name, **kwargs) self.job_name = job_name if self.job_name is None: diff --git a/scheduler/worker/commands/worker_commands.py b/scheduler/worker/commands/worker_commands.py index c7045e7..b5fd0cf 100644 --- a/scheduler/worker/commands/worker_commands.py +++ b/scheduler/worker/commands/worker_commands.py @@ -1,7 +1,7 @@ import json from abc import ABC from datetime import datetime, timezone -from typing import Type, Dict, Any +from typing import Type, Dict, Any, Optional from scheduler.settings import logger from scheduler.types import ConnectionType, Self @@ -19,8 +19,8 @@ class WorkerCommand(ABC): _registry: Dict[str, Type[Self]] = dict() command_name: str = "" - def __init__(self, *args, worker_name: str, **kwargs) -> None: - self.worker_name = worker_name + def __init__(self, *args, worker_name: Optional[str], **kwargs) -> None: + self.worker_name: Optional[str] = worker_name def command_payload(self, **kwargs) -> Dict[str, Any]: commands_channel = WorkerCommandsChannelListener._commands_channel(self.worker_name) diff --git a/scheduler/worker/worker.py b/scheduler/worker/worker.py index bd1ee54..66646b7 100644 --- a/scheduler/worker/worker.py +++ b/scheduler/worker/worker.py @@ -99,18 +99,18 @@ def from_model(cls, model: WorkerModel) -> Self: return res def __init__( - self, - queues, - name: str, - connection: ConnectionType, - maintenance_interval: int = SCHEDULER_CONFIG.DEFAULT_MAINTENANCE_TASK_INTERVAL, - job_monitoring_interval=SCHEDULER_CONFIG.DEFAULT_JOB_MONITORING_INTERVAL, - dequeue_strategy: DequeueStrategy = DequeueStrategy.DEFAULT, - disable_default_exception_handler: bool = False, - fork_job_execution: bool = True, - with_scheduler: bool = True, - burst: bool = False, - model: Optional[WorkerModel] = None, + self, + queues, + name: str, + connection: ConnectionType, + maintenance_interval: int = SCHEDULER_CONFIG.DEFAULT_MAINTENANCE_TASK_INTERVAL, + job_monitoring_interval=SCHEDULER_CONFIG.DEFAULT_JOB_MONITORING_INTERVAL, + dequeue_strategy: DequeueStrategy = DequeueStrategy.DEFAULT, + disable_default_exception_handler: bool = False, + fork_job_execution: bool = True, + with_scheduler: bool = True, + burst: bool = False, + model: Optional[WorkerModel] = None, ): # noqa self.fork_job_execution = fork_job_execution self.job_monitoring_interval: int = job_monitoring_interval @@ -375,7 +375,7 @@ def run_maintenance_tasks(self) -> None: self._model.save(connection=self.connection) def dequeue_job_and_maintain_ttl( - self, timeout: Optional[int], max_idle_time: Optional[int] = None + self, timeout: Optional[int], max_idle_time: Optional[int] = None ) -> Tuple[JobModel, Queue]: """Dequeues a job while maintaining the TTL. :param timeout: The timeout for the dequeue operation. @@ -550,7 +550,7 @@ def reorder_queues(self, reference_queue: Queue) -> None: return if self._dequeue_strategy == DequeueStrategy.ROUND_ROBIN: pos = self._ordered_queues.index(reference_queue) - self._ordered_queues = self._ordered_queues[pos + 1:] + self._ordered_queues[: pos + 1] + self._ordered_queues = self._ordered_queues[pos + 1 :] + self._ordered_queues[: pos + 1] return if self._dequeue_strategy == DequeueStrategy.RANDOM: shuffle(self._ordered_queues) @@ -634,7 +634,7 @@ def monitor_job_execution_process(self, job: JobModel, queue: Queue) -> None: while True: try: with SCHEDULER_CONFIG.DEATH_PENALTY_CLASS( - self.job_monitoring_interval, JobExecutionMonitorTimeoutException + self.job_monitoring_interval, JobExecutionMonitorTimeoutException ): retpid, ret_val = self.wait_for_job_execution_process() break @@ -877,7 +877,7 @@ class RoundRobinWorker(Worker): def reorder_queues(self, reference_queue: Queue) -> None: pos = self._ordered_queues.index(reference_queue) - self._ordered_queues = self._ordered_queues[pos + 1:] + self._ordered_queues[: pos + 1] + self._ordered_queues = self._ordered_queues[pos + 1 :] + self._ordered_queues[: pos + 1] class RandomWorker(Worker): @@ -894,9 +894,7 @@ def _get_ip_address_from_connection(connection: ConnectionType, client_name: str warnings.warn("CLIENT SETNAME command not supported, setting ip_address to unknown", Warning) return "unknown" client_list = connection.client_list() - client_adresses: List[str] = [ - client["addr"] for client in client_list if client["name"] == client_name - ] + client_adresses: List[str] = [client["addr"] for client in client_list if client["name"] == client_name] if len(client_adresses) > 0: return client_adresses[0] else: diff --git a/uv.lock b/uv.lock index b6b82b1..fa245b7 100644 --- a/uv.lock +++ b/uv.lock @@ -175,6 +175,7 @@ dev = [ { name = "pyyaml" }, { name = "ruff" }, { name = "time-machine" }, + { name = "types-croniter" }, ] [package.metadata] @@ -198,6 +199,7 @@ dev = [ { name = "pyyaml", specifier = ">=6,<7" }, { name = "ruff", specifier = ">=0.11" }, { name = "time-machine", specifier = ">=2.16.0,<3" }, + { name = "types-croniter", specifier = ">=6.0.0.20250411" }, ] [[package]] @@ -538,6 +540,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "types-croniter" +version = "6.0.0.20250411" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/5a/837dd0f759b8df7c5ce28692f1b6d752035e5389098dd364784283b30a19/types_croniter-6.0.0.20250411.tar.gz", hash = "sha256:ee97025b7768f2cc556ef52a2f10c97c503c1634f372fd3e9683d8f7c960eeb7", size = 11722, upload-time = "2025-04-11T02:56:33.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/e3/ec8880c9a7a484048457c5db3a340a949863f54043384f36eb0e4d9edc2f/types_croniter-6.0.0.20250411-py3-none-any.whl", hash = "sha256:4acdaccf4190017daa51699bd3110a0617c5df72459e62dea8b72549c62fad90", size = 9754, upload-time = "2025-04-11T02:56:32.317Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250516" From ebbce689d473269d33cf5fbfa3115f57d27e07fb Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 25 Jun 2025 08:23:39 -0400 Subject: [PATCH 05/12] wip --- scheduler/helpers/callback.py | 2 +- scheduler/helpers/queues/getters.py | 11 +++--- scheduler/helpers/queues/queue_logic.py | 39 ++++++++----------- scheduler/helpers/utils.py | 8 ++-- scheduler/models/args.py | 2 +- scheduler/models/task.py | 10 ++--- .../redis_models/registry/base_registry.py | 4 +- scheduler/redis_models/result.py | 7 +++- scheduler/types/settings_types.py | 2 +- scheduler/views/helpers.py | 2 +- scheduler/worker/scheduler.py | 2 +- 11 files changed, 43 insertions(+), 46 deletions(-) diff --git a/scheduler/helpers/callback.py b/scheduler/helpers/callback.py index 4b3c96d..6250d4f 100644 --- a/scheduler/helpers/callback.py +++ b/scheduler/helpers/callback.py @@ -30,7 +30,7 @@ def __init__(self, func: Union[str, Callable[..., Any]], timeout: Optional[int] def name(self) -> str: return f"{self.func.__module__}.{self.func.__qualname__}" - def __call__(self, *args, **kwargs): + def __call__(self, *args: Any, **kwargs: Any) -> Any: from scheduler.settings import SCHEDULER_CONFIG with SCHEDULER_CONFIG.DEATH_PENALTY_CLASS(self.timeout, JobTimeoutException): diff --git a/scheduler/helpers/queues/getters.py b/scheduler/helpers/queues/getters.py index d491a72..fd00ad0 100644 --- a/scheduler/helpers/queues/getters.py +++ b/scheduler/helpers/queues/getters.py @@ -1,15 +1,14 @@ from typing import Set from scheduler.redis_models.worker import WorkerModel -from scheduler.settings import SCHEDULER_CONFIG, get_queue_names, get_queue_configuration, QueueConfiguration, logger -from scheduler.types import ConnectionErrorTypes, BrokerMetaData, Broker +from scheduler.settings import SCHEDULER_CONFIG, get_queue_names, get_queue_configuration, logger +from scheduler.types import ConnectionErrorTypes, BrokerMetaData, Broker, ConnectionType, QueueConfiguration from .queue_logic import Queue - _BAD_QUEUE_CONFIGURATION = set() -def _get_connection(config: QueueConfiguration, use_strict_broker=False): +def _get_connection(config: QueueConfiguration, use_strict_broker: bool = False) -> ConnectionType: """Returns a Broker connection to use based on parameters in SCHEDULER_QUEUES""" if SCHEDULER_CONFIG.BROKER == Broker.FAKEREDIS: import fakeredis @@ -32,7 +31,7 @@ def _get_connection(config: QueueConfiguration, use_strict_broker=False): sentinel_kwargs = config.SENTINEL_KWARGS or {} SentinelClass = BrokerMetaData[(SCHEDULER_CONFIG.BROKER, use_strict_broker)].sentinel_type sentinel = SentinelClass(config.SENTINELS, sentinel_kwargs=sentinel_kwargs, **connection_kwargs) - return sentinel.master_for( + return sentinel.master_for( # type: ignore service_name=config.MASTER_NAME, redis_class=broker_cls, ) @@ -47,7 +46,7 @@ def _get_connection(config: QueueConfiguration, use_strict_broker=False): ) -def get_queue(name="default") -> Queue: +def get_queue(name: str = "default") -> Queue: """Returns an DjangoQueue using parameters defined in `SCHEDULER_QUEUES`""" queue_settings = get_queue_configuration(name) is_async = queue_settings.ASYNC diff --git a/scheduler/helpers/queues/queue_logic.py b/scheduler/helpers/queues/queue_logic.py index 6caa26f..2a14fd7 100644 --- a/scheduler/helpers/queues/queue_logic.py +++ b/scheduler/helpers/queues/queue_logic.py @@ -19,7 +19,7 @@ ) from scheduler.redis_models import JobStatus, SchedulerLock, Result, ResultType, JobModel from scheduler.settings import logger, SCHEDULER_CONFIG -from scheduler.types import ConnectionType, FunctionReferenceType, Self +from scheduler.types import ConnectionType, FunctionReferenceType, Self, PipelineType class InvalidJobOperation(Exception): @@ -49,17 +49,17 @@ def perform_job(job_model: JobModel, connection: ConnectionType) -> Any: # noqa coro_result = loop.run_until_complete(result) result = coro_result if job_model.success_callback: - job_model.success_callback(job_model, connection, result) # type: ignore + job_model.success_callback(job_model, connection, result) return result except: if job_model.failure_callback: - job_model.failure_callback(job_model, connection, *sys.exc_info()) # type: ignore + job_model.failure_callback(job_model, connection, *sys.exc_info()) raise finally: assert job_model is _job_stack.pop() -_job_stack = [] +_job_stack: List[JobModel] = [] class Queue: @@ -89,11 +89,11 @@ def __init__(self, connection: ConnectionType, name: str, is_async: bool = True) self.scheduled_job_registry = ScheduledJobRegistry(connection=self.connection, name=self.name) self.canceled_job_registry = CanceledJobRegistry(connection=self.connection, name=self.name) - def __len__(self): + def __len__(self) -> int: return self.count @property - def scheduler_pid(self) -> int: + def scheduler_pid(self) -> Optional[int]: lock = SchedulerLock(self.name) pid = lock.value(self.connection) return int(pid.decode()) if pid is not None else None @@ -162,7 +162,7 @@ def count(self) -> int: def get_registry(self, name: str) -> JobNamesRegistry: name = name.lower() if name in Queue.REGISTRIES: - return getattr(self, Queue.REGISTRIES[name]) + return getattr(self, Queue.REGISTRIES[name]) # type: ignore raise NoSuchRegistryError(f"Unknown registry name {name}") def get_all_job_names(self) -> List[str]: @@ -182,8 +182,8 @@ def get_all_jobs(self) -> List[JobModel]: def create_and_enqueue_job( self, func: FunctionReferenceType, - args: Union[Tuple, List, None] = None, - kwargs: Optional[Dict] = None, + args: Union[Tuple[Any, ...], List[Any], None] = None, + kwargs: Optional[Dict[str, Any]] = None, when: Optional[datetime] = None, timeout: Optional[int] = None, result_ttl: Optional[int] = None, @@ -191,13 +191,12 @@ def create_and_enqueue_job( description: Optional[str] = None, name: Optional[str] = None, at_front: bool = False, - meta: Optional[Dict] = None, + meta: Optional[Dict[str, Any]] = None, on_success: Optional[Callback] = None, on_failure: Optional[Callback] = None, on_stopped: Optional[Callback] = None, task_type: Optional[str] = None, scheduled_task_id: Optional[int] = None, - pipeline: Optional[ConnectionType] = None, ) -> JobModel: """Creates a job to represent the delayed function call and enqueues it. :param when: When to schedule the job (None to enqueue immediately) @@ -216,7 +215,6 @@ def create_and_enqueue_job( :param on_stopped: Callback for on stopped :param task_type: The task type :param scheduled_task_id: The scheduled task id - :param pipeline: The Broker Pipeline :returns: The enqueued Job """ status = JobStatus.QUEUED if when is None else JobStatus.SCHEDULED @@ -240,7 +238,7 @@ def create_and_enqueue_job( scheduled_task_id=scheduled_task_id, ) if when is None: - job_model = self.enqueue_job(job_model, connection=pipeline, at_front=at_front) + job_model = self.enqueue_job(job_model, at_front=at_front) elif isinstance(when, datetime): job_model.save(connection=self.connection) self.scheduled_job_registry.schedule(self.connection, job_model.name, when) @@ -250,7 +248,7 @@ def create_and_enqueue_job( def job_handle_success( self, job: JobModel, result: Any, job_info_ttl: int, result_ttl: int, connection: ConnectionType - ): + ) -> None: """Saves and cleanup job after successful execution""" job.after_execution( job_info_ttl, @@ -268,7 +266,7 @@ def job_handle_success( ttl=result_ttl, ) - def job_handle_failure(self, status: JobStatus, job: JobModel, exc_string: str, connection: ConnectionType): + def job_handle_failure(self, status: JobStatus, job: JobModel, exc_string: str, connection: ConnectionType) -> None: # Does not set job status since the job might be stopped job.after_execution( SCHEDULER_CONFIG.DEFAULT_FAILURE_TTL, @@ -308,10 +306,7 @@ def run_sync(self, job: JobModel) -> JobModel: @classmethod def dequeue_any( - cls, - queues: List[Self], - timeout: Optional[int], - connection: Optional[ConnectionType] = None, + cls, queues: List[Self], timeout: Optional[int], connection: ConnectionType ) -> Tuple[Optional[JobModel], Optional[Self]]: """Class method returning a Job instance at the front of the given set of Queues, where the order of the queues is important. @@ -414,19 +409,19 @@ def delete_job(self, job_name: str, expire_job_model: bool = True) -> None: pass def enqueue_job( - self, job_model: JobModel, connection: Optional[ConnectionType] = None, at_front: bool = False + self, job_model: JobModel, pipeline: Optional[PipelineType] = None, at_front: bool = False ) -> JobModel: """Enqueues a job for delayed execution without checking dependencies. If Queue is instantiated with is_async=False, job is executed immediately. :param job_model: The job redis model - :param connection: The Redis Pipeline + :param pipeline: The Broker Pipeline :param at_front: Whether to enqueue the job at the front :returns: The enqueued JobModel """ - pipe = connection if connection is not None else self.connection.pipeline() + pipe: PipelineType = pipeline if pipeline is not None else self.connection.pipeline() job_model.started_at = None job_model.ended_at = None job_model.status = JobStatus.QUEUED diff --git a/scheduler/helpers/utils.py b/scheduler/helpers/utils.py index dae312c..a45c4fa 100644 --- a/scheduler/helpers/utils.py +++ b/scheduler/helpers/utils.py @@ -1,7 +1,7 @@ import datetime import importlib import time -from typing import Callable +from typing import Callable, Any def current_timestamp() -> int: @@ -14,10 +14,10 @@ def utcnow() -> datetime.datetime: return datetime.datetime.now(datetime.timezone.utc) -def callable_func(callable_str: str) -> Callable: +def callable_func(callable_str: str) -> Callable[[Any], Any]: path = callable_str.split(".") module = importlib.import_module(".".join(path[:-1])) - func = getattr(module, path[-1]) - if callable(func) is False: + func: Callable[[Any], Any] = getattr(module, path[-1]) + if not callable(func): raise TypeError(f"'{callable_str}' is not callable") return func diff --git a/scheduler/models/args.py b/scheduler/models/args.py index a7d02c7..15f9498 100644 --- a/scheduler/models/args.py +++ b/scheduler/models/args.py @@ -9,7 +9,7 @@ from scheduler.helpers import utils -ARG_TYPE_TYPES_DICT:Dict[str,Type] = { +ARG_TYPE_TYPES_DICT: Dict[str, Type] = { "str": str, "int": int, "bool": bool, diff --git a/scheduler/models/task.py b/scheduler/models/task.py index 01efe45..1bb22b0 100644 --- a/scheduler/models/task.py +++ b/scheduler/models/task.py @@ -33,7 +33,7 @@ def _get_task_for_job(job: JobModel) -> Optional["Task"]: return task -def failure_callback(job: JobModel, connection:ConnectionType, result: Any, *args: Any, **kwargs: Any) -> None: +def failure_callback(job: JobModel, connection: ConnectionType, result: Any, *args: Any, **kwargs: Any) -> None: task = _get_task_for_job(job) if task is None: logger.warn(f"Could not find task for job {job.name}") @@ -176,7 +176,7 @@ class TimeUnits(models.TextChoices): ), ) - def callable_func(self)->Callable: + def callable_func(self) -> Callable: """Translate callable string to callable""" return utils.callable_func(self.callable) @@ -187,9 +187,9 @@ def is_scheduled(self) -> bool: return False # check whether job_id is in scheduled/queued/active jobs res = ( - (self.job_name in self.rqueue.scheduled_job_registry.all()) - or (self.job_name in self.rqueue.queued_job_registry.all()) - or (self.job_name in self.rqueue.active_job_registry.all()) + (self.job_name in self.rqueue.scheduled_job_registry.all()) + or (self.job_name in self.rqueue.queued_job_registry.all()) + or (self.job_name in self.rqueue.active_job_registry.all()) ) # If the job_id is not scheduled/queued/started, # update the job_id to None. (The job_id belongs to a previous run which is completed) diff --git a/scheduler/redis_models/registry/base_registry.py b/scheduler/redis_models/registry/base_registry.py index af9b211..0aa9d47 100644 --- a/scheduler/redis_models/registry/base_registry.py +++ b/scheduler/redis_models/registry/base_registry.py @@ -74,11 +74,11 @@ def get_first(self) -> Optional[str]: first_job = self.connection.zrange(self._key, 0, 0) return first_job[0].decode() if first_job else None - def get_last_timestamp(self) -> Optional[float]: + def get_last_timestamp(self) -> Optional[int]: """Returns the last timestamp in the registry.""" self.cleanup(self.connection) last_timestamp = self.connection.zrange(self._key, -1, -1, withscores=True) - return last_timestamp[0][1] if last_timestamp else None + return int(last_timestamp[0][1]) if last_timestamp else None @property def key(self) -> str: diff --git a/scheduler/redis_models/result.py b/scheduler/redis_models/result.py index a89af18..5366e24 100644 --- a/scheduler/redis_models/result.py +++ b/scheduler/redis_models/result.py @@ -5,6 +5,7 @@ from scheduler.helpers.utils import utcnow from scheduler.redis_models.base import StreamModel, decode_dict +from scheduler.settings import logger from scheduler.types import ConnectionType, Self @@ -34,19 +35,21 @@ def create( cls, connection: ConnectionType, job_name: str, - worker_name: str, + worker_name: Optional[str], _type: ResultType, ttl: int, return_value: Any = None, exc_string: Optional[str] = None, ) -> Self: + if worker_name is None: + logger.warning(f"Job {job_name} has no worker name, will save result with 'unknown_worker'") result = cls( parent=job_name, ttl=ttl, type=_type, return_value=return_value, exc_string=exc_string, - worker_name=worker_name, + worker_name=worker_name or "unknown_worker", ) result.save(connection) return result diff --git a/scheduler/types/settings_types.py b/scheduler/types/settings_types.py index 1deb903..82adc54 100644 --- a/scheduler/types/settings_types.py +++ b/scheduler/types/settings_types.py @@ -65,7 +65,7 @@ class QueueConfiguration: USERNAME: Optional[str] = None PASSWORD: Optional[str] = None - ASYNC: Optional[bool] = True + ASYNC: bool = True SENTINELS: Optional[List[Tuple[str, int]]] = None SENTINEL_KWARGS: Optional[Dict[str, str]] = None diff --git a/scheduler/views/helpers.py b/scheduler/views/helpers.py index 335f540..777e2ab 100644 --- a/scheduler/views/helpers.py +++ b/scheduler/views/helpers.py @@ -68,7 +68,7 @@ def _enqueue_multiple_jobs(queue: Queue, job_names: List[str], at_front: bool = if job is None: continue job.save(connection=pipe) - queue.enqueue_job(job, connection=pipe, at_front=at_front) + queue.enqueue_job(job, pipeline=pipe, at_front=at_front) jobs_requeued += 1 pipe.execute() return jobs_requeued diff --git a/scheduler/worker/scheduler.py b/scheduler/worker/scheduler.py index c8bc9d6..b14da2b 100644 --- a/scheduler/worker/scheduler.py +++ b/scheduler/worker/scheduler.py @@ -158,7 +158,7 @@ def enqueue_scheduled_jobs(self) -> None: with self.connection.pipeline() as pipeline: for job in jobs: if job is not None: - queue.enqueue_job(job, connection=pipeline, at_front=job.at_front) + queue.enqueue_job(job, pipeline=pipeline, at_front=job.at_front) pipeline.execute() self.status = SchedulerStatus.STARTED From 30dbd7b1889aa63ea608c82e60e0d88ef8ad254f Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 25 Jun 2025 08:28:53 -0400 Subject: [PATCH 06/12] wip --- scheduler/worker/commands/kill_worker.py | 23 ++++++++++----------- scheduler/worker/commands/stop_job.py | 2 +- scheduler/worker/commands/suspend_worker.py | 1 + scheduler/worker/scheduler.py | 12 +++++------ scheduler/worker/worker.py | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/scheduler/worker/commands/kill_worker.py b/scheduler/worker/commands/kill_worker.py index 595271a..fa64ecc 100644 --- a/scheduler/worker/commands/kill_worker.py +++ b/scheduler/worker/commands/kill_worker.py @@ -1,11 +1,11 @@ import errno import os import signal -from typing import Optional +from typing import Any -from scheduler.types import ConnectionType from scheduler.redis_models import WorkerModel from scheduler.settings import logger +from scheduler.types import ConnectionType from scheduler.worker.commands.worker_commands import WorkerCommand @@ -14,27 +14,26 @@ class KillWorkerCommand(WorkerCommand): command_name = "kill-worker" - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.worker_pid: Optional[int] = None def process_command(self, connection: ConnectionType) -> None: from scheduler.worker import Worker - - logger.info("Received kill-worker command.") + if self.worker_name is None: + raise ValueError("Worker name must be provided") + logger.info(f"Received kill-worker command for {self.worker_name}") worker_model = WorkerModel.get(self.worker_name, connection) - self.worker_pid = worker_model.pid - if self.worker_pid is None: + if worker_model is None or worker_model.pid is None: raise ValueError("Worker PID is not set") - logger.info(f"Killing worker main process {self.worker_pid}...") + logger.info(f"Killing worker main process {worker_model.pid}...") try: Worker.from_model(worker_model).request_stop(signal.SIGTERM, None) - os.killpg(os.getpgid(self.worker_pid), signal.SIGTERM) - logger.info(f"Killed worker main process pid {self.worker_pid}") + os.killpg(os.getpgid(worker_model.pid), signal.SIGTERM) + logger.info(f"Killed worker main process pid {worker_model.pid}") except OSError as e: if e.errno == errno.ESRCH: logger.debug( - f"Worker main process for {self.worker_name}:{self.worker_pid} already dead" + f"Worker main process for {self.worker_name}:{worker_model.pid} already dead" ) # "No such process" is fine with us else: raise diff --git a/scheduler/worker/commands/stop_job.py b/scheduler/worker/commands/stop_job.py index e60813d..b4490af 100644 --- a/scheduler/worker/commands/stop_job.py +++ b/scheduler/worker/commands/stop_job.py @@ -19,7 +19,7 @@ def __init__(self, *args, job_name: str, worker_name: Optional[str], **kwargs) - if self.job_name is None: raise WorkerCommandError("job_name for kill-job command is required") - def command_payload(self) -> Dict[str, Any]: + def command_payload(self, **kwargs) -> Dict[str, Any]: return super().command_payload(job_name=self.job_name) def process_command(self, connection: ConnectionType) -> None: diff --git a/scheduler/worker/commands/suspend_worker.py b/scheduler/worker/commands/suspend_worker.py index e56e444..d719ea2 100644 --- a/scheduler/worker/commands/suspend_worker.py +++ b/scheduler/worker/commands/suspend_worker.py @@ -14,6 +14,7 @@ def process_command(self, connection: ConnectionType) -> None: worker_model = WorkerModel.get(self.worker_name, connection) if worker_model is None: logger.warning(f"Worker {self.worker_name} not found") + return if worker_model.is_suspended: logger.warning(f"Worker {self.worker_name} already suspended") return diff --git a/scheduler/worker/scheduler.py b/scheduler/worker/scheduler.py index b14da2b..b4fecbe 100644 --- a/scheduler/worker/scheduler.py +++ b/scheduler/worker/scheduler.py @@ -23,7 +23,7 @@ class SchedulerStatus(str, Enum): STOPPED = "stopped" -def _reschedule_tasks(): +def _reschedule_tasks() -> None: enabled_jobs = list(Task.objects.filter(enabled=True)) for item in enabled_jobs: logger.debug(f"Rescheduling {str(item)}") @@ -32,11 +32,11 @@ def _reschedule_tasks(): class WorkerScheduler: def __init__( - self, - queues: Sequence[Queue], - connection: ConnectionType, - worker_name: str, - interval: Optional[int] = None, + self, + queues: Sequence[Queue], + connection: ConnectionType, + worker_name: str, + interval: Optional[int] = None, ) -> None: interval = interval or SCHEDULER_CONFIG.SCHEDULER_INTERVAL self._queues = queues diff --git a/scheduler/worker/worker.py b/scheduler/worker/worker.py index 66646b7..cd730fb 100644 --- a/scheduler/worker/worker.py +++ b/scheduler/worker/worker.py @@ -312,7 +312,7 @@ def handle_job_failure(self, job: JobModel, queue: Queue, exc_string="") -> None # Ensure that custom exception handlers are called even if the Broker is down pass - def bootstrap(self): + def bootstrap(self)-> None: """Bootstraps the worker. Runs the basic tasks that should run when the worker actually starts working. Used so that new workers can focus on the work loop implementation rather From 7bff43b13804fdde1eff159e51f828982c4e8bb8 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Thu, 26 Jun 2025 09:03:40 -0400 Subject: [PATCH 07/12] wip --- scheduler/worker/commands/kill_worker.py | 2 - scheduler/worker/commands/stop_job.py | 8 ++-- scheduler/worker/commands/suspend_worker.py | 3 +- scheduler/worker/commands/worker_commands.py | 20 ++++---- scheduler/worker/scheduler.py | 6 +-- scheduler/worker/worker.py | 49 ++++++++++---------- 6 files changed, 45 insertions(+), 43 deletions(-) diff --git a/scheduler/worker/commands/kill_worker.py b/scheduler/worker/commands/kill_worker.py index fa64ecc..b6d4542 100644 --- a/scheduler/worker/commands/kill_worker.py +++ b/scheduler/worker/commands/kill_worker.py @@ -19,8 +19,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: def process_command(self, connection: ConnectionType) -> None: from scheduler.worker import Worker - if self.worker_name is None: - raise ValueError("Worker name must be provided") logger.info(f"Received kill-worker command for {self.worker_name}") worker_model = WorkerModel.get(self.worker_name, connection) if worker_model is None or worker_model.pid is None: diff --git a/scheduler/worker/commands/stop_job.py b/scheduler/worker/commands/stop_job.py index b4490af..df182c2 100644 --- a/scheduler/worker/commands/stop_job.py +++ b/scheduler/worker/commands/stop_job.py @@ -1,10 +1,10 @@ import os import signal -from typing import Dict, Any, Optional +from typing import Dict, Any -from scheduler.types import ConnectionType from scheduler.redis_models import WorkerModel, JobModel from scheduler.settings import logger +from scheduler.types import ConnectionType from scheduler.worker.commands.worker_commands import WorkerCommand, WorkerCommandError @@ -13,13 +13,13 @@ class StopJobCommand(WorkerCommand): command_name = "stop-job" - def __init__(self, *args, job_name: str, worker_name: Optional[str], **kwargs) -> None: + def __init__(self, *args: Any, job_name: str, worker_name: str, **kwargs: Any) -> None: super().__init__(*args, worker_name=worker_name, **kwargs) self.job_name = job_name if self.job_name is None: raise WorkerCommandError("job_name for kill-job command is required") - def command_payload(self, **kwargs) -> Dict[str, Any]: + def command_payload(self, **kwargs: Any) -> Dict[str, Any]: return super().command_payload(job_name=self.job_name) def process_command(self, connection: ConnectionType) -> None: diff --git a/scheduler/worker/commands/suspend_worker.py b/scheduler/worker/commands/suspend_worker.py index d719ea2..b51a5be 100644 --- a/scheduler/worker/commands/suspend_worker.py +++ b/scheduler/worker/commands/suspend_worker.py @@ -1,6 +1,6 @@ -from scheduler.types import ConnectionType from scheduler.redis_models import WorkerModel from scheduler.settings import logger +from scheduler.types import ConnectionType from scheduler.worker.commands.worker_commands import WorkerCommand @@ -32,6 +32,7 @@ def process_command(self, connection: ConnectionType) -> None: worker_model = WorkerModel.get(self.worker_name, connection) if worker_model is None: logger.warning(f"Worker {self.worker_name} not found") + return if not worker_model.is_suspended: logger.warning(f"Worker {self.worker_name} not suspended and therefore can't be resumed") return diff --git a/scheduler/worker/commands/worker_commands.py b/scheduler/worker/commands/worker_commands.py index b5fd0cf..649b054 100644 --- a/scheduler/worker/commands/worker_commands.py +++ b/scheduler/worker/commands/worker_commands.py @@ -1,12 +1,13 @@ import json from abc import ABC from datetime import datetime, timezone -from typing import Type, Dict, Any, Optional +from typing import Type, Dict, Any from scheduler.settings import logger from scheduler.types import ConnectionType, Self _PUBSUB_CHANNEL_TEMPLATE: str = ":workers:pubsub:{}" +_WORKER_COMMANDS_REGISTRY: Dict[str, Type["WorkerCommand"]] = dict() class WorkerCommandError(Exception): @@ -16,13 +17,12 @@ class WorkerCommandError(Exception): class WorkerCommand(ABC): """Abstract class for commands to be sent to a worker and processed by worker""" - _registry: Dict[str, Type[Self]] = dict() command_name: str = "" - def __init__(self, *args, worker_name: Optional[str], **kwargs) -> None: - self.worker_name: Optional[str] = worker_name + def __init__(self, *args: Any, worker_name: str, **kwargs: Any) -> None: + self.worker_name: str = worker_name - def command_payload(self, **kwargs) -> Dict[str, Any]: + def command_payload(self, **kwargs: Any) -> Dict[str, Any]: commands_channel = WorkerCommandsChannelListener._commands_channel(self.worker_name) payload = { "command": self.command_name, @@ -41,17 +41,19 @@ def process_command(self, connection: ConnectionType) -> None: raise NotImplementedError @classmethod - def __init_subclass__(cls, *args, **kwargs): + def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None: if cls is WorkerCommand: return if not cls.command_name: - raise NotImplementedError(f"{cls.__name__} must have a name attribute") - WorkerCommand._registry[cls.command_name] = cls + raise NotImplementedError(f"{cls.__name__} must have a command_name attribute") + _WORKER_COMMANDS_REGISTRY[cls.command_name] = cls @classmethod def from_payload(cls, payload: Dict[str, Any]) -> Type[Self]: command_name = payload.get("command") - command_class = WorkerCommand._registry.get(command_name) + if command_name is None: + raise WorkerCommandError("Payload must contain 'command' key") + command_class = _WORKER_COMMANDS_REGISTRY.get(command_name) if command_class is None: raise WorkerCommandError(f"Invalid command: {command_name}") return command_class(**payload) diff --git a/scheduler/worker/scheduler.py b/scheduler/worker/scheduler.py index b4fecbe..ead24b2 100644 --- a/scheduler/worker/scheduler.py +++ b/scheduler/worker/scheduler.py @@ -41,7 +41,7 @@ def __init__( interval = interval or SCHEDULER_CONFIG.SCHEDULER_INTERVAL self._queues = queues self._scheduled_job_registries: List[ScheduledJobRegistry] = [] - self.lock_acquisition_time = None + self.lock_acquisition_time: Optional[datetime] = None self._pool_class = connection.connection_pool.connection_class self._pool_kwargs = connection.connection_pool.connection_kwargs.copy() self._locks: Dict[str, SchedulerLock] = dict() @@ -49,7 +49,7 @@ def __init__( self.interval = interval self._stop_requested = False self.status = SchedulerStatus.STOPPED - self._thread = None + self._thread: Optional[Thread] = None self._pid: Optional[int] = None self.worker_name = worker_name @@ -163,7 +163,7 @@ def enqueue_scheduled_jobs(self) -> None: self.status = SchedulerStatus.STARTED -def run_scheduler(scheduler: WorkerScheduler): +def run_scheduler(scheduler: WorkerScheduler) -> None: try: scheduler.work() except: # noqa diff --git a/scheduler/worker/worker.py b/scheduler/worker/worker.py index cd730fb..2bd1bca 100644 --- a/scheduler/worker/worker.py +++ b/scheduler/worker/worker.py @@ -14,7 +14,7 @@ from enum import Enum from random import shuffle from types import FrameType -from typing import List, Optional, Tuple, Any, Iterable, Collection +from typing import List, Optional, Tuple, Any, Iterable, Collection, Union import scheduler from scheduler.helpers.queues import get_queue @@ -63,7 +63,7 @@ class QueueConnectionDiscrepancyError(Exception): ) -def signal_name(signum) -> str: +def signal_name(signum: int) -> str: try: return signal.Signals(signum).name except KeyError: @@ -99,19 +99,19 @@ def from_model(cls, model: WorkerModel) -> Self: return res def __init__( - self, - queues, - name: str, - connection: ConnectionType, - maintenance_interval: int = SCHEDULER_CONFIG.DEFAULT_MAINTENANCE_TASK_INTERVAL, - job_monitoring_interval=SCHEDULER_CONFIG.DEFAULT_JOB_MONITORING_INTERVAL, - dequeue_strategy: DequeueStrategy = DequeueStrategy.DEFAULT, - disable_default_exception_handler: bool = False, - fork_job_execution: bool = True, - with_scheduler: bool = True, - burst: bool = False, - model: Optional[WorkerModel] = None, - ): # noqa + self, + queues: Iterable[Union[str, Queue]], + name: str, + connection: ConnectionType, + maintenance_interval: int = SCHEDULER_CONFIG.DEFAULT_MAINTENANCE_TASK_INTERVAL, + job_monitoring_interval: int = SCHEDULER_CONFIG.DEFAULT_JOB_MONITORING_INTERVAL, + dequeue_strategy: DequeueStrategy = DequeueStrategy.DEFAULT, + disable_default_exception_handler: bool = False, + fork_job_execution: bool = True, + with_scheduler: bool = True, + burst: bool = False, + model: Optional[WorkerModel] = None, + ) -> None: self.fork_job_execution = fork_job_execution self.job_monitoring_interval: int = job_monitoring_interval self.maintenance_interval = maintenance_interval @@ -232,7 +232,7 @@ def work(self, max_jobs: Optional[int] = None, max_idle_time: Optional[int] = No timeout = None if self.burst else (SCHEDULER_CONFIG.DEFAULT_WORKER_TTL - 15) job, queue = self.dequeue_job_and_maintain_ttl(timeout, max_idle_time) - if job is None: + if job is None or queue is None: if self.burst: logger.info(f"[Worker {self.name}/{self._pid}]: done, quitting") break @@ -267,7 +267,7 @@ def work(self, max_jobs: Optional[int] = None, max_idle_time: Optional[int] = No self.teardown() return False - def handle_job_failure(self, job: JobModel, queue: Queue, exc_string="") -> None: + def handle_job_failure(self, job: JobModel, queue: Queue, exc_string: str = "") -> None: """ Handles the failure or an executing job by: 1. Setting the job status to failed @@ -312,7 +312,7 @@ def handle_job_failure(self, job: JobModel, queue: Queue, exc_string="") -> None # Ensure that custom exception handlers are called even if the Broker is down pass - def bootstrap(self)-> None: + def bootstrap(self) -> None: """Bootstraps the worker. Runs the basic tasks that should run when the worker actually starts working. Used so that new workers can focus on the work loop implementation rather @@ -327,7 +327,8 @@ def bootstrap(self)-> None: self._model.has_scheduler = True self._model.save(connection=self.connection) if self.with_scheduler and self.burst: - self.scheduler.request_stop_and_wait() + if self.scheduler is not None: + self.scheduler.request_stop_and_wait() self._model.has_scheduler = False self._model.save(connection=self.connection) qnames = [queue.name for queue in self.queues] @@ -375,8 +376,8 @@ def run_maintenance_tasks(self) -> None: self._model.save(connection=self.connection) def dequeue_job_and_maintain_ttl( - self, timeout: Optional[int], max_idle_time: Optional[int] = None - ) -> Tuple[JobModel, Queue]: + self, timeout: Optional[int], max_idle_time: Optional[int] = None + ) -> Tuple[Optional[JobModel], Optional[Queue]]: """Dequeues a job while maintaining the TTL. :param timeout: The timeout for the dequeue operation. :param max_idle_time: The maximum idle time for the worker. @@ -550,7 +551,7 @@ def reorder_queues(self, reference_queue: Queue) -> None: return if self._dequeue_strategy == DequeueStrategy.ROUND_ROBIN: pos = self._ordered_queues.index(reference_queue) - self._ordered_queues = self._ordered_queues[pos + 1 :] + self._ordered_queues[: pos + 1] + self._ordered_queues = self._ordered_queues[pos + 1:] + self._ordered_queues[: pos + 1] return if self._dequeue_strategy == DequeueStrategy.RANDOM: shuffle(self._ordered_queues) @@ -634,7 +635,7 @@ def monitor_job_execution_process(self, job: JobModel, queue: Queue) -> None: while True: try: with SCHEDULER_CONFIG.DEATH_PENALTY_CLASS( - self.job_monitoring_interval, JobExecutionMonitorTimeoutException + self.job_monitoring_interval, JobExecutionMonitorTimeoutException ): retpid, ret_val = self.wait_for_job_execution_process() break @@ -877,7 +878,7 @@ class RoundRobinWorker(Worker): def reorder_queues(self, reference_queue: Queue) -> None: pos = self._ordered_queues.index(reference_queue) - self._ordered_queues = self._ordered_queues[pos + 1 :] + self._ordered_queues[: pos + 1] + self._ordered_queues = self._ordered_queues[pos + 1:] + self._ordered_queues[: pos + 1] class RandomWorker(Worker): From 00472d77be74c558620aab8f18ec06517ce7832d Mon Sep 17 00:00:00 2001 From: Daniel M Date: Thu, 26 Jun 2025 09:04:49 -0400 Subject: [PATCH 08/12] wip --- scheduler/worker/commands/kill_worker.py | 1 + scheduler/worker/scheduler.py | 10 ++++---- scheduler/worker/worker.py | 32 ++++++++++++------------ 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/scheduler/worker/commands/kill_worker.py b/scheduler/worker/commands/kill_worker.py index b6d4542..45a75d3 100644 --- a/scheduler/worker/commands/kill_worker.py +++ b/scheduler/worker/commands/kill_worker.py @@ -19,6 +19,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: def process_command(self, connection: ConnectionType) -> None: from scheduler.worker import Worker + logger.info(f"Received kill-worker command for {self.worker_name}") worker_model = WorkerModel.get(self.worker_name, connection) if worker_model is None or worker_model.pid is None: diff --git a/scheduler/worker/scheduler.py b/scheduler/worker/scheduler.py index ead24b2..761d942 100644 --- a/scheduler/worker/scheduler.py +++ b/scheduler/worker/scheduler.py @@ -32,11 +32,11 @@ def _reschedule_tasks() -> None: class WorkerScheduler: def __init__( - self, - queues: Sequence[Queue], - connection: ConnectionType, - worker_name: str, - interval: Optional[int] = None, + self, + queues: Sequence[Queue], + connection: ConnectionType, + worker_name: str, + interval: Optional[int] = None, ) -> None: interval = interval or SCHEDULER_CONFIG.SCHEDULER_INTERVAL self._queues = queues diff --git a/scheduler/worker/worker.py b/scheduler/worker/worker.py index 2bd1bca..54793b6 100644 --- a/scheduler/worker/worker.py +++ b/scheduler/worker/worker.py @@ -99,18 +99,18 @@ def from_model(cls, model: WorkerModel) -> Self: return res def __init__( - self, - queues: Iterable[Union[str, Queue]], - name: str, - connection: ConnectionType, - maintenance_interval: int = SCHEDULER_CONFIG.DEFAULT_MAINTENANCE_TASK_INTERVAL, - job_monitoring_interval: int = SCHEDULER_CONFIG.DEFAULT_JOB_MONITORING_INTERVAL, - dequeue_strategy: DequeueStrategy = DequeueStrategy.DEFAULT, - disable_default_exception_handler: bool = False, - fork_job_execution: bool = True, - with_scheduler: bool = True, - burst: bool = False, - model: Optional[WorkerModel] = None, + self, + queues: Iterable[Union[str, Queue]], + name: str, + connection: ConnectionType, + maintenance_interval: int = SCHEDULER_CONFIG.DEFAULT_MAINTENANCE_TASK_INTERVAL, + job_monitoring_interval: int = SCHEDULER_CONFIG.DEFAULT_JOB_MONITORING_INTERVAL, + dequeue_strategy: DequeueStrategy = DequeueStrategy.DEFAULT, + disable_default_exception_handler: bool = False, + fork_job_execution: bool = True, + with_scheduler: bool = True, + burst: bool = False, + model: Optional[WorkerModel] = None, ) -> None: self.fork_job_execution = fork_job_execution self.job_monitoring_interval: int = job_monitoring_interval @@ -376,7 +376,7 @@ def run_maintenance_tasks(self) -> None: self._model.save(connection=self.connection) def dequeue_job_and_maintain_ttl( - self, timeout: Optional[int], max_idle_time: Optional[int] = None + self, timeout: Optional[int], max_idle_time: Optional[int] = None ) -> Tuple[Optional[JobModel], Optional[Queue]]: """Dequeues a job while maintaining the TTL. :param timeout: The timeout for the dequeue operation. @@ -551,7 +551,7 @@ def reorder_queues(self, reference_queue: Queue) -> None: return if self._dequeue_strategy == DequeueStrategy.ROUND_ROBIN: pos = self._ordered_queues.index(reference_queue) - self._ordered_queues = self._ordered_queues[pos + 1:] + self._ordered_queues[: pos + 1] + self._ordered_queues = self._ordered_queues[pos + 1 :] + self._ordered_queues[: pos + 1] return if self._dequeue_strategy == DequeueStrategy.RANDOM: shuffle(self._ordered_queues) @@ -635,7 +635,7 @@ def monitor_job_execution_process(self, job: JobModel, queue: Queue) -> None: while True: try: with SCHEDULER_CONFIG.DEATH_PENALTY_CLASS( - self.job_monitoring_interval, JobExecutionMonitorTimeoutException + self.job_monitoring_interval, JobExecutionMonitorTimeoutException ): retpid, ret_val = self.wait_for_job_execution_process() break @@ -878,7 +878,7 @@ class RoundRobinWorker(Worker): def reorder_queues(self, reference_queue: Queue) -> None: pos = self._ordered_queues.index(reference_queue) - self._ordered_queues = self._ordered_queues[pos + 1:] + self._ordered_queues[: pos + 1] + self._ordered_queues = self._ordered_queues[pos + 1 :] + self._ordered_queues[: pos + 1] class RandomWorker(Worker): From a6ed3321333c6eebf1f62d75ef05a6fb256a2766 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Thu, 26 Jun 2025 09:05:03 -0400 Subject: [PATCH 09/12] wip --- scheduler/management/commands/scheduler_stats.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scheduler/management/commands/scheduler_stats.py b/scheduler/management/commands/scheduler_stats.py index a8f072e..3f19f08 100644 --- a/scheduler/management/commands/scheduler_stats.py +++ b/scheduler/management/commands/scheduler_stats.py @@ -1,5 +1,4 @@ import time -from argparse import ArgumentParser from typing import Any, Dict, List, Optional import click From 6f9898f9f20dabcf49011ca8d34817b0cf74a693 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Thu, 26 Jun 2025 09:06:17 -0400 Subject: [PATCH 10/12] wip --- pyproject.toml | 3 --- scheduler/admin/task_admin.py | 4 +-- uv.lock | 49 +++++++---------------------------- 3 files changed, 11 insertions(+), 45 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 79832dc..e51e3bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,3 @@ follow_imports = "silent" ignore_missing_imports = true scripts_are_modules = true check_untyped_defs = true - -[tool.uv.sources] -fakeredis = { path = "../fakeredis" } diff --git a/scheduler/admin/task_admin.py b/scheduler/admin/task_admin.py index e33cbef..1bf1590 100644 --- a/scheduler/admin/task_admin.py +++ b/scheduler/admin/task_admin.py @@ -3,7 +3,7 @@ from django.contrib import admin, messages from django.contrib.contenttypes.admin import GenericStackedInline from django.db.models import QuerySet -from django.http import HttpRequest +from django.http import HttpRequest, HttpResponse from django.utils import timezone, formats from django.utils.translation import gettext_lazy as _ @@ -137,7 +137,7 @@ def task_schedule(self, o: Task) -> str: def next_run(self, o: Task) -> str: return get_next_cron_time(o.cron_string) - def change_view(self, request: HttpRequest, object_id, form_url="", extra_context=None): + def change_view(self, request: HttpRequest, object_id, form_url="", extra_context=None) -> HttpResponse: extra = extra_context or {} obj = self.get_object(request, object_id) try: diff --git a/uv.lock b/uv.lock index fa245b7..8971c37 100644 --- a/uv.lock +++ b/uv.lock @@ -183,7 +183,7 @@ requires-dist = [ { name = "click", specifier = "~=8.2" }, { name = "croniter", specifier = ">=2.0" }, { name = "django", specifier = ">=5" }, - { name = "fakeredis", directory = "../fakeredis" }, + { name = "fakeredis" }, { name = "pyyaml", marker = "extra == 'yaml'", specifier = "~=6.0" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = "~=2.19" }, { name = "types-pyyaml", marker = "extra == 'yaml'", specifier = ">=6.0.12.20250516" }, @@ -194,7 +194,7 @@ provides-extras = ["yaml", "valkey", "sentry"] [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = "~=7.6" }, - { name = "fakeredis", directory = "../fakeredis" }, + { name = "fakeredis", specifier = "~=2.28" }, { name = "mypy", specifier = ">=1.16.0" }, { name = "pyyaml", specifier = ">=6,<7" }, { name = "ruff", specifier = ">=0.11" }, @@ -205,46 +205,15 @@ dev = [ [[package]] name = "fakeredis" version = "2.30.1" -source = { directory = "../fakeredis" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "redis" }, { name = "sortedcontainers" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] - -[package.metadata] -requires-dist = [ - { name = "jsonpath-ng", marker = "extra == 'json'", specifier = "~=1.6" }, - { name = "lupa", marker = "extra == 'lua'", specifier = ">=2.1,<3.0" }, - { name = "pyprobables", marker = "extra == 'bf'", specifier = ">=0.6" }, - { name = "pyprobables", marker = "extra == 'cf'", specifier = ">=0.6" }, - { name = "pyprobables", marker = "extra == 'probabilistic'", specifier = ">=0.6" }, - { name = "redis", marker = "python_full_version < '3.8'", specifier = ">=4" }, - { name = "redis", marker = "python_full_version >= '3.9'", specifier = ">=4.3" }, - { name = "sortedcontainers", specifier = ">=2,<3" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = "~=4.7" }, -] -provides-extras = ["lua", "json", "bf", "cf", "probabilistic"] - -[package.metadata.requires-dev] -dev = [ - { name = "mypy", marker = "python_full_version >= '3.10'", specifier = ">=1.15" }, - { name = "pre-commit", marker = "python_full_version >= '3.10'", specifier = "~=4.2" }, - { name = "ruff", marker = "python_full_version >= '3.10'", specifier = ">=0.12" }, -] -docs = [ - { name = "pygithub", marker = "python_full_version >= '3.10'", specifier = "~=2.3" }, - { name = "python-dotenv", marker = "python_full_version >= '3.10'", specifier = ">=1,<2" }, -] -test = [ - { name = "coverage", marker = "python_full_version >= '3.9'", specifier = "~=7.6" }, - { name = "hypothesis", marker = "python_full_version >= '3.9'", specifier = "~=6.111" }, - { name = "pytest", marker = "python_full_version >= '3.9'", specifier = "~=8.3" }, - { name = "pytest-asyncio", marker = "python_full_version >= '3.9'", specifier = ">=0.24,<0.25" }, - { name = "pytest-cov", marker = "python_full_version >= '3.9'", specifier = "~=6.0" }, - { name = "pytest-html", marker = "python_full_version >= '3.9'", specifier = "~=4.1" }, - { name = "pytest-mock", marker = "python_full_version >= '3.9'", specifier = "~=3.14" }, - { name = "pytest-timeout", marker = "python_full_version >= '3.9'", specifier = ">=2.3.1,<3" }, +sdist = { url = "https://files.pythonhosted.org/packages/86/3b/eb1d4d0fdc138df1d8e625dfa6b500189030e6c1c265b8dd22b783b2f9ec/fakeredis-2.30.1.tar.gz", hash = "sha256:6489f2926e39815c9bf0fce80751635e0898e333c43a767825adf101180dbc45", size = 167724, upload-time = "2025-06-19T17:55:45.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/ee/acc3de71b8c66029ea4567d83e9c736d79836b2d97aa2cacf1b83f96c678/fakeredis-2.30.1-py3-none-any.whl", hash = "sha256:b594a9c20aef8b94c4d923f489210ef443e4001e62ad3cd73b9a01298dcef743", size = 116215, upload-time = "2025-06-19T17:55:43.893Z" }, ] [[package]] @@ -542,11 +511,11 @@ wheels = [ [[package]] name = "types-croniter" -version = "6.0.0.20250411" +version = "6.0.0.20250626" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/5a/837dd0f759b8df7c5ce28692f1b6d752035e5389098dd364784283b30a19/types_croniter-6.0.0.20250411.tar.gz", hash = "sha256:ee97025b7768f2cc556ef52a2f10c97c503c1634f372fd3e9683d8f7c960eeb7", size = 11722, upload-time = "2025-04-11T02:56:33.172Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/7e/2bb235f73d545fe3b77658a63b71e8d68b4fcabfbedd7956661bc31e6303/types_croniter-6.0.0.20250626.tar.gz", hash = "sha256:c32243b16d4dfa7c9989a5eadc6762459d093dded023f3c363fdee6b96578a77", size = 11745, upload-time = "2025-06-26T03:12:04.28Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/e3/ec8880c9a7a484048457c5db3a340a949863f54043384f36eb0e4d9edc2f/types_croniter-6.0.0.20250411-py3-none-any.whl", hash = "sha256:4acdaccf4190017daa51699bd3110a0617c5df72459e62dea8b72549c62fad90", size = 9754, upload-time = "2025-04-11T02:56:32.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/d51e29ccfe9ae23bb0cd0a4c91741ef03c923f7385eeac4b00411e20988c/types_croniter-6.0.0.20250626-py3-none-any.whl", hash = "sha256:3e8c37b54b541f323b2c487f3a1c9dcb27a7092333a2d5c09fff0c4d41c68380", size = 9757, upload-time = "2025-06-26T03:12:03.241Z" }, ] [[package]] From 23d2f8feb913915e6e67175c22e825fdd0fd974d Mon Sep 17 00:00:00 2001 From: Daniel M Date: Thu, 26 Jun 2025 09:34:55 -0400 Subject: [PATCH 11/12] wip --- scheduler/worker/worker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scheduler/worker/worker.py b/scheduler/worker/worker.py index 54793b6..ed3e5cd 100644 --- a/scheduler/worker/worker.py +++ b/scheduler/worker/worker.py @@ -895,9 +895,9 @@ def _get_ip_address_from_connection(connection: ConnectionType, client_name: str warnings.warn("CLIENT SETNAME command not supported, setting ip_address to unknown", Warning) return "unknown" client_list = connection.client_list() - client_adresses: List[str] = [client["addr"] for client in client_list if client["name"] == client_name] - if len(client_adresses) > 0: - return client_adresses[0] + client_address_list: List[str] = [client["addr"] for client in client_list if client["name"] == client_name] + if len(client_address_list) > 0: + return client_address_list[0] else: warnings.warn("CLIENT LIST command not supported, setting ip_address to unknown", Warning) return "unknown" From b1ec02fa6cedf0e2b3991613a2462f02bd9850b9 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Thu, 26 Jun 2025 09:36:00 -0400 Subject: [PATCH 12/12] wip --- scheduler/worker/scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scheduler/worker/scheduler.py b/scheduler/worker/scheduler.py index 761d942..b086e30 100644 --- a/scheduler/worker/scheduler.py +++ b/scheduler/worker/scheduler.py @@ -166,7 +166,7 @@ def enqueue_scheduled_jobs(self) -> None: def run_scheduler(scheduler: WorkerScheduler) -> None: try: scheduler.work() - except: # noqa + except Exception: # noqa logger.error(f"Scheduler [PID {os.getpid()}] raised an exception.\n{traceback.format_exc()}") raise logger.info(f"Scheduler with PID {os.getpid()} has stopped")