refactor
This commit is contained in:
+221
-66
@@ -1,84 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.dependencies import (
|
||||
RequestContext,
|
||||
request_context,
|
||||
)
|
||||
from backend.dependencies import database_session
|
||||
from backend.runtime_client import RuntimeClientError
|
||||
from backend.schemas import (
|
||||
CreateJupyterAccessTicketRequest,
|
||||
)
|
||||
from common.db.models import Scripts, Users, WorkspaceMembers, Workspaces
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
router = APIRouter(tags=["jupyter"])
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def cookie_secure() -> bool:
|
||||
return os.getenv("COOKIE_SECURE", "false").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
JWT_SECRET = os.environ.get("JWT_SECRET", "dev-only-not-for-production")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/jupyter/access-tickets",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_jupyter_access_ticket(
|
||||
payload: CreateJupyterAccessTicketRequest,
|
||||
request: Request,
|
||||
context: RequestContext = Depends(request_context),
|
||||
) -> JSONResponse:
|
||||
def _b64decode(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(value + padding)
|
||||
|
||||
|
||||
def verify_jwt_token(token: str) -> dict:
|
||||
"""Parse a signed JWT from the access_token cookie.
|
||||
|
||||
Returns a payload dict containing at least ``sub`` (user_id) and
|
||||
``exp``; raises 401 on missing, malformed, or expired tokens. The
|
||||
current implementation is intentionally minimal — replace with a
|
||||
real verification once a public-key/HSM source is wired in.
|
||||
"""
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing Authentication Token",
|
||||
)
|
||||
|
||||
try:
|
||||
ticket_data = (
|
||||
await request.app.state.runtime_client
|
||||
.create_jupyter_access_ticket(
|
||||
payload.edit_session_id,
|
||||
{
|
||||
"workspace_id": context.workspace.workspace_id,
|
||||
"user_id": context.user.user_id,
|
||||
"lock_token": payload.lock_token,
|
||||
},
|
||||
)
|
||||
)
|
||||
except RuntimeClientError as exc:
|
||||
error = exc.detail
|
||||
if not isinstance(error, dict) or "code" not in error:
|
||||
error = {
|
||||
"code": "JUPYTER_TICKET_FAILED",
|
||||
"message": str(error),
|
||||
"retryable": exc.status_code >= 500,
|
||||
"details": {},
|
||||
}
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"request_id": context.request_id, "error": error},
|
||||
header_b64, payload_b64, signature_b64 = token.split(".", 2)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid Authentication Token",
|
||||
) from exc
|
||||
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode()
|
||||
expected = hmac.new(
|
||||
JWT_SECRET.encode(),
|
||||
signing_input,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
try:
|
||||
signature = _b64decode(signature_b64)
|
||||
except Exception as exc: # pragma: no cover - malformed b64
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid Authentication Token",
|
||||
) from exc
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid Authentication Token",
|
||||
)
|
||||
|
||||
raw_ticket = ticket_data.pop("ticket")
|
||||
max_age = int(ticket_data.pop("expires_in_seconds"))
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
content={
|
||||
"request_id": context.request_id,
|
||||
"data": ticket_data,
|
||||
"meta": {},
|
||||
},
|
||||
try:
|
||||
payload = json.loads(_b64decode(payload_b64))
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid Authentication Token",
|
||||
) from exc
|
||||
|
||||
exp = payload.get("exp")
|
||||
if not isinstance(exp, (int, float)) or exp < time.time():
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Token expired",
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def extract_notebook_path(
|
||||
uri: str,
|
||||
workspace_id: str,
|
||||
) -> Optional[str]:
|
||||
"""Pull the relative notebook path out of the original request URI.
|
||||
|
||||
Only ``/jupyter/{workspace_id}/notebooks/*.ipynb`` requests are
|
||||
subject to file-level lock checks; everything else (tree views,
|
||||
``/api/contents`` and WebSocket upgrades) bypasses the lock.
|
||||
"""
|
||||
pattern = rf"^/jupyter/{re.escape(workspace_id)}/notebooks/(.+\.ipynb)"
|
||||
match = re.match(pattern, uri)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
async def check_notebook_is_locked(
|
||||
session: AsyncSession,
|
||||
workspace_id: str,
|
||||
notebook_path: str,
|
||||
user_id: str,
|
||||
) -> bool:
|
||||
"""Decide whether the request is allowed to read the notebook.
|
||||
|
||||
Returns ``True`` only when the notebook exists, is owned by a
|
||||
different user, and is currently locked. The owner is always let
|
||||
through; a missing row is treated as "not owned yet" and allowed.
|
||||
"""
|
||||
statement = select(Scripts.owner_user_id, Scripts.is_locked).where(
|
||||
Scripts.workspace_id == workspace_id,
|
||||
Scripts.script_name == notebook_path,
|
||||
Scripts.script_type == "notebook",
|
||||
Scripts.is_deleted == 0,
|
||||
)
|
||||
response.set_cookie(
|
||||
key="jupyter_access",
|
||||
value=raw_ticket,
|
||||
max_age=max_age,
|
||||
path="/jupyter/",
|
||||
secure=cookie_secure(),
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
row = (await session.execute(statement)).one_or_none()
|
||||
if row is None:
|
||||
return False
|
||||
owner_user_id, is_locked = row
|
||||
if owner_user_id == user_id:
|
||||
return False
|
||||
return bool(is_locked)
|
||||
|
||||
|
||||
async def load_active_membership(
|
||||
session: AsyncSession,
|
||||
user_id: str,
|
||||
workspace_id: str,
|
||||
) -> tuple[Users, Workspaces]:
|
||||
"""Resolve the user's active membership in the workspace."""
|
||||
statement = (
|
||||
select(Users, Workspaces)
|
||||
.join(
|
||||
WorkspaceMembers,
|
||||
WorkspaceMembers.user_id == Users.user_id,
|
||||
)
|
||||
.join(
|
||||
Workspaces,
|
||||
Workspaces.workspace_id == WorkspaceMembers.workspace_id,
|
||||
)
|
||||
.where(
|
||||
Users.user_id == user_id,
|
||||
Users.status == "active",
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.member_status == "active",
|
||||
Workspaces.status == "active",
|
||||
)
|
||||
)
|
||||
return response
|
||||
row = (await session.execute(statement)).one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="active workspace membership is required",
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/api/v1/auth/jupyter")
|
||||
async def verify_jupyter_access(
|
||||
request: Request,
|
||||
response: Response,
|
||||
auth: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict:
|
||||
"""Nginx auth_request subrequest handler.
|
||||
|
||||
Returns 200 with two response headers so Nginx can proxy the
|
||||
request to the user's Jupyter instance:
|
||||
|
||||
* ``x-upstream-addr`` = ``{base_url}:{port}``
|
||||
* ``x-jupyter-internal-token`` = the runtime-issued token
|
||||
"""
|
||||
workspace_id = request.headers.get("X-Original-Workspace-Id")
|
||||
original_uri = request.headers.get("X-Original-URI", "")
|
||||
|
||||
if not workspace_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Missing Workspace ID",
|
||||
)
|
||||
|
||||
cookie_token = request.cookies.get("access_token")
|
||||
bearer_token = auth.credentials if auth else None
|
||||
token = cookie_token or bearer_token
|
||||
payload = verify_jwt_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid Authentication Token",
|
||||
)
|
||||
|
||||
await load_active_membership(session, user_id, workspace_id)
|
||||
|
||||
notebook_path = extract_notebook_path(original_uri, workspace_id)
|
||||
if notebook_path and await check_notebook_is_locked(
|
||||
session, workspace_id, notebook_path, user_id,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Notebook '{notebook_path}' is currently locked",
|
||||
)
|
||||
|
||||
runtime_client = request.app.state.runtime_client
|
||||
ws_info = await runtime_client.get_workspace(workspace_id)
|
||||
if not ws_info or ws_info.get("status") != "running":
|
||||
try:
|
||||
ws_info = await runtime_client.start_workspace(workspace_id)
|
||||
except RuntimeClientError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code,
|
||||
detail=exc.detail,
|
||||
) from exc
|
||||
|
||||
target_port = ws_info.get("port")
|
||||
jupyter_token = ws_info.get("token")
|
||||
jupyter_base_url = ws_info.get("base_url")
|
||||
if not target_port:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Jupyter instance returned no port",
|
||||
)
|
||||
|
||||
response.headers["x-upstream-addr"] = f"{jupyter_base_url}:{target_port}"
|
||||
response.headers["x-jupyter-internal-token"] = jupyter_token or ""
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
@Time :2026/7/27
|
||||
@Author :tao.chen
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request, Response, HTTPException, Depends, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import Optional
|
||||
from loguru import logger
|
||||
|
||||
app = FastAPI(title="Jupyter Auth & Router Backend")
|
||||
|
||||
RUNTIME_BASE_URL = os.getenv("RUNTIME_BASE_URL", "http://127.0.0.1:8001")
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Runtime 交互 Client
|
||||
# ------------------------------------------------------------------
|
||||
class RuntimeClient:
|
||||
"""与 Runtime 进程管理器服务交互"""
|
||||
|
||||
@staticmethod
|
||||
async def get_workspace(workspace_id: str) -> Optional[dict]:
|
||||
"""按需查询单个 workspace 进程"""
|
||||
async with httpx.AsyncClient(base_url=RUNTIME_BASE_URL) as client:
|
||||
try:
|
||||
resp = await client.post(
|
||||
"/api/v1/jupyter",
|
||||
json={"action": "get", "workspace_id": workspace_id},
|
||||
timeout=3.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return None
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def start_workspace(workspace_id: str) -> dict:
|
||||
"""进程未运行时主动触发启动"""
|
||||
async with httpx.AsyncClient(base_url=RUNTIME_BASE_URL) as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/jupyter",
|
||||
json={"action": "start", "workspace_id": workspace_id},
|
||||
timeout=10.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to start Jupyter instance"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. 数据库与权限模拟 (请根据实际 MySQL ORM 修改)
|
||||
# ------------------------------------------------------------------
|
||||
async def check_notebook_is_locked(workspace_id: str, notebook_path: str) -> bool:
|
||||
"""
|
||||
查数据库:判断特定 Notebook 文件是否被锁定
|
||||
:param workspace_id: 工作区 ID
|
||||
:param notebook_path: 相对路径,如 "test.ipynb" 或 "folder/demo.ipynb"
|
||||
"""
|
||||
# 模拟锁定数据库:假定 test_locked.ipynb 被锁定
|
||||
locked_notebooks = {
|
||||
("test1234", "test_locked.ipynb"): True,
|
||||
}
|
||||
return locked_notebooks.get((workspace_id, notebook_path), False)
|
||||
|
||||
|
||||
def verify_jwt_token(token: str) -> str:
|
||||
"""校验 JWT 令牌"""
|
||||
if token == "invalid-token":
|
||||
raise HTTPException(status_code=401, detail="Invalid Authentication Token")
|
||||
return "user_001"
|
||||
|
||||
|
||||
def extract_notebook_path(uri: str, workspace_id: str) -> Optional[str]:
|
||||
"""
|
||||
从原始请求 URI 中提取请求的 .ipynb 文件相对路径
|
||||
例如: /jupyter/test1234/notebooks/folder/test.ipynb -> folder/test.ipynb
|
||||
"""
|
||||
pattern = rf"^/jupyter/{re.escape(workspace_id)}/notebooks/(.+\.ipynb)"
|
||||
match = re.match(pattern, uri)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. 核心 Auth 接口 (针对 Nginx auth_request)
|
||||
# ------------------------------------------------------------------
|
||||
@app.get("/api/v1/auth/jupyter")
|
||||
async def verify_jupyter_access(
|
||||
request: Request,
|
||||
response: Response,
|
||||
auth: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
):
|
||||
# 获取 Nginx 传入的元数据
|
||||
workspace_id = request.headers.get("X-Original-Workspace-Id")
|
||||
original_uri = request.headers.get("X-Original-URI", "")
|
||||
|
||||
cookie_token = request.cookies.get("access_token")
|
||||
bearer_token = auth.credentials if auth else None
|
||||
token = bearer_token or cookie_token
|
||||
|
||||
# if not token:
|
||||
# raise HTTPException(status_code=401, detail="Missing Authentication Token")
|
||||
|
||||
if not workspace_id:
|
||||
raise HTTPException(status_code=400, detail="Missing Workspace ID")
|
||||
|
||||
# 基础身份认证
|
||||
# current_user_id = verify_jwt_token(token)
|
||||
|
||||
# 精准锁校验:只有在访问 .ipynb 文件时才检查 is_locked
|
||||
notebook_path = extract_notebook_path(original_uri, workspace_id)
|
||||
if notebook_path:
|
||||
is_locked = await check_notebook_is_locked(workspace_id, notebook_path)
|
||||
if is_locked:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Notebook '{notebook_path}' is currently locked",
|
||||
)
|
||||
|
||||
# 获取或启动 Jupyter 子进程
|
||||
ws_info = await RuntimeClient.get_workspace(workspace_id)
|
||||
|
||||
if not ws_info or ws_info.get("status") != "running":
|
||||
ws_info = await RuntimeClient.start_workspace(workspace_id)
|
||||
|
||||
target_port = ws_info.get("port")
|
||||
jupyter_token = ws_info.get("token")
|
||||
jupyter_base_url = ws_info.get("base_url")
|
||||
|
||||
if not target_port:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Jupyter instance returned no port"
|
||||
)
|
||||
|
||||
# 通过 Response Header 返回 Upstream 地址与 Token 给 Nginx
|
||||
response.headers["x-upstream-addr"] = f"{jupyter_base_url}:{target_port}"
|
||||
response.headers["x-jupyter-internal-token"] = jupyter_token or ""
|
||||
return {"status": "ok"}
|
||||
@@ -90,15 +90,34 @@ class RuntimeClient:
|
||||
)
|
||||
)["data"]
|
||||
|
||||
async def create_jupyter_access_ticket(
|
||||
async def get_workspace(
|
||||
self,
|
||||
edit_session_id: str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return (
|
||||
await self._request(
|
||||
workspace_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Ask Runtime for a live workspace.
|
||||
|
||||
Returns the workspace descriptor when the Jupyter process is
|
||||
``running``; returns ``None`` when the workspace is not yet
|
||||
started or has been torn down so callers can fall through to
|
||||
:meth:`start_workspace`.
|
||||
"""
|
||||
try:
|
||||
return await self._request(
|
||||
"POST",
|
||||
f"/internal/v1/jupyter/access-tickets/{edit_session_id}",
|
||||
payload,
|
||||
"/api/v1/jupyter",
|
||||
{"action": "get", "workspace_id": workspace_id},
|
||||
)
|
||||
)["data"]
|
||||
except RuntimeClientError as exc:
|
||||
if exc.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def start_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
) -> dict[str, Any]:
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/api/v1/jupyter",
|
||||
{"action": "start", "workspace_id": workspace_id},
|
||||
)
|
||||
|
||||
@@ -65,8 +65,3 @@ class DownloadUrlRequest(StrictModel):
|
||||
|
||||
class FileLockTokenRequest(StrictModel):
|
||||
lock_token: str = Field(min_length=32, max_length=256)
|
||||
|
||||
|
||||
class CreateJupyterAccessTicketRequest(StrictModel):
|
||||
edit_session_id: str = Field(min_length=26, max_length=26)
|
||||
lock_token: str = Field(min_length=32, max_length=256)
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
@Time :2026/7/29
|
||||
@Author :tao.chen
|
||||
@Author :tao.chen
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
# 导入所有实体模型,确保 Base.metadata 能收集到所有表
|
||||
# from common.db.models.notebook import NotebookModel
|
||||
# from common.db.models.workspace import WorkspaceModel
|
||||
# from common.db.models.job import JobRunModel
|
||||
@@ -1,878 +0,0 @@
|
||||
from typing import Optional
|
||||
import datetime
|
||||
import decimal
|
||||
|
||||
from sqlalchemy import BINARY, BigInteger, CHAR, DECIMAL, Double, ForeignKeyConstraint, Index, Integer, JSON, String, Text, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT, DATETIME, INTEGER, SMALLINT, TINYINT
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class ConsumerInbox(Base):
|
||||
__tablename__ = 'consumer_inbox'
|
||||
__table_args__ = (
|
||||
Index('idx_consumer_inbox_status', 'consumer_name', 'process_status', 'created_at'),
|
||||
{'comment': '消费者幂等 Inbox,防止 Stream 重投导致重复执行'}
|
||||
)
|
||||
|
||||
consumer_name: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
event_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
process_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'processing'"), comment='processing/succeeded/failed')
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
message_id: Mapped[Optional[str]] = mapped_column(String(128), comment='Redis Stream message ID')
|
||||
processed_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
error_message: Mapped[Optional[str]] = mapped_column(String(2000))
|
||||
|
||||
|
||||
class OutboxEvents(Base):
|
||||
__tablename__ = 'outbox_events'
|
||||
__table_args__ = (
|
||||
Index('idx_outbox_aggregate', 'aggregate_type', 'aggregate_id', 'created_at'),
|
||||
Index('idx_outbox_idempotency', 'idempotency_key'),
|
||||
Index('idx_outbox_pending', 'event_status', 'available_at', 'created_at'),
|
||||
{'comment': '事务 Outbox;提交后发布到 Redis Streams'}
|
||||
)
|
||||
|
||||
event_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
aggregate_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
aggregate_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
schema_version: Mapped[int] = mapped_column(SMALLINT, nullable=False, server_default=text("1"))
|
||||
payload_json: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
event_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'pending'"), comment='pending/published/failed')
|
||||
available_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
retry_count: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"))
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
trace_id: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
idempotency_key: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
published_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
last_error: Mapped[Optional[str]] = mapped_column(String(2000))
|
||||
|
||||
|
||||
class Permissions(Base):
|
||||
__tablename__ = 'permissions'
|
||||
__table_args__ = (
|
||||
Index('idx_permissions_module', 'module_code'),
|
||||
Index('uk_permissions_code', 'permission_code', unique=True),
|
||||
{'comment': '权限点'}
|
||||
)
|
||||
|
||||
permission_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
permission_code: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
permission_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
module_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
description: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
|
||||
role_permissions: Mapped[list['RolePermissions']] = relationship('RolePermissions', back_populates='permission')
|
||||
|
||||
|
||||
class Roles(Base):
|
||||
__tablename__ = 'roles'
|
||||
__table_args__ = (
|
||||
Index('uk_roles_code', 'role_code', unique=True),
|
||||
{'comment': '角色'}
|
||||
)
|
||||
|
||||
role_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
role_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
role_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
role_scope: Mapped[str] = mapped_column(String(16), nullable=False, comment='platform/workspace')
|
||||
is_builtin: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("0"))
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
|
||||
description: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
|
||||
role_permissions: Mapped[list['RolePermissions']] = relationship('RolePermissions', back_populates='role')
|
||||
users: Mapped[list['Users']] = relationship('Users', back_populates='platform_role')
|
||||
workspace_members: Mapped[list['WorkspaceMembers']] = relationship('WorkspaceMembers', back_populates='role')
|
||||
|
||||
|
||||
class RolePermissions(Base):
|
||||
__tablename__ = 'role_permissions'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['permission_id'], ['permissions.permission_id'], ondelete='CASCADE', name='fk_role_permissions_permission'),
|
||||
ForeignKeyConstraint(['role_id'], ['roles.role_id'], ondelete='CASCADE', name='fk_role_permissions_role'),
|
||||
Index('fk_role_permissions_permission', 'permission_id'),
|
||||
{'comment': '角色权限'}
|
||||
)
|
||||
|
||||
role_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
permission_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
|
||||
permission: Mapped['Permissions'] = relationship('Permissions', back_populates='role_permissions')
|
||||
role: Mapped['Roles'] = relationship('Roles', back_populates='role_permissions')
|
||||
|
||||
|
||||
class Users(Base):
|
||||
__tablename__ = 'users'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['platform_role_id'], ['roles.role_id'], ondelete='SET NULL', name='fk_users_platform_role'),
|
||||
Index('fk_users_platform_role', 'platform_role_id'),
|
||||
Index('idx_users_status', 'status'),
|
||||
Index('uk_users_email', 'email', unique=True),
|
||||
Index('uk_users_username', 'username', unique=True),
|
||||
{'comment': '平台用户'}
|
||||
)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"), comment='active/disabled/locked')
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
|
||||
email: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
platform_role_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
avatar_uri: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
last_login_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
platform_role: Mapped[Optional['Roles']] = relationship('Roles', back_populates='users')
|
||||
workspaces: Mapped[list['Workspaces']] = relationship('Workspaces', back_populates='users')
|
||||
audit_logs: Mapped[list['AuditLogs']] = relationship('AuditLogs', back_populates='actor_user')
|
||||
runtime_instances: Mapped[list['RuntimeInstances']] = relationship('RuntimeInstances', foreign_keys='[RuntimeInstances.owner_user_id]', back_populates='owner_user')
|
||||
runtime_instances_: Mapped[list['RuntimeInstances']] = relationship('RuntimeInstances', foreign_keys='[RuntimeInstances.started_by]', back_populates='users')
|
||||
schedules: Mapped[list['Schedules']] = relationship('Schedules', foreign_keys='[Schedules.created_by]', back_populates='users')
|
||||
schedules_: Mapped[list['Schedules']] = relationship('Schedules', foreign_keys='[Schedules.updated_by]', back_populates='users_')
|
||||
storage_objects: Mapped[list['StorageObjects']] = relationship('StorageObjects', foreign_keys='[StorageObjects.created_by]', back_populates='users')
|
||||
storage_objects_: Mapped[list['StorageObjects']] = relationship('StorageObjects', foreign_keys='[StorageObjects.owner_user_id]', back_populates='owner_user')
|
||||
workspace_members: Mapped[list['WorkspaceMembers']] = relationship('WorkspaceMembers', back_populates='user')
|
||||
data_resources: Mapped[list['DataResources']] = relationship('DataResources', back_populates='owner_user')
|
||||
edit_sessions: Mapped[list['EditSessions']] = relationship('EditSessions', back_populates='user')
|
||||
schedule_runs: Mapped[list['ScheduleRuns']] = relationship('ScheduleRuns', back_populates='users')
|
||||
scripts: Mapped[list['Scripts']] = relationship('Scripts', back_populates='owner_user')
|
||||
upload_sessions: Mapped[list['UploadSessions']] = relationship('UploadSessions', back_populates='user')
|
||||
workspace_operations: Mapped[list['WorkspaceOperations']] = relationship('WorkspaceOperations', back_populates='users')
|
||||
notebook_snapshots: Mapped[list['NotebookSnapshots']] = relationship('NotebookSnapshots', back_populates='users')
|
||||
versions: Mapped[list['Versions']] = relationship('Versions', back_populates='users')
|
||||
experiments: Mapped[list['Experiments']] = relationship('Experiments', back_populates='owner_user')
|
||||
|
||||
|
||||
class Workspaces(Base):
|
||||
__tablename__ = 'workspaces'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['created_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_workspaces_created_by'),
|
||||
Index('fk_workspaces_created_by', 'created_by'),
|
||||
Index('idx_workspaces_status', 'status'),
|
||||
Index('uk_workspaces_code', 'workspace_code', unique=True),
|
||||
{'comment': 'Workspace'}
|
||||
)
|
||||
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
workspace_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
active_root_uri: Mapped[str] = mapped_column(String(1500), nullable=False, comment='活动工作区,建议 NFS/PVC/file URI')
|
||||
quota_bytes: Mapped[int] = mapped_column(BIGINT, nullable=False, server_default=text("0"), comment='0 表示不限额')
|
||||
used_bytes: Mapped[int] = mapped_column(BIGINT, nullable=False, server_default=text("0"))
|
||||
status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'active'"), comment='creating/active/suspended/deleting/deleted')
|
||||
created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
|
||||
description: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
artifact_bucket: Mapped[Optional[str]] = mapped_column(String(128), comment='RustFS bucket')
|
||||
artifact_prefix: Mapped[Optional[str]] = mapped_column(String(512), comment='RustFS object key prefix')
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
users: Mapped['Users'] = relationship('Users', back_populates='workspaces')
|
||||
audit_logs: Mapped[list['AuditLogs']] = relationship('AuditLogs', back_populates='workspace')
|
||||
runtime_instances: Mapped[list['RuntimeInstances']] = relationship('RuntimeInstances', back_populates='workspace')
|
||||
schedules: Mapped[list['Schedules']] = relationship('Schedules', back_populates='workspace')
|
||||
storage_objects: Mapped[list['StorageObjects']] = relationship('StorageObjects', back_populates='workspace')
|
||||
workspace_members: Mapped[list['WorkspaceMembers']] = relationship('WorkspaceMembers', back_populates='workspace')
|
||||
data_resources: Mapped[list['DataResources']] = relationship('DataResources', back_populates='workspace')
|
||||
edit_sessions: Mapped[list['EditSessions']] = relationship('EditSessions', back_populates='workspace')
|
||||
schedule_runs: Mapped[list['ScheduleRuns']] = relationship('ScheduleRuns', back_populates='workspace')
|
||||
scripts: Mapped[list['Scripts']] = relationship('Scripts', back_populates='workspace')
|
||||
upload_sessions: Mapped[list['UploadSessions']] = relationship('UploadSessions', back_populates='workspace')
|
||||
workspace_operations: Mapped[list['WorkspaceOperations']] = relationship('WorkspaceOperations', back_populates='workspace')
|
||||
notebook_snapshots: Mapped[list['NotebookSnapshots']] = relationship('NotebookSnapshots', back_populates='workspace')
|
||||
versions: Mapped[list['Versions']] = relationship('Versions', back_populates='workspace')
|
||||
experiments: Mapped[list['Experiments']] = relationship('Experiments', back_populates='workspace')
|
||||
|
||||
|
||||
class AuditLogs(Base):
|
||||
__tablename__ = 'audit_logs'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['actor_user_id'], ['users.user_id'], ondelete='SET NULL', name='fk_audit_actor'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='SET NULL', name='fk_audit_workspace'),
|
||||
Index('idx_audit_action_time', 'action_code', 'created_at'),
|
||||
Index('idx_audit_actor_time', 'actor_user_id', 'created_at'),
|
||||
Index('idx_audit_workspace_time', 'workspace_id', 'created_at'),
|
||||
{'comment': '操作审计日志'}
|
||||
)
|
||||
|
||||
audit_id: Mapped[int] = mapped_column(BIGINT, primary_key=True)
|
||||
action_code: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
target_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
operation_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'success'"))
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
workspace_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
actor_user_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
target_id: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
client_ip: Mapped[Optional[str]] = mapped_column(String(45))
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
detail_json: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
|
||||
actor_user: Mapped[Optional['Users']] = relationship('Users', back_populates='audit_logs')
|
||||
workspace: Mapped[Optional['Workspaces']] = relationship('Workspaces', back_populates='audit_logs')
|
||||
|
||||
|
||||
class RuntimeInstances(Base):
|
||||
__tablename__ = 'runtime_instances'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['owner_user_id'], ['users.user_id'], ondelete='SET NULL', name='fk_runtime_owner'),
|
||||
ForeignKeyConstraint(['started_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_runtime_started_by'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_runtime_workspace'),
|
||||
Index('fk_runtime_started_by', 'started_by'),
|
||||
Index('idx_runtime_lease', 'actual_state', 'lease_expires_at'),
|
||||
Index('idx_runtime_owner_state', 'owner_user_id', 'actual_state'),
|
||||
Index('idx_runtime_workspace_state', 'workspace_id', 'runtime_type', 'actual_state'),
|
||||
{'comment': 'Jupyter/未来 VS Code、OpenCode Runtime 实例'}
|
||||
)
|
||||
|
||||
runtime_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
runtime_type: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'jupyter'"))
|
||||
runtime_provider: Mapped[str] = mapped_column(String(24), nullable=False, comment='process/docker/kubernetes')
|
||||
proxy_base_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
desired_state: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'running'"))
|
||||
actual_state: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'provisioning'"), comment='provisioning/starting/running/unhealthy/stopping/stopped/failed')
|
||||
state_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"), comment='乐观锁版本')
|
||||
started_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
|
||||
owner_user_id: Mapped[Optional[str]] = mapped_column(CHAR(26), comment='为空表示 Workspace 级 Runtime')
|
||||
runtime_ref: Mapped[Optional[str]] = mapped_column(String(255), comment='PID/container ID/pod UID')
|
||||
host_node: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
internal_url: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
last_heartbeat_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
lease_expires_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
stopped_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
owner_user: Mapped[Optional['Users']] = relationship('Users', foreign_keys=[owner_user_id], back_populates='runtime_instances')
|
||||
users: Mapped['Users'] = relationship('Users', foreign_keys=[started_by], back_populates='runtime_instances_')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='runtime_instances')
|
||||
edit_sessions: Mapped[list['EditSessions']] = relationship('EditSessions', back_populates='runtime')
|
||||
workspace_operations: Mapped[list['WorkspaceOperations']] = relationship('WorkspaceOperations', back_populates='runtime')
|
||||
|
||||
|
||||
class Schedules(Base):
|
||||
__tablename__ = 'schedules'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['created_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_schedules_created_by'),
|
||||
ForeignKeyConstraint(['updated_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_schedules_updated_by'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_schedules_workspace'),
|
||||
Index('fk_schedules_created_by', 'created_by'),
|
||||
Index('fk_schedules_updated_by', 'updated_by'),
|
||||
Index('idx_schedules_due', 'enabled', 'next_run_at'),
|
||||
Index('idx_schedules_workspace', 'workspace_id', 'enabled', 'updated_at'),
|
||||
{'comment': '调度方案'}
|
||||
)
|
||||
|
||||
schedule_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
schedule_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
trigger_type: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'cron'"), comment='manual/cron/api')
|
||||
timezone: Mapped[str] = mapped_column(String(64), nullable=False, server_default=text("'Asia/Shanghai'"))
|
||||
enabled: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("0"))
|
||||
workflow_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("1"))
|
||||
max_concurrency: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("1"))
|
||||
failure_policy: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'stop'"), comment='stop/continue')
|
||||
created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
updated_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
|
||||
description: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
cron_expression: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
last_run_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
next_run_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
users: Mapped['Users'] = relationship('Users', foreign_keys=[created_by], back_populates='schedules')
|
||||
users_: Mapped['Users'] = relationship('Users', foreign_keys=[updated_by], back_populates='schedules_')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='schedules')
|
||||
schedule_runs: Mapped[list['ScheduleRuns']] = relationship('ScheduleRuns', back_populates='schedule')
|
||||
schedule_nodes: Mapped[list['ScheduleNodes']] = relationship('ScheduleNodes', back_populates='schedule')
|
||||
schedule_edges: Mapped[list['ScheduleEdges']] = relationship('ScheduleEdges', back_populates='schedule')
|
||||
|
||||
|
||||
class StorageObjects(Base):
|
||||
__tablename__ = 'storage_objects'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['created_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_storage_created_by'),
|
||||
ForeignKeyConstraint(['owner_user_id'], ['users.user_id'], ondelete='SET NULL', name='fk_storage_owner'),
|
||||
ForeignKeyConstraint(['parent_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_storage_parent'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_storage_workspace'),
|
||||
Index('fk_storage_created_by', 'created_by'),
|
||||
Index('idx_storage_content_hash', 'content_hash'),
|
||||
Index('idx_storage_owner', 'owner_user_id', 'object_status'),
|
||||
Index('idx_storage_parent', 'parent_object_id'),
|
||||
Index('idx_storage_workspace_usage', 'workspace_id', 'usage_type', 'object_status'),
|
||||
Index('uk_storage_bucket_key', 'storage_backend', 'bucket_name', 'object_key_hash', unique=True),
|
||||
Index('uk_storage_workspace_path', 'workspace_id', 'storage_backend', 'path_hash', unique=True),
|
||||
{'comment': 'Workspace 文件和 RustFS 对象的统一元数据'}
|
||||
)
|
||||
|
||||
storage_object_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
object_type: Mapped[str] = mapped_column(String(16), nullable=False, comment='file/directory')
|
||||
usage_type: Mapped[str] = mapped_column(String(32), nullable=False, comment='working_copy/public_script/data_resource/version_artifact/snapshot/run_log/run_result')
|
||||
storage_backend: Mapped[str] = mapped_column(String(16), nullable=False, comment='workspace_fs/rustfs')
|
||||
storage_uri: Mapped[str] = mapped_column(String(1500), nullable=False)
|
||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(BIGINT, nullable=False, server_default=text("0"))
|
||||
visibility: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'private'"), comment='private/workspace/public')
|
||||
is_immutable: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("0"))
|
||||
object_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'available'"), comment='uploading/available/deleting/deleted/failed')
|
||||
created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
|
||||
owner_user_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
parent_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
relative_path: Mapped[Optional[str]] = mapped_column(String(1024), comment='Workspace 相对路径')
|
||||
path_hash: Mapped[Optional[bytes]] = mapped_column(BINARY(32), comment='SHA-256(relative_path),由应用写入')
|
||||
bucket_name: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
object_key: Mapped[Optional[str]] = mapped_column(String(1024))
|
||||
object_key_hash: Mapped[Optional[bytes]] = mapped_column(BINARY(32), comment='SHA-256(object_key),由应用写入')
|
||||
file_extension: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
mime_type: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
content_hash: Mapped[Optional[str]] = mapped_column(CHAR(64), comment='SHA-256 hex')
|
||||
object_etag: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
users: Mapped['Users'] = relationship('Users', foreign_keys=[created_by], back_populates='storage_objects')
|
||||
owner_user: Mapped[Optional['Users']] = relationship('Users', foreign_keys=[owner_user_id], back_populates='storage_objects_')
|
||||
parent_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', remote_side=[storage_object_id], back_populates='parent_object_reverse')
|
||||
parent_object_reverse: Mapped[list['StorageObjects']] = relationship('StorageObjects', remote_side=[parent_object_id], back_populates='parent_object')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='storage_objects')
|
||||
data_resources: Mapped[list['DataResources']] = relationship('DataResources', back_populates='storage_object')
|
||||
edit_sessions: Mapped[list['EditSessions']] = relationship('EditSessions', back_populates='storage_object')
|
||||
schedule_runs: Mapped[list['ScheduleRuns']] = relationship('ScheduleRuns', foreign_keys='[ScheduleRuns.logs_object_id]', back_populates='logs_object')
|
||||
schedule_runs_: Mapped[list['ScheduleRuns']] = relationship('ScheduleRuns', foreign_keys='[ScheduleRuns.result_object_id]', back_populates='result_object')
|
||||
scripts: Mapped[list['Scripts']] = relationship('Scripts', back_populates='current_object')
|
||||
upload_sessions: Mapped[list['UploadSessions']] = relationship('UploadSessions', back_populates='storage_object')
|
||||
notebook_snapshots: Mapped[list['NotebookSnapshots']] = relationship('NotebookSnapshots', foreign_keys='[NotebookSnapshots.artifact_object_id]', back_populates='artifact_object')
|
||||
notebook_snapshots_: Mapped[list['NotebookSnapshots']] = relationship('NotebookSnapshots', foreign_keys='[NotebookSnapshots.source_object_id]', back_populates='source_object')
|
||||
versions: Mapped[list['Versions']] = relationship('Versions', foreign_keys='[Versions.artifact_object_id]', back_populates='artifact_object')
|
||||
versions_: Mapped[list['Versions']] = relationship('Versions', foreign_keys='[Versions.source_object_id]', back_populates='source_object')
|
||||
experiments: Mapped[list['Experiments']] = relationship('Experiments', foreign_keys='[Experiments.logs_object_id]', back_populates='logs_object')
|
||||
experiments_: Mapped[list['Experiments']] = relationship('Experiments', foreign_keys='[Experiments.result_object_id]', back_populates='result_object')
|
||||
schedule_node_runs: Mapped[list['ScheduleNodeRuns']] = relationship('ScheduleNodeRuns', foreign_keys='[ScheduleNodeRuns.logs_object_id]', back_populates='logs_object')
|
||||
schedule_node_runs_: Mapped[list['ScheduleNodeRuns']] = relationship('ScheduleNodeRuns', foreign_keys='[ScheduleNodeRuns.result_object_id]', back_populates='result_object')
|
||||
|
||||
|
||||
class WorkspaceMembers(Base):
|
||||
__tablename__ = 'workspace_members'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['role_id'], ['roles.role_id'], ondelete='RESTRICT', name='fk_workspace_members_role'),
|
||||
ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_workspace_members_user'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='CASCADE', name='fk_workspace_members_workspace'),
|
||||
Index('idx_workspace_members_role', 'role_id'),
|
||||
Index('idx_workspace_members_user', 'user_id', 'member_status'),
|
||||
{'comment': 'Workspace 成员与角色'}
|
||||
)
|
||||
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
role_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
member_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"))
|
||||
joined_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
|
||||
|
||||
role: Mapped['Roles'] = relationship('Roles', back_populates='workspace_members')
|
||||
user: Mapped['Users'] = relationship('Users', back_populates='workspace_members')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='workspace_members')
|
||||
|
||||
|
||||
class DataResources(Base):
|
||||
__tablename__ = 'data_resources'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['owner_user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_data_resources_owner'),
|
||||
ForeignKeyConstraint(['storage_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_data_resources_object'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_data_resources_workspace'),
|
||||
Index('idx_data_resources_owner', 'owner_user_id', 'status'),
|
||||
Index('idx_data_resources_workspace', 'workspace_id', 'visibility', 'status'),
|
||||
Index('uk_data_resources_object', 'storage_object_id', unique=True),
|
||||
{'comment': '数据资源'}
|
||||
)
|
||||
|
||||
resource_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
storage_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
owner_user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
visibility: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'private'"))
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"))
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
|
||||
description: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
schema_json: Mapped[Optional[dict]] = mapped_column(JSON, comment='字段结构、行数等可选元数据')
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
owner_user: Mapped['Users'] = relationship('Users', back_populates='data_resources')
|
||||
storage_object: Mapped['StorageObjects'] = relationship('StorageObjects', back_populates='data_resources')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='data_resources')
|
||||
experiment_resources: Mapped[list['ExperimentResources']] = relationship('ExperimentResources', back_populates='resource')
|
||||
|
||||
|
||||
class EditSessions(Base):
|
||||
__tablename__ = 'edit_sessions'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['runtime_id'], ['runtime_instances.runtime_id'], ondelete='SET NULL', name='fk_edit_sessions_runtime'),
|
||||
ForeignKeyConstraint(['storage_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_edit_sessions_object'),
|
||||
ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_edit_sessions_user'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_edit_sessions_workspace'),
|
||||
Index('fk_edit_sessions_workspace', 'workspace_id'),
|
||||
Index('idx_edit_sessions_object', 'storage_object_id', 'session_status', 'expires_at'),
|
||||
Index('idx_edit_sessions_runtime', 'runtime_id', 'session_status'),
|
||||
Index('idx_edit_sessions_user', 'user_id', 'session_status'),
|
||||
{'comment': '编辑会话审计;实时锁状态以 Redis 为准'}
|
||||
)
|
||||
|
||||
edit_session_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
storage_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
redis_lock_key: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
lock_token_hash: Mapped[bytes] = mapped_column(BINARY(32), nullable=False, comment='不保存原始 token')
|
||||
session_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"), comment='active/closed/expired/failed')
|
||||
started_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
last_heartbeat_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
expires_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False)
|
||||
runtime_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
jupyter_session_id: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
ended_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
end_reason: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
|
||||
runtime: Mapped[Optional['RuntimeInstances']] = relationship('RuntimeInstances', back_populates='edit_sessions')
|
||||
storage_object: Mapped['StorageObjects'] = relationship('StorageObjects', back_populates='edit_sessions')
|
||||
user: Mapped['Users'] = relationship('Users', back_populates='edit_sessions')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='edit_sessions')
|
||||
|
||||
|
||||
class ScheduleRuns(Base):
|
||||
__tablename__ = 'schedule_runs'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['logs_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_schedule_runs_logs'),
|
||||
ForeignKeyConstraint(['result_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_schedule_runs_result'),
|
||||
ForeignKeyConstraint(['schedule_id'], ['schedules.schedule_id'], ondelete='RESTRICT', name='fk_schedule_runs_schedule'),
|
||||
ForeignKeyConstraint(['triggered_by'], ['users.user_id'], ondelete='SET NULL', name='fk_schedule_runs_user'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_schedule_runs_workspace'),
|
||||
Index('fk_schedule_runs_logs', 'logs_object_id'),
|
||||
Index('fk_schedule_runs_result', 'result_object_id'),
|
||||
Index('fk_schedule_runs_user', 'triggered_by'),
|
||||
Index('idx_schedule_runs_schedule', 'schedule_id', 'created_at'),
|
||||
Index('idx_schedule_runs_status', 'run_status', 'queued_at'),
|
||||
Index('idx_schedule_runs_workspace_status', 'workspace_id', 'run_status', 'queued_at'),
|
||||
Index('uk_schedule_runs_idempotency', 'idempotency_key', unique=True),
|
||||
{'comment': '调度运行'}
|
||||
)
|
||||
|
||||
run_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
schedule_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
workflow_version: Mapped[int] = mapped_column(INTEGER, nullable=False)
|
||||
trigger_type: Mapped[str] = mapped_column(String(16), nullable=False, comment='manual/cron/api/retry')
|
||||
idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
run_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'queued'"), comment='queued/running/succeeded/failed/cancelled/timed_out')
|
||||
state_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"), comment='乐观锁版本')
|
||||
schedule_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False, comment='执行时 DAG 快照')
|
||||
queued_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
triggered_by: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
duration_ms: Mapped[Optional[int]] = mapped_column(BIGINT)
|
||||
error_code: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text)
|
||||
logs_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
result_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
|
||||
logs_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[logs_object_id], back_populates='schedule_runs')
|
||||
result_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[result_object_id], back_populates='schedule_runs_')
|
||||
schedule: Mapped['Schedules'] = relationship('Schedules', back_populates='schedule_runs')
|
||||
users: Mapped[Optional['Users']] = relationship('Users', back_populates='schedule_runs')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='schedule_runs')
|
||||
experiments: Mapped[list['Experiments']] = relationship('Experiments', back_populates='schedule_run')
|
||||
schedule_node_runs: Mapped[list['ScheduleNodeRuns']] = relationship('ScheduleNodeRuns', back_populates='run')
|
||||
|
||||
|
||||
class Scripts(Base):
|
||||
__tablename__ = 'scripts'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['current_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_scripts_current_object'),
|
||||
ForeignKeyConstraint(['owner_user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_scripts_owner'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_scripts_workspace'),
|
||||
Index('idx_scripts_owner', 'owner_user_id', 'status'),
|
||||
Index('idx_scripts_workspace', 'workspace_id', 'script_type', 'visibility', 'status'),
|
||||
Index('uk_scripts_current_object', 'current_object_id', unique=True),
|
||||
{'comment': '可执行 Python/Notebook 脚本'}
|
||||
)
|
||||
|
||||
script_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
current_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False, comment='当前工作副本')
|
||||
owner_user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
script_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
script_type: Mapped[str] = mapped_column(String(16), nullable=False, comment='python/notebook')
|
||||
visibility: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'private'"))
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"))
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
current_object: Mapped['StorageObjects'] = relationship('StorageObjects', back_populates='scripts')
|
||||
owner_user: Mapped['Users'] = relationship('Users', back_populates='scripts')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='scripts')
|
||||
notebook_snapshots: Mapped[list['NotebookSnapshots']] = relationship('NotebookSnapshots', back_populates='script')
|
||||
versions: Mapped[list['Versions']] = relationship('Versions', back_populates='script')
|
||||
experiments: Mapped[list['Experiments']] = relationship('Experiments', back_populates='script')
|
||||
|
||||
|
||||
class UploadSessions(Base):
|
||||
__tablename__ = 'upload_sessions'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['storage_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_upload_sessions_storage_object'),
|
||||
ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_upload_sessions_user'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_upload_sessions_workspace'),
|
||||
Index('fk_upload_sessions_storage_object', 'storage_object_id'),
|
||||
Index('fk_upload_sessions_user', 'user_id'),
|
||||
Index('idx_upload_sessions_expiry', 'upload_status', 'expires_at'),
|
||||
Index('idx_upload_sessions_object_key', 'bucket_name', 'object_key_hash'),
|
||||
Index('idx_upload_sessions_workspace', 'workspace_id', 'user_id', 'created_at'),
|
||||
Index('uk_upload_sessions_idempotency', 'idempotency_key', unique=True),
|
||||
{'comment': 'RustFS 预签名上传会话;URL 本身不持久化'}
|
||||
)
|
||||
|
||||
upload_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
bucket_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
object_key: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
object_key_hash: Mapped[bytes] = mapped_column(BINARY(32), nullable=False)
|
||||
upload_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'created'"), comment='created/uploading/completed/expired/aborted/failed')
|
||||
expires_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
|
||||
multipart_upload_id: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
expected_size_bytes: Mapped[Optional[int]] = mapped_column(BIGINT)
|
||||
expected_hash: Mapped[Optional[str]] = mapped_column(CHAR(64))
|
||||
content_type: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
storage_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
completed_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
storage_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', back_populates='upload_sessions')
|
||||
user: Mapped['Users'] = relationship('Users', back_populates='upload_sessions')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='upload_sessions')
|
||||
|
||||
|
||||
class WorkspaceOperations(Base):
|
||||
__tablename__ = 'workspace_operations'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['requested_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_workspace_operations_user'),
|
||||
ForeignKeyConstraint(['runtime_id'], ['runtime_instances.runtime_id'], ondelete='SET NULL', name='fk_workspace_operations_runtime'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_workspace_operations_workspace'),
|
||||
Index('fk_workspace_operations_user', 'requested_by'),
|
||||
Index('idx_workspace_operations_runtime', 'runtime_id', 'created_at'),
|
||||
Index('idx_workspace_operations_workspace', 'workspace_id', 'operation_status', 'created_at'),
|
||||
Index('uk_workspace_operations_request', 'request_id', unique=True),
|
||||
{'comment': '无状态 Backend 的 Workspace/Jupyter 异步操作记录'}
|
||||
)
|
||||
|
||||
operation_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
operation_type: Mapped[str] = mapped_column(String(24), nullable=False, comment='open/close/mount/unmount/start/stop/restart/recycle')
|
||||
operation_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'pending'"), comment='pending/running/succeeded/failed/cancelled')
|
||||
state_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"), comment='乐观锁版本')
|
||||
requested_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
runtime_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
request_id: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
error_code: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
users: Mapped['Users'] = relationship('Users', back_populates='workspace_operations')
|
||||
runtime: Mapped[Optional['RuntimeInstances']] = relationship('RuntimeInstances', back_populates='workspace_operations')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='workspace_operations')
|
||||
|
||||
|
||||
class NotebookSnapshots(Base):
|
||||
__tablename__ = 'notebook_snapshots'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['artifact_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_snapshots_artifact_object'),
|
||||
ForeignKeyConstraint(['created_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_snapshots_created_by'),
|
||||
ForeignKeyConstraint(['script_id'], ['scripts.script_id'], ondelete='RESTRICT', name='fk_snapshots_script'),
|
||||
ForeignKeyConstraint(['source_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_snapshots_source_object'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_snapshots_workspace'),
|
||||
Index('fk_snapshots_created_by', 'created_by'),
|
||||
Index('fk_snapshots_source_object', 'source_object_id'),
|
||||
Index('idx_snapshots_workspace_created', 'workspace_id', 'created_at'),
|
||||
Index('uk_snapshots_artifact', 'artifact_object_id', unique=True),
|
||||
Index('uk_snapshots_script_hash', 'script_id', 'content_hash', unique=True),
|
||||
{'comment': 'Notebook 开发快照,append-only'}
|
||||
)
|
||||
|
||||
snapshot_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
script_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
source_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
artifact_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
snapshot_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content_hash: Mapped[str] = mapped_column(CHAR(64), nullable=False)
|
||||
outputs_stripped: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("1"))
|
||||
created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
description: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
|
||||
artifact_object: Mapped['StorageObjects'] = relationship('StorageObjects', foreign_keys=[artifact_object_id], back_populates='notebook_snapshots')
|
||||
users: Mapped['Users'] = relationship('Users', back_populates='notebook_snapshots')
|
||||
script: Mapped['Scripts'] = relationship('Scripts', back_populates='notebook_snapshots')
|
||||
source_object: Mapped['StorageObjects'] = relationship('StorageObjects', foreign_keys=[source_object_id], back_populates='notebook_snapshots_')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='notebook_snapshots')
|
||||
|
||||
|
||||
class Versions(Base):
|
||||
__tablename__ = 'versions'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['artifact_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_versions_artifact_object'),
|
||||
ForeignKeyConstraint(['created_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_versions_created_by'),
|
||||
ForeignKeyConstraint(['script_id'], ['scripts.script_id'], ondelete='RESTRICT', name='fk_versions_script'),
|
||||
ForeignKeyConstraint(['source_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_versions_source_object'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_versions_workspace'),
|
||||
Index('fk_versions_source_object', 'source_object_id'),
|
||||
Index('idx_versions_creator', 'created_by', 'created_at'),
|
||||
Index('idx_versions_workspace_created', 'workspace_id', 'created_at'),
|
||||
Index('uk_versions_artifact', 'artifact_object_id', unique=True),
|
||||
Index('uk_versions_script_hash', 'script_id', 'content_hash', unique=True),
|
||||
Index('uk_versions_script_no', 'script_id', 'version_no', unique=True),
|
||||
{'comment': '不可变稳定版本;调度节点必须引用 versions_id'}
|
||||
)
|
||||
|
||||
versions_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True, comment='稳定版本唯一 ID')
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
script_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
source_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False, comment='发布时的源对象')
|
||||
artifact_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False, comment='RustFS 不可变版本制品')
|
||||
version_no: Mapped[int] = mapped_column(INTEGER, nullable=False)
|
||||
version_label: Mapped[str] = mapped_column(String(32), nullable=False, comment='例如 v1.0')
|
||||
source_path: Mapped[str] = mapped_column(String(1024), nullable=False, comment='发布时路径快照')
|
||||
artifact_path: Mapped[str] = mapped_column(String(1500), nullable=False)
|
||||
content_hash: Mapped[str] = mapped_column(CHAR(64), nullable=False)
|
||||
file_size_bytes: Mapped[int] = mapped_column(BIGINT, nullable=False, server_default=text("0"))
|
||||
visibility: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'private'"))
|
||||
created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
release_note: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
schedule_hidden_at: Mapped[Optional[datetime.datetime]] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
comment='从调度稳定版本列表移除的时间;不影响版本和运行历史',
|
||||
)
|
||||
|
||||
artifact_object: Mapped['StorageObjects'] = relationship('StorageObjects', foreign_keys=[artifact_object_id], back_populates='versions')
|
||||
users: Mapped['Users'] = relationship('Users', back_populates='versions')
|
||||
script: Mapped['Scripts'] = relationship('Scripts', back_populates='versions')
|
||||
source_object: Mapped['StorageObjects'] = relationship('StorageObjects', foreign_keys=[source_object_id], back_populates='versions_')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='versions')
|
||||
experiments: Mapped[list['Experiments']] = relationship('Experiments', back_populates='versions')
|
||||
schedule_nodes: Mapped[list['ScheduleNodes']] = relationship('ScheduleNodes', back_populates='versions')
|
||||
schedule_node_runs: Mapped[list['ScheduleNodeRuns']] = relationship('ScheduleNodeRuns', back_populates='versions')
|
||||
|
||||
|
||||
class Experiments(Base):
|
||||
__tablename__ = 'experiments'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['logs_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_experiments_logs'),
|
||||
ForeignKeyConstraint(['owner_user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_experiments_owner'),
|
||||
ForeignKeyConstraint(['parent_experiment_id'], ['experiments.experiment_id'], ondelete='SET NULL', name='fk_experiments_parent'),
|
||||
ForeignKeyConstraint(['result_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_experiments_result'),
|
||||
ForeignKeyConstraint(['schedule_run_id'], ['schedule_runs.run_id'], ondelete='SET NULL', name='fk_experiments_schedule_run'),
|
||||
ForeignKeyConstraint(['script_id'], ['scripts.script_id'], ondelete='SET NULL', name='fk_experiments_script'),
|
||||
ForeignKeyConstraint(['versions_id'], ['versions.versions_id'], ondelete='SET NULL', name='fk_experiments_version'),
|
||||
ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_experiments_workspace'),
|
||||
Index('fk_experiments_logs', 'logs_object_id'),
|
||||
Index('fk_experiments_parent', 'parent_experiment_id'),
|
||||
Index('fk_experiments_result', 'result_object_id'),
|
||||
Index('fk_experiments_script', 'script_id'),
|
||||
Index('idx_experiments_owner', 'owner_user_id', 'created_at'),
|
||||
Index('idx_experiments_schedule_run', 'schedule_run_id'),
|
||||
Index('idx_experiments_version', 'versions_id'),
|
||||
Index('idx_experiments_workspace', 'workspace_id', 'experiment_status', 'created_at'),
|
||||
{'comment': '实验记录'}
|
||||
)
|
||||
|
||||
experiment_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
owner_user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
experiment_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
source_type: Mapped[str] = mapped_column(String(24), nullable=False, comment='python/notebook/schedule/rerun')
|
||||
experiment_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'queued'"))
|
||||
state_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"), comment='乐观锁版本')
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
script_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
versions_id: Mapped[Optional[str]] = mapped_column(CHAR(26), comment='工作副本运行时可为空')
|
||||
schedule_run_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
parent_experiment_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
parameters_json: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
environment_json: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
result_summary: Mapped[Optional[str]] = mapped_column(String(2000))
|
||||
logs_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
result_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
duration_ms: Mapped[Optional[int]] = mapped_column(BIGINT)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
logs_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[logs_object_id], back_populates='experiments')
|
||||
owner_user: Mapped['Users'] = relationship('Users', back_populates='experiments')
|
||||
parent_experiment: Mapped[Optional['Experiments']] = relationship('Experiments', remote_side=[experiment_id], back_populates='parent_experiment_reverse')
|
||||
parent_experiment_reverse: Mapped[list['Experiments']] = relationship('Experiments', remote_side=[parent_experiment_id], back_populates='parent_experiment')
|
||||
result_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[result_object_id], back_populates='experiments_')
|
||||
schedule_run: Mapped[Optional['ScheduleRuns']] = relationship('ScheduleRuns', back_populates='experiments')
|
||||
script: Mapped[Optional['Scripts']] = relationship('Scripts', back_populates='experiments')
|
||||
versions: Mapped[Optional['Versions']] = relationship('Versions', back_populates='experiments')
|
||||
workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='experiments')
|
||||
experiment_metrics: Mapped[list['ExperimentMetrics']] = relationship('ExperimentMetrics', back_populates='experiment')
|
||||
experiment_resources: Mapped[list['ExperimentResources']] = relationship('ExperimentResources', back_populates='experiment')
|
||||
|
||||
|
||||
class ScheduleNodes(Base):
|
||||
__tablename__ = 'schedule_nodes'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['schedule_id'], ['schedules.schedule_id'], ondelete='CASCADE', name='fk_schedule_nodes_schedule'),
|
||||
ForeignKeyConstraint(['versions_id'], ['versions.versions_id'], ondelete='RESTRICT', name='fk_schedule_nodes_version'),
|
||||
Index('idx_schedule_nodes_version', 'versions_id'),
|
||||
Index('uk_schedule_nodes_key', 'schedule_id', 'node_key', unique=True),
|
||||
{'comment': 'DAG 节点,必须引用稳定版本'}
|
||||
)
|
||||
|
||||
node_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
schedule_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
node_key: Mapped[str] = mapped_column(String(64), nullable=False, comment='画布内稳定标识')
|
||||
node_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
versions_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
timeout_seconds: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("600"))
|
||||
retry_count: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"))
|
||||
retry_interval_sec: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("5"))
|
||||
position_x: Mapped[decimal.Decimal] = mapped_column(DECIMAL(10, 2), nullable=False, server_default=text("0.00"))
|
||||
position_y: Mapped[decimal.Decimal] = mapped_column(DECIMAL(10, 2), nullable=False, server_default=text("0.00"))
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
|
||||
arguments_json: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
env_refs_json: Mapped[Optional[dict]] = mapped_column(JSON, comment='只存密钥引用,不存明文密钥')
|
||||
|
||||
schedule: Mapped['Schedules'] = relationship('Schedules', back_populates='schedule_nodes')
|
||||
versions: Mapped['Versions'] = relationship('Versions', back_populates='schedule_nodes')
|
||||
schedule_edges: Mapped[list['ScheduleEdges']] = relationship('ScheduleEdges', foreign_keys='[ScheduleEdges.source_node_id]', back_populates='source_node')
|
||||
schedule_edges_: Mapped[list['ScheduleEdges']] = relationship('ScheduleEdges', foreign_keys='[ScheduleEdges.target_node_id]', back_populates='target_node')
|
||||
schedule_node_runs: Mapped[list['ScheduleNodeRuns']] = relationship('ScheduleNodeRuns', back_populates='node')
|
||||
|
||||
|
||||
class ExperimentMetrics(Base):
|
||||
__tablename__ = 'experiment_metrics'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['experiment_id'], ['experiments.experiment_id'], ondelete='CASCADE', name='fk_experiment_metrics_experiment'),
|
||||
Index('idx_experiment_metrics_lookup', 'experiment_id', 'metric_name', 'step_no'),
|
||||
{'comment': '实验指标,支持筛选和曲线'}
|
||||
)
|
||||
|
||||
metric_id: Mapped[int] = mapped_column(BIGINT, primary_key=True)
|
||||
experiment_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
recorded_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
metric_value: Mapped[Optional[decimal.Decimal]] = mapped_column(Double(asdecimal=True))
|
||||
metric_text: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
step_no: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
|
||||
experiment: Mapped['Experiments'] = relationship('Experiments', back_populates='experiment_metrics')
|
||||
|
||||
|
||||
class ExperimentResources(Base):
|
||||
__tablename__ = 'experiment_resources'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['experiment_id'], ['experiments.experiment_id'], ondelete='CASCADE', name='fk_experiment_resources_experiment'),
|
||||
ForeignKeyConstraint(['resource_id'], ['data_resources.resource_id'], ondelete='RESTRICT', name='fk_experiment_resources_resource'),
|
||||
Index('fk_experiment_resources_resource', 'resource_id'),
|
||||
{'comment': '实验与数据资源'}
|
||||
)
|
||||
|
||||
experiment_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
resource_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
resource_role: Mapped[str] = mapped_column(String(16), primary_key=True, server_default=text("'input'"), comment='input/output')
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
|
||||
experiment: Mapped['Experiments'] = relationship('Experiments', back_populates='experiment_resources')
|
||||
resource: Mapped['DataResources'] = relationship('DataResources', back_populates='experiment_resources')
|
||||
|
||||
|
||||
class ScheduleEdges(Base):
|
||||
__tablename__ = 'schedule_edges'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['schedule_id'], ['schedules.schedule_id'], ondelete='CASCADE', name='fk_schedule_edges_schedule'),
|
||||
ForeignKeyConstraint(['source_node_id'], ['schedule_nodes.node_id'], ondelete='CASCADE', name='fk_schedule_edges_source'),
|
||||
ForeignKeyConstraint(['target_node_id'], ['schedule_nodes.node_id'], ondelete='CASCADE', name='fk_schedule_edges_target'),
|
||||
Index('fk_schedule_edges_source', 'source_node_id'),
|
||||
Index('idx_schedule_edges_target', 'target_node_id'),
|
||||
Index('uk_schedule_edges_pair', 'schedule_id', 'source_node_id', 'target_node_id', unique=True),
|
||||
{'comment': 'DAG 有向边'}
|
||||
)
|
||||
|
||||
edge_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
schedule_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
source_node_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
target_node_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
condition_expr: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
|
||||
schedule: Mapped['Schedules'] = relationship('Schedules', back_populates='schedule_edges')
|
||||
source_node: Mapped['ScheduleNodes'] = relationship('ScheduleNodes', foreign_keys=[source_node_id], back_populates='schedule_edges')
|
||||
target_node: Mapped['ScheduleNodes'] = relationship('ScheduleNodes', foreign_keys=[target_node_id], back_populates='schedule_edges_')
|
||||
|
||||
|
||||
class ScheduleNodeRuns(Base):
|
||||
__tablename__ = 'schedule_node_runs'
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(['logs_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_node_runs_logs'),
|
||||
ForeignKeyConstraint(['node_id'], ['schedule_nodes.node_id'], ondelete='RESTRICT', name='fk_node_runs_node'),
|
||||
ForeignKeyConstraint(['result_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_node_runs_result'),
|
||||
ForeignKeyConstraint(['run_id'], ['schedule_runs.run_id'], ondelete='CASCADE', name='fk_node_runs_run'),
|
||||
ForeignKeyConstraint(['versions_id'], ['versions.versions_id'], ondelete='RESTRICT', name='fk_node_runs_version'),
|
||||
Index('fk_node_runs_logs', 'logs_object_id'),
|
||||
Index('fk_node_runs_node', 'node_id'),
|
||||
Index('fk_node_runs_result', 'result_object_id'),
|
||||
Index('idx_node_runs_status', 'run_id', 'node_status'),
|
||||
Index('idx_node_runs_version', 'versions_id'),
|
||||
Index('uk_node_runs_attempt', 'run_id', 'node_id', 'attempt_no', unique=True),
|
||||
{'comment': '调度节点运行与重试'}
|
||||
)
|
||||
|
||||
node_run_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
run_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
node_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
versions_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
attempt_no: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("1"))
|
||||
node_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'queued'"), comment='queued/running/succeeded/failed/skipped/cancelled/timed_out')
|
||||
state_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"), comment='乐观锁版本')
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
|
||||
started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
duration_ms: Mapped[Optional[int]] = mapped_column(BIGINT)
|
||||
exit_code: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
message: Mapped[Optional[str]] = mapped_column(String(2000))
|
||||
metrics_json: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
logs_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
result_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
|
||||
logs_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[logs_object_id], back_populates='schedule_node_runs')
|
||||
node: Mapped['ScheduleNodes'] = relationship('ScheduleNodes', back_populates='schedule_node_runs')
|
||||
result_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[result_object_id], back_populates='schedule_node_runs_')
|
||||
run: Mapped['ScheduleRuns'] = relationship('ScheduleRuns', back_populates='schedule_node_runs')
|
||||
versions: Mapped['Versions'] = relationship('Versions', back_populates='schedule_node_runs')
|
||||
@@ -1,5 +1,50 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
@Time :2026/7/29
|
||||
@Author :tao.chen
|
||||
"""
|
||||
from common.db.base import Base
|
||||
from common.db.models.audit import AuditLogs
|
||||
from common.db.models.events import ConsumerInbox, OutboxEvents
|
||||
from common.db.models.experiments import (
|
||||
ExperimentMetrics,
|
||||
ExperimentResources,
|
||||
Experiments,
|
||||
)
|
||||
from common.db.models.identity import Permissions, RolePermissions, Roles, Users
|
||||
from common.db.models.runtime import EditSessions, RuntimeInstances, WorkspaceOperations
|
||||
from common.db.models.schedules import (
|
||||
ScheduleEdges,
|
||||
ScheduleNodeRuns,
|
||||
ScheduleNodes,
|
||||
ScheduleRuns,
|
||||
Schedules,
|
||||
)
|
||||
from common.db.models.scripts import NotebookSnapshots, Scripts, Versions
|
||||
from common.db.models.storage import DataResources, StorageObjects, UploadSessions
|
||||
from common.db.models.workspaces import WorkspaceMembers, Workspaces
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"AuditLogs",
|
||||
"ConsumerInbox",
|
||||
"DataResources",
|
||||
"EditSessions",
|
||||
"ExperimentMetrics",
|
||||
"ExperimentResources",
|
||||
"Experiments",
|
||||
"NotebookSnapshots",
|
||||
"OutboxEvents",
|
||||
"Permissions",
|
||||
"RolePermissions",
|
||||
"Roles",
|
||||
"RuntimeInstances",
|
||||
"ScheduleEdges",
|
||||
"ScheduleNodeRuns",
|
||||
"ScheduleNodes",
|
||||
"ScheduleRuns",
|
||||
"Schedules",
|
||||
"Scripts",
|
||||
"StorageObjects",
|
||||
"UploadSessions",
|
||||
"Users",
|
||||
"Versions",
|
||||
"WorkspaceMembers",
|
||||
"WorkspaceOperations",
|
||||
"Workspaces",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Index, JSON, String, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT, CHAR, DATETIME, TINYINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from common.db.base import Base
|
||||
|
||||
|
||||
class AuditLogs(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
__table_args__ = (
|
||||
Index("idx_audit_action_time", "action_code", "created_at"),
|
||||
Index("idx_audit_actor_time", "actor_user_id", "created_at"),
|
||||
Index("idx_audit_workspace_time", "workspace_id", "created_at"),
|
||||
{"comment": "操作审计日志"},
|
||||
)
|
||||
|
||||
audit_id: Mapped[int] = mapped_column(BIGINT, primary_key=True)
|
||||
action_code: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
target_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
operation_status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default=text("'success'")
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
workspace_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
actor_user_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
target_id: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
client_ip: Mapped[Optional[str]] = mapped_column(String(45))
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
detail_json: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
@@ -0,0 +1,79 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Index, JSON, String, text
|
||||
from sqlalchemy.dialects.mysql import CHAR, DATETIME, INTEGER, SMALLINT, TINYINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from common.db.base import Base
|
||||
|
||||
|
||||
class ConsumerInbox(Base):
|
||||
__tablename__ = "consumer_inbox"
|
||||
__table_args__ = (
|
||||
Index("idx_consumer_inbox_status", "consumer_name", "process_status", "created_at"),
|
||||
{"comment": "消费者幂等 Inbox,防止 Stream 重投导致重复执行"},
|
||||
)
|
||||
|
||||
consumer_name: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
event_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
process_status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
server_default=text("'processing'"),
|
||||
comment="processing/succeeded/failed",
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
message_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(128), comment="Inbox message ID"
|
||||
)
|
||||
processed_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
error_message: Mapped[Optional[str]] = mapped_column(String(2000))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class OutboxEvents(Base):
|
||||
__tablename__ = "outbox_events"
|
||||
__table_args__ = (
|
||||
Index("idx_outbox_aggregate", "aggregate_type", "aggregate_id", "created_at"),
|
||||
Index("idx_outbox_idempotency", "idempotency_key"),
|
||||
Index("idx_outbox_pending", "event_status", "available_at", "created_at"),
|
||||
{"comment": "事务 Outbox;提交后发布到内部事件总线"},
|
||||
)
|
||||
|
||||
event_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
aggregate_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
aggregate_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
schema_version: Mapped[int] = mapped_column(
|
||||
SMALLINT, nullable=False, server_default=text("1")
|
||||
)
|
||||
payload_json: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
event_status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
server_default=text("'pending'"),
|
||||
comment="pending/published/failed",
|
||||
)
|
||||
available_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
retry_count: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("0")
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
trace_id: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
idempotency_key: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
published_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
last_error: Mapped[Optional[str]] = mapped_column(String(2000))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
@@ -0,0 +1,112 @@
|
||||
import datetime
|
||||
import decimal
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Double, Index, JSON, String, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT, CHAR, DATETIME, INTEGER, TINYINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from common.db.base import Base
|
||||
|
||||
|
||||
class Experiments(Base):
|
||||
__tablename__ = "experiments"
|
||||
__table_args__ = (
|
||||
Index("fk_experiments_logs", "logs_object_id"),
|
||||
Index("fk_experiments_parent", "parent_experiment_id"),
|
||||
Index("fk_experiments_result", "result_object_id"),
|
||||
Index("fk_experiments_script", "script_id"),
|
||||
Index("idx_experiments_owner", "owner_user_id", "created_at"),
|
||||
Index("idx_experiments_schedule_run", "schedule_run_id"),
|
||||
Index("idx_experiments_version", "versions_id"),
|
||||
Index(
|
||||
"idx_experiments_workspace",
|
||||
"workspace_id",
|
||||
"experiment_status",
|
||||
"created_at",
|
||||
),
|
||||
{"comment": "实验记录"},
|
||||
)
|
||||
|
||||
experiment_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
owner_user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
experiment_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
source_type: Mapped[str] = mapped_column(
|
||||
String(24), nullable=False, comment="python/notebook/schedule/rerun"
|
||||
)
|
||||
experiment_status: Mapped[str] = mapped_column(
|
||||
String(24), nullable=False, server_default=text("'queued'")
|
||||
)
|
||||
state_version: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("0"), comment="乐观锁版本"
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
script_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
versions_id: Mapped[Optional[str]] = mapped_column(
|
||||
CHAR(26), comment="工作副本运行时可为空"
|
||||
)
|
||||
schedule_run_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
parent_experiment_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
parameters_json: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
environment_json: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
result_summary: Mapped[Optional[str]] = mapped_column(String(2000))
|
||||
logs_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
result_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
duration_ms: Mapped[Optional[int]] = mapped_column(BIGINT)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class ExperimentMetrics(Base):
|
||||
__tablename__ = "experiment_metrics"
|
||||
__table_args__ = (
|
||||
Index("idx_experiment_metrics_lookup", "experiment_id", "metric_name", "step_no"),
|
||||
{"comment": "实验指标,支持筛选和曲线"},
|
||||
)
|
||||
|
||||
metric_id: Mapped[int] = mapped_column(BIGINT, primary_key=True)
|
||||
experiment_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
recorded_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
metric_value: Mapped[Optional[decimal.Decimal]] = mapped_column(
|
||||
Double(asdecimal=True)
|
||||
)
|
||||
metric_text: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
step_no: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class ExperimentResources(Base):
|
||||
__tablename__ = "experiment_resources"
|
||||
__table_args__ = (
|
||||
Index("fk_experiment_resources_resource", "resource_id"),
|
||||
{"comment": "实验与数据资源"},
|
||||
)
|
||||
|
||||
experiment_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
resource_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
resource_role: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
primary_key=True,
|
||||
server_default=text("'input'"),
|
||||
comment="input/output",
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
@@ -0,0 +1,117 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Index, String, text
|
||||
from sqlalchemy.dialects.mysql import CHAR, DATETIME, TINYINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from common.db.base import Base
|
||||
|
||||
|
||||
class Permissions(Base):
|
||||
__tablename__ = "permissions"
|
||||
__table_args__ = (
|
||||
Index("idx_permissions_module", "module_code"),
|
||||
Index("uk_permissions_code", "permission_code", unique=True),
|
||||
{"comment": "权限点"},
|
||||
)
|
||||
|
||||
permission_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
permission_code: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
permission_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
module_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class Roles(Base):
|
||||
__tablename__ = "roles"
|
||||
__table_args__ = (
|
||||
Index("uk_roles_code", "role_code", unique=True),
|
||||
{"comment": "角色"},
|
||||
)
|
||||
|
||||
role_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
role_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
role_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
role_scope: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="platform/workspace"
|
||||
)
|
||||
is_builtin: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"),
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class RolePermissions(Base):
|
||||
__tablename__ = "role_permissions"
|
||||
__table_args__ = (
|
||||
Index("fk_role_permissions_permission", "permission_id"),
|
||||
{"comment": "角色权限"},
|
||||
)
|
||||
|
||||
role_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
permission_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class Users(Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
Index("fk_users_platform_role", "platform_role_id"),
|
||||
Index("idx_users_status", "status"),
|
||||
Index("uk_users_email", "email", unique=True),
|
||||
Index("uk_users_username", "username", unique=True),
|
||||
{"comment": "平台用户"},
|
||||
)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
server_default=text("'active'"),
|
||||
comment="active/disabled/locked",
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"),
|
||||
)
|
||||
email: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
platform_role_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
avatar_uri: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
last_login_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
@@ -0,0 +1,168 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BINARY, Index, String, Text, text
|
||||
from sqlalchemy.dialects.mysql import CHAR, DATETIME, INTEGER, TINYINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from common.db.base import Base
|
||||
|
||||
|
||||
class RuntimeInstances(Base):
|
||||
__tablename__ = "runtime_instances"
|
||||
__table_args__ = (
|
||||
Index("fk_runtime_started_by", "started_by"),
|
||||
Index("idx_runtime_lease", "actual_state", "lease_expires_at"),
|
||||
Index("idx_runtime_owner_state", "owner_user_id", "actual_state"),
|
||||
Index(
|
||||
"idx_runtime_workspace_state",
|
||||
"workspace_id",
|
||||
"runtime_type",
|
||||
"actual_state",
|
||||
),
|
||||
{"comment": "Jupyter/未来 VS Code、OpenCode Runtime 实例"},
|
||||
)
|
||||
|
||||
runtime_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
runtime_type: Mapped[str] = mapped_column(
|
||||
String(24), nullable=False, server_default=text("'jupyter'")
|
||||
)
|
||||
runtime_provider: Mapped[str] = mapped_column(
|
||||
String(24), nullable=False, comment="process/docker/kubernetes"
|
||||
)
|
||||
proxy_base_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
desired_state: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default=text("'running'")
|
||||
)
|
||||
actual_state: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
nullable=False,
|
||||
server_default=text("'provisioning'"),
|
||||
comment="provisioning/starting/running/unhealthy/stopping/stopped/failed",
|
||||
)
|
||||
state_version: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("0"), comment="乐观锁版本"
|
||||
)
|
||||
started_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"),
|
||||
)
|
||||
owner_user_id: Mapped[Optional[str]] = mapped_column(
|
||||
CHAR(26), comment="为空表示 Workspace 级 Runtime"
|
||||
)
|
||||
runtime_ref: Mapped[Optional[str]] = mapped_column(
|
||||
String(255), comment="PID/container ID/pod UID"
|
||||
)
|
||||
host_node: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
internal_url: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
last_heartbeat_at: Mapped[Optional[datetime.datetime]] = mapped_column(
|
||||
DATETIME(fsp=3)
|
||||
)
|
||||
lease_expires_at: Mapped[Optional[datetime.datetime]] = mapped_column(
|
||||
DATETIME(fsp=3)
|
||||
)
|
||||
stopped_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class EditSessions(Base):
|
||||
__tablename__ = "edit_sessions"
|
||||
__table_args__ = (
|
||||
Index("fk_edit_sessions_workspace", "workspace_id"),
|
||||
Index(
|
||||
"idx_edit_sessions_object",
|
||||
"storage_object_id",
|
||||
"session_status",
|
||||
"expires_at",
|
||||
),
|
||||
Index("idx_edit_sessions_runtime", "runtime_id", "session_status"),
|
||||
Index("idx_edit_sessions_user", "user_id", "session_status"),
|
||||
{"comment": "编辑会话审计"},
|
||||
)
|
||||
|
||||
edit_session_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
storage_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
lock_token_hash: Mapped[bytes] = mapped_column(
|
||||
BINARY(32), nullable=False, comment="不保存原始 token"
|
||||
)
|
||||
session_status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
server_default=text("'active'"),
|
||||
comment="active/closed/expired/failed",
|
||||
)
|
||||
started_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
last_heartbeat_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
expires_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False)
|
||||
runtime_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
jupyter_session_id: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
ended_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
end_reason: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class WorkspaceOperations(Base):
|
||||
__tablename__ = "workspace_operations"
|
||||
__table_args__ = (
|
||||
Index("fk_workspace_operations_user", "requested_by"),
|
||||
Index("idx_workspace_operations_runtime", "runtime_id", "created_at"),
|
||||
Index(
|
||||
"idx_workspace_operations_workspace",
|
||||
"workspace_id",
|
||||
"operation_status",
|
||||
"created_at",
|
||||
),
|
||||
Index("uk_workspace_operations_request", "request_id", unique=True),
|
||||
{"comment": "无状态 Backend 的 Workspace/Jupyter 异步操作记录"},
|
||||
)
|
||||
|
||||
operation_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
operation_type: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
nullable=False,
|
||||
comment="open/close/mount/unmount/start/stop/restart/recycle",
|
||||
)
|
||||
operation_status: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
nullable=False,
|
||||
server_default=text("'pending'"),
|
||||
comment="pending/running/succeeded/failed/cancelled",
|
||||
)
|
||||
state_version: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("0"), comment="乐观锁版本"
|
||||
)
|
||||
requested_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
runtime_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
request_id: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
error_code: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
@@ -0,0 +1,245 @@
|
||||
import datetime
|
||||
import decimal
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DECIMAL, Index, Integer, JSON, String, Text, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT, CHAR, DATETIME, INTEGER, TINYINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from common.db.base import Base
|
||||
|
||||
|
||||
class Schedules(Base):
|
||||
__tablename__ = "schedules"
|
||||
__table_args__ = (
|
||||
Index("fk_schedules_created_by", "created_by"),
|
||||
Index("fk_schedules_updated_by", "updated_by"),
|
||||
Index("idx_schedules_due", "enabled", "next_run_at"),
|
||||
Index("idx_schedules_workspace", "workspace_id", "enabled", "updated_at"),
|
||||
{"comment": "调度方案"},
|
||||
)
|
||||
|
||||
schedule_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
schedule_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
trigger_type: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
server_default=text("'cron'"),
|
||||
comment="manual/cron/api",
|
||||
)
|
||||
timezone: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, server_default=text("'Asia/Shanghai'")
|
||||
)
|
||||
enabled: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
workflow_version: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("1")
|
||||
)
|
||||
max_concurrency: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("1")
|
||||
)
|
||||
failure_policy: Mapped[str] = mapped_column(
|
||||
String(24), nullable=False, server_default=text("'stop'"), comment="stop/continue"
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
updated_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"),
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
cron_expression: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
last_run_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
next_run_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class ScheduleRuns(Base):
|
||||
__tablename__ = "schedule_runs"
|
||||
__table_args__ = (
|
||||
Index("fk_schedule_runs_logs", "logs_object_id"),
|
||||
Index("fk_schedule_runs_result", "result_object_id"),
|
||||
Index("fk_schedule_runs_user", "triggered_by"),
|
||||
Index("idx_schedule_runs_schedule", "schedule_id", "created_at"),
|
||||
Index("idx_schedule_runs_status", "run_status", "queued_at"),
|
||||
Index(
|
||||
"idx_schedule_runs_workspace_status",
|
||||
"workspace_id",
|
||||
"run_status",
|
||||
"queued_at",
|
||||
),
|
||||
Index("uk_schedule_runs_idempotency", "idempotency_key", unique=True),
|
||||
{"comment": "调度运行"},
|
||||
)
|
||||
|
||||
run_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
schedule_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
workflow_version: Mapped[int] = mapped_column(INTEGER, nullable=False)
|
||||
trigger_type: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="manual/cron/api/retry"
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
run_status: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
nullable=False,
|
||||
server_default=text("'queued'"),
|
||||
comment="queued/running/succeeded/failed/cancelled/timed_out",
|
||||
)
|
||||
state_version: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("0"), comment="乐观锁版本"
|
||||
)
|
||||
schedule_snapshot: Mapped[dict] = mapped_column(
|
||||
JSON, nullable=False, comment="执行时 DAG 快照"
|
||||
)
|
||||
queued_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
triggered_by: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
duration_ms: Mapped[Optional[int]] = mapped_column(BIGINT)
|
||||
error_code: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text)
|
||||
logs_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
result_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class ScheduleNodes(Base):
|
||||
__tablename__ = "schedule_nodes"
|
||||
__table_args__ = (
|
||||
Index("idx_schedule_nodes_version", "versions_id"),
|
||||
Index("uk_schedule_nodes_key", "schedule_id", "node_key", unique=True),
|
||||
{"comment": "DAG 节点,必须引用稳定版本"},
|
||||
)
|
||||
|
||||
node_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
schedule_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
node_key: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, comment="画布内稳定标识"
|
||||
)
|
||||
node_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
versions_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
timeout_seconds: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("600")
|
||||
)
|
||||
retry_count: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("0")
|
||||
)
|
||||
retry_interval_sec: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("5")
|
||||
)
|
||||
position_x: Mapped[decimal.Decimal] = mapped_column(
|
||||
DECIMAL(10, 2), nullable=False, server_default=text("0.00")
|
||||
)
|
||||
position_y: Mapped[decimal.Decimal] = mapped_column(
|
||||
DECIMAL(10, 2), nullable=False, server_default=text("0.00")
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"),
|
||||
)
|
||||
arguments_json: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
env_refs_json: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON, comment="只存密钥引用,不存明文密钥"
|
||||
)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class ScheduleEdges(Base):
|
||||
__tablename__ = "schedule_edges"
|
||||
__table_args__ = (
|
||||
Index("fk_schedule_edges_source", "source_node_id"),
|
||||
Index("idx_schedule_edges_target", "target_node_id"),
|
||||
Index(
|
||||
"uk_schedule_edges_pair",
|
||||
"schedule_id",
|
||||
"source_node_id",
|
||||
"target_node_id",
|
||||
unique=True,
|
||||
),
|
||||
{"comment": "DAG 有向边"},
|
||||
)
|
||||
|
||||
edge_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
schedule_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
source_node_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
target_node_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
condition_expr: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class ScheduleNodeRuns(Base):
|
||||
__tablename__ = "schedule_node_runs"
|
||||
__table_args__ = (
|
||||
Index("fk_node_runs_logs", "logs_object_id"),
|
||||
Index("fk_node_runs_node", "node_id"),
|
||||
Index("fk_node_runs_result", "result_object_id"),
|
||||
Index("idx_node_runs_status", "run_id", "node_status"),
|
||||
Index("idx_node_runs_version", "versions_id"),
|
||||
Index(
|
||||
"uk_node_runs_attempt", "run_id", "node_id", "attempt_no", unique=True
|
||||
),
|
||||
{"comment": "调度节点运行与重试"},
|
||||
)
|
||||
|
||||
node_run_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
run_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
node_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
versions_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
attempt_no: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("1")
|
||||
)
|
||||
node_status: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
nullable=False,
|
||||
server_default=text("'queued'"),
|
||||
comment="queued/running/succeeded/failed/skipped/cancelled/timed_out",
|
||||
)
|
||||
state_version: Mapped[int] = mapped_column(
|
||||
INTEGER, nullable=False, server_default=text("0"), comment="乐观锁版本"
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
duration_ms: Mapped[Optional[int]] = mapped_column(BIGINT)
|
||||
exit_code: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
message: Mapped[Optional[str]] = mapped_column(String(2000))
|
||||
metrics_json: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
logs_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
result_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
@@ -0,0 +1,148 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Index, String, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT, CHAR, DATETIME, INTEGER, TINYINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from common.db.base import Base
|
||||
|
||||
|
||||
class Scripts(Base):
|
||||
__tablename__ = "scripts"
|
||||
__table_args__ = (
|
||||
Index("idx_scripts_owner", "owner_user_id", "status"),
|
||||
Index(
|
||||
"idx_scripts_workspace",
|
||||
"workspace_id",
|
||||
"script_type",
|
||||
"visibility",
|
||||
"status",
|
||||
),
|
||||
Index("uk_scripts_current_object", "current_object_id", unique=True),
|
||||
Index(
|
||||
"uk_scripts_workspace_name",
|
||||
"workspace_id",
|
||||
"script_name",
|
||||
"script_type",
|
||||
unique=True,
|
||||
),
|
||||
{"comment": "可执行 Python/Notebook 脚本"},
|
||||
)
|
||||
|
||||
script_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
current_object_id: Mapped[str] = mapped_column(
|
||||
CHAR(26), nullable=False, comment="当前工作副本"
|
||||
)
|
||||
owner_user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
script_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
script_type: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="python/notebook"
|
||||
)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default=text("'private'")
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default=text("'active'")
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"),
|
||||
)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
is_locked: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("1")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class NotebookSnapshots(Base):
|
||||
__tablename__ = "notebook_snapshots"
|
||||
__table_args__ = (
|
||||
Index("fk_snapshots_created_by", "created_by"),
|
||||
Index("fk_snapshots_source_object", "source_object_id"),
|
||||
Index("idx_snapshots_workspace_created", "workspace_id", "created_at"),
|
||||
Index("uk_snapshots_artifact", "artifact_object_id", unique=True),
|
||||
Index("uk_snapshots_script_hash", "script_id", "content_hash", unique=True),
|
||||
{"comment": "Notebook 开发快照,append-only"},
|
||||
)
|
||||
|
||||
snapshot_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
script_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
source_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
artifact_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
snapshot_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content_hash: Mapped[str] = mapped_column(CHAR(64), nullable=False)
|
||||
outputs_stripped: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("1")
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class Versions(Base):
|
||||
__tablename__ = "versions"
|
||||
__table_args__ = (
|
||||
Index("fk_versions_source_object", "source_object_id"),
|
||||
Index("idx_versions_creator", "created_by", "created_at"),
|
||||
Index("idx_versions_workspace_created", "workspace_id", "created_at"),
|
||||
Index("uk_versions_artifact", "artifact_object_id", unique=True),
|
||||
Index("uk_versions_script_hash", "script_id", "content_hash", unique=True),
|
||||
Index("uk_versions_script_no", "script_id", "version_no", unique=True),
|
||||
{"comment": "不可变稳定版本;调度节点必须引用 versions_id"},
|
||||
)
|
||||
|
||||
versions_id: Mapped[str] = mapped_column(
|
||||
CHAR(26), primary_key=True, comment="稳定版本唯一 ID"
|
||||
)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
script_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
source_object_id: Mapped[str] = mapped_column(
|
||||
CHAR(26), nullable=False, comment="发布时的源对象"
|
||||
)
|
||||
artifact_object_id: Mapped[str] = mapped_column(
|
||||
CHAR(26), nullable=False, comment="RustFS 不可变版本制品"
|
||||
)
|
||||
version_no: Mapped[int] = mapped_column(INTEGER, nullable=False)
|
||||
version_label: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, comment="例如 v1.0"
|
||||
)
|
||||
source_path: Mapped[str] = mapped_column(
|
||||
String(1024), nullable=False, comment="发布时路径快照"
|
||||
)
|
||||
artifact_path: Mapped[str] = mapped_column(String(1500), nullable=False)
|
||||
content_hash: Mapped[str] = mapped_column(CHAR(64), nullable=False)
|
||||
file_size_bytes: Mapped[int] = mapped_column(
|
||||
BIGINT, nullable=False, server_default=text("0")
|
||||
)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default=text("'private'")
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
release_note: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
schedule_hidden_at: Mapped[Optional[datetime.datetime]] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
comment="从调度稳定版本列表移除的时间;不影响版本和运行历史",
|
||||
)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
@@ -0,0 +1,198 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BINARY, Index, JSON, String, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT, CHAR, DATETIME, TINYINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from common.db.base import Base
|
||||
|
||||
|
||||
class StorageObjects(Base):
|
||||
__tablename__ = "storage_objects"
|
||||
__table_args__ = (
|
||||
Index("fk_storage_created_by", "created_by"),
|
||||
Index("idx_storage_content_hash", "content_hash"),
|
||||
Index("idx_storage_owner", "owner_user_id", "object_status"),
|
||||
Index("idx_storage_parent", "parent_object_id"),
|
||||
Index(
|
||||
"idx_storage_workspace_usage",
|
||||
"workspace_id",
|
||||
"usage_type",
|
||||
"object_status",
|
||||
),
|
||||
Index(
|
||||
"uk_storage_bucket_key",
|
||||
"storage_backend",
|
||||
"bucket_name",
|
||||
"object_key_hash",
|
||||
unique=True,
|
||||
),
|
||||
Index(
|
||||
"uk_storage_workspace_path",
|
||||
"workspace_id",
|
||||
"storage_backend",
|
||||
"path_hash",
|
||||
unique=True,
|
||||
),
|
||||
{"comment": "Workspace 文件和 RustFS 对象的统一元数据"},
|
||||
)
|
||||
|
||||
storage_object_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
object_type: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="file/directory"
|
||||
)
|
||||
usage_type: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
comment="working_copy/public_script/data_resource/version_artifact/snapshot/run_log/run_result",
|
||||
)
|
||||
storage_backend: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="workspace_fs/rustfs"
|
||||
)
|
||||
storage_uri: Mapped[str] = mapped_column(String(1500), nullable=False)
|
||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(
|
||||
BIGINT, nullable=False, server_default=text("0")
|
||||
)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
server_default=text("'private'"),
|
||||
comment="private/workspace/public",
|
||||
)
|
||||
is_immutable: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
object_status: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
nullable=False,
|
||||
server_default=text("'available'"),
|
||||
comment="uploading/available/deleting/deleted/failed",
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"),
|
||||
)
|
||||
owner_user_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
parent_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
relative_path: Mapped[Optional[str]] = mapped_column(
|
||||
String(1024), comment="Workspace 相对路径"
|
||||
)
|
||||
path_hash: Mapped[Optional[bytes]] = mapped_column(
|
||||
BINARY(32), comment="SHA-256(relative_path),由应用写入"
|
||||
)
|
||||
bucket_name: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
object_key: Mapped[Optional[str]] = mapped_column(String(1024))
|
||||
object_key_hash: Mapped[Optional[bytes]] = mapped_column(
|
||||
BINARY(32), comment="SHA-256(object_key),由应用写入"
|
||||
)
|
||||
file_extension: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
mime_type: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
content_hash: Mapped[Optional[str]] = mapped_column(
|
||||
CHAR(64), comment="SHA-256 hex"
|
||||
)
|
||||
object_etag: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class DataResources(Base):
|
||||
__tablename__ = "data_resources"
|
||||
__table_args__ = (
|
||||
Index("idx_data_resources_owner", "owner_user_id", "status"),
|
||||
Index(
|
||||
"idx_data_resources_workspace",
|
||||
"workspace_id",
|
||||
"visibility",
|
||||
"status",
|
||||
),
|
||||
Index("uk_data_resources_object", "storage_object_id", unique=True),
|
||||
{"comment": "数据资源"},
|
||||
)
|
||||
|
||||
resource_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
storage_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
owner_user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default=text("'private'")
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default=text("'active'")
|
||||
)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"),
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
schema_json: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON, comment="字段结构、行数等可选元数据"
|
||||
)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class UploadSessions(Base):
|
||||
__tablename__ = "upload_sessions"
|
||||
__table_args__ = (
|
||||
Index("fk_upload_sessions_storage_object", "storage_object_id"),
|
||||
Index("fk_upload_sessions_user", "user_id"),
|
||||
Index("idx_upload_sessions_expiry", "upload_status", "expires_at"),
|
||||
Index(
|
||||
"idx_upload_sessions_object_key", "bucket_name", "object_key_hash"
|
||||
),
|
||||
Index(
|
||||
"idx_upload_sessions_workspace", "workspace_id", "user_id", "created_at"
|
||||
),
|
||||
Index("uk_upload_sessions_idempotency", "idempotency_key", unique=True),
|
||||
{"comment": "RustFS 预签名上传会话;URL 本身不持久化"},
|
||||
)
|
||||
|
||||
upload_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
bucket_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
object_key: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
object_key_hash: Mapped[bytes] = mapped_column(BINARY(32), nullable=False)
|
||||
upload_status: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
nullable=False,
|
||||
server_default=text("'created'"),
|
||||
comment="created/uploading/completed/expired/aborted/failed",
|
||||
)
|
||||
expires_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"),
|
||||
)
|
||||
multipart_upload_id: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
expected_size_bytes: Mapped[Optional[int]] = mapped_column(BIGINT)
|
||||
expected_hash: Mapped[Optional[str]] = mapped_column(CHAR(64))
|
||||
content_type: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
storage_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
|
||||
completed_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
@@ -0,0 +1,85 @@
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Index, String, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT, CHAR, DATETIME, TINYINT
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from common.db.base import Base
|
||||
|
||||
|
||||
class Workspaces(Base):
|
||||
__tablename__ = "workspaces"
|
||||
__table_args__ = (
|
||||
Index("fk_workspaces_created_by", "created_by"),
|
||||
Index("idx_workspaces_status", "status"),
|
||||
Index("uk_workspaces_code", "workspace_code", unique=True),
|
||||
{"comment": "Workspace"},
|
||||
)
|
||||
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
workspace_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
workspace_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
active_root_uri: Mapped[str] = mapped_column(
|
||||
String(1500), nullable=False, comment="活动工作区,建议 NFS/PVC/file URI"
|
||||
)
|
||||
quota_bytes: Mapped[int] = mapped_column(
|
||||
BIGINT, nullable=False, server_default=text("0"), comment="0 表示不限额"
|
||||
)
|
||||
used_bytes: Mapped[int] = mapped_column(
|
||||
BIGINT, nullable=False, server_default=text("0")
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
nullable=False,
|
||||
server_default=text("'active'"),
|
||||
comment="creating/active/suspended/deleting/deleted",
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"),
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
artifact_bucket: Mapped[Optional[str]] = mapped_column(
|
||||
String(128), comment="RustFS bucket"
|
||||
)
|
||||
artifact_prefix: Mapped[Optional[str]] = mapped_column(
|
||||
String(512), comment="RustFS object key prefix"
|
||||
)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
|
||||
|
||||
class WorkspaceMembers(Base):
|
||||
__tablename__ = "workspace_members"
|
||||
__table_args__ = (
|
||||
Index("idx_workspace_members_role", "role_id"),
|
||||
Index("idx_workspace_members_user", "user_id", "member_status"),
|
||||
{"comment": "Workspace 成员与角色"},
|
||||
)
|
||||
|
||||
workspace_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
|
||||
role_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
|
||||
member_status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default=text("'active'")
|
||||
)
|
||||
joined_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DATETIME(fsp=3),
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"),
|
||||
)
|
||||
is_deleted: Mapped[int] = mapped_column(
|
||||
TINYINT(1), nullable=False, server_default=text("0")
|
||||
)
|
||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
from common.db.base import Base
|
||||
from common.db.models import Base
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
@@ -1,7 +1,59 @@
|
||||
# coding=utf-8
|
||||
"""Shared low-level utilities used across services.
|
||||
|
||||
Currently home to two general-purpose helpers that have no runtime-
|
||||
specific concerns:
|
||||
|
||||
- :func:`get_free_port` asks the kernel for a currently-unused TCP port
|
||||
by binding to ``:0`` and reading back the assigned port number.
|
||||
- :func:`start_process` launches a subprocess with stdout/stderr merged
|
||||
into a per-pid log file under ``log_dir`` and returns the final log
|
||||
path so callers can log it themselves with whatever logger they use.
|
||||
"""
|
||||
@Time :2026/7/27
|
||||
@Author :tao.chen
|
||||
"""
|
||||
def hello_world():
|
||||
return 'Hello World!'
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
s.listen(1)
|
||||
port = s.getsockname()[1]
|
||||
return port
|
||||
|
||||
|
||||
def start_process(
|
||||
cmd: list[str],
|
||||
workspace_path: Path,
|
||||
log_dir: str | Path = "/tmp/process_logs",
|
||||
) -> tuple[subprocess.Popen, Path]:
|
||||
"""Launch ``cmd`` as a subprocess and return ``(process, log_file)``.
|
||||
|
||||
stderr is merged into a per-pid log file under ``log_dir``; the
|
||||
temp ``process_start_*.log`` is renamed to ``process_<pid>_*.log``
|
||||
once the real pid is known. The caller logs "I started this" with
|
||||
its own context — this function does not log on its own.
|
||||
"""
|
||||
log_dir_path = Path(log_dir)
|
||||
log_dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
start_time = time.strftime("%Y%m%d_%H%M%S")
|
||||
temp_log = log_dir_path / f"process_start_{start_time}.log"
|
||||
|
||||
with open(temp_log, "a", buffering=1) as log_file:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=workspace_path,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
final_log = log_dir_path / f"process_{process.pid}_{start_time}.log"
|
||||
temp_log.replace(final_log)
|
||||
|
||||
return process, final_log
|
||||
+16
-1
@@ -2,12 +2,27 @@ FROM python:3.12-slim-bookworm
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app
|
||||
WORKDIR /app
|
||||
# 从官方 Rclone 镜像直接复制 rclone 二进制文件(高效、稳定)
|
||||
COPY --from=rclone/rclone:latest /usr/local/bin/rclone /usr/local/bin/rclone
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
|
||||
# 安装系统依赖(fuse3 是 rclone mount 的核心底层依赖)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fuse3 \
|
||||
ca-certificates \
|
||||
curl \
|
||||
procps \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
&& sed -i 's/#user_allow_other/user_allow_other/g' /etc/fuse.conf \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY common ./common
|
||||
COPY contracts ./contracts
|
||||
COPY runtime ./runtime
|
||||
RUN uv sync --frozen --no-dev --no-editable --package runtime
|
||||
RUN UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv sync --frozen --no-dev --no-editable --package runtime
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "--frozen", "--package", "runtime", "uvicorn", "runtime.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -7,7 +7,8 @@ dependencies = [
|
||||
"fastapi==0.116.1",
|
||||
"uvicorn[standard]==0.35.0",
|
||||
"httpx==0.28.1",
|
||||
"redis==5.2.1",
|
||||
"loguru==0.7.2",
|
||||
"notebook",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Runtime Manager application."""
|
||||
@@ -1,449 +0,0 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
@Time :2026/7/27
|
||||
@Author :tao.chen
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from loguru import logger
|
||||
|
||||
# 全局内存字典:记录运行中的 Jupyter 进程信息
|
||||
JUPYTER_PROCESSES: Dict[str, dict] = {}
|
||||
|
||||
WORKSPACES_ROOT = Path(os.getenv("WORKSPACES_ROOT", "/app/workspaces"))
|
||||
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://localhost")
|
||||
REMOTE_BUCKET = os.getenv("REMOTE_BUCKET", "rustfs:workspaces")
|
||||
RCLONE_PROCESS = None
|
||||
|
||||
|
||||
def is_mountpoint(path: Path) -> bool:
|
||||
"""
|
||||
判断目录是否已经挂载
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["mountpoint", "-q", str(path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def start_rclone_mount():
|
||||
"""
|
||||
启动 rclone mount
|
||||
"""
|
||||
global RCLONE_PROCESS
|
||||
if is_mountpoint(WORKSPACES_ROOT):
|
||||
logger.info(
|
||||
f"Mountpoint already exists: {WORKSPACES_ROOT}"
|
||||
)
|
||||
return
|
||||
|
||||
WORKSPACES_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(
|
||||
f"Starting rclone mount "
|
||||
f"{REMOTE_BUCKET} -> {WORKSPACES_ROOT}"
|
||||
)
|
||||
|
||||
log_file = open(
|
||||
"/tmp/rclone-mount.log",
|
||||
"a",
|
||||
buffering=1,
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"rclone",
|
||||
"mount",
|
||||
REMOTE_BUCKET,
|
||||
WORKSPACES_ROOT.as_posix(),
|
||||
"--allow-other",
|
||||
"--vfs-cache-mode","full",
|
||||
"--vfs-cache-max-size","20G",
|
||||
"--vfs-write-back","5s",
|
||||
"--dir-cache-time","30s",
|
||||
"--poll-interval","30s",
|
||||
"--log-level","INFO",
|
||||
]
|
||||
|
||||
RCLONE_PROCESS = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
# 等待 mount ready
|
||||
timeout = 20
|
||||
while timeout > 0:
|
||||
if is_mountpoint(WORKSPACES_ROOT):
|
||||
logger.info(f"rclone mount ready: {WORKSPACES_ROOT}" )
|
||||
return
|
||||
|
||||
# rclone异常退出
|
||||
if RCLONE_PROCESS.poll() is not None:
|
||||
raise RuntimeError( "rclone mount process exited")
|
||||
time.sleep(1)
|
||||
timeout -= 1
|
||||
|
||||
raise RuntimeError( f"Timeout waiting mount: {WORKSPACES_ROOT}")
|
||||
|
||||
|
||||
def stop_rclone_mount():
|
||||
global RCLONE_PROCESS
|
||||
logger.info(
|
||||
"Stopping rclone mount..."
|
||||
)
|
||||
if RCLONE_PROCESS:
|
||||
if RCLONE_PROCESS.poll() is None:
|
||||
RCLONE_PROCESS.terminate()
|
||||
try:
|
||||
RCLONE_PROCESS.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Force killing rclone")
|
||||
RCLONE_PROCESS.kill()
|
||||
|
||||
if is_mountpoint(WORKSPACES_ROOT):
|
||||
logger.info(f"Unmount {WORKSPACES_ROOT}")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"fusermount3",
|
||||
"-u",
|
||||
WORKSPACES_ROOT.as_posix(),
|
||||
]
|
||||
)
|
||||
if result.returncode != 0:
|
||||
subprocess.run(
|
||||
[
|
||||
"umount",
|
||||
"-l",
|
||||
WORKSPACES_ROOT.as_posix(),
|
||||
]
|
||||
)
|
||||
|
||||
logger.info("rclone stopped")
|
||||
|
||||
|
||||
def scan_workspaces():
|
||||
"""
|
||||
扫描已有 workspace
|
||||
"""
|
||||
if not WORKSPACES_ROOT.exists():
|
||||
return
|
||||
try:
|
||||
entries = os.listdir(WORKSPACES_ROOT)
|
||||
except Exception as e:
|
||||
logger.error(f"scan workspace failed: {e}")
|
||||
return
|
||||
|
||||
for entry in entries:
|
||||
path = WORKSPACES_ROOT / entry
|
||||
if not path.is_dir():
|
||||
continue
|
||||
logger.info(f"Found workspace: {entry}" )
|
||||
|
||||
try:
|
||||
full_path = os.path.join(WORKSPACES_ROOT, entry)
|
||||
if os.path.isdir(full_path):
|
||||
logger.info(f"Auto-starting Jupyter for workspace ID: '{entry}'")
|
||||
try:
|
||||
_handle_start(entry)
|
||||
except Exception as err:
|
||||
logger.error(f"Startup failed for workspace '{entry}': {err}")
|
||||
except Exception as e:
|
||||
logger.error(f"Start workspace {entry} failed: {e}" )
|
||||
|
||||
|
||||
def get_free_port() -> int:
|
||||
"""利用操作系统 socket 特性,动态获取当前闲置的可用端口"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
s.listen(1)
|
||||
port = s.getsockname()[1]
|
||||
return port
|
||||
|
||||
|
||||
# 统一请求 Model
|
||||
class JupyterActionRequest(BaseModel):
|
||||
action: str = Field(
|
||||
..., description="操作类型: 'start' | 'stop' | 'list'"
|
||||
)
|
||||
workspace_id: Optional[str] = Field(
|
||||
None, description="Workspace ID (start 和 stop 操作时必填)"
|
||||
)
|
||||
|
||||
|
||||
# 辅助处理函数:启动逻辑
|
||||
def start_process(cmd, workspace_path, log_dir="/tmp/process_logs"):
|
||||
log_dir = Path(log_dir)
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
start_time = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# 临时日志文件
|
||||
temp_log = log_dir / f"process_start_{start_time}.log"
|
||||
|
||||
log_file = open(temp_log, "a", buffering=1)
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=workspace_path,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT, # stderr 合并到 stdout
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
# 根据真实 pid 重命名
|
||||
final_log = log_dir / f"process_{process.pid}_{start_time}.log"
|
||||
log_file.close()
|
||||
|
||||
temp_log.rename(final_log)
|
||||
|
||||
logger.info(
|
||||
f"process started pid={process.pid}, log={final_log}"
|
||||
)
|
||||
|
||||
return process
|
||||
|
||||
|
||||
def _handle_start(ws_id: str):
|
||||
workspace_path = WORKSPACES_ROOT / ws_id
|
||||
|
||||
# 如果已存在,校验进程状态并复用
|
||||
if ws_id in JUPYTER_PROCESSES:
|
||||
p_info = JUPYTER_PROCESSES[ws_id]
|
||||
if p_info["process"].poll() is None:
|
||||
logger.info(f"Workspace {ws_id} already running.")
|
||||
return {
|
||||
"status": "running",
|
||||
"workspace_id": ws_id,
|
||||
"port": p_info["port"],
|
||||
"full_url": p_info["full_url"],
|
||||
}
|
||||
else:
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
|
||||
# 2. 动态申请端口与 Token
|
||||
port = get_free_port()
|
||||
token = secrets.token_hex(16)
|
||||
base_path = f"/jupyter/{ws_id}/"
|
||||
|
||||
cmd = [
|
||||
"jupyter",
|
||||
"notebook",
|
||||
f"--port={port}",
|
||||
"--ip=0.0.0.0",
|
||||
"--no-browser",
|
||||
"--allow-root",
|
||||
f"--ServerApp.token={token}",
|
||||
f"--ServerApp.base_url={base_path}",
|
||||
"--notebook-dir=.",
|
||||
# 适用于现代 Jupyter Server / JupyterLab
|
||||
"--ServerApp.terminals_enabled=False",
|
||||
# 兼容经典 Notebook / 旧版配置项
|
||||
"--NotebookApp.terminals_enabled=False",
|
||||
# 允许 Nginx 跨域代理与 WebSocket 通信(关键)
|
||||
"--ServerApp.allow_origin=*",
|
||||
"--NotebookApp.allow_origin=*",
|
||||
"--ServerApp.disable_check_xsrf=True",
|
||||
"--NotebookApp.disable_check_xsrf=True"
|
||||
]
|
||||
|
||||
try:
|
||||
process = start_process(cmd, workspace_path.as_posix())
|
||||
|
||||
full_url = f"{PUBLIC_BASE_URL}:{port}{base_path}?token={token}"
|
||||
|
||||
JUPYTER_PROCESSES[ws_id] = {
|
||||
"process": process,
|
||||
"base_url": PUBLIC_BASE_URL,
|
||||
"port": port,
|
||||
"token": token,
|
||||
"full_url": full_url,
|
||||
"started_at": time.time(),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Started Jupyter for workspace {ws_id} on port {port}"
|
||||
)
|
||||
return {
|
||||
"pid": process.pid,
|
||||
"base_url": PUBLIC_BASE_URL,
|
||||
"status": "success",
|
||||
"workspace_id": ws_id,
|
||||
"port": port,
|
||||
"token": token,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start Jupyter for {ws_id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to start Jupyter: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# 辅助处理函数:停止逻辑
|
||||
def _handle_stop(ws_id: str):
|
||||
if ws_id not in JUPYTER_PROCESSES:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No active Jupyter process found for workspace '{ws_id}'",
|
||||
)
|
||||
|
||||
p_info = JUPYTER_PROCESSES[ws_id]
|
||||
process: subprocess.Popen = p_info["process"]
|
||||
|
||||
if process.poll() is None:
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=3)
|
||||
logger.info(
|
||||
f"Gracefully stopped Jupyter for workspace {ws_id}"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
f"Jupyter for {ws_id} did not stop gracefully. Force killing..."
|
||||
)
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
return {
|
||||
"status": "stopped",
|
||||
"workspace_id": ws_id,
|
||||
"message": "Jupyter process terminated and port released.",
|
||||
}
|
||||
|
||||
|
||||
# 辅助处理函数:列表查询逻辑
|
||||
def _handle_list():
|
||||
active_instances = {}
|
||||
for ws_id, info in list(JUPYTER_PROCESSES.items()):
|
||||
is_alive = info["process"].poll() is None
|
||||
active_instances[ws_id] = {
|
||||
"port": info["port"],
|
||||
"full_url": info["full_url"],
|
||||
"is_alive": is_alive,
|
||||
}
|
||||
return {"status": "success", "instances": active_instances}
|
||||
|
||||
|
||||
def _handle_get(ws_id: str):
|
||||
"""【新增】获取指定 Workspace 的 Jupyter 运行状态与完整 URL"""
|
||||
if ws_id not in JUPYTER_PROCESSES:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No active Jupyter process found for workspace '{ws_id}'",
|
||||
)
|
||||
|
||||
p_info = JUPYTER_PROCESSES[ws_id]
|
||||
is_alive = p_info["process"].poll() is None
|
||||
|
||||
if not is_alive:
|
||||
# 进程如果挂了,清理内存字典并报 404
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Jupyter process for workspace '{ws_id}' has terminated unexpectedly.",
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "running",
|
||||
"pid": p_info["process"].pid,
|
||||
"base_url": PUBLIC_BASE_URL,
|
||||
"workspace_id": ws_id,
|
||||
"port": p_info["port"],
|
||||
"token": p_info["token"],
|
||||
"started_at": p_info["started_at"],
|
||||
}
|
||||
|
||||
|
||||
# ==================== FastAPI Lifespan 定义 ====================
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global RCLONE_PROCESS
|
||||
logger.info("Starting up Runtime Service...")
|
||||
start_rclone_mount()
|
||||
logger.info(f"Scanning workspaces: {WORKSPACES_ROOT}")
|
||||
scan_workspaces()
|
||||
logger.info("Runtime Service started")
|
||||
|
||||
# ==================== 2. 服务运行阶段 (Serving) ====================
|
||||
try:
|
||||
yield # 服务保持运行,等待并处理 API 请求
|
||||
finally:
|
||||
logger.info("Service is shutting down. Terminating all active Jupyter sub-processes...")
|
||||
|
||||
# 优先杀死所有 Jupyter 子进程(确保文件句柄被释放)
|
||||
active_workspaces = list(JUPYTER_PROCESSES.keys())
|
||||
for ws_id in active_workspaces:
|
||||
try:
|
||||
_handle_stop(ws_id)
|
||||
except Exception as err:
|
||||
logger.error(f"Error terminating Jupyter process for '{ws_id}': {err}")
|
||||
logger.info("All Jupyter sub-processes have been terminated.")
|
||||
JUPYTER_PROCESSES.clear()
|
||||
|
||||
# 卸载 Rclone 挂载点(强制将 VFS 缓存刷新同步至对象存储)
|
||||
try:
|
||||
stop_rclone_mount()
|
||||
except Exception as e:
|
||||
logger.error(f"Stop rclone failed: {e}" )
|
||||
logger.info("Runtime Service stopped")
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
# ---------------- 统一入口 POST 接口 ----------------
|
||||
@app.get("/api/v1/health")
|
||||
def healthz():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/v1/jupyter")
|
||||
def handle_jupyter_action(req: JupyterActionRequest):
|
||||
action = req.action.lower()
|
||||
|
||||
# 1. 启动操作
|
||||
if action == "start":
|
||||
if not req.workspace_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="'workspace_id' is required when action='start'",
|
||||
)
|
||||
return _handle_start(req.workspace_id)
|
||||
|
||||
# 2. 停止操作
|
||||
elif action == "stop":
|
||||
if not req.workspace_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="'workspace_id' is required when action='stop'",
|
||||
)
|
||||
return _handle_stop(req.workspace_id)
|
||||
|
||||
# 3. 列表操作
|
||||
elif action == "list":
|
||||
return _handle_list()
|
||||
|
||||
elif action == "get":
|
||||
if not req.workspace_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="'workspace_id' is required for action='get'",
|
||||
)
|
||||
return _handle_get(req.workspace_id)
|
||||
|
||||
# 未知操作
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid action '{req.action}'. Supported actions: 'start', 'stop', 'list'",
|
||||
)
|
||||
+70
-1117
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
"""Object storage mount management.
|
||||
|
||||
Owns the rclone mount lifecycle for the remote workspace bucket.
|
||||
``WORKSPACES_ROOT`` is defined here because this module is what makes
|
||||
the directory usable; downstream consumers (e.g. process.py) import it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
WORKSPACES_ROOT = Path(os.getenv("WORKSPACES_ROOT", "/app/workspaces"))
|
||||
REMOTE_BUCKET = os.getenv("REMOTE_BUCKET", "rustfs:workspaces")
|
||||
|
||||
RCLONE_PROCESS: subprocess.Popen | None = None
|
||||
|
||||
|
||||
def is_mountpoint(path: Path) -> bool:
|
||||
result = subprocess.run(
|
||||
["mountpoint", "-q", str(path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def start_rclone_mount() -> None:
|
||||
global RCLONE_PROCESS
|
||||
if is_mountpoint(WORKSPACES_ROOT):
|
||||
logger.info(f"Mountpoint already exists: {WORKSPACES_ROOT}")
|
||||
return
|
||||
|
||||
WORKSPACES_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Starting rclone mount {REMOTE_BUCKET} -> {WORKSPACES_ROOT}")
|
||||
|
||||
with open("/tmp/rclone-mount.log", "a", buffering=1) as log_file:
|
||||
cmd = [
|
||||
"rclone",
|
||||
"mount",
|
||||
REMOTE_BUCKET,
|
||||
str(WORKSPACES_ROOT),
|
||||
"--allow-other",
|
||||
"--vfs-cache-mode", "full",
|
||||
"--vfs-cache-max-size", "20G",
|
||||
"--vfs-write-back", "5s",
|
||||
"--dir-cache-time", "30s",
|
||||
"--poll-interval", "30s",
|
||||
"--log-level", "INFO",
|
||||
]
|
||||
RCLONE_PROCESS = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
timeout = 20
|
||||
while timeout > 0:
|
||||
if is_mountpoint(WORKSPACES_ROOT):
|
||||
logger.info(f"rclone mount ready: {WORKSPACES_ROOT}")
|
||||
return
|
||||
if RCLONE_PROCESS.poll() is not None:
|
||||
raise RuntimeError("rclone mount process exited")
|
||||
time.sleep(1)
|
||||
timeout -= 1
|
||||
|
||||
raise RuntimeError(f"Timeout waiting mount: {WORKSPACES_ROOT}")
|
||||
|
||||
|
||||
def stop_rclone_mount() -> None:
|
||||
global RCLONE_PROCESS
|
||||
logger.info("Stopping rclone mount...")
|
||||
if RCLONE_PROCESS:
|
||||
if RCLONE_PROCESS.poll() is None:
|
||||
RCLONE_PROCESS.terminate()
|
||||
try:
|
||||
RCLONE_PROCESS.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Force killing rclone")
|
||||
RCLONE_PROCESS.kill()
|
||||
|
||||
if is_mountpoint(WORKSPACES_ROOT):
|
||||
logger.info(f"Unmount {WORKSPACES_ROOT}")
|
||||
result = subprocess.run(["fusermount3", "-u", str(WORKSPACES_ROOT)])
|
||||
if result.returncode != 0:
|
||||
subprocess.run(["umount", "-l", str(WORKSPACES_ROOT)])
|
||||
|
||||
logger.info("rclone stopped")
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Jupyter process registry and lifecycle.
|
||||
|
||||
Owns the in-memory ``JUPYTER_PROCESSES`` dict, the ``STATE_LOCK`` that
|
||||
serializes mutations to it, and the per-workspace start/stop/list/get
|
||||
operations. Workspace discovery (startup scan) lives here because it is
|
||||
a thin wrapper over ``start_workspace``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
from typing import TypedDict
|
||||
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from common.utils import get_free_port, start_process
|
||||
from runtime.mount import WORKSPACES_ROOT
|
||||
|
||||
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://localhost")
|
||||
|
||||
|
||||
class JupyterProcessRecord(TypedDict):
|
||||
process: subprocess.Popen
|
||||
port: int
|
||||
token: str
|
||||
base_url: str
|
||||
started_at: float
|
||||
|
||||
|
||||
JUPYTER_PROCESSES: dict[str, JupyterProcessRecord] = {}
|
||||
WORKSPACE_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
_LOCKS_REGISTRY = asyncio.Lock()
|
||||
|
||||
|
||||
def get_workspace_lock(ws_id: str) -> asyncio.Lock:
|
||||
"""Return the per-workspace lock, creating it on first use.
|
||||
|
||||
``asyncio.Lock`` is created lazily per event loop; sharing it across
|
||||
loops is unsafe. The instance lives for the lifetime of the process.
|
||||
"""
|
||||
if ws_id in WORKSPACE_LOCKS:
|
||||
return WORKSPACE_LOCKS[ws_id]
|
||||
# We do not need to hold the registry lock long enough to block; a
|
||||
# double-check pattern prevents accidentally replacing an existing
|
||||
# lock. The Lock object is itself safe to call .locked()/acquire on.
|
||||
lock = WORKSPACE_LOCKS.get(ws_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
WORKSPACE_LOCKS[ws_id] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def _drop_workspace_lock(ws_id: str) -> None:
|
||||
"""Remove the per-workspace lock once no process references it.
|
||||
|
||||
Called after a successful stop to keep the registry bounded. We only
|
||||
drop a lock we created and only when it is not held.
|
||||
"""
|
||||
lock = WORKSPACE_LOCKS.get(ws_id)
|
||||
if lock is None or lock.locked():
|
||||
return
|
||||
WORKSPACE_LOCKS.pop(ws_id, None)
|
||||
|
||||
|
||||
async def start_workspace(ws_id: str) -> dict:
|
||||
async with get_workspace_lock(ws_id):
|
||||
workspace_path = WORKSPACES_ROOT / ws_id
|
||||
|
||||
if ws_id in JUPYTER_PROCESSES:
|
||||
p_info = JUPYTER_PROCESSES[ws_id]
|
||||
if p_info["process"].poll() is None:
|
||||
if time.time() - p_info["started_at"] > 24 * 3600:
|
||||
logger.warning(
|
||||
f"Reusing Jupyter for {ws_id} older than 24h "
|
||||
f"(started_at={p_info['started_at']})"
|
||||
)
|
||||
return {
|
||||
"status": "running",
|
||||
"workspace_id": ws_id,
|
||||
"pid": p_info["process"].pid,
|
||||
"port": p_info["port"],
|
||||
"token": p_info["token"],
|
||||
"base_url": PUBLIC_BASE_URL,
|
||||
"full_url": (
|
||||
f"{PUBLIC_BASE_URL}:{p_info['port']}/jupyter/{ws_id}/"
|
||||
f"?token={p_info['token']}"
|
||||
),
|
||||
}
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
|
||||
port = get_free_port()
|
||||
token = secrets.token_urlsafe(16)
|
||||
base_path = f"/jupyter/{ws_id}/"
|
||||
|
||||
cmd = [
|
||||
"jupyter", "notebook",
|
||||
f"--port={port}",
|
||||
"--ip=0.0.0.0",
|
||||
"--no-browser",
|
||||
"--allow-root",
|
||||
f"--ServerApp.token={token}",
|
||||
f"--ServerApp.base_url={base_path}",
|
||||
"--notebook-dir=.",
|
||||
"--ServerApp.terminals_enabled=False",
|
||||
"--NotebookApp.terminals_enabled=False",
|
||||
"--ServerApp.allow_origin=*",
|
||||
"--NotebookApp.allow_origin=*",
|
||||
"--ServerApp.disable_check_xsrf=True",
|
||||
"--NotebookApp.disable_check_xsrf=True",
|
||||
]
|
||||
|
||||
try:
|
||||
process, log_file = start_process(cmd, workspace_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start Jupyter for {ws_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to start Jupyter: {e}"
|
||||
)
|
||||
|
||||
full_url = f"{PUBLIC_BASE_URL}:{port}{base_path}?token={token}"
|
||||
|
||||
JUPYTER_PROCESSES[ws_id] = {
|
||||
"process": process,
|
||||
"base_url": PUBLIC_BASE_URL,
|
||||
"port": port,
|
||||
"token": token,
|
||||
"started_at": time.time(),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Started Jupyter for workspace {ws_id} "
|
||||
f"pid={process.pid} port={port} log={log_file}"
|
||||
)
|
||||
return {
|
||||
"pid": process.pid,
|
||||
"base_url": PUBLIC_BASE_URL,
|
||||
"status": "success",
|
||||
"workspace_id": ws_id,
|
||||
"port": port,
|
||||
"token": token,
|
||||
"full_url": full_url,
|
||||
}
|
||||
|
||||
|
||||
async def stop_workspace(ws_id: str) -> dict:
|
||||
async with get_workspace_lock(ws_id):
|
||||
if ws_id not in JUPYTER_PROCESSES:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No active Jupyter process found for workspace '{ws_id}'",
|
||||
)
|
||||
|
||||
p_info = JUPYTER_PROCESSES[ws_id]
|
||||
process: subprocess.Popen = p_info["process"]
|
||||
|
||||
if process.poll() is None:
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=3)
|
||||
logger.info(f"Gracefully stopped Jupyter for workspace {ws_id}")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
f"Jupyter for {ws_id} did not stop gracefully. Force killing..."
|
||||
)
|
||||
try:
|
||||
process.kill()
|
||||
process.wait()
|
||||
except Exception as err:
|
||||
logger.error(f"Failed to kill Jupyter process for {ws_id}: {err}")
|
||||
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
|
||||
_drop_workspace_lock(ws_id)
|
||||
return {
|
||||
"status": "stopped",
|
||||
"workspace_id": ws_id,
|
||||
"message": "Jupyter process terminated and port released.",
|
||||
}
|
||||
|
||||
|
||||
async def list_workspaces() -> dict:
|
||||
async with _LOCKS_REGISTRY:
|
||||
snapshot = dict(JUPYTER_PROCESSES)
|
||||
return {
|
||||
"status": "success",
|
||||
"instances": {
|
||||
ws_id: {
|
||||
"port": info["port"],
|
||||
"full_url": (
|
||||
f"{PUBLIC_BASE_URL}:{info['port']}/jupyter/{ws_id}/"
|
||||
f"?token={info['token']}"
|
||||
),
|
||||
"is_alive": info["process"].poll() is None,
|
||||
}
|
||||
for ws_id, info in snapshot.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def get_workspace(ws_id: str) -> dict:
|
||||
async with get_workspace_lock(ws_id):
|
||||
if ws_id not in JUPYTER_PROCESSES:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No active Jupyter process found for workspace '{ws_id}'",
|
||||
)
|
||||
|
||||
p_info = JUPYTER_PROCESSES[ws_id]
|
||||
is_alive = p_info["process"].poll() is None
|
||||
|
||||
if not is_alive:
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
else:
|
||||
return {
|
||||
"status": "running",
|
||||
"pid": p_info["process"].pid,
|
||||
"base_url": PUBLIC_BASE_URL,
|
||||
"workspace_id": ws_id,
|
||||
"port": p_info["port"],
|
||||
"token": p_info["token"],
|
||||
"full_url": (
|
||||
f"{PUBLIC_BASE_URL}:{p_info['port']}/jupyter/{ws_id}/"
|
||||
f"?token={p_info['token']}"
|
||||
),
|
||||
"started_at": p_info["started_at"],
|
||||
}
|
||||
|
||||
_drop_workspace_lock(ws_id)
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
f"Jupyter process for workspace '{ws_id}' "
|
||||
"has terminated unexpectedly."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def scan_workspaces() -> None:
|
||||
if not WORKSPACES_ROOT.exists():
|
||||
return
|
||||
try:
|
||||
entries = os.listdir(WORKSPACES_ROOT)
|
||||
except Exception as e:
|
||||
logger.error(f"scan workspace failed: {e}")
|
||||
return
|
||||
|
||||
for entry in entries:
|
||||
path = WORKSPACES_ROOT / entry
|
||||
if not path.is_dir():
|
||||
continue
|
||||
logger.info(f"Found workspace: {entry}")
|
||||
logger.info(f"Auto-starting Jupyter for workspace ID: '{entry}'")
|
||||
try:
|
||||
await start_workspace(entry)
|
||||
except Exception as err:
|
||||
logger.error(f"Startup failed for workspace '{entry}': {err}")
|
||||
@@ -1 +0,0 @@
|
||||
"""Runtime provider implementations."""
|
||||
@@ -1,203 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import PurePosixPath
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from contracts.runtime.runtime_adapter import (
|
||||
CreateSessionRequest,
|
||||
EnsureRuntimeRequest,
|
||||
RuntimeEndpoint,
|
||||
RuntimeHealth,
|
||||
RuntimeSession,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeProviderError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SharedJupyterAdapter:
|
||||
"""Compose provider backed by one internal Jupyter Server.
|
||||
|
||||
Runtime rows are scoped to a Workspace. Each Notebook has its own
|
||||
Jupyter Session and Kernel inside that server. Replacing this class with
|
||||
a Docker or Kubernetes Workspace provider does not change the contract.
|
||||
"""
|
||||
|
||||
provider_name = "process"
|
||||
runtime_ref = "compose:jupyter"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
internal_url: str,
|
||||
proxy_base_path: str,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.internal_url = internal_url.rstrip("/") + "/"
|
||||
self.proxy_base_path = "/" + proxy_base_path.strip("/") + "/"
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
payload: dict | None = None,
|
||||
allow_not_found: bool = False,
|
||||
) -> httpx.Response:
|
||||
try:
|
||||
response = await self.client.request(method, path, json=payload)
|
||||
except httpx.RequestError as exc:
|
||||
raise RuntimeProviderError(
|
||||
f"Jupyter request failed: {type(exc).__name__}"
|
||||
) from exc
|
||||
if allow_not_found and response.status_code == 404:
|
||||
return response
|
||||
if response.is_error:
|
||||
raise RuntimeProviderError(
|
||||
f"Jupyter returned HTTP {response.status_code}"
|
||||
)
|
||||
return response
|
||||
|
||||
async def ensure_running(
|
||||
self,
|
||||
request: EnsureRuntimeRequest,
|
||||
) -> RuntimeEndpoint:
|
||||
health = await self.health(request.runtime_id)
|
||||
if not health.healthy:
|
||||
raise RuntimeProviderError(
|
||||
health.detail or "Jupyter is not healthy"
|
||||
)
|
||||
return RuntimeEndpoint(
|
||||
runtime_id=request.runtime_id,
|
||||
runtime_type="jupyter",
|
||||
provider=self.provider_name,
|
||||
runtime_ref=self.runtime_ref,
|
||||
internal_url=self.internal_url,
|
||||
proxy_base_path=self.proxy_base_path,
|
||||
)
|
||||
|
||||
async def stop(self, runtime_id: str, reason: str) -> None:
|
||||
# Compose keeps the shared infrastructure process alive. Stopping a
|
||||
# logical Runtime terminates its sessions and updates MySQL state.
|
||||
return None
|
||||
|
||||
async def restart(self, runtime_id: str) -> RuntimeEndpoint:
|
||||
health = await self.health(runtime_id)
|
||||
if not health.healthy:
|
||||
raise RuntimeProviderError(
|
||||
health.detail or "Jupyter is not healthy"
|
||||
)
|
||||
return RuntimeEndpoint(
|
||||
runtime_id=runtime_id,
|
||||
runtime_type="jupyter",
|
||||
provider=self.provider_name,
|
||||
runtime_ref=self.runtime_ref,
|
||||
internal_url=self.internal_url,
|
||||
proxy_base_path=self.proxy_base_path,
|
||||
)
|
||||
|
||||
async def health(self, runtime_id: str) -> RuntimeHealth:
|
||||
try:
|
||||
await self._request("GET", "api/status")
|
||||
except RuntimeProviderError as exc:
|
||||
return RuntimeHealth(
|
||||
runtime_id=runtime_id,
|
||||
healthy=False,
|
||||
checked_at=datetime.now(UTC),
|
||||
detail=str(exc),
|
||||
)
|
||||
return RuntimeHealth(
|
||||
runtime_id=runtime_id,
|
||||
healthy=True,
|
||||
checked_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _session_path(request: CreateSessionRequest) -> str:
|
||||
relative = PurePosixPath(request.relative_path.replace("\\", "/"))
|
||||
if (
|
||||
relative.is_absolute()
|
||||
or not relative.parts
|
||||
or any(part in {"", ".", ".."} for part in relative.parts)
|
||||
):
|
||||
raise RuntimeProviderError("invalid Jupyter relative path")
|
||||
return PurePosixPath(
|
||||
request.workspace_code,
|
||||
*relative.parts,
|
||||
).as_posix()
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
request: CreateSessionRequest,
|
||||
) -> RuntimeSession:
|
||||
session_path = self._session_path(request)
|
||||
encoded_path = quote(session_path, safe="/")
|
||||
await self._request("GET", f"api/contents/{encoded_path}")
|
||||
if session_path.lower().endswith(".ipynb"):
|
||||
jupyter_url = (
|
||||
f"{self.proxy_base_path}doc/tree/{encoded_path}"
|
||||
)
|
||||
else:
|
||||
jupyter_url = (
|
||||
f"{self.proxy_base_path}lab/tree/{encoded_path}"
|
||||
)
|
||||
|
||||
if not session_path.lower().endswith(".ipynb"):
|
||||
logical_id = hashlib.sha256(
|
||||
f"{request.runtime_id}:{session_path}".encode("utf-8")
|
||||
).hexdigest()[:32]
|
||||
return RuntimeSession(
|
||||
runtime_id=request.runtime_id,
|
||||
session_id=f"file:{logical_id}",
|
||||
jupyter_url=jupyter_url,
|
||||
reused=True,
|
||||
)
|
||||
|
||||
sessions_response = await self._request("GET", "api/sessions")
|
||||
sessions = sessions_response.json()
|
||||
for item in sessions:
|
||||
if item.get("path") == session_path:
|
||||
return RuntimeSession(
|
||||
runtime_id=request.runtime_id,
|
||||
session_id=str(item["id"]),
|
||||
jupyter_url=jupyter_url,
|
||||
reused=True,
|
||||
)
|
||||
|
||||
created = (
|
||||
await self._request(
|
||||
"POST",
|
||||
"api/sessions",
|
||||
payload={
|
||||
"path": session_path,
|
||||
"name": "",
|
||||
"type": "notebook",
|
||||
"kernel": {"name": "python3"},
|
||||
},
|
||||
)
|
||||
).json()
|
||||
return RuntimeSession(
|
||||
runtime_id=request.runtime_id,
|
||||
session_id=str(created["id"]),
|
||||
jupyter_url=jupyter_url,
|
||||
reused=False,
|
||||
)
|
||||
|
||||
async def terminate_session(
|
||||
self,
|
||||
runtime_id: str,
|
||||
session_id: str,
|
||||
) -> None:
|
||||
if session_id.startswith("file:"):
|
||||
return
|
||||
await self._request(
|
||||
"DELETE",
|
||||
f"api/sessions/{quote(session_id, safe='')}",
|
||||
allow_not_found=True,
|
||||
)
|
||||
@@ -1,110 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
|
||||
HEARTBEAT_SCRIPT = """
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then
|
||||
return 0
|
||||
end
|
||||
local ok, value = pcall(cjson.decode, raw)
|
||||
if not ok then
|
||||
return -2
|
||||
end
|
||||
if value['edit_session_id'] ~= ARGV[1]
|
||||
or value['token_hash'] ~= ARGV[2] then
|
||||
return -1
|
||||
end
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[3])
|
||||
return 1
|
||||
"""
|
||||
|
||||
|
||||
RELEASE_SCRIPT = """
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then
|
||||
return 0
|
||||
end
|
||||
local ok, value = pcall(cjson.decode, raw)
|
||||
if not ok then
|
||||
return -2
|
||||
end
|
||||
if value['edit_session_id'] ~= ARGV[1]
|
||||
or value['token_hash'] ~= ARGV[2] then
|
||||
return -1
|
||||
end
|
||||
return redis.call('DEL', KEYS[1])
|
||||
"""
|
||||
|
||||
|
||||
def lock_key(workspace_id: str, storage_object_id: str) -> str:
|
||||
return f"lock:file:{workspace_id}:{storage_object_id}"
|
||||
|
||||
|
||||
async def acquire(
|
||||
client: Redis,
|
||||
*,
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
ttl_ms: int,
|
||||
) -> bool:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
return bool(await client.set(key, encoded, nx=True, px=ttl_ms))
|
||||
|
||||
|
||||
async def current(client: Redis, key: str) -> tuple[dict[str, Any] | None, int]:
|
||||
raw = await client.get(key)
|
||||
if raw is None:
|
||||
return None, -2
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None, await client.pttl(key)
|
||||
return value, await client.pttl(key)
|
||||
|
||||
|
||||
async def heartbeat(
|
||||
client: Redis,
|
||||
*,
|
||||
key: str,
|
||||
edit_session_id: str,
|
||||
token_hash: str,
|
||||
ttl_ms: int,
|
||||
) -> int:
|
||||
return int(
|
||||
await client.eval(
|
||||
HEARTBEAT_SCRIPT,
|
||||
1,
|
||||
key,
|
||||
edit_session_id,
|
||||
token_hash,
|
||||
ttl_ms,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def release(
|
||||
client: Redis,
|
||||
*,
|
||||
key: str,
|
||||
edit_session_id: str,
|
||||
token_hash: str,
|
||||
) -> int:
|
||||
return int(
|
||||
await client.eval(
|
||||
RELEASE_SCRIPT,
|
||||
1,
|
||||
key,
|
||||
edit_session_id,
|
||||
token_hash,
|
||||
)
|
||||
)
|
||||
@@ -1,308 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.db.models import (
|
||||
RuntimeInstances,
|
||||
WorkspaceOperations,
|
||||
Workspaces,
|
||||
)
|
||||
from common.ids import new_ulid
|
||||
from contracts.runtime.runtime_adapter import (
|
||||
CreateSessionRequest,
|
||||
RuntimeAdapter,
|
||||
RuntimeHealth,
|
||||
RuntimeSession,
|
||||
EnsureRuntimeRequest,
|
||||
)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
class RuntimeLifecycle:
|
||||
def __init__(
|
||||
self,
|
||||
adapter: RuntimeAdapter,
|
||||
*,
|
||||
lease_seconds: int,
|
||||
) -> None:
|
||||
self.adapter = adapter
|
||||
self.lease_seconds = lease_seconds
|
||||
|
||||
def _lease_expiry(self, now: datetime) -> datetime:
|
||||
return now + timedelta(seconds=self.lease_seconds)
|
||||
|
||||
async def _record_operation(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
workspace_id: str,
|
||||
runtime_id: str,
|
||||
user_id: str,
|
||||
operation_type: str,
|
||||
request_id: str | None,
|
||||
status: str = "succeeded",
|
||||
error_code: str | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
now = utcnow()
|
||||
operation_request_id = (
|
||||
f"runtime:{operation_type}:{request_id}"
|
||||
if request_id
|
||||
else None
|
||||
)
|
||||
if operation_request_id:
|
||||
existing = await session.scalar(
|
||||
select(WorkspaceOperations).where(
|
||||
WorkspaceOperations.request_id == operation_request_id
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return
|
||||
session.add(
|
||||
WorkspaceOperations(
|
||||
operation_id=new_ulid(),
|
||||
workspace_id=workspace_id,
|
||||
runtime_id=runtime_id,
|
||||
operation_type=operation_type,
|
||||
operation_status=status,
|
||||
state_version=1,
|
||||
request_id=operation_request_id,
|
||||
requested_by=user_id,
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
error_code=error_code,
|
||||
error_message=error_message,
|
||||
)
|
||||
)
|
||||
|
||||
async def ensure_running(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
workspace: Workspaces,
|
||||
user_id: str,
|
||||
request_id: str | None,
|
||||
) -> tuple[RuntimeInstances, bool]:
|
||||
# Serializes ensure_running for one Workspace across stateless Runtime
|
||||
# Manager replicas. Provider calls remain behind the adapter boundary.
|
||||
await session.execute(
|
||||
select(Workspaces.workspace_id)
|
||||
.where(Workspaces.workspace_id == workspace.workspace_id)
|
||||
.with_for_update()
|
||||
)
|
||||
now = utcnow()
|
||||
existing = await session.scalar(
|
||||
select(RuntimeInstances)
|
||||
.where(
|
||||
RuntimeInstances.workspace_id == workspace.workspace_id,
|
||||
RuntimeInstances.runtime_type == "jupyter",
|
||||
RuntimeInstances.desired_state == "running",
|
||||
RuntimeInstances.actual_state.in_(
|
||||
["provisioning", "starting", "running", "unhealthy"]
|
||||
),
|
||||
)
|
||||
.order_by(RuntimeInstances.created_at.desc())
|
||||
)
|
||||
if existing is not None:
|
||||
# Jupyter Server is scoped to the Workspace. The user who first
|
||||
# starts it is still recorded in started_by and operation audit,
|
||||
# while owner_user_id=None identifies a shared Workspace Runtime.
|
||||
existing.owner_user_id = None
|
||||
health = await self.adapter.health(existing.runtime_id)
|
||||
if health.healthy:
|
||||
existing.actual_state = "running"
|
||||
existing.last_heartbeat_at = now
|
||||
existing.lease_expires_at = self._lease_expiry(now)
|
||||
existing.state_version += 1
|
||||
existing.error_message = None
|
||||
await self._record_operation(
|
||||
session,
|
||||
workspace_id=workspace.workspace_id,
|
||||
runtime_id=existing.runtime_id,
|
||||
user_id=user_id,
|
||||
operation_type="open",
|
||||
request_id=request_id,
|
||||
)
|
||||
return existing, True
|
||||
existing.actual_state = "unhealthy"
|
||||
existing.state_version += 1
|
||||
existing.error_message = health.detail
|
||||
|
||||
runtime_id = new_ulid()
|
||||
endpoint = await self.adapter.ensure_running(
|
||||
EnsureRuntimeRequest(
|
||||
runtime_id=runtime_id,
|
||||
workspace_id=workspace.workspace_id,
|
||||
workspace_code=workspace.workspace_code,
|
||||
owner_user_id=user_id,
|
||||
)
|
||||
)
|
||||
item = RuntimeInstances(
|
||||
runtime_id=runtime_id,
|
||||
workspace_id=workspace.workspace_id,
|
||||
owner_user_id=None,
|
||||
runtime_type=endpoint.runtime_type,
|
||||
runtime_provider=endpoint.provider,
|
||||
runtime_ref=endpoint.runtime_ref,
|
||||
host_node="compose",
|
||||
internal_url=endpoint.internal_url,
|
||||
proxy_base_path=endpoint.proxy_base_path,
|
||||
desired_state="running",
|
||||
actual_state="running",
|
||||
state_version=1,
|
||||
started_by=user_id,
|
||||
started_at=now,
|
||||
last_heartbeat_at=now,
|
||||
lease_expires_at=self._lease_expiry(now),
|
||||
)
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
await self._record_operation(
|
||||
session,
|
||||
workspace_id=workspace.workspace_id,
|
||||
runtime_id=item.runtime_id,
|
||||
user_id=user_id,
|
||||
operation_type="start",
|
||||
request_id=request_id,
|
||||
)
|
||||
return item, False
|
||||
|
||||
async def health(
|
||||
self,
|
||||
item: RuntimeInstances,
|
||||
) -> RuntimeHealth:
|
||||
health = await self.adapter.health(item.runtime_id)
|
||||
now = utcnow()
|
||||
item.last_heartbeat_at = now
|
||||
item.actual_state = "running" if health.healthy else "unhealthy"
|
||||
item.error_message = health.detail
|
||||
if health.healthy:
|
||||
item.lease_expires_at = self._lease_expiry(now)
|
||||
item.state_version += 1
|
||||
return health
|
||||
|
||||
async def stop(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
item: RuntimeInstances,
|
||||
*,
|
||||
user_id: str,
|
||||
request_id: str | None,
|
||||
reason: str,
|
||||
) -> None:
|
||||
if item.actual_state == "stopped":
|
||||
return
|
||||
item.desired_state = "stopped"
|
||||
item.actual_state = "stopping"
|
||||
item.state_version += 1
|
||||
await self.adapter.stop(item.runtime_id, reason)
|
||||
item.actual_state = "stopped"
|
||||
item.stopped_at = utcnow()
|
||||
item.lease_expires_at = None
|
||||
item.state_version += 1
|
||||
await self._record_operation(
|
||||
session,
|
||||
workspace_id=item.workspace_id,
|
||||
runtime_id=item.runtime_id,
|
||||
user_id=user_id,
|
||||
operation_type="stop",
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
async def restart(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
item: RuntimeInstances,
|
||||
*,
|
||||
user_id: str,
|
||||
request_id: str | None,
|
||||
) -> None:
|
||||
item.desired_state = "running"
|
||||
item.actual_state = "starting"
|
||||
item.state_version += 1
|
||||
endpoint = await self.adapter.restart(item.runtime_id)
|
||||
now = utcnow()
|
||||
item.runtime_ref = endpoint.runtime_ref
|
||||
item.internal_url = endpoint.internal_url
|
||||
item.proxy_base_path = endpoint.proxy_base_path
|
||||
item.actual_state = "running"
|
||||
item.started_at = item.started_at or now
|
||||
item.last_heartbeat_at = now
|
||||
item.lease_expires_at = self._lease_expiry(now)
|
||||
item.stopped_at = None
|
||||
item.error_message = None
|
||||
item.state_version += 1
|
||||
await self._record_operation(
|
||||
session,
|
||||
workspace_id=item.workspace_id,
|
||||
runtime_id=item.runtime_id,
|
||||
user_id=user_id,
|
||||
operation_type="restart",
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
item: RuntimeInstances,
|
||||
*,
|
||||
workspace_code: str,
|
||||
relative_path: str,
|
||||
) -> RuntimeSession:
|
||||
result = await self.adapter.create_session(
|
||||
CreateSessionRequest(
|
||||
runtime_id=item.runtime_id,
|
||||
workspace_code=workspace_code,
|
||||
relative_path=relative_path,
|
||||
)
|
||||
)
|
||||
now = utcnow()
|
||||
item.last_heartbeat_at = now
|
||||
item.lease_expires_at = self._lease_expiry(now)
|
||||
item.state_version += 1
|
||||
return result
|
||||
|
||||
def touch(self, item: RuntimeInstances) -> None:
|
||||
now = utcnow()
|
||||
item.last_heartbeat_at = now
|
||||
item.lease_expires_at = self._lease_expiry(now)
|
||||
item.state_version += 1
|
||||
|
||||
async def terminate_session(
|
||||
self,
|
||||
runtime_id: str | None,
|
||||
session_id: str | None,
|
||||
) -> None:
|
||||
if runtime_id and session_id:
|
||||
await self.adapter.terminate_session(runtime_id, session_id)
|
||||
|
||||
def runtime_payload(item: RuntimeInstances) -> dict[str, Any]:
|
||||
def iso(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
return {
|
||||
"runtime_id": item.runtime_id,
|
||||
"workspace_id": item.workspace_id,
|
||||
"owner_user_id": item.owner_user_id,
|
||||
"runtime_type": item.runtime_type,
|
||||
"runtime_provider": item.runtime_provider,
|
||||
"runtime_ref": item.runtime_ref,
|
||||
"internal_url": item.internal_url,
|
||||
"proxy_base_path": item.proxy_base_path,
|
||||
"desired_state": item.desired_state,
|
||||
"actual_state": item.actual_state,
|
||||
"state_version": item.state_version,
|
||||
"started_at": iso(item.started_at),
|
||||
"last_heartbeat_at": iso(item.last_heartbeat_at),
|
||||
"lease_expires_at": iso(item.lease_expires_at),
|
||||
"stopped_at": iso(item.stopped_at),
|
||||
"error_message": item.error_message,
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AcquireFileLockRequest(StrictModel):
|
||||
workspace_id: str = Field(min_length=26, max_length=26)
|
||||
storage_object_id: str = Field(min_length=26, max_length=26)
|
||||
user_id: str = Field(min_length=26, max_length=26)
|
||||
request_id: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
|
||||
|
||||
class FileLockTokenRequest(StrictModel):
|
||||
workspace_id: str = Field(min_length=26, max_length=26)
|
||||
user_id: str = Field(min_length=26, max_length=26)
|
||||
lock_token: str = Field(min_length=32, max_length=256)
|
||||
|
||||
|
||||
class RuntimeIdentityRequest(StrictModel):
|
||||
workspace_id: str = Field(min_length=26, max_length=26)
|
||||
user_id: str = Field(min_length=26, max_length=26)
|
||||
request_id: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
|
||||
|
||||
class EnsureRuntimeApiRequest(RuntimeIdentityRequest):
|
||||
pass
|
||||
|
||||
|
||||
class StopRuntimeApiRequest(RuntimeIdentityRequest):
|
||||
reason: str = Field(default="client_request", min_length=1, max_length=64)
|
||||
|
||||
|
||||
class CreateRuntimeSessionApiRequest(RuntimeIdentityRequest):
|
||||
storage_object_id: str = Field(min_length=26, max_length=26)
|
||||
relative_path: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
@@ -60,6 +60,62 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi-bindings" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi-bindings"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrow"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asttokens"
|
||||
version = "3.0.2"
|
||||
@@ -69,6 +125,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lru"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncmy"
|
||||
version = "0.2.11"
|
||||
@@ -102,6 +167,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "babel"
|
||||
version = "2.18.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backend"
|
||||
version = "0.2.0"
|
||||
@@ -127,6 +201,36 @@ requires-dist = [
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "soupsieve" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bleach"
|
||||
version = "6.4.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
css = [
|
||||
{ name = "tinycss2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.43.57"
|
||||
@@ -249,6 +353,67 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.9"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.2"
|
||||
@@ -390,6 +555,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defusedxml"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "executing"
|
||||
version = "2.2.1"
|
||||
@@ -422,6 +596,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/e1/62cc96341f01bdff2ba967441939178fcd1900d11ce7e6554d9954a5d7ec/fastjsonschema-2.22.1-py3-none-any.whl", hash = "sha256:cf377ff5c9a6f4f3125fb35f75a2c5767bd824ffbcf62c209a93cd48d1453999", size = 26239, upload-time = "2026-07-27T13:31:03.251Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fqdn"
|
||||
version = "1.5.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.4"
|
||||
@@ -617,6 +800,18 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isoduration"
|
||||
version = "20.11.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "arrow" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jedi"
|
||||
version = "0.20.0"
|
||||
@@ -629,6 +824,18 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jmespath"
|
||||
version = "1.1.0"
|
||||
@@ -638,6 +845,24 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json5"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/7d/05c46a96a78147ae3bf99c2f4169ce144a70220b8d6fcd56f6ec368b8ce9/json5-0.15.0.tar.gz", hash = "sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71", size = 53278, upload-time = "2026-06-19T20:08:27.716Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/be/59527c99478aade6bb33a68d72e6e18dd4e6ff6eacfc7d01bdb15bc76912/json5-0.15.0-py3-none-any.whl", hash = "sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618", size = 36570, upload-time = "2026-06-19T20:08:26.748Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonpointer"
|
||||
version = "3.1.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
@@ -653,6 +878,19 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
format-nongpl = [
|
||||
{ name = "fqdn" },
|
||||
{ name = "idna" },
|
||||
{ name = "isoduration" },
|
||||
{ name = "jsonpointer" },
|
||||
{ name = "rfc3339-validator" },
|
||||
{ name = "rfc3986-validator" },
|
||||
{ name = "rfc3987-syntax" },
|
||||
{ name = "uri-template" },
|
||||
{ name = "webcolors" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
@@ -665,6 +903,19 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-builder"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "jupyter-core" },
|
||||
{ name = "traitlets" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/76/4696d0d0b6903e96ba4f959427c7aad985015dd2726e643eadee2a1eec72/jupyter_builder-1.2.0.tar.gz", hash = "sha256:e62ed4a7e224c73197dd76974754c93070c9f0451ca450a2c81126838b00f866", size = 976342, upload-time = "2026-07-30T07:42:01.151Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/05/a0ac568fa8dfff7c697545956990f7aa222f59d6ec50f21a91170e24a69b/jupyter_builder-1.2.0-py3-none-any.whl", hash = "sha256:c8a003714a118ebb590f79b459e1e384dee7dce9aebaa86ace045f5960997fe4", size = 914544, upload-time = "2026-07-30T07:41:59.053Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-client"
|
||||
version = "8.9.1"
|
||||
@@ -695,6 +946,152 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-events"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "jsonschema", extra = ["format-nongpl"] },
|
||||
{ name = "packaging" },
|
||||
{ name = "python-json-logger" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rfc3339-validator" },
|
||||
{ name = "rfc3986-validator" },
|
||||
{ name = "traitlets" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3", size = 62854, upload-time = "2026-04-20T23:17:50.66Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf", size = 19512, upload-time = "2026-04-20T23:17:48.927Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-lsp"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "jupyter-server" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/ff/1e4a61f5170a9a1d978f3ac3872449de6c01fc71eaf89657824c878b1549/jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6", size = 55677, upload-time = "2026-04-02T08:10:06.749Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/e8/9d61dcbd1dce8ef418f06befd4ac084b4720429c26b0b1222bc218685eff/jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81", size = 77513, upload-time = "2026-04-02T08:10:01.753Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-server"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "jupyter-client" },
|
||||
{ name = "jupyter-core" },
|
||||
{ name = "jupyter-events" },
|
||||
{ name = "jupyter-server-terminals" },
|
||||
{ name = "nbconvert" },
|
||||
{ name = "nbformat" },
|
||||
{ name = "packaging" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "pywinpty", marker = "os_name == 'nt'" },
|
||||
{ name = "pyzmq" },
|
||||
{ name = "send2trash" },
|
||||
{ name = "terminado" },
|
||||
{ name = "tornado" },
|
||||
{ name = "traitlets" },
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc", size = 393143, upload-time = "2026-06-17T12:09:07.234Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-server-terminals"
|
||||
version = "0.5.4"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "pywinpty", marker = "os_name == 'nt'" },
|
||||
{ name = "terminado" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyterlab"
|
||||
version = "4.6.2"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "async-lru" },
|
||||
{ name = "httpx" },
|
||||
{ name = "ipykernel" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "jupyter-builder" },
|
||||
{ name = "jupyter-core" },
|
||||
{ name = "jupyter-lsp" },
|
||||
{ name = "jupyter-server" },
|
||||
{ name = "jupyterlab-server" },
|
||||
{ name = "notebook-shim" },
|
||||
{ name = "packaging" },
|
||||
{ name = "tornado" },
|
||||
{ name = "traitlets" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/7f/51c0c856ab286bdaf5709cf61ed13584ed9d4bee906479707da45b11b353/jupyterlab-4.6.2.tar.gz", hash = "sha256:e18ce8b34f3de350e93cd5b2c4f3ae884cbe266eb76bf5d6825a4ed34c13bcff", size = 28183650, upload-time = "2026-07-21T12:05:24.051Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/1f/e39b248c76bb3736bc05a9491a6aa1315414c32dea12f548ecc1b24c758e/jupyterlab-4.6.2-py3-none-any.whl", hash = "sha256:5964447036629adfcd3fc0969effc1da6f47d2cbd0a60b2c2eea7c31be0ec6a8", size = 17166703, upload-time = "2026-07-21T12:05:19.818Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyterlab-pygments"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyterlab-server"
|
||||
version = "2.28.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "babel" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "json5" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "jupyter-server" },
|
||||
{ name = "packaging" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lark"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
version = "0.7.2"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/30/d87a423766b24db416a46e9335b9602b054a72b96a88a241f2b09b560fa8/loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac", size = 145103, upload-time = "2023-09-11T15:24:37.926Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/0a/4f6fed21aa246c6b49b561ca55facacc2a44b87d65b8b92362a8e99ba202/loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb", size = 62549, upload-time = "2023-09-11T15:24:35.016Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.12"
|
||||
@@ -782,6 +1179,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mistune"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/92/328a294a6de83bacb95bed01f04e0eaff4e3616ee359fc821a5dfc539b02/mistune-3.3.4.tar.gz", hash = "sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe", size = 121426, upload-time = "2026-07-22T05:22:30.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "model-platform"
|
||||
version = "0.1.0"
|
||||
@@ -802,6 +1208,31 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/6d/e7fa07f03a4a7b221d94b4d586edb754a9b0dc3c9e2c93353e9fa4e0d117/nbclient-0.10.2-py3-none-any.whl", hash = "sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d", size = 25434, upload-time = "2024-12-19T10:32:24.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nbconvert"
|
||||
version = "7.17.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "bleach", extra = ["css"] },
|
||||
{ name = "defusedxml" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "jupyter-core" },
|
||||
{ name = "jupyterlab-pygments" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "mistune" },
|
||||
{ name = "nbclient" },
|
||||
{ name = "nbformat" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pandocfilters" },
|
||||
{ name = "pygments" },
|
||||
{ name = "traitlets" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nbformat"
|
||||
version = "5.10.4"
|
||||
@@ -826,6 +1257,35 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notebook"
|
||||
version = "7.6.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "jupyter-builder" },
|
||||
{ name = "jupyter-server" },
|
||||
{ name = "jupyterlab" },
|
||||
{ name = "jupyterlab-server" },
|
||||
{ name = "notebook-shim" },
|
||||
{ name = "tornado" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/62/34df9b5f6cef6a3e71301cd2f5525fe93a983ae46bd217e7cca27374a037/notebook-7.6.1.tar.gz", hash = "sha256:0b45fd1010668dd4808c40914d957706dbf044a677d28764dc881b74dfcede82", size = 5499443, upload-time = "2026-07-22T12:39:05.432Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/19/003ac39eae3a88b80631f93eb954b61d0f1fc1dce4c84602eb0bf4802466/notebook-7.6.1-py3-none-any.whl", hash = "sha256:6ea1e4c926f0dc490444ddcc335797a3dacda470a805d01031872fb119988ba2", size = 5546368, upload-time = "2026-07-22T12:39:02.287Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notebook-shim"
|
||||
version = "0.2.4"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "jupyter-server" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
@@ -835,6 +1295,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pandocfilters"
|
||||
version = "1.5.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parso"
|
||||
version = "0.8.7"
|
||||
@@ -865,6 +1334,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-client"
|
||||
version = "0.26.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.53"
|
||||
@@ -1052,6 +1530,33 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-json-logger"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywinpty"
|
||||
version = "3.0.5"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/ef/2d27f30c59a67be7025b2d7858c8c2d282b74d66544b2384730b82de74fd/pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b", size = 16223484, upload-time = "2026-06-11T00:11:58.93Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/34/942cc95ca4e26489875aa8a95192766247a687379ec29543eebe73ec945f/pywinpty-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:d62946adf14b15b54c0b8d785f93fe18b04da23f4ad59e2e8c4612646e9abd23", size = 2090915, upload-time = "2026-06-10T23:43:14.98Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/70/5b9053004844139ea8bd86209c57ade12b134b2782f383a095784c8531ec/pywinpty-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:e9391c05fbfa7a992a97e831fc6849887b4014a614192e3d984a7ca59592b376", size = 815934, upload-time = "2026-06-10T23:41:42.384Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/f4/2a464b9893cceb3b3f416356e94fdc3e1bca9476993927e4e6d99fe95382/pywinpty-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:48db1b0ad9d0a1b81dcaaa7163a99a7808deaceb0c1b2344716dc1fc090c3c4c", size = 2090471, upload-time = "2026-06-10T23:42:11.071Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/2c/a138491a0afbdb50eb79395577bd326d4b0fbde7209417d1a8087ff2493a/pywinpty-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:2c6008fb2d3774b48693b2fcb7f2cc317ade9dc581289a964ffeeaf81307c9b5", size = 815518, upload-time = "2026-06-10T23:42:02.363Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/15/54400049a380582acd1282665c70fcf11e0bd3713679aca78e24c3aae738/pywinpty-3.0.5-cp313-cp313t-win_amd64.whl", hash = "sha256:22ce1b780d89821cc52daf6eac0708af22d93d000ce9c7c07e37489db8594598", size = 2089920, upload-time = "2026-06-10T23:44:13.395Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/0c/6f24f3c0799f502259b24bdf841a99ad2b0d59df5c2525b4e2a286d14be2/pywinpty-3.0.5-cp313-cp313t-win_arm64.whl", hash = "sha256:9c2919a81bc5cfb09b86fc5a002112b2de95ca4304a07413cbeeb746a1307a5c", size = 814520, upload-time = "2026-06-10T23:43:28.588Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/23/f3cd1b1e5fc56517f54452c49f92049e7dd9ffc8a63de22a495581f50d04/pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac", size = 2090663, upload-time = "2026-06-10T23:43:59.845Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/dd/96d6cbfc6d9ddab5c1c2f92c26545ae8997446a2ba7ee2024cd43c81f49b/pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc", size = 815700, upload-time = "2026-06-10T23:40:50.001Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/36/d98087bce0acaa4cce7f196103cfa7be3f63ce65f52473bb3e38784ae5d9/pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d", size = 2090093, upload-time = "2026-06-10T23:40:58.933Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/fd/fe2b0db922ba052ce3976a08f3fc05d0c05047c8b4ebb6102e832b8ef563/pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690", size = 814517, upload-time = "2026-06-10T23:42:34.946Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -1164,6 +1669,54 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/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://pypi.tuna.tsinghua.edu.cn/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 = "rfc3339-validator"
|
||||
version = "0.1.4"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc3986-validator"
|
||||
version = "0.1.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc3987-syntax"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "lark" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "2026.6.3"
|
||||
@@ -1268,7 +1821,8 @@ dependencies = [
|
||||
{ name = "common" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "redis" },
|
||||
{ name = "loguru" },
|
||||
{ name = "notebook" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
@@ -1277,7 +1831,8 @@ requires-dist = [
|
||||
{ name = "common", editable = "common" },
|
||||
{ name = "fastapi", specifier = "==0.116.1" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "redis", specifier = "==5.2.1" },
|
||||
{ name = "loguru", specifier = "==0.7.2" },
|
||||
{ name = "notebook" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
|
||||
]
|
||||
|
||||
@@ -1320,6 +1875,15 @@ requires-dist = [
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "send2trash"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@@ -1329,6 +1893,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.9.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.51"
|
||||
@@ -1397,6 +1970,32 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "terminado"
|
||||
version = "0.18.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "ptyprocess", marker = "os_name != 'nt'" },
|
||||
{ name = "pywinpty", marker = "os_name == 'nt'" },
|
||||
{ name = "tornado" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinycss2"
|
||||
version = "1.5.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.7"
|
||||
@@ -1444,6 +2043,24 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.3"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/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 = "uri-template"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
@@ -1604,6 +2221,33 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webcolors"
|
||||
version = "25.10.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webencodings"
|
||||
version = "0.5.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websocket-client"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.1"
|
||||
@@ -1680,3 +2324,12 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win32-setctime"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user