From eaffeb58f59e1bf5a95d0162c92c6e7ba164fc81 Mon Sep 17 00:00:00 2001 From: hyh Date: Mon, 31 Aug 2026 13:47:29 +0800 Subject: [PATCH] feat: requirements analysis, submission checks (stability stress test), AI inspection pipeline --- ojplatform/urls.py | 1 - pyproject.toml | 1 + requirements/admin.py | 31 ++- requirements/checker.py | 272 ++++++++++++++++++++++++ requirements/code_reader.py | 54 +++++ requirements/llm.py | 133 ++++++++++++ requirements/migrations/0001_initial.py | 46 ++++ requirements/models.py | 45 +++- requirements/serializers.py | 20 ++ requirements/tasks.py | 81 +++++++ requirements/tests.py | 119 ++++++++++- requirements/urls.py | 15 ++ requirements/views.py | 125 ++++++++++- smoke_test.py | 69 ++++++ submissions/urls.py | 6 + test_settings.py | 49 ++++- uv.lock | 159 ++++++++++++++ 17 files changed, 1218 insertions(+), 8 deletions(-) create mode 100644 requirements/checker.py create mode 100644 requirements/code_reader.py create mode 100644 requirements/llm.py create mode 100644 requirements/migrations/0001_initial.py create mode 100644 requirements/serializers.py create mode 100644 requirements/tasks.py create mode 100644 requirements/urls.py create mode 100644 smoke_test.py create mode 100644 submissions/urls.py diff --git a/ojplatform/urls.py b/ojplatform/urls.py index f4e6e6b..cb4e613 100644 --- a/ojplatform/urls.py +++ b/ojplatform/urls.py @@ -7,7 +7,6 @@ from django.urls import include, path urlpatterns = [ path('admin/', admin.site.urls), path('api/', include('requirements.urls')), - path('api/', include('submissions.urls')), path('api/auth/login/', include('rest_framework.urls')), ] diff --git a/pyproject.toml b/pyproject.toml index 63ee462..b8e9137 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "djangorestframework[authtoken]>=3.18", "django-cors-headers>=4.7", "python-dotenv>=1.2.3", + "requests>=2.32", ] [tool.uv] diff --git a/requirements/admin.py b/requirements/admin.py index 8c38f3f..ab5f9c8 100644 --- a/requirements/admin.py +++ b/requirements/admin.py @@ -1,3 +1,32 @@ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import User -# Register your models here. +from .models import Requirement, Submission + + +class SubmissionInline(admin.TabularInline): + model = Submission + extra = 0 + fields = ["name", "status", "submitted_by"] + readonly_fields = ["status"] + + +@admin.register(Requirement) +class RequirementAdmin(admin.ModelAdmin): + list_display = ["title", "created_at"] + search_fields = ["title", "content"] + inlines = [SubmissionInline] + + +@admin.register(Submission) +class SubmissionAdmin(admin.ModelAdmin): + list_display = ["name", "requirement", "status", "submitted_by", "created_at"] + list_filter = ["status"] + search_fields = ["name"] + readonly_fields = ["status", "report"] + + +admin.site.site_header = "OJplatform 管理" +admin.site.site_title = "OJplatform" +admin.site.index_title = "项目交付检测" diff --git a/requirements/checker.py b/requirements/checker.py new file mode 100644 index 0000000..39c54b9 --- /dev/null +++ b/requirements/checker.py @@ -0,0 +1,272 @@ +"""项目检测引擎: 解压项目包, 执行稳定度/压力测试, 汇总报告。""" +import logging +import os +import shutil +import tarfile +import threading +import time +import zipfile +from pathlib import Path + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +PROBE_PATHS = ["/", "/index.html", "/health", "/api", "/healthz", "/status"] + + +def _detect_kind(archive: Path) -> str: + with open(archive, "rb") as fh: + head = fh.read(512) + if head[:4] == b"PK\x03\x04": + return "zip" + if head[:2] == b"\x1f\x8b": + return "tar.gz" + if b"ustar" in head[:262]: + return "tar" + return archive.suffix.lstrip(".") or "unknown" + + +def _extract(archive: Path, dest: Path) -> Path: + kind = _detect_kind(archive) + if kind == "zip": + with zipfile.ZipFile(archive) as zf: + for member in zf.namelist(): + target = (dest / member).resolve() + if not str(target).startswith(str(dest.resolve())): + raise ValueError("压缩包含非法路径") + zf.extractall(dest) + elif kind in ("tar", "tgz", "tar.gz"): + with tarfile.open(archive) as tf: + for member in tf.getmembers(): + target = (dest / member.name).resolve() + if not str(target).startswith(str(dest.resolve())): + raise ValueError("压缩包含非法路径") + tf.extractall(dest, filter="data") + else: + raise ValueError(f"不支持的归档格式: {archive.suffix}") + if len(list(dest.iterdir())) == 1: + only = next(dest.iterdir()) + if only.is_dir(): + return only + return dest + + +_MARKERS = { + "requirements.txt": "python", "pyproject.toml": "python", "manage.py": "python", + "setup.py": "python", "main.py": "python", "app.py": "python", + "package.json": "nodejs", "pom.xml": "java", "build.gradle": "java", + "go.mod": "go", "Cargo.toml": "rust", "tsconfig.json": "typescript", +} + + +def _project_language(root: Path) -> str: + for name in ("requirements.txt", "pyproject.toml", "manage.py", + "package.json", "pom.xml", "go.mod", "Cargo.toml"): + if (root / name).exists(): + return _MARKERS[name] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for name in filenames: + if name in _MARKERS: + return _MARKERS[name] + return "unknown" + + +def _probe_server(base: str) -> bool: + for path in PROBE_PATHS: + try: + resp = requests.get(base + path, timeout=5, verify=False) + if resp.status_code < 500: + return True + except requests.RequestException: + continue + return False + + +def _stress_one(base: str, duration: float, results: list, lock: threading.Lock): + ok = err = 0 + errors: list[str] = [] + start = time.monotonic() + while time.monotonic() - start < duration: + try: + resp = requests.get(base, timeout=5, verify=False) + if resp.status_code < 500: + ok += 1 + else: + err += 1 + if len(errors) < 5: + errors.append(f"HTTP {resp.status_code}") + except requests.RequestException as exc: + err += 1 + if len(errors) < 5: + errors.append(type(exc).__name__) + with lock: + results.append({"ok": ok, "err": err, "errors": errors}) + + +def _find_entry(root: Path, names: list[str]): + if (root / names[0]).exists(): + return names[0] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for name in filenames: + if name in names: + return os.path.relpath(Path(dirpath) / name, root) + return None + + +def run_stability_check(root: Path, requirements_text: str) -> dict: + """启动项目服务 → 压测基准/加压两阶段 → 返回稳定度指标""" + lang = _project_language(root) + env = {**os.environ, "PORT": "8321", "FLASK_RUN_PORT": "8321"} + interpreter = None + venv_python = root / ".venv" / "bin" / "python" + if venv_python.exists(): + interpreter = str(venv_python) + elif shutil.which("python"): + interpreter = shutil.which("python") + start_cmd = None + if lang == "python" and interpreter: + entry = _find_entry(root, ["manage.py", "app.py", "main.py"]) + if entry and entry == "manage.py": + start_cmd = [interpreter, "manage.py", "runserver", "0.0.0.0:8321"] + elif entry: + start_cmd = [interpreter, entry] + elif lang == "nodejs": + entry = _find_entry(root, ["server.js", "index.js", "app.js"]) + if entry and shutil.which("node"): + start_cmd = ["node", entry] + + if not start_cmd: + return { + "pass": None, + "reason": f"无法自动启动项目(语言: {lang}), 未执行压力测试", + "base_error_rate": None, "rampup_crash_rate": None, "total_requests": 0, + "details": {}, + } + + import subprocess + log_path = str(root / ".server.log") + try: + logfh = open(log_path, "ab") + server = subprocess.Popen( + start_cmd, cwd=str(root), + stdout=logfh, stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + except OSError as exc: + return {"pass": None, "reason": f"启动失败: {exc}", "base_error_rate": None, + "rampup_crash_rate": None, "total_requests": 0, "details": {}} + + base = "http://127.0.0.1:8321" + ready = False + for _ in range(30): + if _probe_server(base): + ready = True + break + time.sleep(1) + if not ready: + server.terminate() + return { + "pass": None, "reason": "服务 30s 内未就绪, 未执行压力测试", + "base_error_rate": None, "rampup_crash_rate": None, "total_requests": 0, "details": {}, + } + + try: + lock = threading.Lock() + n = settings.STABILITY_CONCURRENCY + base_res: list = [] + threads = [threading.Thread(target=_stress_one, args=(base, settings.STABILITY_BASE_DURATION, base_res, lock)) for _ in range(n)] + for t in threads: + t.start() + for t in threads: + t.join() + + ramp_res: list = [] + threads = [threading.Thread(target=_stress_one, args=(base, settings.STABILITY_RAMPUP_DURATION, ramp_res, lock)) for _ in range(n + n // 2)] + for t in threads: + t.start() + for t in threads: + t.join() + + base_ok = sum(r["ok"] for r in base_res) + base_err = sum(r["err"] for r in base_res) + ramp_ok = sum(r["ok"] for r in ramp_res) + ramp_err = sum(r["err"] for r in ramp_res) + base_total, ramp_total = base_ok + base_err, ramp_ok + ramp_err + base_error_rate = round(base_err / base_total, 4) if base_total else None + rampup_crash_rate = round(ramp_err / ramp_total, 4) if ramp_total else None + + pass_flag = base_error_rate is not None and base_error_rate <= 0.01 + if rampup_crash_rate is not None: + pass_flag = pass_flag and rampup_crash_rate <= 0.005 + + error_samples = [] + for r in base_res + ramp_res: + error_samples.extend(r["errors"]) + + return { + "pass": pass_flag, + "requirements": requirements_text, + "base_error_rate": base_error_rate, + "rampup_crash_rate": rampup_crash_rate, + "total_requests": base_total + ramp_total, + "details": { + "base_total": base_total, "base_err": base_err, + "ramp_total": ramp_total, "ramp_err": ramp_err, + "error_samples": error_samples[:10], + }, + } + finally: + import signal + try: + os.killpg(os.getpgid(server.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError): + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(server.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + server.kill() + + +def run_checks(archive_path: Path, spec: dict) -> tuple[dict, Path]: + """对提交的项目包执行全部启用的检测, 返回 (report, project_root)""" + workdir = Path(settings.WORKSPACE_DIR) / f"{archive_path.stem}-{threading.get_ident()}" + workdir.mkdir(parents=True, exist_ok=True) + report = { + "language": None, + "stability": {"enabled": False}, + "performance": {"enabled": False}, + "security": {"enabled": False}, + "functional": {"enabled": False, "points": []}, + } + project_root = _extract(archive_path, workdir) + report["language"] = _project_language(project_root) + + if spec.get("stability", {}).get("enabled"): + report["stability"] = { + "enabled": True, + "requirements": spec["stability"].get("requirements", ""), + **run_stability_check(project_root, spec["stability"].get("requirements", "")), + } + if spec.get("performance", {}).get("enabled"): + report["performance"] = { + "enabled": True, + "requirements": spec["performance"].get("requirements", ""), + "note": "性能检测依赖稳定度压测数据(P95 等), 当前版本在稳定度结果中体现", + } + if spec.get("security", {}).get("enabled"): + report["security"] = { + "enabled": True, + "requirements": spec["security"].get("requirements", ""), + "note": "安全检测当前未接入扫描器, 交由 AI 检测项覆盖", + } + func = spec.get("functional", {}) + report["functional"] = {"enabled": bool(func.get("enabled")), "points": func.get("points", [])} + return report, project_root diff --git a/requirements/code_reader.py b/requirements/code_reader.py new file mode 100644 index 0000000..dbf18af --- /dev/null +++ b/requirements/code_reader.py @@ -0,0 +1,54 @@ +"""读取项目关键文件用于 AI 审查""" +import os +from pathlib import Path + +SKIP_DIRS = { + "node_modules", ".git", "venv", ".venv", "__pycache__", "dist", "build", + "target", ".idea", ".vscode", "vendor", "env", +} +SKIP_SUFFIXES = { + ".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".zip", ".tar", ".gz", + ".whl", ".jar", ".class", ".pyc", ".so", ".dll", ".exe", ".bin", ".woff", + ".woff2", ".ttf", ".eot", ".mp4", ".mp3", +} +MAX_FILE_CHARS = 8000 + + +def iter_source_files(root: str): + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for name in sorted(filenames): + path = Path(dirpath) / name + if path.suffix.lower() in SKIP_SUFFIXES: + continue + try: + if path.stat().st_size > 512 * 1024: + continue + if not path.is_file(): + continue + except OSError: + continue + yield path + + +def read_key_files(root: str, max_chars: int = 12000) -> str: + parts = [] + total = 0 + for path in iter_source_files(root): + try: + text = path.read_text(encoding="utf-8", errors="replace") + except (OSError, UnicodeDecodeError): + continue + if len(text) > MAX_FILE_CHARS: + text = text[:MAX_FILE_CHARS] + "\n... (截断)" + rel = os.path.relpath(path, root) + block = f"### 文件: {rel}\n```\n{text}\n```" + if total + len(block) > max_chars: + break + parts.append(block) + total += len(block) + if len(parts) >= 25: + break + if not parts: + return "(未找到可读源码文件)" + return "\n\n".join(parts) diff --git a/requirements/llm.py b/requirements/llm.py new file mode 100644 index 0000000..89565c8 --- /dev/null +++ b/requirements/llm.py @@ -0,0 +1,133 @@ +import json +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +ANALYZE_PROMPT = """\ +你是 OJ 检测平台的分析引擎。请根据下面的作业要求,分析出需要对该成员提交的项目做哪些检测。 + +必须始终包含 ai_inspection(AI 检测)。根据作业要求酌情包含以下维度: +- stability: 稳定度检测(压测)。requirements 用中文给出具体指标,例如: + 基准并发 {stability_concurrency} 下错误率 ≤ 1%, 基准持续 {base_duration}s 后加压 50% 持续 {rampup_duration}s 崩溃率 ≤ 0.5% +- functional: 功能点完善度检测。points 为需要核实的中文功能点列表(必须覆盖作业要求中的全部功能点) +- performance: 性能检测。requirements 为中文指标,例如: 基准并发下 P95 延迟 ≤ 500ms +- security: 安全检测(漏洞扫描),requirements 为中文说明 + +输出格式: 只输出一个 JSON 对象,不要输出任何其他文字: +{{"ai_inspection": true, "stability": {{"enabled": true, "requirements": "中文要求"}}, "functional": {{"enabled": true, "points": ["功能点1", "功能点2"]}}, "performance": {{"enabled": false, "requirements": ""}}, "security": {{"enabled": false, "requirements": ""}}}} + +作业要求如下: +{content} +""" + + +def _validate_spec(spec: dict) -> dict: + if not isinstance(spec, dict): + raise ValueError("LLM 未返回 JSON 对象") + result: dict = {"ai_inspection": bool(spec.get("ai_inspection", True))} + for key in ("stability", "performance", "security"): + node = spec.get(key) + if isinstance(node, dict): + result[key] = { + "enabled": bool(node.get("enabled")), + "requirements": str(node.get("requirements", "")), + } + else: + result[key] = {"enabled": False, "requirements": ""} + func = spec.get("functional") + if isinstance(func, dict): + points = func.get("points") + result["functional"] = { + "enabled": bool(func.get("enabled")), + "points": [str(p) for p in points] if isinstance(points, list) else [], + } + else: + result["functional"] = {"enabled": True, "points": []} + return result + + +def _extract_json(text: str) -> dict: + start, end = text.find("{"), text.rfind("}") + if start == -1 or end <= start: + raise ValueError("LLM 响应中未找到 JSON") + return json.loads(text[start:end + 1]) + + +def analyze_requirements(content: str) -> dict: + """调用 LLM 分析作业要求,返回检测维度计划。未配置 LLM 时返回默认计划。""" + key = settings.LLM_API_KEY + if not key or key.startswith("sk-placeholder") or not settings.LLM_MODEL: + logger.warning("LLM 未配置,使用默认检测计划") + return _default_spec() + url = settings.LLM_BASE_URL.rstrip("/") + "/chat/completions" + prompt = ANALYZE_PROMPT.format( + content=content[:8000], + stability_concurrency=settings.STABILITY_CONCURRENCY, + base_duration=settings.STABILITY_BASE_DURATION, + rampup_duration=settings.STABILITY_RAMPUP_DURATION, + ) + resp = requests.post( + url, + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, + json={ + "model": settings.LLM_MODEL, + "messages": [ + {"role": "system", "content": "你是严格遵循 JSON 输出格式的 OJ 检测分析引擎。"}, + {"role": "user", "content": prompt}, + ], + "temperature": 0.1, + }, + timeout=120, + ) + resp.raise_for_status() + text = resp.json()["choices"][0]["message"]["content"] + spec = _extract_json(text) + return _validate_spec(spec) + + +def _default_spec() -> dict: + return { + "ai_inspection": True, + "stability": {"enabled": True, "requirements": "默认稳定度要求: 基准并发下错误率≤1%, 加压后崩溃率≤0.5%"}, + "functional": {"enabled": True, "points": []}, + "performance": {"enabled": True, "requirements": "默认性能要求: 基准并发下 P95 延迟 ≤ 500ms"}, + "security": {"enabled": False, "requirements": ""}, + } + + +def ai_summarize(project_dir: str, spec: dict) -> dict: + """AI 检测: 阅读项目关键文件,对照检测计划输出中文报告片段。""" + key = settings.LLM_API_KEY + if not key or key.startswith("sk-placeholder") or not settings.LLM_MODEL: + return {"summary": "LLM 未配置,跳过 AI 检测。请在 .env 中配置 LLM_API_KEY。", "issues": []} + from .code_reader import read_key_files + excerpt = read_key_files(project_dir, max_chars=12000) + prompt = ( + "你是 OJ 平台的代码审查专家。下面是成员提交的项目关键代码摘录和检测计划(JSON):\n" + f"{json.dumps(spec, ensure_ascii=False)}\n\n" + f"代码摘录:\n{excerpt}\n\n" + "请输出一个 JSON 对象: {\"summary\": \"中文总体评价(200字内)\", " + "\"issues\": [{\"point\": \"检查点\", \"status\": \"pass|fail|warn\", \"detail\": \"中文说明\"}]}\n" + "对照检测计划逐项核查,重点关注: 功能点是否完善、稳定度是否可能达标、潜在风险。只输出 JSON。" + ) + url = settings.LLM_BASE_URL.rstrip("/") + "/chat/completions" + resp = requests.post( + url, + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, + json={ + "model": settings.LLM_MODEL, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.2, + }, + timeout=180, + ) + resp.raise_for_status() + text = resp.json()["choices"][0]["message"]["content"] + data = _extract_json(text) + return { + "summary": str(data.get("summary", "")), + "issues": data.get("issues", []) if isinstance(data.get("issues"), list) else [], + } diff --git a/requirements/migrations/0001_initial.py b/requirements/migrations/0001_initial.py new file mode 100644 index 0000000..71f5def --- /dev/null +++ b/requirements/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 6.1 on 2026-08-31 04:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Requirement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='标题')), + ('content', models.TextField(verbose_name='作业要求内容')), + ('spec', models.JSONField(blank=True, default=dict, verbose_name='检测维度计划')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Submission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='提交名称')), + ('file', models.FileField(upload_to='submissions/%Y%m/', verbose_name='项目包')), + ('status', models.CharField(choices=[('pending', '待检测'), ('running', '检测中'), ('completed', '已完成'), ('failed', '检测失败')], default='pending', max_length=20, verbose_name='状态')), + ('report', models.JSONField(blank=True, default=dict, verbose_name='检测报告')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='requirements.requirement')), + ('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='提交人')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/requirements/models.py b/requirements/models.py index 71a8362..d8e7461 100644 --- a/requirements/models.py +++ b/requirements/models.py @@ -1,3 +1,46 @@ from django.db import models -# Create your models here. + +class Requirement(models.Model): + """作业要求:内容 + AI 分析出的检测维度计划""" + + title = models.CharField("标题", max_length=200) + content = models.TextField("作业要求内容") + spec = models.JSONField("检测维度计划", default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return self.title + + +class Submission(models.Model): + """作业提交:项目代码包 + 检测报告""" + + STATUS_CHOICES = [ + ("pending", "待检测"), + ("running", "检测中"), + ("completed", "已完成"), + ("failed", "检测失败"), + ] + + requirement = models.ForeignKey( + Requirement, on_delete=models.CASCADE, related_name="submissions" + ) + name = models.CharField("提交名称", max_length=200) + file = models.FileField("项目包", upload_to="submissions/%Y%m/") + status = models.CharField("状态", max_length=20, choices=STATUS_CHOICES, default="pending") + report = models.JSONField("检测报告", default=dict, blank=True) + submitted_by = models.ForeignKey( + "auth.User", on_delete=models.SET_NULL, null=True, blank=True, + verbose_name="提交人", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.name} ({self.status})" diff --git a/requirements/serializers.py b/requirements/serializers.py new file mode 100644 index 0000000..183c0b4 --- /dev/null +++ b/requirements/serializers.py @@ -0,0 +1,20 @@ +from rest_framework import serializers + +from .models import Requirement, Submission + + +class RequirementSerializer(serializers.ModelSerializer): + submission_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Requirement + fields = ["id", "title", "content", "spec", "created_at", "submission_count"] + read_only_fields = ["id", "spec", "created_at"] + + +class SubmissionSerializer(serializers.ModelSerializer): + class Meta: + model = Submission + fields = ["id", "requirement", "name", "file", "status", "report", + "submitted_by", "created_at"] + read_only_fields = ["id", "status", "report", "submitted_by", "created_at"] diff --git a/requirements/tasks.py b/requirements/tasks.py new file mode 100644 index 0000000..7d2a4c7 --- /dev/null +++ b/requirements/tasks.py @@ -0,0 +1,81 @@ +"""后台检测任务""" +import logging + +from django.conf import settings +from django.db import transaction + +from .checker import run_checks +from .llm import ai_summarize +from .models import Submission + +logger = logging.getLogger(__name__) + + +def run_submission(submission_id: int): + with transaction.atomic(): + submission = Submission.objects.select_for_update().get(pk=submission_id) + submission.status = "running" + submission.save(update_fields=["status"]) + file_path = submission.file.path + + from pathlib import Path + + archive = Path(file_path) + root = archive.parent + try: + report, root = run_checks(archive, submission.requirement.spec) + try: + ai = ai_summarize(str(root), submission.spec) + except Exception: + logger.exception("AI 检测失败") + ai = {"summary": "AI 检测执行失败", "issues": []} + report["ai_inspection"] = ai + report["overall"] = _overall(report) + except Exception as exc: + logger.exception("检测任务失败") + report = {"error": str(exc), "ai_inspection": None} + finally: + import shutil + shutil.rmtree(root, ignore_errors=True) + if root != archive.parent: + shutil.rmtree(archive.parent, ignore_errors=True) + + with transaction.atomic(): + submission = Submission.objects.get(pk=submission_id) + submission.status = "failed" if report.get("error") else "completed" + submission.report = report + submission.save(update_fields=["status", "report"]) + + +def _overall(report: dict) -> dict: + checks = [] + stab = report.get("stability", {}) + if stab.get("enabled"): + checks.append({ + "name": "稳定度", + "pass": stab.get("pass"), + "note": stab.get("reason") or ( + f"基准错误率 {stab.get('base_error_rate')}, 加压崩溃率 {stab.get('rampup_crash_rate')}" + ), + }) + perf = report.get("performance", {}) + if perf.get("enabled"): + checks.append({"name": "性能", "pass": None, "note": perf.get("note", "依赖稳定度数据")}) + sec = report.get("security", {}) + if sec.get("enabled"): + checks.append({"name": "安全", "pass": None, "note": sec.get("note", "由 AI 检测覆盖")}) + ai = report.get("ai_inspection") or {} + if ai: + fail_points = [i for i in ai.get("issues", []) if i.get("status") == "fail"] + checks.append({ + "name": "AI 检测", + "pass": not fail_points if ai.get("issues") is not None else None, + "note": ai.get("summary", ""), + }) + passed = sum(1 for c in checks if c.get("pass") is True) + decided = sum(1 for c in checks if c.get("pass") is not None) + return { + "passed_checks": passed, + "decided_checks": decided, + "checks": checks, + } diff --git a/requirements/tests.py b/requirements/tests.py index 7ce503c..572ad83 100644 --- a/requirements/tests.py +++ b/requirements/tests.py @@ -1,3 +1,118 @@ -from django.test import TestCase +"""端到端测试: 创建作业要求 → 上传项目包 → 检测 → 报告""" +import io +import tarfile +import textwrap +import unittest +from unittest import mock -# Create your tests here. +from django.contrib.auth.models import User +from rest_framework.test import APITestCase + +from requirements.models import Requirement, Submission + +FLASK_PROJECT = textwrap.dedent("""\ + from flask import Flask + app = Flask(__name__) + + @app.route("/") + def index(): + return "ok" + + if __name__ == "__main__": + app.run(host="0.0.0.0", port=8321) + """) + + +def make_tar_bytes(name: str, files: dict) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + for fname, content in files.items(): + data = content.encode() + info = tarfile.TarInfo(name=f"{name}/{fname}") + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +class EndToEndTests(APITestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user("tester", password="pass12345") + + def setUp(self): + self._login() + + def _login(self): + from rest_framework.authtoken.models import Token + token, _ = Token.objects.get_or_create(user=self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}") + + def test_create_requirement_analyzes_spec(self): + self._login() + with mock.patch("requirements.llm.requests.post") as post: + post.return_value = mock.Mock( + json=lambda: {"choices": [{"message": {"content": ( + '{"ai_inspection": true, ' + '"stability": {"enabled": true, "requirements": "基准并发20错误率≤1%"}, ' + '"functional": {"enabled": true, "points": ["首页可访问", "返回 ok"]}, ' + '"performance": {"enabled": false, "requirements": ""}, ' + '"security": {"enabled": false, "requirements": ""}}' + )}}]}, + raise_for_status=lambda: None, + ) + resp = self.client.post("/api/requirements/create/", { + "title": "作业1-分析", + "content": "实现一个 web 服务, 首页返回 ok, 要求稳定度达到 99%", + }, format="json") + self.assertEqual(resp.status_code, 201, resp.content) + requirement = Requirement.objects.get(title="作业1-分析") + self.assertTrue(requirement.spec["stability"]["enabled"]) + self.assertIn("首页可访问", requirement.spec["functional"]["points"]) + + def test_submission_full_pipeline(self): + self._login() + requirement = Requirement.objects.create( + title="作业1", + content="实现 web 服务", + spec={ + "ai_inspection": True, + "stability": {"enabled": True, "requirements": "基准错误率≤1%"}, + "functional": {"enabled": True, "points": ["首页可访问"]}, + "performance": {"enabled": False, "requirements": ""}, + "security": {"enabled": False, "requirements": ""}, + }, + ) + tar_bytes = make_tar_bytes("proj", {"app.py": FLASK_PROJECT}) + with mock.patch("requirements.llm.ai_summarize", return_value={ + "summary": "代码结构清晰, 功能点完备", + "issues": [{"point": "首页可访问", "status": "pass", "detail": "/ 路由存在"}], + }), mock.patch.dict("django.conf.settings.__dict__", { + "STABILITY_BASE_DURATION": 2, "STABILITY_RAMPUP_DURATION": 2, "STABILITY_CONCURRENCY": 4, + }): + resp = self.client.post( + f"/api/requirements/{requirement.pk}/submissions/", + {"name": "tester-sub", "file": io.BytesIO(tar_bytes)}, + format="multipart", + ) + self.assertEqual(resp.status_code, 201, resp.content) + submission = Submission.objects.get() + if submission.status != "completed": + submission.report["error"] = submission.report.get("error", "?") + raise AssertionError(f"status={submission.status} report={submission.report}") + self.assertIn("ai_inspection", submission.report) + self.assertIn("stability", submission.report) + report = self.client.get(f"/api/submissions/{submission.pk}/report/") + self.assertEqual(report.status_code, 200) + self.assertIn("stability", report.json()) + + +class AuthTests(APITestCase): + def test_requires_login(self): + resp = self.client.get("/api/requirements/") + self.assertIn(resp.status_code, (401, 403)) + + def test_token_login(self): + User.objects.create_user("alice", password="pass12345") + resp = self.client.post("/api/auth/token/", {"username": "alice", "password": "pass12345"}) + self.assertEqual(resp.status_code, 200) + self.assertIn("token", resp.json()) diff --git a/requirements/urls.py b/requirements/urls.py new file mode 100644 index 0000000..c060375 --- /dev/null +++ b/requirements/urls.py @@ -0,0 +1,15 @@ +from django.urls import path +from rest_framework.authtoken.views import obtain_auth_token + +from . import views + +urlpatterns = [ + path("requirements/", views.list_requirements, name="requirement-list"), + path("requirements/create/", views.create_requirement, name="requirement-create"), + path("requirements//", views.get_requirement, name="requirement-detail"), + path("requirements//submissions/", views.create_submission, name="submission-create"), + path("submissions/", views.list_submissions, name="submission-list"), + path("submissions//", views.get_submission, name="submission-detail"), + path("submissions//report/", views.get_report, name="submission-report"), + path("auth/token/", obtain_auth_token, name="api-token-auth"), +] diff --git a/requirements/views.py b/requirements/views.py index 91ea44a..3a0798f 100644 --- a/requirements/views.py +++ b/requirements/views.py @@ -1,3 +1,124 @@ -from django.shortcuts import render +import logging -# Create your views here. +from django.db import transaction +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .llm import analyze_requirements +from .models import Requirement, Submission + +logger = logging.getLogger(__name__) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@transaction.atomic +def create_requirement(request): + title = (request.data.get("title") or "").strip() + content = (request.data.get("content") or "").strip() + if not title or not content: + return Response({"detail": "title 和 content 不能为空"}, status=status.HTTP_400_BAD_REQUEST) + try: + spec = analyze_requirements(content) + except Exception: + logger.exception("AI 分析作业要求失败, 使用默认检测计划") + from .llm import _default_spec + spec = _default_spec() + requirement = Requirement.objects.create(title=title, content=content, spec=spec) + from .serializers import RequirementSerializer + return Response(RequirementSerializer(requirement).data, status=status.HTTP_201_CREATED) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def list_requirements(request): + qs = Requirement.objects.all() + q = request.query_params.get("q") + if q: + qs = qs.filter(title__icontains=q) | qs.filter(content__icontains=q) + from .serializers import RequirementSerializer + page = int(request.query_params.get("page", 1)) + size = int(request.query_params.get("size", 20)) + items = qs[(page - 1) * size: page * size] + return Response({ + "count": qs.count(), + "page": page, + "items": RequirementSerializer(items, many=True).data, + }) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_requirement(request, pk): + try: + requirement = Requirement.objects.get(pk=pk) + except Requirement.DoesNotExist: + return Response({"detail": "不存在"}, status=status.HTTP_404_NOT_FOUND) + from .serializers import RequirementSerializer + return Response(RequirementSerializer(requirement).data) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@transaction.atomic +def create_submission(request, pk): + try: + requirement = Requirement.objects.get(pk=pk) + except Requirement.DoesNotExist: + return Response({"detail": "作业要求不存在"}, status=status.HTTP_404_NOT_FOUND) + name = (request.data.get("name") or "").strip() + file = request.FILES.get("file") + if not file: + return Response({"detail": "缺少上传文件 file"}, status=status.HTTP_400_BAD_REQUEST) + submission = Submission.objects.create( + requirement=requirement, + name=name or file.name, + file=file, + submitted_by=request.user, + ) + from .tasks import run_submission + try: + run_submission(submission.id) + except Exception: + logger.exception("检测任务启动失败") + submission.status = "failed" + submission.save(update_fields=["status"]) + from .serializers import SubmissionSerializer + return Response(SubmissionSerializer(submission).data, status=status.HTTP_201_CREATED) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def list_submissions(request): + qs = Submission.objects.all() + req_id = request.query_params.get("requirement") + if req_id: + qs = qs.filter(requirement_id=req_id) + from .serializers import SubmissionSerializer + return Response({"items": SubmissionSerializer(qs, many=True).data}) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_submission(request, pk): + try: + submission = Submission.objects.get(pk=pk) + except Submission.DoesNotExist: + return Response({"detail": "不存在"}, status=status.HTTP_404_NOT_FOUND) + from .serializers import SubmissionSerializer + return Response(SubmissionSerializer(submission).data) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_report(request, pk): + try: + submission = Submission.objects.get(pk=pk) + except Submission.DoesNotExist: + return Response({"detail": "不存在"}, status=status.HTTP_404_NOT_FOUND) + if not submission.report: + return Response({"detail": "报告尚未生成", "status": submission.status}, + status=status.HTTP_202_ACCEPTED) + return Response(submission.report) diff --git a/smoke_test.py b/smoke_test.py new file mode 100644 index 0000000..9c870bc --- /dev/null +++ b/smoke_test.py @@ -0,0 +1,69 @@ +"""真实 API 冒烟测试: 起服务 → 注册 → 建要求 → 提交 → 轮询报告""" +import os +import subprocess +import sys +import time + +import requests + +BASE = "http://127.0.0.1:8765" + + +def main(): + import flask + if not flask: + pass + server = subprocess.Popen( + [sys.executable, "manage.py", "runserver", "127.0.0.1:8765", "--noreload"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + cwd=os.path.dirname(os.path.abspath(__file__)), + ) + try: + for _ in range(30): + try: + requests.get(BASE + "/api/requirements/", timeout=1) + break + except requests.RequestException: + time.sleep(1) + s = requests.Session() + r = s.post(BASE + "/api/auth/token/", data={"username": "smoke_user", "password": ""}) + if r.status_code != 200: + from django.contrib.auth.models import User + import django + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ojplatform.settings") + django.setup() + User.objects.create_user("smoke_user", password="pass12345") + r = s.post(BASE + "/api/auth/token/", data={"username": "smoke_user", "password": "pass12345"}) + assert r.status_code == 200, r.text + token = r.json()["token"] + s.headers["Authorization"] = f"Token {token}" + + r = s.post(BASE + "/api/requirements/create/", json={ + "title": "冒烟作业", + "content": "实现 web 服务, 首页返回 ok", + }) + assert r.status_code == 201, r.text + req_id = r.json()["id"] + print("requirement:", req_id, r.json()["spec"].get("stability", {}).get("enabled")) + + with open("/tmp/opencode/smoke/proj.tar", "rb") as fh: + r = s.post(f"{BASE}/api/requirements/{req_id}/submissions/", + data={"name": "smoke"}, files={"file": ("proj.tar", fh, "application/x-tar")}) + assert r.status_code == 201, r.text + sub_id = r.json()["id"] + + deadline = time.time() + 300 + while time.time() < deadline: + r = s.get(f"{BASE}/api/submissions/{sub_id}/report/") + if r.status_code == 200: + print("REPORT OK:", str(r.json())[:400]) + return 0 + time.sleep(3) + print("TIMEOUT") + return 1 + finally: + server.terminate() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/submissions/urls.py b/submissions/urls.py new file mode 100644 index 0000000..69c20d6 --- /dev/null +++ b/submissions/urls.py @@ -0,0 +1,6 @@ +"""OJplatform API 汇总 URL""" +from django.urls import include + +urlpatterns = [ + include("requirements.urls"), +] diff --git a/test_settings.py b/test_settings.py index e3fe963..6030b8c 100644 --- a/test_settings.py +++ b/test_settings.py @@ -8,12 +8,59 @@ load_dotenv(BASE_DIR / ".env") SECRET_KEY = os.getenv("SECRET_KEY", "django-insecure-dev-only-change-me") DEBUG = os.getenv("DEBUG", "true").lower() in ("1", "true", "yes") ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "*").split(",") -INSTALLED_APPS = ["django.contrib.contenttypes", "django.contrib.auth"] +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", +] +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.contenttypes", + "django.contrib.auth", + "django.contrib.sessions", + "django.contrib.messages", + "rest_framework", + "rest_framework.authtoken", + "requirements", + "submissions", +] DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", } } +LLM_BASE_URL = "" +LLM_API_KEY = "sk-test" +LLM_MODEL = "test-model" +MAX_UPLOAD_SIZE_MB = 200 +STABILITY_BASE_DURATION = 30 +STABILITY_RAMPUP_DURATION = 60 +STABILITY_CONCURRENCY = 20 +WORKSPACE_DIR = Path(__file__).parent / "workspace" +ROOT_URLCONF = "ojplatform.urls" +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.TokenAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], +} DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" USE_TZ = True diff --git a/uv.lock b/uv.lock index af55208..33fcc7a 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,130 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + [[package]] name = "django" version = "6.1" @@ -50,6 +174,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/d7/7461738e819b853275b35fd35712a85806c09a327e1fd5f8ff766bdd317e/djangorestframework-3.18.0-py3-none-any.whl", hash = "sha256:381fc44d3249c9565c5f723850855b734e99030eb30957a49f506d3fe11d7dcb", size = 900032, upload-time = "2026-08-07T14:53:31.881Z" }, ] +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + [[package]] name = "ojplatform" version = "0.1.0" @@ -59,6 +192,7 @@ dependencies = [ { name = "django-cors-headers" }, { name = "djangorestframework" }, { name = "python-dotenv" }, + { name = "requests" }, ] [package.metadata] @@ -67,6 +201,7 @@ requires-dist = [ { name = "django-cors-headers", specifier = ">=4.7" }, { name = "djangorestframework", extras = ["authtoken"], specifier = ">=3.18" }, { name = "python-dotenv", specifier = ">=1.2.3" }, + { name = "requests", specifier = ">=2.32" }, ] [[package]] @@ -78,6 +213,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "sqlparse" version = "0.6.0" @@ -95,3 +245,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42 wheels = [ { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]