feat: requirements analysis, submission checks (stability stress test), AI inspection pipeline

This commit is contained in:
hyh
2026-08-31 13:47:29 +08:00
parent a9bbe2f9b4
commit eaffeb58f5
17 changed files with 1218 additions and 8 deletions
-1
View File
@@ -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')),
]
+1
View File
@@ -12,6 +12,7 @@ dependencies = [
"djangorestframework[authtoken]>=3.18",
"django-cors-headers>=4.7",
"python-dotenv>=1.2.3",
"requests>=2.32",
]
[tool.uv]
+30 -1
View File
@@ -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 = "项目交付检测"
+272
View File
@@ -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
+54
View File
@@ -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)
+133
View File
@@ -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 [],
}
+46
View File
@@ -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'],
},
),
]
+44 -1
View File
@@ -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})"
+20
View File
@@ -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"]
+81
View File
@@ -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,
}
+117 -2
View File
@@ -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())
+15
View File
@@ -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/<int:pk>/", views.get_requirement, name="requirement-detail"),
path("requirements/<int:pk>/submissions/", views.create_submission, name="submission-create"),
path("submissions/", views.list_submissions, name="submission-list"),
path("submissions/<int:pk>/", views.get_submission, name="submission-detail"),
path("submissions/<int:pk>/report/", views.get_report, name="submission-report"),
path("auth/token/", obtain_auth_token, name="api-token-auth"),
]
+123 -2
View File
@@ -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)
+69
View File
@@ -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())
+6
View File
@@ -0,0 +1,6 @@
"""OJplatform API 汇总 URL"""
from django.urls import include
urlpatterns = [
include("requirements.urls"),
]
+48 -1
View File
@@ -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
Generated
+159
View File
@@ -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" },
]