diff --git a/README.md b/README.md index 933ef2c2..56ff2f1b 100644 --- a/README.md +++ b/README.md @@ -126,3 +126,87 @@ src/firetower/ # Django backend frontend/ # React frontend sdk/ # Python SDK ``` + +## Scheduled Tasks + +Scheduled tasks are stored as database objects and are managed via migrations. + +### Adding a Task + +First, define you task in the `SCHEDULES` map in `src/firetower/incidents/tasks.py`. + +Then, create a new migration referencing it: + +```python +from django.db import migrations + +from firetower.incidents.tasks import SCHEDULES + + +def create_schedule(apps, schema_editor): + Schedule = apps.get_model("django_q", "Schedule") + schedule_name = "[schedule name goes here]" + Schedule.objects.get_or_create( + name=schedule_name, defaults=SCHEDULES[schedule_name] + ) + + +def delete_schedule(apps, schema_editor): + Schedule = apps.get_model("django_q", "Schedule") + schedule_name = "schedule_demo" + Schedule.objects.filter(name=schedule_name).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("incidents", "[previous migration goes here]"), + ] + + operations = [ + migrations.RunPython(create_schedule, delete_schedule), + ] +``` + +### Removing a task + +First, generate the opposite of the migration used to add the schedule: + +```python +from django.db import migrations + +from firetower.incidents.tasks import SCHEDULES + + +def create_schedule(apps, schema_editor): + Schedule = apps.get_model("django_q", "Schedule") + schedule_name = "[schedule name goes here]" + Schedule.objects.get_or_create( + name=schedule_name, defaults=SCHEDULES[schedule_name] + ) + + +def delete_schedule(apps, schema_editor): + Schedule = apps.get_model("django_q", "Schedule") + schedule_name = "schedule_demo" + Schedule.objects.filter(name=schedule_name).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("incidents", "[previous migration goes here]"), + ] + + operations = [ + migrations.RunPython(delete_schedule, create_schedule), + ] +``` + +Once this migration has run everywhere, you can then remove the body from the `SCHEDULES` array, but keep the key since these are still referenced in legacy migrations! + +```python +SCHEDULES = { + "schedule_demo": { + # Removed in + }, +} +``` diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b250e3a6..53082f95 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -4,16 +4,18 @@ set -e set -u set -x +export PYTHONPATH="/app:${PYTHONPATH-}" + if [ z"$1" = "zmigrate" ]; then - COMMAND="/app/.venv/bin/django-admin migrate --settings firetower.settings" + /app/.venv/bin/ddtrace-run /app/.venv/bin/django-admin createcachetable --settings firetower.settings + exec /app/.venv/bin/ddtrace-run /app/.venv/bin/django-admin migrate --settings firetower.settings elif [ z"$1" = "zserver" ]; then - COMMAND="/app/.venv/bin/granian --interface wsgi --host 0.0.0.0 --port $PORT firetower.wsgi:application" + exec /app/.venv/bin/ddtrace-run /app/.venv/bin/granian --interface wsgi --host 0.0.0.0 --port "${PORT}" firetower.wsgi:application elif [ z"$1" = "zslack-bot" ]; then - COMMAND="/app/.venv/bin/django-admin run_slack_bot --settings firetower.settings" + exec /app/.venv/bin/ddtrace-run /app/.venv/bin/django-admin run_slack_bot --settings firetower.settings +elif [ z"$1" = "zworker" ]; then + exec /app/.venv/bin/ddtrace-run /app/.venv/bin/django-admin wrapped_worker --settings firetower.settings else - echo "Usage: $0 (migrate|server|slack-bot)" + echo "Usage: $0 (migrate|server|slack-bot|worker)" exit 1 fi - -export PYTHONPATH=/app:\$PYTHONPATH -/app/.venv/bin/ddtrace-run $COMMAND diff --git a/pyproject.toml b/pyproject.toml index 3acf843d..98984f9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pyserde[toml]>=0.28.0", "notion-client>=3.0.0,<4.0.0", "requests>=2.32.0", + "django-q2>=1.7.4", "slack-bolt>=1.27.0", "slack-sdk>=3.31.0", "sentry-sdk>=2.47.0", @@ -40,6 +41,7 @@ dev = [ "ty>=0.0.1a19", "django-stubs>=5.2.7", "django-stubs-ext>=5.2.7", + "blessed>=1.39.0", ] prod = [ "granian>=2.6.0", diff --git a/src/firetower/config.py b/src/firetower/config.py index a9ae7392..eef886a5 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -153,7 +153,7 @@ def __init__(self) -> None: self.pagerduty = None self.statuspage = None self.project_key = "" - self.django_secret_key = "" + self.django_secret_key = "dummy_value_DO_NOT_USE" self.sentry_dsn = "" self.region_grouping: list[list[str]] = [] self.firetower_base_url = "" diff --git a/src/firetower/incidents/management/__init__.py b/src/firetower/incidents/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/firetower/incidents/management/commands/__init__.py b/src/firetower/incidents/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/firetower/incidents/management/commands/wrapped_worker.py b/src/firetower/incidents/management/commands/wrapped_worker.py new file mode 100644 index 00000000..e013d2a0 --- /dev/null +++ b/src/firetower/incidents/management/commands/wrapped_worker.py @@ -0,0 +1,133 @@ +import logging +import os +import re +import shutil +import signal +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any + +from django.core.management.base import BaseCommand +from django_q.conf import Conf +from django_q.humanhash import humanize +from django_q.status import Stat + +logger = logging.getLogger(__name__) + +_CLUSTER_NAME_RE = re.compile(r"Q Cluster (\S+) starting\.") + +_state: dict[str, Any] = {} +_shutdown = threading.Event() + + +class _HealthHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + cluster_name = _state.get("cluster_name") + + if not cluster_name: + self._respond(503, "cluster not yet started", None) + return + + # TODO: this is awkward. Because the output is "humanized" we can't do a simple query. + # TODO: is there maybe some way to un-humanize? + target = next( + (s for s in Stat.get_all() if humanize(s.cluster_id.hex) == cluster_name), + None, + ) + + if target is None: + self._respond(503, cluster_name, "not found or still starting") + elif target.status in (Conf.IDLE, Conf.WORKING): + self._respond(200, cluster_name, target.status) + else: + status = target.status + self._respond(500, cluster_name, status) + + def _respond(self, code: int, cluster_name: str, status: Any) -> None: + self.send_response(code) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + ( + "Django-Q Health Check" + f"

Health check returned {code} response

" + f"

Cluster {cluster_name} status: {status}

" + ).encode() + ) + + def log_message(self, format: str, *args: Any) -> None: + pass + + +def _start_health_server() -> HTTPServer: + port = int(os.environ.get("PORT", "8080")) + server = HTTPServer(("0.0.0.0", port), _HealthHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + logger.info("Health check server listening on port %d", port) + return server + + +def _handle_shutdown(signum: int, frame: Any) -> None: + logger.info("Received signal %d, shutting down", signum) + _shutdown.set() + + +class Command(BaseCommand): + help = "Run a Q cluster subprocess wrapped with an HTTP health check server." + + def handle(self, *args: Any, **options: Any) -> None: + _shutdown.clear() + _state.clear() + signal.signal(signal.SIGTERM, _handle_shutdown) + signal.signal(signal.SIGINT, _handle_shutdown) + + server = _start_health_server() + + django_admin = shutil.which("django-admin") + if django_admin is None or django_admin == "": + django_admin = "/app/.venv/bin/django-admin" + + proc = None + try: + proc = subprocess.Popen( + [django_admin, "qcluster", "--settings", "firetower.settings"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + def _pump_output() -> None: + assert proc.stdout is not None + for line in proc.stdout: + sys.stdout.write(line) + sys.stdout.flush() + if "cluster_name" not in _state: + match = _CLUSTER_NAME_RE.search(line) + if match: + _state["cluster_name"] = match.group(1) + logger.info("Detected cluster name: %s", match.group(1)) + + pump_thread = threading.Thread(target=_pump_output, daemon=True) + pump_thread.start() + + while not _shutdown.is_set(): + if proc.poll() is not None: + logger.warning( + "qcluster subprocess exited with code %s", proc.returncode + ) + break + _shutdown.wait(timeout=1) + finally: + server.shutdown() + server.server_close() + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() diff --git a/src/firetower/incidents/migrations/0016_schedule_demo.py b/src/firetower/incidents/migrations/0016_schedule_demo.py new file mode 100644 index 00000000..876b06e5 --- /dev/null +++ b/src/firetower/incidents/migrations/0016_schedule_demo.py @@ -0,0 +1,28 @@ +from django.db import migrations + +from firetower.incidents.tasks import SCHEDULES + + +def create_schedule(apps, schema_editor): + Schedule = apps.get_model("django_q", "Schedule") + schedule_name = "schedule_demo" + Schedule.objects.get_or_create( + name=schedule_name, defaults=SCHEDULES[schedule_name] + ) + + +def delete_schedule(apps, schema_editor): + Schedule = apps.get_model("django_q", "Schedule") + schedule_name = "schedule_demo" + Schedule.objects.filter(name=schedule_name).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("incidents", "0015_add_notion_troubleshooting_link_type"), + ("django_q", "0018_task_success_index"), + ] + + operations = [ + migrations.RunPython(create_schedule, delete_schedule), + ] diff --git a/src/firetower/incidents/tasks.py b/src/firetower/incidents/tasks.py new file mode 100644 index 00000000..49d79c3c --- /dev/null +++ b/src/firetower/incidents/tasks.py @@ -0,0 +1,60 @@ +import functools +import logging +import re +from typing import Protocol + +from datadog import statsd +from django_q.tasks import Schedule + +from firetower.incidents.models import Incident + +SCHEDULES = { + "schedule_demo": { + "func": "firetower.incidents.tasks.schedule_demo", + "schedule_type": Schedule.MINUTES, # Minutes + "minutes": 5, + "repeats": -1, # repeat indefinitely + }, +} + +DATADOG_INVALID_CHARS = re.compile(r"[^A-Za-z0-9-_.\/]") + + +logger = logging.getLogger(__name__) + + +class NamedFunction(Protocol): + __name__: str + + def __call__(self) -> None: + pass + + +def datadog_log(f: NamedFunction) -> NamedFunction: + task_name: str = DATADOG_INVALID_CHARS.sub("_", f.__name__) + tags = [f"task:{task_name}"] + + @functools.wraps(f) + def wrapper() -> None: + statsd.increment("django_q.task.run", 1, tags) + try: + f() + except Exception as e: + statsd.increment("django_q.task.error", 1, tags) + logger.error( + f"Error while executing task '{task_name}': {e}", exc_info=True + ) + raise e + else: + statsd.increment("django_q.task.success", 1, tags) + + return wrapper + + +@datadog_log +def schedule_demo() -> None: + incident = Incident.objects.order_by("-created_at").first() + if incident: + logger.info(f"Most recent incident: INC-{incident.id}: {incident.title}") + else: + logger.info("No incidents found.") diff --git a/src/firetower/incidents/tests/test_tasks.py b/src/firetower/incidents/tests/test_tasks.py new file mode 100644 index 00000000..5eb1d820 --- /dev/null +++ b/src/firetower/incidents/tests/test_tasks.py @@ -0,0 +1,99 @@ +from unittest.mock import call, patch + +from firetower.incidents.tasks import datadog_log + + +class TestDatadogLogTaskName: + @patch("firetower.incidents.tasks.statsd") + def test_replaces_invalid_chars_with_underscore(self, mock_statsd): + def f_with_bad_chars() -> None: + pass + + f_with_bad_chars.__name__ = "task with spaces & symbols!" + wrapped = datadog_log(f_with_bad_chars) + wrapped() + + expected_tags = ["task:task_with_spaces___symbols_"] + mock_statsd.increment.assert_any_call("django_q.task.run", 1, expected_tags) + + @patch("firetower.incidents.tasks.statsd") + def test_preserves_alphanumerics_dash_underscore_dot_slash(self, mock_statsd): + def f() -> None: + pass + + f.__name__ = "namespace/sub-task_v1.2" + wrapped = datadog_log(f) + wrapped() + + expected_tags = ["task:namespace/sub-task_v1.2"] + mock_statsd.increment.assert_any_call("django_q.task.run", 1, expected_tags) + + @patch("firetower.incidents.tasks.statsd") + def test_replaces_consecutive_invalid_chars_individually(self, mock_statsd): + def f() -> None: + pass + + f.__name__ = "a@@b" + wrapped = datadog_log(f) + wrapped() + + expected_tags = ["task:a__b"] + mock_statsd.increment.assert_any_call("django_q.task.run", 1, expected_tags) + + +class TestDatadogLogStatsdIncrements: + @patch("firetower.incidents.tasks.statsd") + def test_increments_run_and_success_on_normal_completion(self, mock_statsd): + def f() -> None: + pass + + f.__name__ = "ok_task" + wrapped = datadog_log(f) + wrapped() + + tags = ["task:ok_task"] + assert mock_statsd.increment.call_args_list == [ + call("django_q.task.run", 1, tags), + call("django_q.task.success", 1, tags), + ] + + @patch("firetower.incidents.tasks.statsd") + def test_increments_run_and_error_when_function_raises(self, mock_statsd): + def f() -> None: + raise ValueError("boom") + + f.__name__ = "broken_task" + wrapped = datadog_log(f) + wrapped() + + tags = ["task:broken_task"] + assert mock_statsd.increment.call_args_list == [ + call("django_q.task.run", 1, tags), + call("django_q.task.error", 1, tags), + ] + + @patch("firetower.incidents.tasks.statsd") + def test_does_not_increment_success_when_function_raises(self, mock_statsd): + def f() -> None: + raise RuntimeError("nope") + + f.__name__ = "failing_task" + wrapped = datadog_log(f) + wrapped() + + success_calls = [ + c + for c in mock_statsd.increment.call_args_list + if c.args[0] == "django_q.task.success" + ] + assert success_calls == [] + + @patch("firetower.incidents.tasks.statsd") + def test_swallows_exception_from_wrapped_function(self, mock_statsd): + def f() -> None: + raise Exception("should not propagate") + + f.__name__ = "raises" + wrapped = datadog_log(f) + + wrapped() diff --git a/src/firetower/settings.py b/src/firetower/settings.py index fcb94dac..246395f0 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -123,6 +123,7 @@ def _coerce_region_grouping(raw: list[Any]) -> list[list[str]]: "firetower.incidents", "firetower.integrations", "firetower.slack_app", + "django_q", ] MIDDLEWARE = [ @@ -377,3 +378,25 @@ class StatuspageSettings(TypedDict): }, }, } + +Q_CLUSTER = { + "name": "firetower", + "orm": "default", + "workers": 4, + "timeout": 180, + "retry": 210, + "queue_limit": 50, + "bulk": 10, + "cache": "qcache", +} + +CACHE_TABLE = "django_q_cache" +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + }, + "qcache": { + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": CACHE_TABLE, + }, +} diff --git a/uv.lock b/uv.lock index 720e94ca..42304dc1 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "ansicon" +version = "1.89.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/e2/1c866404ddbd280efedff4a9f15abfe943cb83cde6e895022370f3a61f85/ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1", size = 67312, upload-time = "2019-04-29T20:23:57.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/f9/f1c10e223c7b56a38109a3f2eb4e7fe9a757ea3ed3a166754fb30f65e466/ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec", size = 63675, upload-time = "2019-04-29T20:23:53.83Z" }, +] + [[package]] name = "anyio" version = "4.13.0" @@ -48,6 +57,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] +[[package]] +name = "blessed" +version = "1.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinxed", marker = "sys_platform == 'win32'" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/ca/47457ccbfeac62002079ebc47509e1eccd5c8ec764c78975c7afd81c6b4a/blessed-1.39.0.tar.gz", hash = "sha256:b04fc7141a20a3b2ade6cad741051f1e3ac59cc1e7e90915ed1f9e521332bea4", size = 14011417, upload-time = "2026-05-04T17:50:02.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/9f/e4d4ff45bc63d22fa63c9fc3835c480e3ec6b71009d6338cb603394ef540/blessed-1.39.0-py3-none-any.whl", hash = "sha256:666e7e3fd0a4e38c3a262eaaf1e22a4ce2c81337aa17593c3f60ea136ec24fe1", size = 124254, upload-time = "2026-05-04T17:49:59.976Z" }, +] + [[package]] name = "bytecode" version = "0.17.0" @@ -479,6 +501,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/d0/14e763857e44fc8d846f786527c777d6b85d649e5d990e29cf2b468a91d9/django_kubernetes-1.1.0-py3-none-any.whl", hash = "sha256:068cca992a6b3f8030774618ce23ee22cf68b565a35faa46bb6ccd97f034c029", size = 13731, upload-time = "2025-06-18T16:46:59.95Z" }, ] +[[package]] +name = "django-picklefield" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/03/13114bccbd1ec8c026ac1ff33dae75ae6c6a5632e4769ee9cda283b9f57e/django_picklefield-3.4.0.tar.gz", hash = "sha256:3a1f740536c0e60d0dba43aa89ccdbe86760d4c3f8ec47799eae122baa741d0a", size = 12555, upload-time = "2025-11-27T03:11:53.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/b7/139eb1419ca7b27fd714925b8d0eed6efb592479dcf2155fed6c0c87c956/django_picklefield-3.4.0-py3-none-any.whl", hash = "sha256:929bcfbae5b48bd22a52bc04521fdfdd152eee36abb9f20228f9480f9df65f45", size = 10031, upload-time = "2025-11-27T03:11:51.937Z" }, +] + +[[package]] +name = "django-q2" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-picklefield" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/e6/21375bed54a4be1339f6ee31e4173d361d457dbe91db7bff130b52566126/django_q2-1.9.0.tar.gz", hash = "sha256:ef7facca96fae9c11ddf2c5252d3817975c7a9a6d989fa0d65487d8823d57799", size = 77218, upload-time = "2025-12-04T22:11:29.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/b7/8282f9815fc9df3187d9303a6f54e0388e02742255dee1fed7b4019a03ae/django_q2-1.9.0-py3-none-any.whl", hash = "sha256:4eded27644b0ffb291839c9f9c12fea6c0dec63ebd891fa6881b0b446098a49d", size = 89615, upload-time = "2025-12-04T22:11:28.079Z" }, +] + [[package]] name = "django-stubs" version = "5.2.7" @@ -549,6 +596,7 @@ dependencies = [ { name = "django" }, { name = "django-cors-headers" }, { name = "django-kubernetes" }, + { name = "django-q2" }, { name = "djangorestframework" }, { name = "google-auth" }, { name = "google-genai" }, @@ -563,6 +611,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "blessed" }, { name = "django-stubs" }, { name = "django-stubs-ext" }, { name = "mypy" }, @@ -588,6 +637,7 @@ requires-dist = [ { name = "django", specifier = ">=5.2.14,<6" }, { name = "django-cors-headers", specifier = ">=4.9.0" }, { name = "django-kubernetes", specifier = ">=1.1.0" }, + { name = "django-q2", specifier = ">=1.7.4" }, { name = "djangorestframework", specifier = ">=3.15.2" }, { name = "google-auth", specifier = ">=2.37.0" }, { name = "google-genai", specifier = ">=1.0.0" }, @@ -602,6 +652,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "blessed", specifier = ">=1.39.0" }, { name = "django-stubs", specifier = ">=5.2.7" }, { name = "django-stubs-ext", specifier = ">=5.2.7" }, { name = "mypy", specifier = ">=1.15.0" }, @@ -806,6 +857,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jinxed" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ansicon", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/e9/96633f12b6829eb1e91e70e5846704c0b1293ec47bd65a7b681e19c8eeff/jinxed-1.4.0.tar.gz", hash = "sha256:8f7801a10799de39e509eb5abc6d131ee169c1ce4fd5d568aa85b5f56ed58068", size = 37169, upload-time = "2026-03-26T01:49:38.337Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b7/9ab2b79bcbcc53cf8772a19d26713dd9574d4d81ee4fea29678d8cadcec7/jinxed-1.4.0-py2.py3-none-any.whl", hash = "sha256:95876a8b270081b8e28a9bbcbabe4fa98327faa91102526f724ed1904f9a55ac", size = 34522, upload-time = "2026-03-26T01:49:36.762Z" }, +] + [[package]] name = "legacy-cgi" version = "2.6.4" @@ -1617,6 +1680,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + [[package]] name = "websockets" version = "16.0"