diff --git a/pyproject.toml b/pyproject.toml index 8d308a843..7b20a47f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,8 @@ dependencies = [ "django-cors-headers==4.9.0", "nh3==0.3.3", "configobj==5.0.9", + "black>=26.3.1", + "redis-cli>=1.0.1", ] [tool.uv] diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index d72e9191a..681de4562 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -1,4 +1,5 @@ import asyncio +import json import os import re import traceback @@ -8,10 +9,13 @@ from io import BytesIO from tempfile import TemporaryDirectory, NamedTemporaryFile +import urllib + import oyaml as yaml import requests from celery._state import app_or_default from django.conf import settings +from django_redis import get_redis_connection from django.core.exceptions import ObjectDoesNotExist from django.core.files.base import ContentFile from django.db.models import Subquery, OuterRef, Count, Case, When, Value, F @@ -20,9 +24,10 @@ from django.utils.timezone import now from rest_framework.exceptions import ValidationError -from celery_config import app +from celery_config import app, app_for_vhost from competitions.models import Submission, CompetitionCreationTaskStatus, SubmissionDetails, Competition, \ CompetitionDump, Phase +from queues.models import Queue from competitions.unpackers.utils import CompetitionUnpackingException from competitions.unpackers.v1 import V15Unpacker from competitions.unpackers.v2 import V2Unpacker @@ -31,8 +36,12 @@ from datasets.models import Data from utils.data import make_url_sassy from utils.email import codalab_send_markdown_email +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync import logging + +from utils.worker_utils import WORKER_HEARTBEAT_TTL, WORKERS_REGISTRY_KEY, extract_queue_names, is_compute_worker, known_compute_queue_names logger = logging.getLogger(__name__) COMPETITION_FIELDS = [ @@ -790,3 +799,110 @@ def submission_status_cleanup(): sub.parent.cancel(status=Submission.FAILED) else: sub.cancel(status=Submission.FAILED) + + +# ------------------------------------------------- +def _broadcast_worker_state(payload): + channel_layer = get_channel_layer() + if not channel_layer: + return + + async_to_sync(channel_layer.group_send)( + "compute_workers", + { + "type": "worker.health", + "worker": payload, + }, + ) + + +@app.task(queue="site-worker", soft_time_limit=120) +def refresh_compute_worker_health(): + celery_app = app + r = get_redis_connection("default") + known_queue_names = known_compute_queue_names() + broker_sources = [] + broker_sources.append(("default", celery_app.conf.broker_url, celery_app)) + + private_queues = ( + Queue.objects.filter(competitions__isnull=False) + .exclude(name__isnull=True) + .exclude(name="") + .distinct() + ) + for queue in private_queues: + if not queue.broker_url: + continue + parsed = urllib.parse.urlparse(queue.broker_url) + vhost = parsed.path + broker_url = urllib.parse.urljoin(celery_app.conf.broker_url, vhost) + broker_sources.append((queue.name, broker_url, app_for_vhost(vhost))) + + inspected_brokers = set() + for source_name, broker_url, broker_app in broker_sources: + if broker_url in inspected_brokers: + continue + inspected_brokers.add(broker_url) + + try: + # timeout=5 : 4 appels × 5s × N brokers + inspector = broker_app.control.inspect(timeout=5) + if inspector is None: + logger.warning( + "Celery inspect returned None for broker=%s", source_name + ) + continue + stats = inspector.stats() or {} + active = inspector.active() or {} + reserved = inspector.reserved() or {} + active_queues = inspector.active_queues() or {} + except Exception: + logger.exception( + "Unable to inspect Celery workers for broker %s", source_name + ) + continue + + for worker_name in stats.keys(): + queues = active_queues.get(worker_name, []) or [] + queue_names = extract_queue_names(queues) + if not is_compute_worker(worker_name, queue_names, known_queue_names): + continue + + running_jobs = len(active.get(worker_name, [])) + len( + reserved.get(worker_name, []) + ) + status = "busy" if running_jobs > 0 else "available" + payload = { + "hostname": worker_name, + "status": status, + "running_jobs": running_jobs, + "timestamp": now().timestamp(), + "queue_source": source_name, + "queue_names": sorted(queue_names), + } + heartbeat_key = f"worker:{source_name}:{worker_name}:heartbeat" + r.set(heartbeat_key, json.dumps(payload), ex=WORKER_HEARTBEAT_TTL) + r.hset( + WORKERS_REGISTRY_KEY, + f"{source_name}:{worker_name}", + json.dumps( + { + "hostname": worker_name, + "status": status, + "running_jobs": running_jobs, + "last_seen": payload["timestamp"], + "queue_source": source_name, + "queue_names": sorted(queue_names), + } + ), + ) + _broadcast_worker_state(payload) + # Logs about CW health HERE + # logger.info( + # "[WORKER-HEALTH] source=%s worker=%s status=%s jobs=%d queues=%s", + # source_name, + # worker_name, + # status, + # running_jobs, + # sorted(queue_names), + # ) diff --git a/src/celery_config.py b/src/celery_config.py index 760614783..e8ba5961e 100644 --- a/src/celery_config.py +++ b/src/celery_config.py @@ -1,18 +1,24 @@ +import copy +import urllib.parse + from celery import Celery -from kombu import Queue, Exchange from django.conf import settings -import urllib.parse -import copy +from kombu import Exchange, Queue app = Celery() from django.conf import settings # noqa -app.config_from_object('django.conf:settings', namespace='CELERY') +app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) app.conf.task_queues = [ # Mostly defining queue here so we can set x-max-priority - Queue('compute-worker', Exchange('compute-worker'), routing_key='compute-worker', queue_arguments={'x-max-priority': 10}), + Queue( + "compute-worker", + Exchange("compute-worker"), + routing_key="compute-worker", + queue_arguments={"x-max-priority": 10}, + ), ] _vhost_apps = {} @@ -32,7 +38,7 @@ def app_for_vhost(vhost): # Copy the settings so we can modify the broker url to include the vhost django_settings = copy.copy(settings) django_settings.CELERY_BROKER_URL = broker_url - vhost_app.config_from_object(django_settings, namespace='CELERY') + vhost_app.config_from_object(django_settings, namespace="CELERY") vhost_app.conf.task_queues = app.conf.task_queues _vhost_apps[vhost] = vhost_app return _vhost_apps[vhost] diff --git a/src/routing.py b/src/routing.py index 2ef280e73..adf3f44cc 100644 --- a/src/routing.py +++ b/src/routing.py @@ -1,7 +1,10 @@ from django.urls import re_path from apps.competitions.consumers import SubmissionIOConsumer, SubmissionOutputConsumer +from utils.consumers import ComputeWorkersConsumer + websocket_urlpatterns = [ re_path(r'submission_input/(?P\d+)/(?P\d+)/(?P[^/]+)/$', SubmissionIOConsumer.as_asgi()), re_path(r'submission_output/$', SubmissionOutputConsumer.as_asgi()), + re_path(r"ws/workers/$", ComputeWorkersConsumer.as_asgi()), ] diff --git a/src/settings/base.py b/src/settings/base.py index d2698bb2a..47c76ad6b 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -276,6 +276,10 @@ 'task': 'profiles.tasks.clean_non_activated_users', 'schedule': timedelta(days=1), # Run every 24 hours }, + "refresh_compute_worker_health": { + "task": "competitions.tasks.refresh_compute_worker_health", + "schedule": 60, + }, } CELERY_TIMEZONE = 'UTC' CELERY_WORKER_PREFETCH_MULTIPLIER = 1 diff --git a/src/static/riot/competitions/detail/_header.tag b/src/static/riot/competitions/detail/_header.tag index f8b020377..b8234bb1d 100644 --- a/src/static/riot/competitions/detail/_header.tag +++ b/src/static/riot/competitions/detail/_header.tag @@ -35,6 +35,13 @@ + + + +
diff --git a/src/static/riot/competitions/detail/detail.tag b/src/static/riot/competitions/detail/detail.tag index f971675b3..7bed78b29 100644 --- a/src/static/riot/competitions/detail/detail.tag +++ b/src/static/riot/competitions/detail/detail.tag @@ -645,4 +645,4 @@ } } - + \ No newline at end of file diff --git a/src/static/riot/competitions/detail/worker-monitor-toggle.tag b/src/static/riot/competitions/detail/worker-monitor-toggle.tag new file mode 100644 index 000000000..d31b64e2e --- /dev/null +++ b/src/static/riot/competitions/detail/worker-monitor-toggle.tag @@ -0,0 +1,650 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/templates/competitions/detail.html b/src/templates/competitions/detail.html index 43a2228db..8e011f45f 100644 --- a/src/templates/competitions/detail.html +++ b/src/templates/competitions/detail.html @@ -1,7 +1,11 @@ {% extends "base.html" %} -{% block title %}{{ competition.title }} - Codabench{% endblock %} +{% block title %}{{ competition.title }} – Codabench{% endblock %} {% block content %} - + + {% endblock %} \ No newline at end of file diff --git a/src/urls.py b/src/urls.py index 2634d19d6..88013d5a7 100644 --- a/src/urls.py +++ b/src/urls.py @@ -33,7 +33,6 @@ ] - if settings.DEBUG: # Static files for local dev, so we don't have to collectstatic and such urlpatterns += staticfiles_urlpatterns() diff --git a/src/utils/consumers.py b/src/utils/consumers.py new file mode 100644 index 000000000..6d076bac9 --- /dev/null +++ b/src/utils/consumers.py @@ -0,0 +1,142 @@ +import asyncio +import json +import logging +import time + +from competitions.models import Competition + +from asgiref.sync import sync_to_async +from channels.generic.websocket import AsyncJsonWebsocketConsumer +from django_redis import get_redis_connection + +from utils.worker_utils import WORKER_HEARTBEAT_TTL, WORKERS_REGISTRY_KEY + +logger = logging.getLogger(__name__) + +r = get_redis_connection("default") + + +def _load_snapshot(competition_queue_name=None): + """ + Charge les workers depuis Redis. + - workers par défaut : toujours inclus (queue_source == 'default') + - workers privés : inclus uniquement si leur queue_source correspond + à la queue de la compétition courante + """ + raw = r.hgetall(WORKERS_REGISTRY_KEY) + workers = [] + private_workers = [] + now = time.time() + + for _, value in raw.items(): + try: + worker = json.loads(value) + except Exception: + continue + + if now - worker.get("last_seen", 0) > WORKER_HEARTBEAT_TTL: + continue + + if worker.get("queue_source") == "default": + workers.append(worker) + else: + # Worker privé : n'afficher que si la queue correspond à la compétition + if competition_queue_name and worker.get("queue_source") == competition_queue_name: + private_workers.append(worker) + + workers.sort(key=lambda x: x.get("hostname", "")) + private_workers.sort(key=lambda x: (x.get("queue_source", ""), x.get("hostname", ""))) + return workers, private_workers + + +def _get_competition_queue_name(competition_id): + """Retourne le nom de la queue de la compétition, ou None.""" + if not competition_id: + return None + try: + competition = Competition.objects.select_related("queue").get(pk=competition_id) + if competition.queue and competition.queue.name: + return competition.queue.name + except Exception: + logger.warning("Competition %s not found or has no queue", competition_id) + return None + + +class ComputeWorkersConsumer(AsyncJsonWebsocketConsumer): + + async def connect(self): + user = self.scope.get("user") + if user is None or user.is_anonymous: + await self.close() + return + await self.accept() + await self.channel_layer.group_add("compute_workers", self.channel_name) + self._competition_queue_name = None + self._running = True + self._subscribed = asyncio.Event() + self._task = asyncio.create_task(self._push_workers_loop()) + + async def disconnect(self, close_code): + self._running = False + await self.channel_layer.group_discard("compute_workers", self.channel_name) + task = getattr(self, "_task", None) + if task: + task.cancel() + try: + await task + except (asyncio.CancelledError, RuntimeError): + pass + + async def receive_json(self, content): + logger.debug("WebSocket received: %s", content) + if content.get("type") == "subscribe": + competition_id = content.get("competition_id") + self._competition_queue_name = await sync_to_async(_get_competition_queue_name)( + competition_id) + self._subscribed.set() + + async def _push_workers_loop(self): + try: + try: + await asyncio.wait_for(self._subscribed.wait(), timeout=5.0) + except asyncio.TimeoutError: + logger.warning("WebSocket subscribe timeout, proceeding without competition filter") + + while self._running: + workers, private_workers = await sync_to_async(_load_snapshot)( + self._competition_queue_name + ) + if not self._running: + break + try: + await self.send_json({ + "type": "workers.snapshot", + "workers": workers, + "private_workers": private_workers, + }) + except RuntimeError: + break + await asyncio.sleep(3) + except asyncio.CancelledError: + pass + + async def worker_health(self, event): + worker = event["worker"] + is_default = worker.get("queue_source") == "default" + is_mine = ( + self._competition_queue_name is not None + and worker.get("queue_source") == self._competition_queue_name + ) + if not is_default and not is_mine: + return + try: + workers, private_workers = await sync_to_async(_load_snapshot)( + self._competition_queue_name + ) + await self.send_json({ + "type": "workers.snapshot", + "workers": workers, + "private_workers": private_workers, + }) + except RuntimeError: + pass diff --git a/src/utils/worker_utils.py b/src/utils/worker_utils.py new file mode 100644 index 000000000..f1b9a474b --- /dev/null +++ b/src/utils/worker_utils.py @@ -0,0 +1,28 @@ +from queues.models import Queue + +WORKERS_REGISTRY_KEY = "workers:registry" +WORKER_HEARTBEAT_TTL = 180 + + +def extract_queue_names(active_queues): + names = set() + for q in active_queues or []: + if isinstance(q, dict) and q.get("name"): + names.add(q["name"]) + return names + + +def known_compute_queue_names(): + return set( + Queue.objects.exclude(name__isnull=True) + .exclude(name="") + .values_list("name", flat=True) + ) + + +def is_compute_worker(worker_name, queue_names, known_queue_names): + return ( + bool(queue_names & known_queue_names) + or "compute-worker" in queue_names + or worker_name.startswith("compute-worker") + ) diff --git a/uv.lock b/uv.lock index 41ca175d6..3d6d3e220 100644 --- a/uv.lock +++ b/uv.lock @@ -138,6 +138,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, ] +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + [[package]] name = "blessed" version = "1.38.0" @@ -371,6 +393,7 @@ dependencies = [ { name = "argh" }, { name = "azure-storage-blob" }, { name = "azure-storage-common" }, + { name = "black" }, { name = "blessings" }, { name = "boto3" }, { name = "botocore" }, @@ -412,6 +435,7 @@ dependencies = [ { name = "python-dateutil" }, { name = "pytz" }, { name = "pyyaml" }, + { name = "redis-cli" }, { name = "requests" }, { name = "s3transfer" }, { name = "setuptools" }, @@ -441,6 +465,7 @@ requires-dist = [ { name = "argh", specifier = "==0.31.3" }, { name = "azure-storage-blob", specifier = ">=12,<13" }, { name = "azure-storage-common", specifier = "==2.1.0" }, + { name = "black", specifier = ">=26.3.1" }, { name = "blessings", specifier = "==1.7" }, { name = "boto3", specifier = "==1.42.50" }, { name = "botocore", specifier = "==1.42.50" }, @@ -482,6 +507,7 @@ requires-dist = [ { name = "python-dateutil", specifier = "==2.9.0" }, { name = "pytz", specifier = ">=2025.2" }, { name = "pyyaml", specifier = "==6.0.3" }, + { name = "redis-cli", specifier = ">=1.0.1" }, { name = "requests", specifier = "==2.33.1" }, { name = "s3transfer", specifier = "==0.16.0" }, { name = "setuptools", specifier = "==82.0.0" }, @@ -1272,6 +1298,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, ] +[[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 = "nh3" version = "0.3.3" @@ -1335,6 +1370,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + [[package]] name = "pexpect" version = "4.9.0" @@ -1380,6 +1424,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1552,6 +1605,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/a5/c6ba13860bdf5525f1ab01e01cc667578d6f1efc8a1dba355700fb04c29b/python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b", size = 133681, upload-time = "2020-06-29T12:15:47.502Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pytz" version = "2026.1.post1" @@ -1597,6 +1664,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, ] +[[package]] +name = "redis-cli" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/7a/464077bce2a14cd8399776ae0c694e7c240b58ffe4b231413013f2fb48fe/redis-cli-1.0.1.tar.gz", hash = "sha256:6be46d8e6ae638fec27cb425d499b7f25b1592402c74d5d17f5b85b5e7f988a0", size = 5958, upload-time = "2023-10-02T05:58:36.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/29/33cbdafefd22613cc003f837b6f0295462b2e91696180f06c5210f576a1b/redis_cli-1.0.1-py3-none-any.whl", hash = "sha256:e9f6af4a8b7591d8bfd1e23be89bdd9666ce824df881748fb02b88dd30575dc8", size = 5851, upload-time = "2023-10-02T05:58:32.924Z" }, +] + [[package]] name = "referencing" version = "0.37.0"