diff --git a/.env.example b/.env.example index 7439f6c..15cade1 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,17 @@ COMPOSE_PROJECT_NAME=model-platform-refactored -# Local development ports -GATEWAY_PORT=8081 -MYSQL_PORT=3308 -RUSTFS_API_PORT=9010 -RUSTFS_CONSOLE_PORT=9011 +# ports BACKEND_PORT=8010 RUNTIME_PORT=8012 SCHEDULE_PORT=8013 -# Jupyter only exposes port 8888 inside the Compose network. -JUPYTER_IMAGE=quay.io/jupyter/base-notebook:2025-12-31 -JUPYTER_TOKEN=ChangeMe_Jupyter_Internal_2026 +# MySQL connection URI (async SQLAlchemy driver) +DATABASE_URL=mysql+asyncmy://model_platform:ChangeMe_MySQL_App_2026@mysql:3306/model_platform?charset=utf8mb4 -# MySQL 8 local development credentials -MYSQL_DATABASE=model_platform -MYSQL_USER=model_platform -MYSQL_PASSWORD=ChangeMe_MySQL_App_2026 -MYSQL_ROOT_PASSWORD=ChangeMe_MySQL_Root_2026 - -# RustFS local development image and credentials -RUSTFS_IMAGE=rustfs/rustfs:latest +# Object storage (S3-compatible) endpoints +RUSTFS_INTERNAL_ENDPOINT=http://rustfs:9000 +RUSTFS_PUBLIC_ENDPOINT=http://localhost:9010 RUSTFS_ACCESS_KEY=modelplatform RUSTFS_SECRET_KEY=ChangeMe_RustFS_2026 - -# Internal service authentication; replace in every non-local environment. -INTERNAL_SERVICE_TOKEN=ChangeMe_Internal_Service_2026 +RUSTFS_API_PORT=9010 +RUSTFS_CONSOLE_PORT=9011 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/backend/src/backend/dependencies.py b/backend/src/backend/dependencies.py index f646a77..615a915 100644 --- a/backend/src/backend/dependencies.py +++ b/backend/src/backend/dependencies.py @@ -3,10 +3,11 @@ from __future__ import annotations from dataclasses import dataclass from typing import AsyncIterator -from fastapi import Header, HTTPException, Request, status +from fastapi import Depends, Header, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from common.db import session_scope from common.db.models import ( Roles, Users, @@ -29,13 +30,8 @@ class RequestContext: async def database_session(request: Request) -> AsyncIterator[AsyncSession]: - async with request.app.state.session_factory() as session: - try: - yield session - await session.commit() - except Exception: - await session.rollback() - raise + async with session_scope(request.app.state.session_factory) as session: + yield session async def request_context( diff --git a/backend/src/backend/file_locks.py b/backend/src/backend/file_locks.py deleted file mode 100644 index 7a82cb3..0000000 --- a/backend/src/backend/file_locks.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from typing import Any, Awaitable, Callable - -from fastapi import APIRouter, Depends, Request, status -from fastapi.responses import JSONResponse - -from backend.dependencies import ( - RequestContext, - request_context, -) -from backend.runtime_client import RuntimeClientError -from backend.schemas import FileLockTokenRequest - - -router = APIRouter(tags=["file-locks"]) - - -async def runtime_response( - context: RequestContext, - operation: Callable[[], Awaitable[dict[str, Any]]], - *, - success_status: int = status.HTTP_200_OK, -) -> JSONResponse: - try: - data = await operation() - except RuntimeClientError as exc: - error = exc.detail - if not isinstance(error, dict) or "code" not in error: - error = { - "code": "RUNTIME_REQUEST_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}, - ) - return JSONResponse( - status_code=success_status, - content={ - "request_id": context.request_id, - "data": data, - "meta": {}, - }, - ) - - -@router.post( - "/api/v1/files/{storage_object_id}/lock", - status_code=status.HTTP_201_CREATED, -) -async def acquire_file_lock( - storage_object_id: str, - request: Request, - context: RequestContext = Depends(request_context), -) -> JSONResponse: - return await runtime_response( - context, - lambda: request.app.state.runtime_client.acquire_file_lock( - { - "workspace_id": context.workspace.workspace_id, - "storage_object_id": storage_object_id, - "user_id": context.user.user_id, - "request_id": context.request_id, - } - ), - success_status=status.HTTP_201_CREATED, - ) - - -@router.post("/api/v1/file-locks/{edit_session_id}/heartbeat") -async def heartbeat_file_lock( - edit_session_id: str, - payload: FileLockTokenRequest, - request: Request, - context: RequestContext = Depends(request_context), -) -> JSONResponse: - return await runtime_response( - context, - lambda: request.app.state.runtime_client.heartbeat_file_lock( - edit_session_id, - { - "workspace_id": context.workspace.workspace_id, - "user_id": context.user.user_id, - "lock_token": payload.lock_token, - }, - ), - ) - - -@router.delete("/api/v1/file-locks/{edit_session_id}") -async def release_file_lock( - edit_session_id: str, - payload: FileLockTokenRequest, - request: Request, - context: RequestContext = Depends(request_context), -) -> JSONResponse: - return await runtime_response( - context, - lambda: request.app.state.runtime_client.release_file_lock( - edit_session_id, - { - "workspace_id": context.workspace.workspace_id, - "user_id": context.user.user_id, - "lock_token": payload.lock_token, - }, - ), - ) diff --git a/backend/src/backend/jupyter.py b/backend/src/backend/jupyter.py index bf68028..4f674c7 100644 --- a/backend/src/backend/jupyter.py +++ b/backend/src/backend/jupyter.py @@ -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"} diff --git a/backend/src/backend/legacy_jupyter_auth.py b/backend/src/backend/legacy_jupyter_auth.py deleted file mode 100644 index 0a474d5..0000000 --- a/backend/src/backend/legacy_jupyter_auth.py +++ /dev/null @@ -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"} diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index 976b952..99b9e13 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -13,12 +13,10 @@ from common.db import create_database_engine, create_session_factory from common.service_app import create_service_app from common.storage import RustFSObjectStore from backend.admin import router as admin_router -from backend.file_locks import router as file_locks_router from backend.jupyter import router as jupyter_router from backend.resources import router as resources_router from backend.runtime_client import RuntimeClient from backend.schedule_runs import router as schedule_runs_router -from backend.schedule_client import ScheduleExecutorClient from backend.schedules import router as schedules_router from backend.scripts import router as scripts_router from backend.storage_api import app as storage_app @@ -41,10 +39,6 @@ async def lifespan(app: Any) -> AsyncIterator[None]: "RUSTFS_INTERNAL_ENDPOINT", "http://rustfs:9000", ), - public_endpoint=os.getenv( - "RUSTFS_PUBLIC_ENDPOINT", - "http://localhost:9000", - ), access_key=os.environ["RUSTFS_ACCESS_KEY"], secret_key=os.environ["RUSTFS_SECRET_KEY"], ) @@ -61,31 +55,16 @@ async def lifespan(app: Any) -> AsyncIterator[None]: base_url="http://backend.internal", timeout=httpx.Timeout(30.0), ) - app.state.storage_client = StorageClient( - storage_http_client, - os.environ["INTERNAL_SERVICE_TOKEN"], - ) + app.state.storage_client = StorageClient(storage_http_client) runtime_http_client = httpx.AsyncClient( base_url=os.getenv("RUNTIME_API_URL", "http://runtime:8000"), timeout=httpx.Timeout(30.0), ) - app.state.runtime_client = RuntimeClient( - runtime_http_client, - os.environ["INTERNAL_SERVICE_TOKEN"], - ) - schedule_http_client = httpx.AsyncClient( - base_url=os.getenv("SCHEDULE_API_URL", "http://schedule:8000"), - timeout=httpx.Timeout(10.0), - ) - app.state.schedule_client = ScheduleExecutorClient( - schedule_http_client, - os.environ["INTERNAL_SERVICE_TOKEN"], - ) + app.state.runtime_client = RuntimeClient(runtime_http_client) try: yield finally: await runtime_http_client.aclose() - await schedule_http_client.aclose() await storage_http_client.aclose() await engine.dispose() @@ -94,7 +73,6 @@ app = create_service_app( os.getenv("SERVICE_NAME", "backend"), lifespan=lifespan, ) -app.include_router(file_locks_router) app.include_router(jupyter_router) app.include_router(resources_router) app.include_router(schedule_runs_router) diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py index 14a4d7b..738df2d 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/resources.py @@ -78,7 +78,6 @@ async def create_resource_upload( "expected_size_bytes": payload.expected_size_bytes, "expected_hash": payload.expected_hash, "idempotency_key": idempotency_key, - "url_scope": "public", } ) return {"request_id": context.request_id, "data": data, "meta": {}} diff --git a/backend/src/backend/runtime_client.py b/backend/src/backend/runtime_client.py index e843b30..da3708e 100644 --- a/backend/src/backend/runtime_client.py +++ b/backend/src/backend/runtime_client.py @@ -12,14 +12,20 @@ class RuntimeClientError(Exception): detail: Any +_RUNTIME_TRANSPORT_ERROR = RuntimeClientError( + 503, + { + "code": "RUNTIME_UNAVAILABLE", + "message": "Runtime Manager 暂时不可用", + "retryable": True, + "details": {}, + }, +) + + class RuntimeClient: - def __init__( - self, - client: httpx.AsyncClient, - service_token: str, - ) -> None: + def __init__(self, client: httpx.AsyncClient) -> None: self.client = client - self.headers = {"X-Service-Token": service_token} async def _request( self, @@ -28,22 +34,9 @@ class RuntimeClient: payload: dict[str, Any], ) -> dict[str, Any]: try: - response = await self.client.request( - method, - path, - json=payload, - headers=self.headers, - ) + response = await self.client.request(method, path, json=payload) except httpx.RequestError as exc: - raise RuntimeClientError( - 503, - { - "code": "RUNTIME_UNAVAILABLE", - "message": "Runtime Manager 暂时不可用", - "retryable": True, - "details": {}, - }, - ) from exc + raise _RUNTIME_TRANSPORT_ERROR from exc if response.is_error: try: detail = response.json().get("detail", response.text) @@ -52,53 +45,37 @@ class RuntimeClient: raise RuntimeClientError(response.status_code, detail) return response.json() - async def acquire_file_lock( + async def get_workspace( self, - payload: dict[str, Any], - ) -> dict[str, Any]: - return ( - await self._request( - "POST", - "/internal/v1/file-locks/acquire", - payload, - ) - )["data"] + workspace_id: str, + ) -> dict[str, Any] | None: + """Ask Runtime for a live workspace. - async def heartbeat_file_lock( - self, - edit_session_id: str, - payload: dict[str, Any], - ) -> dict[str, Any]: - return ( - await self._request( + 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/file-locks/{edit_session_id}/heartbeat", - 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 release_file_lock( + async def start_workspace( self, - edit_session_id: str, - payload: dict[str, Any], + workspace_id: str, ) -> dict[str, Any]: - return ( - await self._request( - "DELETE", - f"/internal/v1/file-locks/{edit_session_id}", - payload, - ) - )["data"] + return await self._request( + "POST", + "/api/v1/jupyter", + {"action": "start", "workspace_id": workspace_id}, + ) - async def create_jupyter_access_ticket( - self, - edit_session_id: str, - payload: dict[str, Any], - ) -> dict[str, Any]: - return ( - await self._request( - "POST", - f"/internal/v1/jupyter/access-tickets/{edit_session_id}", - payload, - ) - )["data"] + +__all__ = ["RuntimeClient", "RuntimeClientError"] diff --git a/backend/src/backend/schedule_runs.py b/backend/src/backend/schedule_runs.py index d75de5d..24f322a 100644 --- a/backend/src/backend/schedule_runs.py +++ b/backend/src/backend/schedule_runs.py @@ -20,7 +20,7 @@ from backend.dependencies import ( database_session, request_context, ) -from backend.schedule_schemas import StrictModel +from common.schemas import StrictModel from backend.schedules import ( graph_rows, schedule_row, diff --git a/backend/src/backend/schedule_schemas.py b/backend/src/backend/schedule_schemas.py index 8d658bb..31c7fbc 100644 --- a/backend/src/backend/schedule_schemas.py +++ b/backend/src/backend/schedule_schemas.py @@ -1,15 +1,12 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Literal +from typing import Any from pydantic import Field, field_validator, model_validator -from backend.schemas import StrictModel - - -TriggerType = Literal["manual", "cron", "api"] -FailurePolicy = Literal["stop", "continue"] +from common.db.models.schedules import FailurePolicy, TriggerType +from common.schemas import StrictModel def _required_text(value: str) -> str: diff --git a/backend/src/backend/schemas.py b/backend/src/backend/schemas.py index 33c9c01..a17900b 100644 --- a/backend/src/backend/schemas.py +++ b/backend/src/backend/schemas.py @@ -2,11 +2,9 @@ from __future__ import annotations from typing import Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import Field, field_validator - -class StrictModel(BaseModel): - model_config = ConfigDict(extra="forbid") +from common.schemas import StrictModel class CreateResourceUploadRequest(StrictModel): @@ -61,12 +59,3 @@ class PublishVersionRequest(StrictModel): class DownloadUrlRequest(StrictModel): expires_seconds: int = Field(default=300, ge=30, le=3600) - - -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) diff --git a/backend/src/backend/storage_api.py b/backend/src/backend/storage_api.py index bfd0ffc..00f1bde 100644 --- a/backend/src/backend/storage_api.py +++ b/backend/src/backend/storage_api.py @@ -12,28 +12,26 @@ from datetime import UTC, datetime, timedelta from pathlib import Path, PurePosixPath from typing import Any, AsyncIterator -from fastapi import Depends, Header, HTTPException, Request, status +from fastapi import Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from common.db import create_database_engine, create_session_factory +from common.db import create_database_engine, create_session_factory, session_scope from common.db.models import ( StorageObjects, UploadSessions, Users, WorkspaceMembers, - Workspaces, -) + Workspaces) from common.ids import new_ulid from common.service_app import create_service_app from common.storage import RustFSObjectStore -from backend.storage_schemas import ( +from common.storage.schemas import ( CompleteUploadRequest, CreateUploadRequest, DownloadUrlRequest, RegisterWorkspaceObjectRequest, - ServerObjectRequest, -) + ServerObjectRequest) def utcnow() -> datetime: @@ -47,8 +45,7 @@ def hash_bytes(value: str) -> bytes: def normalized_idempotency_key( workspace_id: str, user_id: str, - value: str, -) -> str: + value: str) -> str: digest = hashlib.sha256( f"{workspace_id}:{user_id}:{value}".encode("utf-8") ).hexdigest() @@ -92,23 +89,15 @@ async def lifespan(app: Any) -> AsyncIterator[None]: app.state.object_store = RustFSObjectStore( internal_endpoint=os.getenv( "RUSTFS_INTERNAL_ENDPOINT", - "http://rustfs:9000", - ), - public_endpoint=os.getenv( - "RUSTFS_PUBLIC_ENDPOINT", - "http://localhost:9000", - ), + "http://rustfs:9000"), access_key=os.environ["RUSTFS_ACCESS_KEY"], - secret_key=os.environ["RUSTFS_SECRET_KEY"], - ) + secret_key=os.environ["RUSTFS_SECRET_KEY"]) app.state.default_bucket = os.getenv( "RUSTFS_DEFAULT_BUCKET", - "model-platform", - ) + "model-platform") await asyncio.to_thread( app.state.object_store.ensure_bucket, - app.state.default_bucket, - ) + app.state.default_bucket) try: yield finally: @@ -117,75 +106,51 @@ async def lifespan(app: Any) -> AsyncIterator[None]: app = create_service_app( os.getenv("SERVICE_NAME", "storage-api"), - lifespan=lifespan, -) + lifespan=lifespan) async def database_session(request: Request) -> AsyncIterator[AsyncSession]: - async with request.app.state.session_factory() as session: - try: - yield session - await session.commit() - except Exception: - await session.rollback() - raise - - -def verify_internal_service( - x_service_token: str = Header(alias="X-Service-Token"), -) -> None: - expected = os.environ.get("INTERNAL_SERVICE_TOKEN", "") - if not expected or not secrets.compare_digest(expected, x_service_token): - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - "invalid internal service identity", - ) + async with session_scope(request.app.state.session_factory) as session: + yield session async def require_workspace_member( session: AsyncSession, workspace_id: str, - user_id: str, -) -> Workspaces: + user_id: str) -> Workspaces: statement = ( select(Workspaces) .join( WorkspaceMembers, - WorkspaceMembers.workspace_id == Workspaces.workspace_id, - ) + WorkspaceMembers.workspace_id == Workspaces.workspace_id) .join(Users, Users.user_id == WorkspaceMembers.user_id) .where( Workspaces.workspace_id == workspace_id, Workspaces.status == "active", WorkspaceMembers.user_id == user_id, WorkspaceMembers.member_status == "active", - Users.status == "active", - ) + Users.status == "active") ) workspace = await session.scalar(statement) if workspace is None: raise HTTPException( status.HTTP_403_FORBIDDEN, - "user is not an active workspace member", - ) + "user is not an active workspace member") return workspace async def create_upload_record( payload: CreateUploadRequest, session: AsyncSession, - request: Request, -) -> dict[str, Any]: + request: Request) -> dict[str, Any]: workspace = await require_workspace_member( session, payload.workspace_id, - payload.user_id, - ) + payload.user_id) stored_key = normalized_idempotency_key( payload.workspace_id, payload.user_id, - payload.idempotency_key, - ) + payload.idempotency_key) existing = await session.scalar( select(UploadSessions).where( UploadSessions.idempotency_key == stored_key @@ -201,8 +166,7 @@ async def create_upload_record( ): raise HTTPException( status.HTTP_409_CONFLICT, - "idempotency key was used with different upload metadata", - ) + "idempotency key was used with different upload metadata") upload = existing else: upload_id = new_ulid() @@ -226,16 +190,14 @@ async def create_upload_record( expires_at=utcnow() + timedelta(minutes=15), expected_size_bytes=payload.expected_size_bytes, expected_hash=payload.expected_hash, - content_type=payload.content_type, - ) + content_type=payload.content_type) session.add(upload) await session.flush() if upload.upload_status == "completed" and upload.storage_object_id: storage_object = await session.get( StorageObjects, - upload.storage_object_id, - ) + upload.storage_object_id) return { "upload_id": upload.upload_id, "status": upload.upload_status, @@ -246,33 +208,55 @@ async def create_upload_record( if upload.upload_status not in {"created", "uploading"}: raise HTTPException( status.HTTP_409_CONFLICT, - f"upload cannot continue from status {upload.upload_status}", - ) + f"upload cannot continue from status {upload.upload_status}") url, headers = request.app.state.object_store.presign_put( bucket_name=upload.bucket_name, object_key=upload.object_key, content_type=upload.content_type or "application/octet-stream", expected_hash=upload.expected_hash, - expires_seconds=900, - public=payload.url_scope == "public", + expires_seconds=900) + presigned_url = request.app.state.object_store.rewrite_to_public_path( + url, + public_base_url=_public_base_url(request), ) return { "upload_id": upload.upload_id, "status": upload.upload_status, "method": "PUT", - "presigned_url": url, + "presigned_url": presigned_url, "required_headers": headers, "expires_at": upload.expires_at.isoformat(), } +def _public_base_url(request: Request) -> str: + """Return the public base URL the client should use. + + Falls back to the inbound request's ``Host`` header and the scheme + Nginx forwards via ``X-Forwarded-Proto`` so the resulting + presigned URL always points at the public edge rather than the + in-cluster RustFS endpoint. + """ + forwarded_proto = request.headers.get("x-forwarded-proto", "").strip() + scheme = forwarded_proto or request.url.scheme or "http" + host = ( + request.headers.get("x-forwarded-host", "").strip() + or request.headers.get("host", "").strip() + ) + if not host: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "cannot determine public host for presigned URL", + ) + return f"{scheme}://{host}" + + async def complete_upload_record( upload_id: str, payload: CompleteUploadRequest, session: AsyncSession, - request: Request, -) -> StorageObjects: + request: Request) -> StorageObjects: upload = await session.scalar( select(UploadSessions) .where(UploadSessions.upload_id == upload_id) @@ -285,14 +269,12 @@ async def complete_upload_record( if item is None: raise HTTPException( status.HTTP_409_CONFLICT, - "completed upload has no storage object", - ) + "completed upload has no storage object") return item if upload.upload_status not in {"created", "uploading"}: raise HTTPException( status.HTTP_409_CONFLICT, - f"upload cannot be completed from status {upload.upload_status}", - ) + f"upload cannot be completed from status {upload.upload_status}") if upload.expires_at < utcnow(): upload.upload_status = "expired" raise HTTPException(status.HTTP_409_CONFLICT, "upload expired") @@ -301,13 +283,11 @@ async def complete_upload_record( head = await asyncio.to_thread( request.app.state.object_store.head, bucket_name=upload.bucket_name, - object_key=upload.object_key, - ) + object_key=upload.object_key) except Exception as exc: raise HTTPException( status.HTTP_409_CONFLICT, - "uploaded object is not available", - ) from exc + "uploaded object is not available") from exc actual_size = int(head.get("ContentLength", 0)) if ( @@ -317,8 +297,7 @@ async def complete_upload_record( upload.upload_status = "failed" raise HTTPException( status.HTTP_409_CONFLICT, - "uploaded object size does not match expected_size_bytes", - ) + "uploaded object size does not match expected_size_bytes") actual_content_type = str( head.get("ContentType") or "application/octet-stream" ) @@ -326,8 +305,7 @@ async def complete_upload_record( upload.upload_status = "failed" raise HTTPException( status.HTTP_409_CONFLICT, - "uploaded object content type does not match", - ) + "uploaded object content type does not match") metadata = { str(key).lower(): str(value).lower() for key, value in dict(head.get("Metadata") or {}).items() @@ -337,14 +315,12 @@ async def complete_upload_record( actual_hash = await asyncio.to_thread( request.app.state.object_store.sha256, bucket_name=upload.bucket_name, - object_key=upload.object_key, - ) + object_key=upload.object_key) if upload.expected_hash and actual_hash != upload.expected_hash: upload.upload_status = "failed" raise HTTPException( status.HTTP_409_CONFLICT, - "uploaded object hash does not match expected_hash", - ) + "uploaded object hash does not match expected_hash") file_name = upload.object_key.rsplit("/", 1)[-1] item = StorageObjects( @@ -367,8 +343,7 @@ async def complete_upload_record( visibility=payload.visibility, is_immutable=int(payload.is_immutable), object_status="available", - created_by=upload.user_id, - ) + created_by=upload.user_id) session.add(item) await session.flush() await session.refresh(item) @@ -378,45 +353,37 @@ async def complete_upload_record( return item -@app.post("/internal/v1/uploads", dependencies=[Depends(verify_internal_service)]) +@app.post("/internal/v1/uploads") async def create_upload( payload: CreateUploadRequest, request: Request, - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: + session: AsyncSession = Depends(database_session)) -> dict[str, Any]: return { "data": await create_upload_record(payload, session, request), } @app.post( - "/internal/v1/uploads/{upload_id}/complete", - dependencies=[Depends(verify_internal_service)], -) + "/internal/v1/uploads/{upload_id}/complete") async def complete_upload( upload_id: str, payload: CompleteUploadRequest, request: Request, - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: + session: AsyncSession = Depends(database_session)) -> dict[str, Any]: item = await complete_upload_record( upload_id, payload, session, - request, - ) + request) return {"data": storage_payload(item)} @app.post( - "/internal/v1/uploads/{upload_id}/abort", - dependencies=[Depends(verify_internal_service)], -) + "/internal/v1/uploads/{upload_id}/abort") async def abort_upload( upload_id: str, request: Request, - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: + session: AsyncSession = Depends(database_session)) -> dict[str, Any]: upload = await session.scalar( select(UploadSessions) .where(UploadSessions.upload_id == upload_id) @@ -427,39 +394,32 @@ async def abort_upload( if upload.upload_status == "completed": raise HTTPException( status.HTTP_409_CONFLICT, - "completed upload cannot be aborted", - ) + "completed upload cannot be aborted") if upload.upload_status != "aborted": await asyncio.to_thread( request.app.state.object_store.delete, bucket_name=upload.bucket_name, - object_key=upload.object_key, - ) + object_key=upload.object_key) upload.upload_status = "aborted" return {"data": {"upload_id": upload_id, "status": "aborted"}} @app.post( - "/internal/v1/objects", - dependencies=[Depends(verify_internal_service)], -) + "/internal/v1/objects") async def create_server_object( payload: ServerObjectRequest, request: Request, - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: + session: AsyncSession = Depends(database_session)) -> dict[str, Any]: try: content = base64.b64decode(payload.content_base64, validate=True) except (binascii.Error, ValueError) as exc: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, - "content_base64 is invalid", - ) from exc + "content_base64 is invalid") from exc if len(content) > 100 * 1024 * 1024: raise HTTPException( status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, - "object exceeds 100 MiB server-side upload limit", - ) + "object exceeds 100 MiB server-side upload limit") content_hash = hashlib.sha256(content).hexdigest() upload_result = await create_upload_record( CreateUploadRequest( @@ -470,12 +430,9 @@ async def create_server_object( content_type=payload.content_type, expected_size_bytes=len(content), expected_hash=content_hash, - idempotency_key=payload.idempotency_key, - url_scope="internal", - ), + idempotency_key=payload.idempotency_key), session, - request, - ) + request) if upload_result.get("status") == "completed": return {"data": upload_result["storage_object"], "meta": {"reused": True}} @@ -483,42 +440,34 @@ async def create_server_object( if upload is None: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, - "upload record disappeared", - ) + "upload record disappeared") await asyncio.to_thread( request.app.state.object_store.put_bytes, bucket_name=upload.bucket_name, object_key=upload.object_key, content=content, content_type=payload.content_type, - content_hash=content_hash, - ) + content_hash=content_hash) item = await complete_upload_record( upload.upload_id, CompleteUploadRequest( usage_type=payload.usage_type, visibility=payload.visibility, - is_immutable=payload.is_immutable, - ), + is_immutable=payload.is_immutable), session, - request, - ) + request) return {"data": storage_payload(item), "meta": {"reused": False}} @app.post( - "/internal/v1/workspace-objects", - dependencies=[Depends(verify_internal_service)], -) + "/internal/v1/workspace-objects") async def register_workspace_object( payload: RegisterWorkspaceObjectRequest, - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: + session: AsyncSession = Depends(database_session)) -> dict[str, Any]: workspace = await require_workspace_member( session, payload.workspace_id, - payload.user_id, - ) + payload.user_id) pure_path = PurePosixPath(payload.relative_path.replace("\\", "/")) if ( pure_path.is_absolute() @@ -527,8 +476,7 @@ async def register_workspace_object( ): raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, - "invalid workspace relative_path", - ) + "invalid workspace relative_path") relative_path = pure_path.as_posix() workspace_root = Path( os.getenv("WORKSPACE_ROOT", "/workspace/workspaces") @@ -538,13 +486,11 @@ async def register_workspace_object( if scoped_root != target and scoped_root not in target.parents: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, - "workspace path escapes its root", - ) + "workspace path escapes its root") if not target.is_file(): raise HTTPException( status.HTTP_404_NOT_FOUND, - "workspace file does not exist", - ) + "workspace file does not exist") content = await asyncio.to_thread(target.read_bytes) content_hash = hashlib.sha256(content).hexdigest() stat_result = target.stat() @@ -553,8 +499,7 @@ async def register_workspace_object( select(StorageObjects).where( StorageObjects.workspace_id == payload.workspace_id, StorageObjects.storage_backend == "workspace_fs", - StorageObjects.path_hash == path_digest, - ) + StorageObjects.path_hash == path_digest) ) reused = item is not None if item is None: @@ -572,19 +517,16 @@ async def register_workspace_object( visibility=payload.visibility, is_immutable=0, object_status="available", - created_by=payload.user_id, - ) + created_by=payload.user_id) session.add(item) elif item.is_immutable: raise HTTPException( status.HTTP_409_CONFLICT, - "immutable workspace object cannot be updated", - ) + "immutable workspace object cannot be updated") elif item.owner_user_id != payload.user_id: raise HTTPException( status.HTTP_403_FORBIDDEN, - "workspace object belongs to another user", - ) + "workspace object belongs to another user") item.usage_type = payload.usage_type item.file_name = target.name item.file_extension = target.suffix.lower() or None @@ -601,15 +543,12 @@ async def register_workspace_object( @app.post( - "/internal/v1/objects/{storage_object_id}/download-url", - dependencies=[Depends(verify_internal_service)], -) + "/internal/v1/objects/{storage_object_id}/download-url") async def create_download_url( storage_object_id: str, payload: DownloadUrlRequest, request: Request, - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: + session: AsyncSession = Depends(database_session)) -> dict[str, Any]: item = await session.get(StorageObjects, storage_object_id) if item is None or item.object_status != "available": raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found") @@ -620,18 +559,20 @@ async def create_download_url( ): raise HTTPException( status.HTTP_409_CONFLICT, - "object does not support a presigned URL", - ) + "object does not support a presigned URL") url = request.app.state.object_store.presign_get( bucket_name=item.bucket_name, object_key=item.object_key, file_name=item.file_name, - expires_seconds=payload.expires_seconds, + expires_seconds=payload.expires_seconds) + presigned_url = request.app.state.object_store.rewrite_to_public_path( + url, + public_base_url=_public_base_url(request), ) return { "data": { "storage_object_id": item.storage_object_id, - "presigned_url": url, + "presigned_url": presigned_url, "method": "GET", "expires_in_seconds": payload.expires_seconds, } @@ -639,14 +580,11 @@ async def create_download_url( @app.delete( - "/internal/v1/objects/{storage_object_id}", - dependencies=[Depends(verify_internal_service)], -) + "/internal/v1/objects/{storage_object_id}") async def delete_object( storage_object_id: str, request: Request, - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: + session: AsyncSession = Depends(database_session)) -> dict[str, Any]: item = await session.scalar( select(StorageObjects) .where(StorageObjects.storage_object_id == storage_object_id) @@ -657,15 +595,13 @@ async def delete_object( if item.is_immutable: raise HTTPException( status.HTTP_409_CONFLICT, - "immutable object cannot be deleted", - ) + "immutable object cannot be deleted") if item.object_status != "deleted": if item.storage_backend == "rustfs" and item.bucket_name and item.object_key: await asyncio.to_thread( request.app.state.object_store.delete, bucket_name=item.bucket_name, - object_key=item.object_key, - ) + object_key=item.object_key) item.object_status = "deleted" item.deleted_at = utcnow() return { diff --git a/backend/src/backend/storage_client.py b/backend/src/backend/storage_client.py index 8a57826..96f41e4 100644 --- a/backend/src/backend/storage_client.py +++ b/backend/src/backend/storage_client.py @@ -1,116 +1,5 @@ -from __future__ import annotations +"""Backward-compatible re-export of the shared storage client.""" -import base64 -from typing import Any +from common.storage.client import StorageClient -import httpx -from fastapi import HTTPException - - -class StorageClient: - def __init__( - self, - client: httpx.AsyncClient, - service_token: str, - ) -> None: - self.client = client - self.headers = {"X-Service-Token": service_token} - - async def _request( - self, - method: str, - path: str, - *, - payload: dict[str, Any] | None = None, - ) -> dict[str, Any]: - response = await self.client.request( - method, - path, - json=payload, - headers=self.headers, - ) - if response.is_error: - try: - detail = response.json().get("detail", response.text) - except ValueError: - detail = response.text - raise HTTPException(response.status_code, detail) - return response.json() - - async def create_upload( - self, - payload: dict[str, Any], - ) -> dict[str, Any]: - return (await self._request( - "POST", - "/internal/v1/uploads", - payload=payload, - ))["data"] - - async def complete_upload( - self, - upload_id: str, - payload: dict[str, Any], - ) -> dict[str, Any]: - return (await self._request( - "POST", - f"/internal/v1/uploads/{upload_id}/complete", - payload=payload, - ))["data"] - - async def register_workspace_object( - self, - payload: dict[str, Any], - ) -> dict[str, Any]: - return (await self._request( - "POST", - "/internal/v1/workspace-objects", - payload=payload, - ))["data"] - - async def create_server_object( - self, - *, - workspace_id: str, - user_id: str, - usage_type: str, - file_name: str, - content_type: str, - content: bytes, - visibility: str, - is_immutable: bool, - idempotency_key: str, - ) -> dict[str, Any]: - result = await self._request( - "POST", - "/internal/v1/objects", - payload={ - "workspace_id": workspace_id, - "user_id": user_id, - "usage_type": usage_type, - "file_name": file_name, - "content_type": content_type, - "content_base64": base64.b64encode(content).decode("ascii"), - "visibility": visibility, - "is_immutable": is_immutable, - "idempotency_key": idempotency_key, - }, - ) - return result["data"] - - async def create_download_url( - self, - storage_object_id: str, - expires_seconds: int, - ) -> dict[str, Any]: - return (await self._request( - "POST", - f"/internal/v1/objects/{storage_object_id}/download-url", - payload={"expires_seconds": expires_seconds}, - ))["data"] - - async def delete_object(self, storage_object_id: str) -> dict[str, Any]: - return (await self._request( - "DELETE", - f"/internal/v1/objects/{storage_object_id}", - ))["data"] +__all__ = ["StorageClient"] diff --git a/backend/src/backend/storage_schemas.py b/backend/src/backend/storage_schemas.py deleted file mode 100644 index 0e5f36f..0000000 --- a/backend/src/backend/storage_schemas.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -from pydantic import BaseModel, ConfigDict, Field, field_validator - - -class StrictModel(BaseModel): - model_config = ConfigDict(extra="forbid") - - -class CreateUploadRequest(StrictModel): - workspace_id: str = Field(min_length=26, max_length=26) - user_id: str = Field(min_length=26, max_length=26) - usage_type: Literal[ - "data_resource", - "version_artifact", - "snapshot", - "run_log", - "run_result", - ] - file_name: str = Field(min_length=1, max_length=255) - content_type: str = Field(min_length=1, max_length=255) - expected_size_bytes: int = Field(ge=0, le=100 * 1024 * 1024) - expected_hash: str | None = Field(default=None, min_length=64, max_length=64) - idempotency_key: str = Field(min_length=8, max_length=128) - url_scope: Literal["public", "internal"] = "public" - - @field_validator("expected_hash") - @classmethod - def validate_hash(cls, value: str | None) -> str | None: - if value is None: - return None - normalized = value.lower() - if any(character not in "0123456789abcdef" for character in normalized): - raise ValueError("expected_hash must be lowercase SHA-256 hex") - return normalized - - -class CompleteUploadRequest(StrictModel): - usage_type: Literal[ - "data_resource", - "version_artifact", - "snapshot", - "run_log", - "run_result", - ] - visibility: Literal["private", "workspace", "public"] = "private" - is_immutable: bool = False - - -class ServerObjectRequest(StrictModel): - workspace_id: str = Field(min_length=26, max_length=26) - user_id: str = Field(min_length=26, max_length=26) - usage_type: Literal[ - "data_resource", - "version_artifact", - "snapshot", - "run_log", - "run_result", - ] - file_name: str = Field(min_length=1, max_length=255) - content_type: str = Field(min_length=1, max_length=255) - content_base64: str = Field(min_length=1) - visibility: Literal["private", "workspace", "public"] = "private" - is_immutable: bool = False - idempotency_key: str = Field(min_length=8, max_length=128) - - -class RegisterWorkspaceObjectRequest(StrictModel): - workspace_id: str = Field(min_length=26, max_length=26) - user_id: str = Field(min_length=26, max_length=26) - relative_path: str = Field(min_length=1, max_length=1024) - usage_type: Literal["working_copy", "public_script"] - visibility: Literal["private", "workspace", "public"] = "private" - - -class DownloadUrlRequest(StrictModel): - expires_seconds: int = Field(default=300, ge=30, le=3600) diff --git a/common/pyproject.toml b/common/pyproject.toml index a79133e..bd88a16 100644 --- a/common/pyproject.toml +++ b/common/pyproject.toml @@ -4,6 +4,7 @@ version = "0.2.0" requires-python = ">=3.12" dependencies = [ "SQLAlchemy==2.0.51", + "apscheduler>=3.11.3", "asyncmy==0.2.11", "boto3>=1.34,<2", "fastapi==0.116.1", @@ -15,3 +16,7 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/common"] + +[[tool.uv.index]] +url = "https://pypi.tuna.tsinghua.edu.cn/simple/" +default = true diff --git a/common/src/common/db/base.py b/common/src/common/db/base.py index 3f203ac..23807ec 100644 --- a/common/src/common/db/base.py +++ b/common/src/common/db/base.py @@ -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 \ No newline at end of file diff --git a/common/src/common/db/models.py b/common/src/common/db/models.py deleted file mode 100644 index f3aa8af..0000000 --- a/common/src/common/db/models.py +++ /dev/null @@ -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,防止数据库事件重复处理'} - ) - - 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='数据库事件处理批次标识') - 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;由 Schedule Executor 直接轮询处理'} - ) - - 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': '编辑会话与数据库租约;MySQL 为锁状态权威'} - ) - - 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_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') diff --git a/common/src/common/db/models/__init__.py b/common/src/common/db/models/__init__.py index f0ff52a..cf0b853 100644 --- a/common/src/common/db/models/__init__.py +++ b/common/src/common/db/models/__init__.py @@ -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", +] diff --git a/common/src/common/db/models/audit.py b/common/src/common/db/models/audit.py new file mode 100644 index 0000000..ed9edba --- /dev/null +++ b/common/src/common/db/models/audit.py @@ -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)) diff --git a/common/src/common/db/models/events.py b/common/src/common/db/models/events.py new file mode 100644 index 0000000..6d0fd94 --- /dev/null +++ b/common/src/common/db/models/events.py @@ -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)) diff --git a/common/src/common/db/models/experiments.py b/common/src/common/db/models/experiments.py new file mode 100644 index 0000000..14b6c5d --- /dev/null +++ b/common/src/common/db/models/experiments.py @@ -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)) diff --git a/common/src/common/db/models/identity.py b/common/src/common/db/models/identity.py new file mode 100644 index 0000000..c22dae5 --- /dev/null +++ b/common/src/common/db/models/identity.py @@ -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)) diff --git a/common/src/common/db/models/runtime.py b/common/src/common/db/models/runtime.py new file mode 100644 index 0000000..c86b13e --- /dev/null +++ b/common/src/common/db/models/runtime.py @@ -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)) diff --git a/common/src/common/db/models/schedules.py b/common/src/common/db/models/schedules.py new file mode 100644 index 0000000..2254fe0 --- /dev/null +++ b/common/src/common/db/models/schedules.py @@ -0,0 +1,249 @@ +import datetime +import decimal +from typing import Literal, 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 + + +TriggerType = Literal["manual", "cron", "api"] +FailurePolicy = Literal["stop", "continue"] + + +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)) diff --git a/common/src/common/db/models/scripts.py b/common/src/common/db/models/scripts.py new file mode 100644 index 0000000..9a3fe01 --- /dev/null +++ b/common/src/common/db/models/scripts.py @@ -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)) diff --git a/common/src/common/db/models/storage.py b/common/src/common/db/models/storage.py new file mode 100644 index 0000000..868aaa3 --- /dev/null +++ b/common/src/common/db/models/storage.py @@ -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)) diff --git a/common/src/common/db/models/workspaces.py b/common/src/common/db/models/workspaces.py new file mode 100644 index 0000000..23df009 --- /dev/null +++ b/common/src/common/db/models/workspaces.py @@ -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)) diff --git a/common/src/common/eventing.py b/common/src/common/eventing.py index 7035679..dc8adc0 100644 --- a/common/src/common/eventing.py +++ b/common/src/common/eventing.py @@ -9,13 +9,6 @@ from common.db.models import OutboxEvents from common.ids import new_ulid -SUPPORTED_EVENT_TYPES = { - "schedule.run.requested", - "job.node.execute", - "job.node.finished", -} - - def utcnow() -> datetime: return datetime.now(UTC).replace(tzinfo=None) @@ -39,28 +32,14 @@ async def add_outbox_event( payload: dict[str, Any], available_at: datetime | None = None, ) -> OutboxEvents: - if event_type not in SUPPORTED_EVENT_TYPES: - raise ValueError(f"unsupported event type: {event_type}") event_id = new_ulid() - envelope = { - "event_id": event_id, - "event_type": event_type, - "schema_version": 1, - "occurred_at": event_time(), - "producer": producer, - "trace_id": trace_id, - "aggregate_type": aggregate_type, - "aggregate_id": aggregate_id, - "idempotency_key": idempotency_key, - "payload": payload, - } item = OutboxEvents( event_id=event_id, aggregate_type=aggregate_type, aggregate_id=aggregate_id, event_type=event_type, schema_version=1, - payload_json=envelope, + payload_json=payload, event_status="pending", available_at=available_at or utcnow(), retry_count=0, @@ -69,3 +48,7 @@ async def add_outbox_event( ) session.add(item) return item + + +__all__ = ["add_outbox_event", "event_time", "utcnow"] + diff --git a/common/src/common/migrations/env.py b/common/src/common/migrations/env.py index 3f8cba8..b83bc31 100644 --- a/common/src/common/migrations/env.py +++ b/common/src/common/migrations/env.py @@ -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 diff --git a/common/src/common/scheduler/__init__.py b/common/src/common/scheduler/__init__.py new file mode 100644 index 0000000..a12a034 --- /dev/null +++ b/common/src/common/scheduler/__init__.py @@ -0,0 +1,59 @@ +"""Shared APScheduler jobstore helpers. + +The same ``apscheduler_jobs`` table is consumed by the FastAPI backend +(to enqueue cron jobs through ``add_job``) and by the schedule executor +(to run them). Both sides agree on the table name and the URL form +that :class:`SQLAlchemyJobStore` expects, so this module is the single +source of truth for that contract. + +``apscheduler`` is declared as an optional peer dependency: only the +schedule service imports it at runtime. This module therefore exposes +the table name and URL helpers but defers actual jobstore construction +to the caller. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - typing only + from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore + + +JOBSTORE_TABLE = "apscheduler_jobs" + + +def to_sync_database_url(database_url: str) -> str: + """Rewrite ``mysql+asyncmy://`` to ``mysql+pymysql://``. + + APScheduler's ``SQLAlchemyJobStore`` uses a synchronous engine; the + project's default async URL needs to be downgraded before it can + drive the jobstore. + """ + return database_url.replace("mysql+asyncmy://", "mysql+pymysql://", 1) + + +def build_sqlalchemy_jobstore( + database_url: str, + *, + tablename: str = JOBSTORE_TABLE, +) -> "SQLAlchemyJobStore": + """Instantiate a :class:`SQLAlchemyJobStore` for the canonical table. + + The caller is responsible for ensuring APScheduler and its sync + driver (``pymysql``) are installed; ``common`` does not depend on + either. + """ + from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore + + return SQLAlchemyJobStore( + url=to_sync_database_url(database_url), + tablename=tablename, + ) + + +__all__ = [ + "JOBSTORE_TABLE", + "build_sqlalchemy_jobstore", + "to_sync_database_url", +] diff --git a/common/src/common/schemas.py b/common/src/common/schemas.py new file mode 100644 index 0000000..b8c8553 --- /dev/null +++ b/common/src/common/schemas.py @@ -0,0 +1,14 @@ +"""Shared Pydantic base models for service-level request validation.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class StrictModel(BaseModel): + """Base model that rejects unknown fields.""" + + model_config = ConfigDict(extra="forbid") + + +__all__ = ["StrictModel"] diff --git a/common/src/common/storage/__init__.py b/common/src/common/storage/__init__.py index 417f62a..61ffe4b 100644 --- a/common/src/common/storage/__init__.py +++ b/common/src/common/storage/__init__.py @@ -1,3 +1,6 @@ +"""Storage building blocks shared by backend and schedule services.""" + +from common.storage.client import StorageClient from common.storage.rustfs import RustFSObjectStore -__all__ = ["RustFSObjectStore"] +__all__ = ["RustFSObjectStore", "StorageClient"] diff --git a/common/src/common/storage/rustfs.py b/common/src/common/storage/rustfs.py index 88bc51c..3930d99 100644 --- a/common/src/common/storage/rustfs.py +++ b/common/src/common/storage/rustfs.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib from typing import Any, BinaryIO +from urllib.parse import urlsplit, urlunsplit import boto3 from botocore.client import Config @@ -13,7 +14,6 @@ class RustFSObjectStore: self, *, internal_endpoint: str, - public_endpoint: str, access_key: str, secret_key: str, ) -> None: @@ -31,11 +31,7 @@ class RustFSObjectStore: endpoint_url=internal_endpoint.rstrip("/"), **common, ) - self.public = boto3.client( - "s3", - endpoint_url=public_endpoint.rstrip("/"), - **common, - ) + self._internal_endpoint = internal_endpoint.rstrip("/") def ensure_bucket(self, bucket_name: str) -> None: try: @@ -54,7 +50,6 @@ class RustFSObjectStore: content_type: str, expected_hash: str | None, expires_seconds: int, - public: bool, ) -> tuple[str, dict[str, str]]: params: dict[str, Any] = { "Bucket": bucket_name, @@ -65,8 +60,7 @@ class RustFSObjectStore: if expected_hash: params["Metadata"] = {"sha256": expected_hash} headers["x-amz-meta-sha256"] = expected_hash - client = self.public if public else self.internal - url = client.generate_presigned_url( + url = self.internal.generate_presigned_url( "put_object", Params=params, ExpiresIn=expires_seconds, @@ -81,7 +75,7 @@ class RustFSObjectStore: file_name: str, expires_seconds: int, ) -> str: - return self.public.generate_presigned_url( + return self.internal.generate_presigned_url( "get_object", Params={ "Bucket": bucket_name, @@ -93,6 +87,36 @@ class RustFSObjectStore: ExpiresIn=expires_seconds, ) + def rewrite_to_public_path( + self, + url: str, + *, + public_base_url: str, + ) -> str: + """Rewrite the host of a presigned URL to the public edge. + + Replaces the scheme + host (and strips any trailing slash) with + ``public_base_url``; the bucket prefix is moved under the + ``/storage/`` path so Nginx can forward the call to RustFS + without exposing its port. + """ + parsed = urlsplit(url) + public = urlsplit(public_base_url.rstrip("/")) + netloc = public.netloc + prefix = public.path.rstrip("/") + # Presigned URLs are generated against the internal S3 endpoint + # and always start with ``/{bucket}/...``; relocate the bucket + # segment under ``/storage/...`` so the public edge can route + # the request to the right bucket. + rewritten_path = f"{prefix}/storage{parsed.path}" + return urlunsplit(( + public.scheme, + netloc, + rewritten_path, + parsed.query, + parsed.fragment, + )) + def put_bytes( self, *, diff --git a/common/src/common/utils.py b/common/src/common/utils.py index 3c4e086..4631119 100644 --- a/common/src/common/utils.py +++ b/common/src/common/utils.py @@ -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!' \ No newline at end of file + +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__*.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 \ No newline at end of file diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/test2-checkpoint.py b/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/test2-checkpoint.py deleted file mode 100644 index 2dc3e1b..0000000 --- a/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/test2-checkpoint.py +++ /dev/null @@ -1,9 +0,0 @@ -"""模型实验开发平台构建脚本。""" - - -def main() -> None: - print("Hello, Model Platform!") - - -if __name__ == "__main__": - main() diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/.ipynb_checkpoints/test-checkpoint.ipynb b/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/.ipynb_checkpoints/test-checkpoint.ipynb deleted file mode 100644 index bcf5520..0000000 --- a/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/.ipynb_checkpoints/test-checkpoint.ipynb +++ /dev/null @@ -1,43 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "e139eae2", - "metadata": {}, - "source": [ - "# 新建模型实验\\n在这里开始数据探索与模型构建。" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7a78bce5", - "metadata": {}, - "outputs": [], - "source": [ - "print('Hello, Model Platform!')" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/NotebookDemo.ipynb b/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/NotebookDemo.ipynb deleted file mode 100644 index 7cd3200..0000000 --- a/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/NotebookDemo.ipynb +++ /dev/null @@ -1,67 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "initial_id", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": ["hello world\n"] - } - ], - "source": ["print(\"hello world\")"] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "979c21d2489e134d", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": ["hello world12323\n"] - } - ], - "source": ["print(\"hello world12323\")"] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "e068092a-3b51-4399-9c64-c44d58f4973c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": ["Python 3.12.13\n"] - } - ], - "source": ["!python --version"] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1f512d9e-92f7-423e-9176-40fec30dd79c", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.12.13" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/test.ipynb b/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/test.ipynb deleted file mode 100644 index 4fc54c1..0000000 --- a/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/test.ipynb +++ /dev/null @@ -1,59 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "e139eae2", - "metadata": {}, - "source": [ - "# 新建模型实验\\n在这里开始数据探索与模型构建。" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "7a78bce5", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "111\n" - ] - } - ], - "source": [ - "print('111')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "42d2764b-5699-4374-afe9-aecda6d2d1cd", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/test2.py b/deploy/data/workspaces/model-dev/users/admin-zhang/test2.py deleted file mode 100644 index 2dc3e1b..0000000 --- a/deploy/data/workspaces/model-dev/users/admin-zhang/test2.py +++ /dev/null @@ -1,9 +0,0 @@ -"""模型实验开发平台构建脚本。""" - - -def main() -> None: - print("Hello, Model Platform!") - - -if __name__ == "__main__": - main() diff --git a/deploy/demo/NotebookDemo.ipynb b/deploy/demo/NotebookDemo.ipynb deleted file mode 100644 index 7cd3200..0000000 --- a/deploy/demo/NotebookDemo.ipynb +++ /dev/null @@ -1,67 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "initial_id", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": ["hello world\n"] - } - ], - "source": ["print(\"hello world\")"] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "979c21d2489e134d", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": ["hello world12323\n"] - } - ], - "source": ["print(\"hello world12323\")"] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "e068092a-3b51-4399-9c64-c44d58f4973c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": ["Python 3.12.13\n"] - } - ], - "source": ["!python --version"] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1f512d9e-92f7-423e-9176-40fec30dd79c", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.12.13" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docker-compose.legacy.yml b/docker-compose.legacy.yml deleted file mode 100644 index 9d27e38..0000000 --- a/docker-compose.legacy.yml +++ /dev/null @@ -1,60 +0,0 @@ -version: '3.8' - -services: - web: - image: nginx:alpine - ports: - - "8888:80" - restart: unless-stopped - volumes: - - ./default.conf:/etc/nginx/conf.d/default.conf:ro - depends_on: - - backend - - backend: - build: - context: . - dockerfile: backend/Dockerfile - ports: - - "8004:8000" - environment: - - RUNTIME_BASE_URL=http://runtime:8001 - volumes: - - ./backend:/app/backend:ro - - ./common:/app/common:ro - depends_on: - - runtime - - runtime: - build: - context: . - dockerfile: runtime/Dockerfile - cap_add: - - SYS_ADMIN - devices: - - /dev/fuse:/dev/fuse - security_opt: - - apparmor:unconfined - - ports: - - "8002:8001" - environment: - - PUBLIC_BASE_URL=http://runtime - # --- Rclone 动态环境变量配置 (对应名称 rustfs) --- - - RCLONE_CONFIG_RUSTFS_TYPE=s3 - - RCLONE_CONFIG_RUSTFS_PROVIDER=Other - - RCLONE_CONFIG_RUSTFS_ACCESS_KEY_ID=BdsXeamEnvSDQnk8tRxh - - RCLONE_CONFIG_RUSTFS_SECRET_ACCESS_KEY=mmBVc3RqzbT2VX3ysKGnirYH5kYD3ww3wFtMVvrb - # 替换为你的 RustFS 服务地址(如果是同 docker-compose 网络下的服务,可以直接填服务名:端口) - - RCLONE_CONFIG_RUSTFS_ENDPOINT=http://8.153.151.51:9000 - # 自建 S3 建议强制开启 Path-style 访问 (http://endpoint/bucket) - - RCLONE_CONFIG_RUSTFS_ENV_AUTH=false - - RCLONE_CONFIG_RUSTFS_FORCE_PATH_STYLE=true - - RCLONE_CONFIG_RUSTFS_REGION=other - - # --- Runtime 逻辑环境变量 --- - - REMOTE_BUCKET=rustfs:workspaces - - WORKSPACES_ROOT=/app/workspaces - volumes: - - ./runtime:/app/runtime:ro - - ./common:/app/common:ro \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f5a6d4b..662bdb8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,90 +1,16 @@ -name: ${COMPOSE_PROJECT_NAME:-model-platform-new} - -x-app-environment: &app-environment - DATABASE_URL: mysql+asyncmy://${MYSQL_USER:-model_platform}:${MYSQL_PASSWORD:-model_platform}@mysql:3306/${MYSQL_DATABASE:-model_platform}?charset=utf8mb4 - INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:-local-internal-token} - JUPYTER_TOKEN: ${JUPYTER_TOKEN:-local-jupyter-token} - WORKSPACE_ROOT: /workspace/workspaces +version: '3.8' services: - mysql: - image: mysql:8.0.36 + web: + image: nginx:alpine restart: unless-stopped - environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-model_platform_root} - MYSQL_DATABASE: ${MYSQL_DATABASE:-model_platform} - MYSQL_USER: ${MYSQL_USER:-model_platform} - MYSQL_PASSWORD: ${MYSQL_PASSWORD:-model_platform} - command: - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_0900_ai_ci - - --default-time-zone=+08:00 ports: - - "${MYSQL_PORT:-3308}:3306" + - "${GATEWAY_PORT:-8888}:80" volumes: - - mysql_data:/var/lib/mysql - healthcheck: - test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"] - interval: 10s - timeout: 5s - retries: 15 - start_period: 20s - - rustfs: - image: ${RUSTFS_IMAGE:-rustfs/rustfs:latest} - restart: unless-stopped - environment: - RUSTFS_ADDRESS: ":9000" - RUSTFS_CONSOLE_ENABLE: "true" - RUSTFS_CONSOLE_ADDRESS: ":9001" - RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform} - RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret} - command: ["/data"] - ports: - - "${RUSTFS_API_PORT:-9010}:9000" - - "${RUSTFS_CONSOLE_PORT:-9011}:9001" - volumes: - - rustfs_data:/data - - jupyter: - image: ${JUPYTER_IMAGE:-quay.io/jupyter/base-notebook:2025-12-31} - restart: unless-stopped - environment: - JUPYTER_TOKEN: ${JUPYTER_TOKEN:-local-jupyter-token} - command: - - start-notebook.py - - --ServerApp.base_url=/jupyter/ - - --ServerApp.root_dir=/home/jovyan/work - - --ServerApp.ip=0.0.0.0 - - --ServerApp.allow_remote_access=True - - --IdentityProvider.token=${JUPYTER_TOKEN:-local-jupyter-token} - - --PasswordIdentityProvider.hashed_password= - volumes: - - ./deploy/data/workspaces:/home/jovyan/work - expose: - - "8888" - healthcheck: - test: - - CMD - - python - - -c - - "import os, urllib.request; r=urllib.request.Request('http://127.0.0.1:8888/jupyter/api/status', headers={'Authorization':'Bearer '+os.environ['JUPYTER_TOKEN']}); urllib.request.urlopen(r, timeout=3)" - interval: 10s - timeout: 5s - retries: 15 - start_period: 30s - - migrate: - build: - context: . - dockerfile: backend/Dockerfile - environment: - <<: *app-environment - command: ["alembic", "upgrade", "head"] + - ./default.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - mysql: - condition: service_healthy - restart: "no" + - backend + - runtime backend: build: @@ -92,72 +18,62 @@ services: dockerfile: backend/Dockerfile restart: unless-stopped environment: - <<: *app-environment - SERVICE_NAME: backend - READINESS_TARGETS: mysql:3306,rustfs:9000 + DATABASE_URL: mysql+asyncmy://${MYSQL_USER:-model_platform}:${MYSQL_PASSWORD:-model_platform}@mysql:3306/${MYSQL_DATABASE:-model_platform}?charset=utf8mb4 + JWT_SECRET: ${JWT_SECRET:-local-jwt-secret} RUNTIME_API_URL: http://runtime:8000 - SCHEDULE_API_URL: http://schedule:8000 + RUNTIME_BASE_URL: http://runtime:8000 RUSTFS_INTERNAL_ENDPOINT: http://rustfs:9000 - RUSTFS_PUBLIC_ENDPOINT: http://localhost:${RUSTFS_API_PORT:-9010} RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform} RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret} RUSTFS_DEFAULT_BUCKET: model-platform - healthcheck: - test: - - CMD - - python - - -c - - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)" - interval: 10s - timeout: 5s - retries: 10 - start_period: 10s + WORKSPACE_ROOT: /workspace/workspaces volumes: - ./deploy/data/workspaces:/workspace/workspaces + - ./backend:/app/backend:ro + - ./common:/app/common:ro ports: - - "${BACKEND_PORT:-8010}:8000" + - "${BACKEND_PORT:-8004}:8000" depends_on: - migrate: - condition: service_completed_successfully - mysql: - condition: service_healthy - rustfs: - condition: service_started + - runtime runtime: build: context: . dockerfile: runtime/Dockerfile restart: unless-stopped + cap_add: + - SYS_ADMIN + devices: + - /dev/fuse:/dev/fuse + security_opt: + - apparmor:unconfined environment: - <<: *app-environment + DATABASE_URL: mysql+asyncmy://${MYSQL_USER:-model_platform}:${MYSQL_PASSWORD:-model_platform}@mysql:3306/${MYSQL_DATABASE:-model_platform}?charset=utf8mb4 SERVICE_NAME: runtime-manager - READINESS_TARGETS: mysql:3306,jupyter:8888 + REDIS_HOST: redis + REDIS_PORT: "6379" + REDIS_PASSWORD: ${REDIS_PASSWORD:-model_platform_redis} FILE_LOCK_ENABLED: "false" - FILE_LOCK_TTL_MS: "45000" JUPYTER_INTERNAL_URL: http://jupyter:8888/jupyter/ JUPYTER_PROXY_BASE_PATH: /jupyter/ JUPYTER_TICKET_TTL_SECONDS: "300" - RUNTIME_LEASE_SECONDS: "1800" - healthcheck: - test: - - CMD - - python - - -c - - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)" - interval: 10s - timeout: 5s - retries: 10 - start_period: 10s + WORKSPACES_ROOT: /workspace/workspaces + PUBLIC_BASE_URL: http://runtime + REMOTE_BUCKET: rustfs:workspaces + RCLONE_CONFIG_RUSTFS_TYPE: s3 + RCLONE_CONFIG_RUSTFS_PROVIDER: Other + RCLONE_CONFIG_RUSTFS_ACCESS_KEY_ID: ${RUSTFS_ACCESS_KEY:-modelplatform} + RCLONE_CONFIG_RUSTFS_SECRET_ACCESS_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret} + RCLONE_CONFIG_RUSTFS_ENDPOINT: http://rustfs:9000 + RCLONE_CONFIG_RUSTFS_ENV_AUTH: "false" + RCLONE_CONFIG_RUSTFS_FORCE_PATH_STYLE: "true" + RCLONE_CONFIG_RUSTFS_REGION: other + volumes: + - ./deploy/data/workspaces:/workspace/workspaces + - ./runtime:/app/runtime:ro + - ./common:/app/common:ro ports: - - "${RUNTIME_PORT:-8012}:8000" - depends_on: - migrate: - condition: service_completed_successfully - mysql: - condition: service_healthy - jupyter: - condition: service_healthy + - "${RUNTIME_PORT:-8002}:8001" schedule: build: @@ -165,60 +81,18 @@ services: dockerfile: schedule/Dockerfile restart: unless-stopped environment: - <<: *app-environment - SERVICE_NAME: schedule-executor - READINESS_TARGETS: mysql:3306,rustfs:9000,backend:8000 + DATABASE_URL: mysql+asyncmy://${MYSQL_USER:-model_platform}:${MYSQL_PASSWORD:-model_platform}@mysql:3306/${MYSQL_DATABASE:-model_platform}?charset=utf8mb4 + REDIS_HOST: redis + REDIS_PORT: "6379" + REDIS_PASSWORD: ${REDIS_PASSWORD:-model_platform_redis} RUSTFS_INTERNAL_ENDPOINT: http://rustfs:9000 RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform} RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret} - BACKEND_API_URL: http://backend:8000 - healthcheck: - test: - - CMD - - python - - -c - - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)" - interval: 10s - timeout: 5s - retries: 10 - start_period: 10s + STORAGE_API_URL: http://backend:8000 + WORKSPACE_ROOT: /workspace/workspaces volumes: - ./deploy/data/workspaces:/workspace/workspaces ports: - "${SCHEDULE_PORT:-8013}:8000" depends_on: - mysql: - condition: service_healthy - rustfs: - condition: service_started - backend: - condition: service_healthy - - gateway: - build: - context: . - dockerfile: nginx/Dockerfile - restart: unless-stopped - environment: - INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:-local-internal-token} - ports: - - "${GATEWAY_PORT:-8081}:80" - depends_on: - backend: - condition: service_healthy - runtime: - condition: service_healthy - schedule: - condition: service_healthy - jupyter: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health >/dev/null 2>&1"] - interval: 10s - timeout: 5s - retries: 10 - start_period: 5s - -volumes: - mysql_data: - rustfs_data: + - backend diff --git a/runtime/Dockerfile b/runtime/Dockerfile index a412b64..0d7cee2 100644 --- a/runtime/Dockerfile +++ b/runtime/Dockerfile @@ -2,11 +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 pip install --system ./common ./runtime +RUN UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv sync --frozen --no-dev --no-editable --package runtime EXPOSE 8000 -CMD ["uvicorn", "runtime.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uv", "run", "--frozen", "--package", "runtime", "uvicorn", "runtime.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/runtime/pyproject.toml b/runtime/pyproject.toml index a385773..3aa3113 100644 --- a/runtime/pyproject.toml +++ b/runtime/pyproject.toml @@ -7,10 +7,12 @@ dependencies = [ "fastapi==0.116.1", "uvicorn[standard]==0.35.0", "httpx==0.28.1", + "loguru==0.7.2", + "notebook", ] [tool.uv.sources] -common = { path = "../common" } +common = { workspace = true } [build-system] requires = ["hatchling"] diff --git a/runtime/src/runtime/__init__.py b/runtime/src/runtime/__init__.py deleted file mode 100644 index da2d535..0000000 --- a/runtime/src/runtime/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Runtime Manager application.""" diff --git a/runtime/src/runtime/legacy_process_runtime.py b/runtime/src/runtime/legacy_process_runtime.py deleted file mode 100644 index 2e9089d..0000000 --- a/runtime/src/runtime/legacy_process_runtime.py +++ /dev/null @@ -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'", - ) diff --git a/runtime/src/runtime/main.py b/runtime/src/runtime/main.py index 507800a..a18a6f5 100644 --- a/runtime/src/runtime/main.py +++ b/runtime/src/runtime/main.py @@ -1,1026 +1,99 @@ +"""Runtime service entrypoint. + +Wires the object-storage mount (:mod:`runtime.mount`) and the Jupyter +process registry (:mod:`runtime.process`) into a FastAPI app. The HTTP +surface is two endpoints; everything else is lifespan orchestration. +""" + from __future__ import annotations -import asyncio -import hashlib -import logging -import os -import secrets from contextlib import asynccontextmanager -from datetime import UTC, datetime, timedelta -from pathlib import PurePosixPath -from typing import Any, AsyncIterator -from urllib.parse import quote, unquote, urlsplit +from typing import Literal -import httpx -from fastapi import ( - Cookie, - Depends, - Header, - HTTPException, - Query, - Request, - Response, - status, -) -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import FastAPI, HTTPException +from loguru import logger +from pydantic import BaseModel, ConfigDict -from common.db import create_database_engine, create_session_factory -from common.db.models import ( - EditSessions, - Roles, - RuntimeInstances, - StorageObjects, - Users, - WorkspaceMembers, - Workspaces, -) -from common.ids import new_ulid -from common.service_app import create_service_app -from runtime.providers.shared_jupyter import ( - RuntimeProviderError, - SharedJupyterAdapter, -) -from runtime.runtime_lifecycle import ( - RuntimeLifecycle, - runtime_payload, -) -from runtime.schemas import ( - AcquireFileLockRequest, - CreateRuntimeSessionApiRequest, - EnsureRuntimeApiRequest, - FileLockTokenRequest, - RuntimeIdentityRequest, - StopRuntimeApiRequest, +from runtime.mount import start_rclone_mount, stop_rclone_mount +from runtime.process import ( + JUPYTER_PROCESSES, + get_workspace, + list_workspaces, + scan_workspaces, + start_workspace, + stop_workspace, ) -logger = logging.getLogger(__name__) +class JupyterActionRequest(BaseModel): + action: Literal["start", "stop", "list", "get"] + workspace_id: str | None = None -def utcnow() -> datetime: - return datetime.now(UTC).replace(tzinfo=None) - - -def utc_iso(value: datetime) -> str: - return value.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z") - - -def token_digest(value: str) -> bytes: - return hashlib.sha256(value.encode("utf-8")).digest() - - -def jupyter_ticket_key(raw_ticket: str) -> str: - digest = hashlib.sha256(raw_ticket.encode("utf-8")).hexdigest() - return f"jupyter:access-ticket:{digest}" - - -def jupyter_url(workspace_code: str, relative_path: str) -> str: - relative = PurePosixPath(relative_path.replace("\\", "/")) - if ( - relative.is_absolute() - or not relative.parts - or any(part in {"", ".", ".."} for part in relative.parts) - ): - raise lock_error( - status.HTTP_409_CONFLICT, - "JUPYTER_PATH_INVALID", - "Notebook 路径无效", - ) - path = PurePosixPath(workspace_code, *relative.parts).as_posix() - encoded_path = quote(path, safe="/") - if relative.suffix.lower() == ".ipynb": - return f"/jupyter/notebooks/{encoded_path}" - return f"/jupyter/lab/tree/{encoded_path}" - - -def lock_error( - status_code: int, - code: str, - message: str, - *, - retryable: bool = False, - details: dict[str, Any] | None = None, -) -> HTTPException: - return HTTPException( - status_code, - { - "code": code, - "message": message, - "retryable": retryable, - "details": details or {}, - }, - ) - - -def verify_internal_service( - x_service_token: str = Header(alias="X-Service-Token"), -) -> None: - expected = os.environ.get("INTERNAL_SERVICE_TOKEN", "") - if not expected or not secrets.compare_digest(expected, x_service_token): - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - "invalid internal service identity", - ) + model_config = ConfigDict(extra="forbid") @asynccontextmanager -async def lifespan(app: Any) -> AsyncIterator[None]: - engine = create_database_engine(os.environ["DATABASE_URL"]) - app.state.session_factory = create_session_factory(engine) - app.state.file_lock_ttl_ms = int( - os.getenv("FILE_LOCK_TTL_MS", "45000") - ) - app.state.file_lock_enabled = ( - os.getenv("FILE_LOCK_ENABLED", "false").strip().lower() - not in {"0", "false", "no", "off"} - ) - if not 5_000 <= app.state.file_lock_ttl_ms <= 300_000: - raise RuntimeError("FILE_LOCK_TTL_MS must be between 5000 and 300000") - app.state.jupyter_ticket_ttl_seconds = int( - os.getenv("JUPYTER_TICKET_TTL_SECONDS", "60") - ) - if not 30 <= app.state.jupyter_ticket_ttl_seconds <= 300: - raise RuntimeError( - "JUPYTER_TICKET_TTL_SECONDS must be between 30 and 300" - ) - app.state.jupyter_tickets: dict[str, dict[str, Any]] = {} - app.state.ticket_lock = asyncio.Lock() - jupyter_internal_url = os.getenv( - "JUPYTER_INTERNAL_URL", - "http://jupyter:8888/jupyter/", - ).rstrip("/") + "/" - app.state.jupyter_http_client = httpx.AsyncClient( - base_url=jupyter_internal_url, - timeout=httpx.Timeout(15.0), - headers={ - "Authorization": f"Bearer {os.environ['JUPYTER_TOKEN']}", - }, - ) - app.state.jupyter_token = os.environ["JUPYTER_TOKEN"] - adapter = SharedJupyterAdapter( - app.state.jupyter_http_client, - internal_url=jupyter_internal_url, - proxy_base_path=os.getenv( - "JUPYTER_PROXY_BASE_PATH", - "/jupyter/", - ), - ) - app.state.runtime_lifecycle = RuntimeLifecycle( - adapter, - lease_seconds=int(os.getenv("RUNTIME_LEASE_SECONDS", "1800")), - ) - cleanup_task = asyncio.create_task( - reconcile_expired_edit_sessions(app) - ) +async def lifespan(app: FastAPI): + logger.info("Starting up Runtime Service...") + start_rclone_mount() + logger.info("Scanning workspaces") + await scan_workspaces() + logger.info("Runtime Service started") + try: yield finally: - cleanup_task.cancel() - try: - await cleanup_task - except asyncio.CancelledError: - pass - await app.state.jupyter_http_client.aclose() - await engine.dispose() - - -app = create_service_app( - os.getenv("SERVICE_NAME", "runtime-manager"), - lifespan=lifespan, -) - - -async def active_member( - session: AsyncSession, - *, - workspace_id: str, - user_id: str, -) -> tuple[Users, Roles, Workspaces]: - statement = ( - select(Users, Roles, Workspaces) - .join( - WorkspaceMembers, - WorkspaceMembers.user_id == Users.user_id, + logger.info( + "Service is shutting down. Terminating all active Jupyter sub-processes..." ) - .join(Roles, Roles.role_id == WorkspaceMembers.role_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", - ) - ) - row = (await session.execute(statement)).one_or_none() - if row is None: - raise lock_error( - status.HTTP_403_FORBIDDEN, - "WORKSPACE_ACCESS_DENIED", - "用户不是有效的 Workspace 成员", - ) - return row - -async def editable_object( - session: AsyncSession, - payload: AcquireFileLockRequest, - role: Roles, -) -> StorageObjects: - item = await session.get(StorageObjects, payload.storage_object_id) - if ( - item is None - or item.workspace_id != payload.workspace_id - or item.object_status != "available" - ): - raise lock_error( - status.HTTP_404_NOT_FOUND, - "FILE_NOT_FOUND", - "文件不存在", - ) - if ( - item.object_type != "file" - or item.storage_backend != "workspace_fs" - or bool(item.is_immutable) - ): - raise lock_error( - status.HTTP_409_CONFLICT, - "FILE_NOT_EDITABLE", - "仅 Workspace 中的可变文件可以申请编辑锁", - ) - is_owner = item.owner_user_id == payload.user_id - is_shared_editor = role.role_code != "viewer" - if not (is_owner or is_shared_editor or role.role_code == "admin"): - raise lock_error( - status.HTTP_403_FORBIDDEN, - "FILE_EDIT_DENIED", - "当前用户没有该文件的编辑权限", - ) - return item - - -def session_payload( - item: EditSessions, - *, - lock_token: str | None = None, - relative_path: str | None = None, - heartbeat_interval_seconds: int = 15, - jupyter_url: str | None = None, -) -> dict[str, Any]: - data: dict[str, Any] = { - "edit_session_id": item.edit_session_id, - "workspace_id": item.workspace_id, - "storage_object_id": item.storage_object_id, - "user_id": item.user_id, - "session_status": item.session_status, - "lease_seconds": 45, - "heartbeat_interval_seconds": heartbeat_interval_seconds, - "expires_at": utc_iso(item.expires_at), - "runtime_id": item.runtime_id, - "jupyter_session_id": item.jupyter_session_id, - "jupyter_url": jupyter_url, - } - if lock_token is not None: - data["lock_token"] = lock_token - if relative_path is not None: - data["relative_path"] = relative_path - return data - - -async def owned_session( - session: AsyncSession, - *, - edit_session_id: str, - workspace_id: str, - user_id: str, -) -> EditSessions: - item = await session.get(EditSessions, edit_session_id) - if ( - item is None - or item.workspace_id != workspace_id - or item.user_id != user_id - ): - raise lock_error( - status.HTTP_404_NOT_FOUND, - "FILE_LOCK_NOT_FOUND", - "编辑会话不存在", - ) - return item - - -def verify_token(item: EditSessions, raw_token: str) -> str: - digest = token_digest(raw_token) - if not secrets.compare_digest(item.lock_token_hash, digest): - raise lock_error( - status.HTTP_403_FORBIDDEN, - "FILE_LOCK_TOKEN_INVALID", - "编辑锁 token 无效", - ) - return digest.hex() - - -@app.post( - "/internal/v1/file-locks/acquire", - status_code=status.HTTP_201_CREATED, - dependencies=[Depends(verify_internal_service)], -) -async def acquire_file_lock( - payload: AcquireFileLockRequest, - request: Request, -) -> dict[str, Any]: - ttl_ms: int = request.app.state.file_lock_ttl_ms - now = utcnow() - edit_session_id = new_ulid() - raw_token = secrets.token_urlsafe(32) - digest = token_digest(raw_token) - key = f"db-lock:{payload.workspace_id}:{payload.storage_object_id}" - if not request.app.state.file_lock_enabled: - key = f"{key}:session:{edit_session_id}" - - async with request.app.state.session_factory() as session: - user, role, workspace = await active_member( - session, - workspace_id=payload.workspace_id, - user_id=payload.user_id, - ) - storage_object = await editable_object(session, payload, role) - - if request.app.state.file_lock_enabled: - current = await session.scalar( - select(EditSessions) - .where( - EditSessions.workspace_id == payload.workspace_id, - EditSessions.storage_object_id == payload.storage_object_id, - EditSessions.session_status == "active", - ) - .order_by(EditSessions.started_at.desc()) - .with_for_update() - ) - if current is not None and current.expires_at > now: - editor = await session.get(Users, current.user_id) - raise lock_error( - status.HTTP_409_CONFLICT, - "FILE_LOCK_CONFLICT", - "文件正在被其他用户编辑", - retryable=True, - details={ - "edit_session_id": current.edit_session_id, - "editor_user_id": current.user_id, - "editor_name": editor.display_name if editor else current.user_id, - "lease_expires_at": utc_iso(current.expires_at), - }, - ) - if current is not None: - current.session_status = "expired" - current.ended_at = now - current.end_reason = "database_lease_expired" - - runtime_session = None - try: - runtime_item, _ = ( - await request.app.state.runtime_lifecycle.ensure_running( - session, - workspace=workspace, - user_id=payload.user_id, - request_id=payload.request_id, - ) - ) - if not storage_object.relative_path: - raise RuntimeProviderError( - "Workspace file has no relative path" - ) - runtime_session = ( - await request.app.state.runtime_lifecycle.create_session( - runtime_item, - workspace_code=workspace.workspace_code, - relative_path=storage_object.relative_path, - ) - ) - expires_at = now + timedelta(milliseconds=ttl_ms) - item = EditSessions( - edit_session_id=edit_session_id, - workspace_id=payload.workspace_id, - storage_object_id=payload.storage_object_id, - user_id=payload.user_id, - runtime_id=runtime_item.runtime_id, - jupyter_session_id=runtime_session.session_id, - lock_key=key, - lock_token_hash=digest, - session_status="active", - started_at=now, - last_heartbeat_at=now, - expires_at=expires_at, - ) - session.add(item) - await session.commit() - except Exception as exc: - await session.rollback() - if runtime_session is not None and not runtime_session.reused: - try: - await request.app.state.runtime_lifecycle.terminate_session( - runtime_session.runtime_id, - runtime_session.session_id, - ) - except RuntimeProviderError: - logger.warning( - "failed to compensate Jupyter session creation", - exc_info=True, - ) - if isinstance(exc, RuntimeProviderError): - raise lock_error( - status.HTTP_503_SERVICE_UNAVAILABLE, - "JUPYTER_UNAVAILABLE", - "Jupyter Runtime 暂时不可用", - retryable=True, - details={"provider_error": str(exc)}, - ) from exc - raise - - return { - "data": session_payload( - item, - lock_token=raw_token, - relative_path=storage_object.relative_path, - heartbeat_interval_seconds=max(1, ttl_ms // 3000), - jupyter_url=runtime_session.jupyter_url, - ) - } - - -@app.post( - "/internal/v1/file-locks/{edit_session_id}/heartbeat", - dependencies=[Depends(verify_internal_service)], -) -async def heartbeat_file_lock( - edit_session_id: str, - payload: FileLockTokenRequest, - request: Request, -) -> dict[str, Any]: - ttl_ms: int = request.app.state.file_lock_ttl_ms - now = utcnow() - async with request.app.state.session_factory() as session: - item = await owned_session( - session, - edit_session_id=edit_session_id, - workspace_id=payload.workspace_id, - user_id=payload.user_id, - ) - verify_token(item, payload.lock_token) - if item.session_status != "active": - raise lock_error( - status.HTTP_409_CONFLICT, - "FILE_LOCK_NOT_ACTIVE", - "编辑锁已结束", - details={"session_status": item.session_status}, - ) - if item.expires_at <= now: - item.session_status = "expired" - item.ended_at = now - item.end_reason = "database_lease_expired" + active_workspaces = list(JUPYTER_PROCESSES.keys()) + for ws_id in active_workspaces: try: - await request.app.state.runtime_lifecycle.terminate_session( - item.runtime_id, - item.jupyter_session_id, - ) - except RuntimeProviderError: - logger.warning( - "failed to terminate an expired Jupyter session", - exc_info=True, - ) - await session.commit() - raise lock_error( - status.HTTP_409_CONFLICT, - "FILE_LOCK_EXPIRED", - "编辑锁已失效,请重新申请", - retryable=True, - ) - item.last_heartbeat_at = now - item.expires_at = now + timedelta(milliseconds=ttl_ms) - if item.runtime_id: - runtime_item = await session.get(RuntimeInstances, item.runtime_id) - if runtime_item is not None: - request.app.state.runtime_lifecycle.touch(runtime_item) - await session.commit() - return {"data": session_payload(item)} + await stop_workspace(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() - -@app.delete( - "/internal/v1/file-locks/{edit_session_id}", - dependencies=[Depends(verify_internal_service)], -) -async def release_file_lock( - edit_session_id: str, - payload: FileLockTokenRequest, - request: Request, -) -> dict[str, Any]: - now = utcnow() - async with request.app.state.session_factory() as session: - item = await owned_session( - session, - edit_session_id=edit_session_id, - workspace_id=payload.workspace_id, - user_id=payload.user_id, - ) - verify_token(item, payload.lock_token) - if item.session_status != "active": - return {"data": session_payload(item), "meta": {"reused": True}} - item.ended_at = now - item.session_status = "closed" - item.end_reason = "client_release" try: - await request.app.state.runtime_lifecycle.terminate_session( - item.runtime_id, - item.jupyter_session_id, - ) - except RuntimeProviderError: - logger.warning( - "failed to terminate a released Jupyter session", - exc_info=True, - ) - await session.commit() - return {"data": session_payload(item), "meta": {"reused": False}} + stop_rclone_mount() + except Exception as e: + logger.error(f"Stop rclone failed: {e}") + logger.info("Runtime Service stopped") -@app.post( - "/internal/v1/jupyter/access-tickets/{edit_session_id}", - status_code=status.HTTP_201_CREATED, - dependencies=[Depends(verify_internal_service)], -) -async def create_jupyter_access_ticket( - edit_session_id: str, - payload: FileLockTokenRequest, - request: Request, -) -> dict[str, Any]: - now = utcnow() - ttl_seconds: int = request.app.state.jupyter_ticket_ttl_seconds - async with request.app.state.session_factory() as session: - item = await owned_session( - session, - edit_session_id=edit_session_id, - workspace_id=payload.workspace_id, - user_id=payload.user_id, - ) - token_hash = verify_token(item, payload.lock_token) - if item.session_status != "active" or item.expires_at <= now: - raise lock_error( - status.HTTP_409_CONFLICT, - "FILE_LOCK_EXPIRED", - "编辑锁已失效,请重新申请", - retryable=True, - ) - - runtime_item = await session.get(RuntimeInstances, item.runtime_id) - if runtime_item is None or runtime_item.actual_state != "running": - raise lock_error( - status.HTTP_409_CONFLICT, - "RUNTIME_NOT_RUNNING", - "Jupyter Runtime 未运行", - retryable=True, - ) - workspace = await session.get(Workspaces, item.workspace_id) - storage_object = await session.get( - StorageObjects, - item.storage_object_id, - ) - if ( - workspace is None - or storage_object is None - or not storage_object.relative_path - ): - raise lock_error( - status.HTTP_409_CONFLICT, - "JUPYTER_TARGET_NOT_FOUND", - "Jupyter 目标文件不存在", - ) - - raw_ticket = secrets.token_urlsafe(32) - expires_at = now + timedelta(seconds=ttl_seconds) - ticket_data = { - "workspace_id": item.workspace_id, - "user_id": item.user_id, - "edit_session_id": item.edit_session_id, - "runtime_id": item.runtime_id, - "jupyter_session_id": item.jupyter_session_id, - "lock_token_hash": token_hash, - "expires_at": expires_at, - } - async with request.app.state.ticket_lock: - request.app.state.jupyter_tickets[ - jupyter_ticket_key(raw_ticket) - ] = ticket_data - - return { - "data": { - "ticket": raw_ticket, - "edit_session_id": item.edit_session_id, - "jupyter_url": jupyter_url( - workspace.workspace_code, - storage_object.relative_path, - ), - "expires_at": utc_iso(expires_at), - "expires_in_seconds": ttl_seconds, - } - } +app = FastAPI(lifespan=lifespan) -@app.get( - "/internal/v1/jupyter/authorize", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(verify_internal_service)], -) -async def authorize_jupyter_proxy( - request: Request, - jupyter_access: str | None = Cookie( - default=None, - alias="jupyter_access", - ), - x_original_uri: str = Header(alias="X-Original-URI"), -) -> Response: - original_path = unquote(urlsplit(x_original_uri).path) - if ( - not original_path.startswith("/jupyter/") - or ".." in PurePosixPath(original_path).parts - ): - raise lock_error( - status.HTTP_403_FORBIDDEN, - "JUPYTER_PATH_FORBIDDEN", - "Jupyter 代理路径不允许访问", - ) - if not jupyter_access: - raise lock_error( - status.HTTP_401_UNAUTHORIZED, - "JUPYTER_TICKET_REQUIRED", - "缺少 Jupyter 访问票据", - ) - - ticket_key = jupyter_ticket_key(jupyter_access) - async with request.app.state.ticket_lock: - ticket_data = request.app.state.jupyter_tickets.get(ticket_key) - if ticket_data and ticket_data["expires_at"] <= utcnow(): - request.app.state.jupyter_tickets.pop(ticket_key, None) - ticket_data = None - if not ticket_data: - raise lock_error( - status.HTTP_401_UNAUTHORIZED, - "JUPYTER_TICKET_EXPIRED", - "Jupyter 访问票据无效或已过期", - ) - - async with request.app.state.session_factory() as session: - item = await session.get(EditSessions, ticket_data["edit_session_id"]) - if ( - item is None - or item.session_status != "active" - or item.expires_at <= utcnow() - or item.user_id != ticket_data["user_id"] - or item.lock_token_hash.hex() != ticket_data["lock_token_hash"] - ): - async with request.app.state.ticket_lock: - request.app.state.jupyter_tickets.pop(ticket_key, None) - raise lock_error( - status.HTTP_403_FORBIDDEN, - "JUPYTER_EDIT_SESSION_INACTIVE", - "编辑会话已失效", - ) - - return Response( - status_code=status.HTTP_204_NO_CONTENT, - headers={ - "Cache-Control": "no-store", - "X-Jupyter-Upstream": "http://jupyter:8888", - "X-Jupyter-Authorization": ( - f"token {request.app.state.jupyter_token}" - ), - "X-Workspace-ID": ticket_data["workspace_id"], - }, - ) +@app.get("/api/v1/health") +def healthz() -> dict: + return {"status": "ok"} -async def reconcile_expired_edit_sessions(app_state: Any) -> None: - while True: - try: - now = utcnow() - async with app_state.state.session_factory() as session: - items = ( - await session.scalars( - select(EditSessions) - .where( - EditSessions.session_status == "active", - EditSessions.expires_at <= now, - ) - .limit(100) - ) - ).all() - for item in items: - try: - await ( - app_state.state.runtime_lifecycle - .terminate_session( - item.runtime_id, - item.jupyter_session_id, - ) - ) - except RuntimeProviderError: - logger.warning( - "failed to terminate an abandoned session", - exc_info=True, - ) - item.session_status = "expired" - item.ended_at = now - item.end_reason = "database_lease_expired" - await session.commit() - async with app_state.state.ticket_lock: - expired_keys = [ - key - for key, value in app_state.state.jupyter_tickets.items() - if value["expires_at"] <= now - ] - for key in expired_keys: - app_state.state.jupyter_tickets.pop(key, None) - except asyncio.CancelledError: - raise - except Exception: - logger.exception("edit-session reconciliation failed") - await asyncio.sleep(5) - - -async def runtime_for_member( - session: AsyncSession, - *, - runtime_id: str, - workspace_id: str, - user_id: str, -) -> tuple[RuntimeInstances, Workspaces, Roles]: - _, role, workspace = await active_member( - session, - workspace_id=workspace_id, - user_id=user_id, - ) - item = await session.get(RuntimeInstances, runtime_id) - if ( - item is None - or item.workspace_id != workspace_id - ): - raise lock_error( - status.HTTP_404_NOT_FOUND, - "RUNTIME_NOT_FOUND", - "Runtime 不存在", - ) - return item, workspace, role - - -@app.post( - "/internal/v1/runtimes/ensure", - dependencies=[Depends(verify_internal_service)], -) -async def ensure_runtime( - payload: EnsureRuntimeApiRequest, - request: Request, -) -> dict[str, Any]: - async with request.app.state.session_factory() as session: - _, _, workspace = await active_member( - session, - workspace_id=payload.workspace_id, - user_id=payload.user_id, - ) - try: - item, reused = ( - await request.app.state.runtime_lifecycle.ensure_running( - session, - workspace=workspace, - user_id=payload.user_id, - request_id=payload.request_id, +@app.post("/api/v1/jupyter") +async def handle_jupyter_action(req: JupyterActionRequest) -> dict: + match req.action: + case "start": + if not req.workspace_id: + raise HTTPException( + status_code=400, + detail="'workspace_id' is required when action='start'", ) - ) - await session.commit() - except RuntimeProviderError as exc: - await session.rollback() - raise lock_error( - status.HTTP_503_SERVICE_UNAVAILABLE, - "JUPYTER_UNAVAILABLE", - "Jupyter Runtime 暂时不可用", - retryable=True, - details={"provider_error": str(exc)}, - ) from exc - return {"data": runtime_payload(item), "meta": {"reused": reused}} - - -@app.get( - "/internal/v1/runtimes/{runtime_id}", - dependencies=[Depends(verify_internal_service)], -) -async def get_runtime( - runtime_id: str, - request: Request, - workspace_id: str = Query(min_length=26, max_length=26), - user_id: str = Query(min_length=26, max_length=26), -) -> dict[str, Any]: - async with request.app.state.session_factory() as session: - item, _, _ = await runtime_for_member( - session, - runtime_id=runtime_id, - workspace_id=workspace_id, - user_id=user_id, - ) - health = await request.app.state.runtime_lifecycle.health(item) - await session.commit() - return { - "data": { - **runtime_payload(item), - "healthy": health.healthy, - "health_detail": health.detail, - } - } - - -@app.post( - "/internal/v1/runtimes/{runtime_id}/restart", - dependencies=[Depends(verify_internal_service)], -) -async def restart_runtime( - runtime_id: str, - payload: RuntimeIdentityRequest, - request: Request, -) -> dict[str, Any]: - async with request.app.state.session_factory() as session: - item, _, _ = await runtime_for_member( - session, - runtime_id=runtime_id, - workspace_id=payload.workspace_id, - user_id=payload.user_id, - ) - try: - await request.app.state.runtime_lifecycle.restart( - session, - item, - user_id=payload.user_id, - request_id=payload.request_id, - ) - await session.commit() - except RuntimeProviderError as exc: - await session.rollback() - raise lock_error( - status.HTTP_503_SERVICE_UNAVAILABLE, - "JUPYTER_UNAVAILABLE", - "Jupyter Runtime 重启失败", - retryable=True, - details={"provider_error": str(exc)}, - ) from exc - return {"data": runtime_payload(item)} - - -@app.delete( - "/internal/v1/runtimes/{runtime_id}", - dependencies=[Depends(verify_internal_service)], -) -async def stop_runtime( - runtime_id: str, - payload: StopRuntimeApiRequest, - request: Request, -) -> dict[str, Any]: - async with request.app.state.session_factory() as session: - item, _, _ = await runtime_for_member( - session, - runtime_id=runtime_id, - workspace_id=payload.workspace_id, - user_id=payload.user_id, - ) - active_sessions = ( - await session.scalars( - select(EditSessions).where( - EditSessions.runtime_id == runtime_id, - EditSessions.session_status == "active", + return await start_workspace(req.workspace_id) + case "stop": + if not req.workspace_id: + raise HTTPException( + status_code=400, + detail="'workspace_id' is required when action='stop'", ) - ) - ).all() - for edit_session in active_sessions: - try: - await request.app.state.runtime_lifecycle.terminate_session( - runtime_id, - edit_session.jupyter_session_id, + return await stop_workspace(req.workspace_id) + case "list": + return await list_workspaces() + case "get": + if not req.workspace_id: + raise HTTPException( + status_code=400, + detail="'workspace_id' is required for action='get'", ) - except RuntimeProviderError: - logger.warning( - "failed to terminate a session during Runtime stop", - exc_info=True, - ) - edit_session.session_status = "closed" - edit_session.ended_at = utcnow() - edit_session.end_reason = "runtime_stop" - await request.app.state.runtime_lifecycle.stop( - session, - item, - user_id=payload.user_id, - request_id=payload.request_id, - reason=payload.reason, - ) - await session.commit() - return {"data": runtime_payload(item)} - - -@app.post( - "/internal/v1/runtimes/{runtime_id}/sessions", - status_code=status.HTTP_201_CREATED, - dependencies=[Depends(verify_internal_service)], -) -async def create_runtime_session( - runtime_id: str, - payload: CreateRuntimeSessionApiRequest, - request: Request, -) -> dict[str, Any]: - async with request.app.state.session_factory() as session: - item, workspace, role = await runtime_for_member( - session, - runtime_id=runtime_id, - workspace_id=payload.workspace_id, - user_id=payload.user_id, - ) - storage_object = await editable_object( - session, - AcquireFileLockRequest( - workspace_id=payload.workspace_id, - storage_object_id=payload.storage_object_id, - user_id=payload.user_id, - request_id=payload.request_id, - ), - role, - ) - if storage_object.relative_path != payload.relative_path: - raise lock_error( - status.HTTP_409_CONFLICT, - "FILE_PATH_MISMATCH", - "文件路径与对象元数据不一致", - ) - try: - runtime_session = ( - await request.app.state.runtime_lifecycle.create_session( - item, - workspace_code=workspace.workspace_code, - relative_path=payload.relative_path, - ) - ) - await session.commit() - except RuntimeProviderError as exc: - await session.rollback() - raise lock_error( - status.HTTP_503_SERVICE_UNAVAILABLE, - "JUPYTER_SESSION_FAILED", - "Jupyter Session 创建失败", - retryable=True, - details={"provider_error": str(exc)}, - ) from exc - return { - "data": { - "runtime_id": runtime_session.runtime_id, - "session_id": runtime_session.session_id, - "jupyter_url": runtime_session.jupyter_url, - }, - "meta": {"reused": runtime_session.reused}, - } - - -@app.delete( - "/internal/v1/runtimes/{runtime_id}/sessions/{session_id}", - dependencies=[Depends(verify_internal_service)], -) -async def terminate_runtime_session( - runtime_id: str, - session_id: str, - payload: RuntimeIdentityRequest, - request: Request, -) -> dict[str, Any]: - async with request.app.state.session_factory() as session: - await runtime_for_member( - session, - runtime_id=runtime_id, - workspace_id=payload.workspace_id, - user_id=payload.user_id, - ) - try: - await request.app.state.runtime_lifecycle.terminate_session( - runtime_id, - session_id, - ) - except RuntimeProviderError as exc: - raise lock_error( - status.HTTP_503_SERVICE_UNAVAILABLE, - "JUPYTER_SESSION_TERMINATION_FAILED", - "Jupyter Session 终止失败", - retryable=True, - details={"provider_error": str(exc)}, - ) from exc - return { - "data": { - "runtime_id": runtime_id, - "session_id": session_id, - "status": "terminated", - } - } - - -@app.get("/internal/health/runtime") -async def internal_health() -> dict[str, str]: - return {"status": "ready", "service": "runtime-manager"} + return await get_workspace(req.workspace_id) \ No newline at end of file diff --git a/runtime/src/runtime/mount.py b/runtime/src/runtime/mount.py new file mode 100644 index 0000000..7e19e4c --- /dev/null +++ b/runtime/src/runtime/mount.py @@ -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") \ No newline at end of file diff --git a/runtime/src/runtime/process.py b/runtime/src/runtime/process.py new file mode 100644 index 0000000..9fc2b7f --- /dev/null +++ b/runtime/src/runtime/process.py @@ -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}") \ No newline at end of file diff --git a/runtime/src/runtime/providers/__init__.py b/runtime/src/runtime/providers/__init__.py deleted file mode 100644 index befd052..0000000 --- a/runtime/src/runtime/providers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Runtime provider implementations.""" diff --git a/runtime/src/runtime/providers/shared_jupyter.py b/runtime/src/runtime/providers/shared_jupyter.py deleted file mode 100644 index bfd8ac3..0000000 --- a/runtime/src/runtime/providers/shared_jupyter.py +++ /dev/null @@ -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, - ) diff --git a/runtime/src/runtime/redis_lock.py b/runtime/src/runtime/redis_lock.py deleted file mode 100644 index 2c770dc..0000000 --- a/runtime/src/runtime/redis_lock.py +++ /dev/null @@ -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, - ) - ) diff --git a/runtime/src/runtime/runtime_lifecycle.py b/runtime/src/runtime/runtime_lifecycle.py deleted file mode 100644 index 7a0fdf0..0000000 --- a/runtime/src/runtime/runtime_lifecycle.py +++ /dev/null @@ -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, - } diff --git a/runtime/src/runtime/schemas.py b/runtime/src/runtime/schemas.py deleted file mode 100644 index 13bf381..0000000 --- a/runtime/src/runtime/schemas.py +++ /dev/null @@ -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) - diff --git a/schedule/src/schedule/main.py b/schedule/src/schedule/main.py index a0196dc..ece1c3b 100644 --- a/schedule/src/schedule/main.py +++ b/schedule/src/schedule/main.py @@ -1,52 +1,35 @@ from __future__ import annotations import os -import secrets from contextlib import asynccontextmanager from pathlib import Path from typing import Any, AsyncIterator -from fastapi import Depends, Header, HTTPException, Request, status - from common.db import create_database_engine, create_session_factory from common.service_app import create_service_app from schedule.service import ( SchedulerService, build_object_store, + build_redis_client, build_storage_http_client, ) from schedule.storage_client import SchedulerStorageClient -def verify_internal_service( - x_service_token: str = Header(alias="X-Service-Token"), -) -> None: - expected = os.environ.get("INTERNAL_SERVICE_TOKEN", "") - if not expected or not secrets.compare_digest(expected, x_service_token): - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - "invalid internal service identity", - ) - - @asynccontextmanager async def lifespan(app: Any) -> AsyncIterator[None]: - database_url = os.environ["DATABASE_URL"] - engine = create_database_engine(database_url) + engine = create_database_engine(os.environ["DATABASE_URL"]) session_factory = create_session_factory(engine) - backend_http_client = build_storage_http_client() + redis = build_redis_client() + storage_http_client = build_storage_http_client() service = SchedulerService( session_factory=session_factory, + redis=redis, object_store=build_object_store(), - storage_client=SchedulerStorageClient( - backend_http_client, - os.environ["INTERNAL_SERVICE_TOKEN"], - ), - backend_http_client=backend_http_client, + storage_client=SchedulerStorageClient(storage_http_client), workspace_root=Path( os.getenv("WORKSPACE_ROOT", "/workspace/workspaces") ), - database_url=database_url, ) app.state.scheduler_service = service await service.start() @@ -54,24 +37,12 @@ async def lifespan(app: Any) -> AsyncIterator[None]: yield finally: await service.close() - await backend_http_client.aclose() + await storage_http_client.aclose() + await redis.aclose() await engine.dispose() app = create_service_app( - os.getenv("SERVICE_NAME", "schedule-executor"), + os.getenv("SERVICE_NAME", "scheduler-worker"), lifespan=lifespan, ) - - -@app.post( - "/internal/v1/runs/{run_id}/dispatch", - dependencies=[Depends(verify_internal_service)], -) -async def dispatch_run(run_id: str, request: Request) -> dict[str, Any]: - processed = await request.app.state.scheduler_service.dispatch_run(run_id) - return { - "status": "accepted", - "run_id": run_id, - "processed_events": processed, - } diff --git a/schedule/src/schedule/service.py b/schedule/src/schedule/service.py index 3c92940..33e5202 100644 --- a/schedule/src/schedule/service.py +++ b/schedule/src/schedule/service.py @@ -14,7 +14,6 @@ from zoneinfo import ZoneInfo import boto3 import httpx -from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from sqlalchemy import select @@ -30,11 +29,7 @@ from common.db.models import ( Versions, Workspaces, ) -from common.eventing import add_outbox_event, event_time, utcnow -from common.ids import new_ulid -from common.db.session import session_scope -from schedule.execution import ExecutionResult, execute_artifact -from schedule.storage_client import SchedulerStorageClient +from common.scheduler import build_sqlalchemy_jobstore LOGGER = logging.getLogger(__name__) @@ -56,10 +51,6 @@ TERMINAL_RUN_STATES = { _ACTIVE_SERVICE: "SchedulerService | None" = None -def _sync_database_url(value: str) -> str: - return value.replace("mysql+asyncmy://", "mysql+pymysql://", 1) - - def _naive_utc(value: datetime | None) -> datetime | None: if value is None: return None @@ -96,10 +87,7 @@ class SchedulerService: self.dispatch_lock = asyncio.Lock() self.scheduler = AsyncIOScheduler( jobstores={ - "default": SQLAlchemyJobStore( - url=_sync_database_url(database_url), - tablename="apscheduler_jobs", - ) + "default": build_sqlalchemy_jobstore(database_url) }, timezone=UTC, ) diff --git a/schedule/src/schedule/storage_client.py b/schedule/src/schedule/storage_client.py index 3177b5a..11f719d 100644 --- a/schedule/src/schedule/storage_client.py +++ b/schedule/src/schedule/storage_client.py @@ -1,16 +1,13 @@ +"""Schedule-specific storage client built on the shared ``StorageClient``.""" + from __future__ import annotations -import base64 from typing import Any -import httpx +from common.storage.client import StorageClient -class SchedulerStorageClient: - def __init__(self, client: httpx.AsyncClient, service_token: str) -> None: - self.client = client - self.headers = {"X-Service-Token": service_token} - +class SchedulerStorageClient(StorageClient): async def create_object( self, *, @@ -22,20 +19,17 @@ class SchedulerStorageClient: content: bytes, idempotency_key: str, ) -> dict[str, Any]: - response = await self.client.post( - "/internal/v1/objects", - headers=self.headers, - json={ - "workspace_id": workspace_id, - "user_id": user_id, - "usage_type": usage_type, - "file_name": file_name, - "content_type": content_type, - "content_base64": base64.b64encode(content).decode("ascii"), - "visibility": "workspace", - "is_immutable": True, - "idempotency_key": idempotency_key, - }, + return await self.create_server_object( + workspace_id=workspace_id, + user_id=user_id, + usage_type=usage_type, + file_name=file_name, + content_type=content_type, + content=content, + visibility="workspace", + is_immutable=True, + idempotency_key=idempotency_key, ) - response.raise_for_status() - return response.json()["data"] + + +__all__ = ["SchedulerStorageClient"] diff --git a/uv.lock b/uv.lock index 6d5807c..1333837 100644 --- a/uv.lock +++ b/uv.lock @@ -60,6 +60,74 @@ 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 = "apscheduler" +version = "3.11.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/6b/eeff360196bb20b312c9e762a820fd1b2c6d809466c755ef57863478e454/apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a", size = 110312, upload-time = "2026-06-28T19:39:22.493Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl", hash = "sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30", size = 66024, upload-time = "2026-06-28T19:39:20.982Z" }, +] + +[[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 +137,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 +179,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 +213,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 +365,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" @@ -284,6 +461,7 @@ name = "common" version = "0.2.0" source = { editable = "common" } dependencies = [ + { name = "apscheduler" }, { name = "asyncmy" }, { name = "boto3" }, { name = "fastapi" }, @@ -292,6 +470,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "apscheduler", specifier = ">=3.11.3" }, { name = "asyncmy", specifier = "==0.2.11" }, { name = "boto3", specifier = ">=1.34,<2" }, { name = "fastapi", specifier = "==0.116.1" }, @@ -390,6 +569,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 +610,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 +814,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 +838,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 +859,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 +892,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 +917,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 +960,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 +1193,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 +1222,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 +1271,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 +1309,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 +1348,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 +1544,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 +1683,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 +1835,8 @@ dependencies = [ { name = "common" }, { name = "fastapi" }, { name = "httpx" }, - { name = "redis" }, + { name = "loguru" }, + { name = "notebook" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -1277,7 +1845,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 +1889,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 +1907,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 +1984,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 +2057,36 @@ 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 = "tzlocal" +version = "5.4.4" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, +] + +[[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 +2247,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 +2350,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" }, +]