This commit is contained in:
tao.chen
2026-07-30 18:57:18 +08:00
parent ff7fc4ef1d
commit c1e15758a3
31 changed files with 2648 additions and 3356 deletions
+221 -66
View File
@@ -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"}
-147
View File
@@ -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"}
+28 -9
View File
@@ -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},
)
-5
View File
@@ -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)
+3 -6
View File
@@ -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
-878
View File
@@ -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')
+50 -5
View File
@@ -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",
]
+38
View File
@@ -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))
+79
View File
@@ -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))
+112
View File
@@ -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))
+117
View File
@@ -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))
+168
View File
@@ -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))
+245
View File
@@ -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))
+148
View File
@@ -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))
+198
View File
@@ -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))
+85
View File
@@ -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))
+1 -1
View File
@@ -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
+58 -6
View File
@@ -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
View File
@@ -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"]
+2 -1
View File
@@ -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
View File
@@ -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'",
)
File diff suppressed because it is too large Load Diff
+93
View File
@@ -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")
+261
View File
@@ -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,
)
-110
View File
@@ -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,
)
)
-308
View File
@@ -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,
}
-40
View File
@@ -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)
Generated
+655 -2
View File
@@ -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" },
]