diff --git a/pyproject.toml b/pyproject.toml index c2c37e3d9..2d92bc13d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,8 @@ dependencies = [ "protobuf>=3.19.0,!=4.21.0,!=5.28.0,<7; sys_platform != 'linux'", "rich>=13.6.0,<14.0.0", "pydantic-settings>=2.8.1", + "orjson<=3.11.5; python_version == '3.9'", + "orjson; python_version > '3.9'", ] # 对应 requirements-media.txt 和 requirements-dashboard.txt [cite: 3, 5] diff --git a/swanlab/__init__.py b/swanlab/__init__.py index e8039572d..18f7597e3 100644 --- a/swanlab/__init__.py +++ b/swanlab/__init__.py @@ -24,6 +24,7 @@ ) from . import utils +from .api import Api __version__ = helper.get_swanlab_version() @@ -56,6 +57,8 @@ "utils", "Settings", "Callback", + # Api + "Api", ] diff --git a/swanlab/__init__.pyi b/swanlab/__init__.pyi index 8d35e3c57..23a870d4b 100644 --- a/swanlab/__init__.pyi +++ b/swanlab/__init__.pyi @@ -10,6 +10,7 @@ from concurrent.futures import Future from typing import Any, Callable, List, Mapping, Optional, Union from . import utils +from .api import Api from .sdk import Audio, Callback, Image, Run, Settings, Text, Video, config from .sdk.typings.cmd import ConfigLike from .sdk.typings.run import AsyncLogType, FinishType, ModeType, ResumeType @@ -51,6 +52,8 @@ __all__ = [ "utils", "Settings", "Callback", + # Api + "Api", ] # ── lifecycle ────────────────────────────────────────────────────────────────── diff --git a/swanlab/api/__init__.py b/swanlab/api/__init__.py new file mode 100644 index 000000000..62a9061b3 --- /dev/null +++ b/swanlab/api/__init__.py @@ -0,0 +1,274 @@ +""" +@author: caddiesnew +@file: __init__.py +@time: 2026/4/20 +@description: SwanLab 公共查询 API 入口,面向用户的 OOP 查询接口 +""" + +from typing import Any, Dict, List, Optional + +from swanlab.exceptions import AuthenticationError +from swanlab.sdk.internal.pkg import nrc, scope +from swanlab.sdk.internal.pkg.client import Client +from swanlab.sdk.internal.settings import settings as global_settings + +from .base import ApiClientContext, BaseEntity +from .column import Column, Columns +from .experiment import Experiment, Experiments +from .project import Project, Projects +from .selfhosted import SelfHosted +from .typings.common import ApiColumnClassLiteral, ApiColumnDataTypeLiteral, PaginatedQuery +from .user import User +from .utils import validate_api_path, validate_non_empty_string +from .workspace import Workspace, Workspaces + + +class Api(BaseEntity): + """ + SwanLab 公共查询 API 入口。 + + 通过独立的 Client 实例与 SwanLab 云端交互,与 SDK 运行时客户端完全隔离。 + 继承 BaseEntity 以复用 _get/_post/_put/_delete/_paginate 等安全 HTTP 方法。 + + 用法:: + + from swanlab import Api + + api = Api() # 自动从 .netrc 读取凭证 + api = Api(api_key="...", host="...") # 显式传入凭证 + + resp = api.project("username/project") + if resp.ok: + project = resp.data + print(project.name) + """ + + def __init__( + self, + api_key: Optional[str] = None, + host: Optional[str] = None, + web_host: Optional[str] = None, + ) -> None: + """ + 初始化 Api 实例。 + + 认证优先级: + 1. 显式参数 (api_key / host / web_host) + 2. scope 登录态(进程内已调用 swanlab.login 时可用) + 3. Settings(含 .netrc / 环境变量) + + 始终创建独立的 Client 实例,与 SDK 运行时单例互不干扰。 + + :param api_key: API 密钥,为 None 时从 Settings / .netrc / 环境变量读取 + :param host: API 主机地址,为 None 时从 Settings 读取 + :param web_host: Web 面板地址,为 None 时从 Settings 读取 + """ + # 优先从 scope 获取已有登录态(如进程内已调用 swanlab.login),直接复用凭证 + login_resp = scope.get_context("login_resp") + api_key, api_host, web_host = self._resolve_credentials(api_key, host, web_host) + _client = Client(api_key=str(api_key), base_url=api_host) + + if login_resp is None: + from swanlab.sdk.internal.pkg.client.bootstrap import login_by_api_key + + login_resp = login_by_api_key(base_url=api_host + "/api", api_key=api_key) + user_info = login_resp.get("userInfo", {}) if login_resp else {} + username = user_info.get("username", "") + name = user_info.get("name", "") or "" + ctx = ApiClientContext(client=_client, web_host=web_host, api_host=api_host, username=username, name=name) + super().__init__(ctx) + + def json(self) -> dict: + """Api 非数据实体,返回空字典。""" + return {} + + @staticmethod + def _resolve_credentials( + api_key: Optional[str], + host: Optional[str], + web_host: Optional[str], + ) -> tuple[str, str, str]: + """ + 按优先级解析凭证:显式参数 > scope 登录态 > Settings(含 .netrc / 环境变量)。 + 返回 (api_key, api_host, web_host)。 + """ + if api_key is None: + api_key = global_settings.api_key + if not isinstance(api_key, str) or not api_key.strip(): + raise AuthenticationError("No API key found. Please login with `swanlab login` or pass api_key parameter.") + api_key = api_key.strip() + + api_host: str = nrc.fmt(host) if host is not None else global_settings.api_host + resolved_web_host: str = nrc.fmt(web_host) if web_host is not None else global_settings.web_host + + return api_key, api_host, resolved_web_host + + # ------------------------------------------------------------------ + # 实体工厂方法 + # - 单实体(workspace/project/run):构造后调用 _fetch() 立即加载并返回 ok/not-ok + # - 列表迭代器(workspaces/projects/runs):惰性构造,迭代时按需分页请求 + # ------------------------------------------------------------------ + + def workspace(self, username: Optional[str] = None) -> Workspace: + """ + 获取工作空间信息,默认为当前登录用户的工作空间。 + + :param username: 指定工作空间用户名,为 None 时使用当前登录用户 + """ + if username is None: + username = self._ctx.username + validate_api_path(username, segments=1, label="workspace") + return Workspace(self._ctx, username=username) + + def workspaces(self, username: Optional[str] = None) -> Workspaces: + """ + 获取工作空间列表迭代器。 + + :param username: 指定用户名,为 None 时使用当前登录用户 + """ + if username is None: + username = self._ctx.username + validate_api_path(username, segments=1, label="workspace") + return Workspaces(self._ctx, username=username) + + def project(self, path: str) -> Project: + """ + 获取项目信息。 + + :param path: 项目路径,格式为 'username/project-name' + """ + validate_api_path(path, segments=2, label="project") + return Project(self._ctx, path=path) + + def projects( + self, + path: str, + sort: Optional[str] = None, + search: Optional[str] = None, + detail: Optional[bool] = True, + page: int = 1, + size: int = 20, + all: bool = False, + ) -> Projects: + """ + 获取工作空间下的项目列表迭代器。 + + :param path: 工作空间名称 'username' + :param sort: 排序方式 + :param search: 搜索关键词 + :param detail: 是否返回详细信息 + :param page: 起始页码,默认 1 + :param size: 每页数量,默认 20 + :param all: 是否获取全部数据,默认 False + """ + validate_api_path(path, segments=1, label="workspace") + query = PaginatedQuery(page=page, size=size, search=search, sort=sort, all=all) + return Projects(self._ctx, path=path, query=query, detail=detail) + + def run(self, path: str) -> Experiment: + """ + 获取单个实验。 + + :param path: 实验路径,格式为 'username/project/run_id' + """ + validate_api_path(path, segments=3, label="run") + return Experiment(self._ctx, path=path) + + def runs( + self, + path: str, + filters: Optional[List[Dict[str, Any]]] = None, + groups: Optional[List[Dict[str, Any]]] = None, + sorts: Optional[List[Dict[str, Any]]] = None, + ) -> Experiments: + """ + 通过条件过滤获取项目下的实验列表。 + + :param path: 项目路径,格式为 'username/project' + :param filters: 过滤规则列表,每项为 {key, type, op, value} + :param groups: 分组规则列表,每项为 {key, type} + :param sorts: 排序规则列表,每项为 {key, type, order} + """ + validate_api_path(path, segments=2, label="project") + return Experiments(self._ctx, path=path, filters=filters, groups=groups, sorts=sorts, mode="post") + + def runs_get( + self, + path: str, + page: int = 1, + size: int = 20, + all: bool = False, + ) -> Experiments: + """ + 通过分页获取项目下的实验列表。 + + :param path: 项目路径,格式为 'username/project' + :param page: 起始页码,默认 1 + :param size: 每页数量,默认 20 + :param all: 是否获取全部数据,默认 False + """ + validate_api_path(path, segments=2, label="project") + query = PaginatedQuery(page=page, size=size, all=all) + return Experiments(self._ctx, path=path, query=query, mode="get") + + def user(self) -> User: + return User(self._ctx) + + def columns( + self, + path: str, + page: int = 1, + size: int = 20, + search: Optional[str] = None, + column_class: ApiColumnClassLiteral = "CUSTOM", + column_type: Optional[ApiColumnDataTypeLiteral] = None, + all: bool = False, + ) -> Columns: + """ + 获取实验下的列列表(分页查询,支持搜索)。 + + :param path: 实验路径,格式为 'username/project/run_id' + :param page: 起始页码,默认 1 + :param size: 每页数量,默认 20 + :param search: 搜索关键词,搜索的是列的 name + :param column_class: 列的分类,CUSTOM 或 SYSTEM, 默认为 CUSTOM + :param column_type: 列的类型,如 FLOAT、STRING、IMAGE 等 + :param all: 是否获取全部数据,默认 False + """ + validate_api_path(path, segments=3, label="run") + query = PaginatedQuery(page=page, size=size, search=search, all=all) + return Columns( + self._ctx, + path=path, + query=query, + column_type=column_type, + column_class=column_class, + ) + + def column( + self, + path: str, + key: str, + column_class: Optional[ApiColumnClassLiteral] = "CUSTOM", + column_type: Optional[ApiColumnDataTypeLiteral] = None, + ) -> Column: + """ + 获取单个列(通过搜索 key 匹配)。 + + :param path: 实验路径,格式为 'username/project/run_id' + :param key: 列的键名, 输入不完整则模糊匹配 name 为首个 key. + :param column_class: 列的分类,CUSTOM 或 SYSTEM,默认 CUSTOM + :param column_type: 列的类型,如 FLOAT、STRING、IMAGE 等,默认为 None + """ + validate_api_path(path, segments=3, label="run") + validate_non_empty_string(key, label="column key") + return Column(self._ctx, path=path, key=key, column_class=column_class, column_type=column_type) + + # ------- + # 私有化相关接口 + # -------- + def self_hosted(self) -> SelfHosted: + return SelfHosted(self._ctx) + + +__all__ = ["Api"] diff --git a/swanlab/api/base.py b/swanlab/api/base.py new file mode 100644 index 000000000..c9cecbad9 --- /dev/null +++ b/swanlab/api/base.py @@ -0,0 +1,131 @@ +""" +@author: caddiesnew +@file: base.py +@time: 2026/4/20 +@description: 所有实体类的公共基类 +""" + +import random +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, Optional + +from swanlab.sdk.internal.pkg import safe + +from .typings.common import ApiPaginationType, ApiResponseType, PaginatedQuery + +if TYPE_CHECKING: + from swanlab.sdk.internal.pkg.client import Client + + +@dataclass(frozen=True) +class ApiClientContext: + """共享上下文:所有子实体复用同一个登录态实例。""" + + client: "Client" + web_host: str + api_host: str + username: str + name: str + + +class BaseEntity(ABC): + """ + swanlab/api 实体类公共基类。 + + 统一持有 _ctx(_ApiContext),提供 _get/_post/_put/_delete HTTP 快捷方法和 _paginate 分页迭代。 + 所有 HTTP 请求通过 _safe_request 包裹,保证任何异常都不会导致程序 crash,统一返回 ApiResponse。 + 子类只需实现 json() 和业务逻辑。 + """ + + def __init__(self, ctx: ApiClientContext) -> None: + self._ctx: ApiClientContext = ctx + self._errors: list[str] = [] + + def _ensure_data(self) -> Any: + """按需加载数据。单实体子类重写此方法;迭代器子类无需重写。""" + return None + + def wrapper(self) -> ApiResponseType: + """Eager 模式:触发子类 _ensure_data 加载数据,根据 _errors 返回 ApiResponseType。""" + self._ensure_data() + if self._errors: + return ApiResponseType(ok=False, errmsg=self._errors[-1]) + return ApiResponseType(ok=True, data=self) + + @abstractmethod + def json(self) -> Dict[str, Any]: + """将实体序列化为 JSON 可序列化的字典。""" + + def _safe_request(self, method: Callable, path: str, **kwargs) -> ApiResponseType: + """安全请求包装:捕获所有异常,始终返回 ApiResponseType 而不抛出。""" + _err: list[str] = [] + common_err: str = f"API Request Failed: {path}" + + @safe.decorator(message=None, on_error=lambda e: _err.append(str(e))) + def _do(): + return method(path, **kwargs).data + + data = _do() + if data is not None: + return ApiResponseType(ok=True, data=data) + errmsg = _err[0] if _err else common_err + self._errors.append(errmsg) + return ApiResponseType(ok=False, errmsg=errmsg) + + def _get(self, path: str, **kwargs) -> ApiResponseType: + return self._safe_request(self._ctx.client.get, path, **kwargs) + + def _post(self, path: str, **kwargs) -> ApiResponseType: + return self._safe_request(self._ctx.client.post, path, **kwargs) + + def _put(self, path: str, **kwargs) -> ApiResponseType: + return self._safe_request(self._ctx.client.put, path, **kwargs) + + def _delete(self, path: str, **kwargs) -> ApiResponseType: + return self._safe_request(self._ctx.client.delete, path, **kwargs) + + def _build_web_url(self, path: str) -> str: + """构建前端 Web 页面 URL(使用 _web_host 而非 _api_host)。""" + return f"{self._ctx.web_host}/{path}" + + def _paginate( + self, + path: str, + query: PaginatedQuery, + *, + page_info: Dict[str, Any], + extra: Optional[Dict[str, Any]] = None, + ) -> Iterator[dict]: + """通用分页迭代器,基于 PaginatedQuery 驱动翻页逻辑。""" + page = query.page + while True: + p = query.to_params(**(extra or {})) + # 覆盖当前页码(翻页时自增) + p["page"] = page + resp = self._get(path, params=p) + if not resp.ok: + return + body: ApiPaginationType = resp.data + + if page_info["total"] == 0: + page_info["total"] = body.get("total", 0) + if page_info["pages"] == 0: + page_info["pages"] = body.get("pages", 1) + items = body.get("list", []) + if not items: + break + yield from items + # 随机休眠控制 qps + time.sleep(random.random()) + if page >= body.get("pages", 1): + break + if not query.all: + break + page += 1 + + def __repr__(self) -> str: + cls = self.__class__.__name__ + ident = getattr(self, "_path", None) or getattr(self, "_username", None) or "?" + return f"{cls}('{ident}')" diff --git a/swanlab/api/column.py b/swanlab/api/column.py new file mode 100644 index 000000000..f8ff82c8d --- /dev/null +++ b/swanlab/api/column.py @@ -0,0 +1,299 @@ +""" +@author: caddiesnew +@file: column.py +@time: 2026/4/20 +@description: Column 实体类 — 实验列的查询与操作 +""" + +from typing import Any, Callable, Dict, Iterator, Optional, cast + +from swanlab.api.base import ApiClientContext, BaseEntity +from swanlab.api.typings.column import ApiColumnType +from swanlab.api.typings.common import ( + ApiColumnClassLiteral, + ApiColumnDataTypeLiteral, + ApiMetricColumnTypeLiteral, + ApiResponseType, + PaginatedQuery, +) +from swanlab.api.utils import get_properties, parse_column_data_type, resolve_run_path, validate_column_params + + +class Column(BaseEntity): + """ + 表示一个 SwanLab 实验列。 + + 支持双模式:构造时传入 data(列表迭代注入),或 data=None(按需懒加载)。 + 注意:列不支持单个获取 API,只能通过列表接口获取。 + """ + + @staticmethod + def _resolve_cuid(entity: BaseEntity, path: str, fallback: str = "") -> str: + resp = entity._get(path) + data = resp.data if resp.ok and isinstance(resp.data, dict) else {} + return data.get("cuid", "") or fallback + + @staticmethod + def _resolve_run_cuid(entity: BaseEntity, project_path: str, run_slug: str) -> str: + return Column._resolve_cuid(entity, f"/project/{project_path}/runs/{run_slug}", fallback=run_slug) + + @staticmethod + def _resolve_project_cuid(entity: BaseEntity, project_path: str) -> str: + return Column._resolve_cuid(entity, f"/project/{project_path}") + + def __init__( + self, + ctx: ApiClientContext, + *, + path: str, + key: str, + column_class: Optional[ApiColumnClassLiteral] = "CUSTOM", + column_type: Optional[ApiColumnDataTypeLiteral] = None, + data: Optional[ApiColumnType] = None, + project_id: Optional[str] = None, + run_id: Optional[str] = None, + project_id_getter: Optional[Callable[[], str]] = None, + ) -> None: + super().__init__(ctx) + self._proj_path, self._run_slug = resolve_run_path(path=path) + self._key = key + self._column_class = column_class + self._column_type = column_type + self._data = data + self._project_id = project_id or (data or {}).get("project_id", "") or None + self._run_id = run_id or (data or {}).get("run_id", "") or "" + self._project_id_getter = project_id_getter + + def _ensure_data(self) -> ApiColumnType: + if self._data is None: + validate_column_params(column_class=self._column_class, column_type=self._column_type) + extra: Dict[str, Any] = {"search": self._key} + run_id = self._ensure_run_id() + if self._column_class: + extra["class"] = self._column_class + resp = self._get( + f"/experiment/{run_id}/column", + params={"page": 1, "size": 10, **extra}, + ) + if resp.data: + items = resp.data.get("list", []) if isinstance(resp.data, dict) else [] + if items: + self._data = cast(ApiColumnType, items[0]) + if self._data is None: + self._data = cast(ApiColumnType, {}) + self._data.setdefault("run_id", self._ensure_run_id()) + return self._data + + def _ensure_run_id(self) -> str: + if self._run_id: + return self._run_id + if self._data and (run_id := self._data.get("run_id", "")): + self._run_id = run_id + return run_id + self._run_id = Column._resolve_run_cuid(self, self._proj_path, self._run_slug) + if self._data is not None: + self._data["run_id"] = self._run_id + return self._run_id + + def _ensure_project_id(self) -> str: + if self._project_id: + return self._project_id + if self._data and (project_id := self._data.get("project_id", "")): + self._project_id = project_id + return project_id + if self._project_id_getter is not None: + self._project_id = self._project_id_getter() + if self._data is not None: + self._data["project_id"] = self._project_id + return self._project_id + if self._project_id is None: + self._project_id = Column._resolve_project_cuid(self, self._proj_path) + if self._data is not None: + self._data["project_id"] = self._project_id + return self._project_id + + @property + def project_id(self) -> str: + return self._ensure_project_id() + + @property + def run_id(self) -> str: + return self._ensure_run_id() + + @property + def key(self) -> str: + if self._key: + return self._key + return self._ensure_data().get("key", "") + + @property + def name(self) -> str: + """列的显示名称,默认为 key 的值。""" + return self._ensure_data().get("name", "") + + @property + def column_class(self) -> str: + """列的分类:CUSTOM 或 SYSTEM。""" + return self._ensure_data().get("class", "") + + @property + def column_type(self) -> str: + """列的数据类型,如 FLOAT、STRING、IMAGE 等。""" + return self._ensure_data().get("type", "") + + @property + def created_at(self) -> int: + """列的创建时间戳。""" + return self._ensure_data().get("createdAt", 0) + + @property + def error(self) -> Optional[Dict[str, Any]]: + """列的错误信息。""" + return self._ensure_data().get("error", {}) + + def metric( + self, + sample: int = 1500, + metric_type: ApiMetricColumnTypeLiteral = "SCALAR", + ignore_timestamp: bool = False, + media_step: Optional[int] = None, + ) -> Dict[str, Any]: + from swanlab.api.metric import Metric + + metric_type = parse_column_data_type(self.column_type) + metric = Metric( + ctx=self._ctx, + project_id=self.project_id, + run_id=self.run_id, + key=self.key, + sample=sample, + metric_type=metric_type, + ignore_timestamp=ignore_timestamp, + media_step=media_step, + ) + return metric.json() + + def export_csv(self) -> ApiResponseType: + from swanlab.api.metric import Metric + + metric_type = parse_column_data_type(self.column_type) + if metric_type != "SCALAR": + err_msg = "export_csv() only support SCALAR metric_type" + return ApiResponseType(ok=False, errmsg=err_msg, data=None) + metric = Metric( + ctx=self._ctx, + project_id=self.project_id, + run_id=self.run_id, + key=self.key, + metric_type=metric_type, + ) + return metric.export_csv() + + def json(self) -> Dict[str, Any]: + return get_properties(self) + + +class Columns(BaseEntity): + """ + 实验下列集合的分页迭代器。 + + 用法:: + + # 获取所有列 + for column in experiment.columns(): + print(column.name, column.data_type) + + # 分页获取列(支持搜索) + for column in experiment.columns(page=1, size=20, search="loss"): + print(column.name) + + # 获取全部列(自动翻页) + for column in experiment.columns(all=True): + print(column.name) + """ + + def __init__( + self, + ctx: ApiClientContext, + *, + path: str, + query: PaginatedQuery, + column_class: Optional[ApiColumnClassLiteral] = None, + column_type: Optional[ApiColumnDataTypeLiteral] = None, + project_id: Optional[str] = None, + run_id: Optional[str] = None, + project_id_getter: Optional[Callable[[], str]] = None, + ) -> None: + super().__init__(ctx) + self._run_path = path + self._proj_path, self._run_slug = resolve_run_path(path=path) + self._run_id = run_id or "" + self._project_id = project_id + self._project_id_getter = project_id_getter + self._query = query + # 校验 column_type 和 column_class 的合法性 + validate_column_params(column_type=column_type, column_class=column_class) + self._column_class = column_class + self._column_type = column_type + self._page_info: Dict[str, Any] = { + "page": query.page, + "size": query.size, + "total": 0, + "pages": 0, + "list": [], + } + + def _ensure_run_id(self) -> str: + if self._run_id: + return self._run_id + self._run_id = Column._resolve_run_cuid(self, self._proj_path, self._run_slug) + return self._run_id + + def _ensure_project_id(self) -> str: + if self._project_id: + return self._project_id + if self._project_id_getter is not None: + self._project_id = self._project_id_getter() + return self._project_id + self._project_id = Column._resolve_project_cuid(self, self._proj_path) + return self._project_id + + def __iter__(self) -> Iterator[Column]: + """迭代分页获取列。""" + extra: Dict[str, Any] = {} + run_id = self._ensure_run_id() + if self._column_type: + extra["type"] = self._column_type + if self._column_class: + extra["class"] = self._column_class + + for item in self._paginate( + f"/experiment/{run_id}/column", + self._query, + page_info=self._page_info, + extra=extra, + ): + data = {**item, "run_id": run_id} + yield Column( + self._ctx, + path=self._run_path, + key=item.get("key", ""), + data=cast(ApiColumnType, data), + run_id=run_id, + project_id_getter=self._ensure_project_id, + ) + + @property + def total(self) -> int: + """获取总数(触发一次请求)。""" + # 触发一次迭代来获取总数 + if self._page_info["total"] == 0: + try: + next(iter(self)) + except StopIteration: + pass + return self._page_info["total"] + + def json(self) -> Dict[str, Any]: + self._page_info["list"] = [c.json() for c in self] + return self._page_info diff --git a/swanlab/api/experiment.py b/swanlab/api/experiment.py new file mode 100644 index 000000000..ee8943987 --- /dev/null +++ b/swanlab/api/experiment.py @@ -0,0 +1,384 @@ +""" +@author: caddiesnew +@file: experiment.py +@time: 2026/4/20 +@description: Experiment 实体类 — 单个实验的查询与操作 +""" + +from typing import Any, Dict, Iterator, List, Optional, Union, cast + +from swanlab.api.base import ApiClientContext, BaseEntity +from swanlab.api.typings.common import ( + ApiColumnClassLiteral, + ApiColumnDataTypeLiteral, + ApiMetricLogLevelLiteral, + PaginatedQuery, +) +from swanlab.api.typings.experiment import ( + ApiExperimentLabelType, + ApiExperimentProfileType, + ApiExperimentType, +) +from swanlab.api.typings.user import ApiUserType +from swanlab.api.utils import ( + get_properties, + resolve_run_path, + validate_filter, + validate_group, + validate_sort, + validate_update_active, +) + + +class Experiment(BaseEntity): + """ + 表示一个 SwanLab 实验(完整信息,通过 POST /runs/shows 或单实验详情接口获取)。 + + 支持双模式:构造时传入 data,或 data=None(按需懒加载)。 + 构造时从 data 中提取 _cuid 缓存,避免 _ensure_data 与 id 属性的循环调用。 + """ + + def __init__( + self, + ctx: ApiClientContext, + *, + path: str, + data: Optional[ApiExperimentType] = None, + ) -> None: + super().__init__(ctx) + self._proj_path, self._run_slug = resolve_run_path(path=path) + self._cuid: str = (data or {}).get("cuid", "") or self._run_slug + self._data: Optional[ApiExperimentType] = data + self._project_id = "" + + def _refresh_cuid(self) -> None: + if self._data: + self._cuid = self._data.get("cuid", "") or self._cuid + + def _ensure_data(self) -> ApiExperimentType: + if self._data is None: + resp = self._get(f"/project/{self._proj_path}/runs/{self._cuid}") + self._data = resp.data if resp.ok and resp.data else cast(ApiExperimentType, {}) + self._refresh_cuid() + assert self._data is not None + return self._data + + def _ensure_project_id(self) -> str: + if self._project_id: + return self._project_id + if self._data and (project_id := str(self._data.get("project_id") or "")): + self._project_id = project_id + return project_id + if not self._project_id: + resp = self._get(f"/project/{self._proj_path}") + proj_data = resp.data if resp.ok else {} + self._project_id = str(proj_data.get("cuid") or "") + if self._data is not None: + self._data["project_id"] = self._project_id + return self._project_id + + @property + def project_id(self) -> str: + return self._ensure_project_id() + + @property + def run_id(self) -> str: + self._ensure_data() + return self._cuid + + def _run_url_ref(self) -> str: + data = self._ensure_data() + return data.get("slug") or self._run_slug or self.run_id + + @property + def name(self) -> str: + return self._ensure_data().get("name", "") + + @property + def description(self) -> str: + return self._ensure_data().get("description", "") + + @property + def type(self) -> str: + return self._ensure_data().get("type", "") + + @property + def state(self) -> str: + return self._ensure_data().get("state", "") + + @property + def url(self) -> str: + return self._build_web_url(f"@{self._proj_path}/runs/{self._run_url_ref()}/chart") + + @property + def show(self) -> bool: + return self._ensure_data().get("show", True) + + @property + def labels(self) -> List[ApiExperimentLabelType]: + return [label for label in self._ensure_data().get("labels", [])] + + @property + def group(self) -> str: + return self._ensure_data().get("cluster", "") + + @property + def job_type(self) -> str: + return self._ensure_data().get("job", "") + + @property + def user(self) -> ApiUserType: + user_data = self._ensure_data().get("user", {}) + return cast(ApiUserType, user_data) + + @property + def created_at(self) -> str: + return self._ensure_data().get("createdAt", "") + + @property + def finished_at(self) -> str: + return self._ensure_data().get("finishedAt", "") + + @property + def profile(self) -> ApiExperimentProfileType: + """Experiment profile containing config, metadata, requirements, and conda.""" + data = self._ensure_data() + if "profile" not in data and self._cuid: + resp = self._get(f"/project/{self._proj_path}/runs/{self._cuid}") + if resp.ok and resp.data: + self._data = resp.data + self._refresh_cuid() + data = self._data + return cast(ApiExperimentProfileType, self._ensure_data().get("profile", {})) + + def column( + self, + key: str, + column_class: Optional[ApiColumnClassLiteral] = "CUSTOM", + column_type: Optional[ApiColumnDataTypeLiteral] = "FLOAT", + ): + """ + 获取实验下指定 key 的单个列。 + + :param key: 列的 key,如 "loss"、"acc" + :param column_class: 列的分类,CUSTOM 或 SYSTEM + :param column_type: 列的数据类型,如 FLOAT、STRING、IMAGE 等 + """ + from swanlab.api.column import Column + + run_id = self.run_id + return Column( + self._ctx, + path=f"{self._proj_path}/{run_id}", + key=key, + column_class=column_class, + column_type=column_type, + run_id=run_id, + project_id_getter=lambda: self.project_id, + ) + + def metrics( + self, + keys: List[str], + sample: int = 1500, + ignore_timestamp: bool = True, + all: bool = False, + ) -> Dict[str, Any]: + from swanlab.api.metric import Metrics + + return Metrics( + ctx=self._ctx, + project_id=self.project_id, + run_id=self.run_id, + keys=keys, + sample=sample, + metric_type="SCALAR", + ignore_timestamp=ignore_timestamp, + all=all, + ).json() + + def medias( + self, + keys: List[str], + step: Optional[int] = 0, + all: bool = False, + ) -> Dict[str, Any]: + from swanlab.api.metric import Metrics + + return Metrics( + ctx=self._ctx, + project_id=self.project_id, + run_id=self.run_id, + keys=keys, + metric_type="MEDIA", + media_step=step, + all=all, + ).json() + + def logs( + self, + offset: Optional[int] = 0, + level: ApiMetricLogLevelLiteral = "INFO", + ignore_timestamp: bool = True, + ) -> Dict[str, Any]: + from swanlab.api.metric import Metric + + logs = Metric( + ctx=self._ctx, + project_id=self.project_id, + run_id=self.run_id, + key="LOG", + log_offset=offset, + log_level=level, + metric_type="LOG", + ignore_timestamp=ignore_timestamp, + ) + return logs.json() + + def columns( + self, + page: int = 1, + size: int = 20, + search: Optional[str] = None, + column_type: Optional[ApiColumnDataTypeLiteral] = None, + column_class: Optional[ApiColumnClassLiteral] = None, + all: bool = False, + ): + """ + 获取实验下的列列表(分页查询,支持搜索)。 + + :param page: 起始页码,默认 1 + :param size: 每页数量,默认 20 + :param search: 搜索关键词,搜索的是列的 name + :param column_type: 列的类型,如 FLOAT、STRING、IMAGE 等 + :param column_class: 列的分类,CUSTOM 或 SYSTEM + :param all: 是否获取全部数据,默认 False + """ + from swanlab.api.column import Columns + + query = PaginatedQuery(page=page, size=size, search=search, all=all) + run_id = self.run_id + return Columns( + self._ctx, + path=f"{self._proj_path}/{run_id}", + query=query, + column_type=column_type, + column_class=column_class, + run_id=run_id, + project_id_getter=lambda: self.project_id, + ) + + def delete(self) -> bool: + """删除此实验。""" + resp = self._delete(f"/project/{self._proj_path}/runs/{self.run_id}") + return resp.ok + + def json(self) -> Dict[str, Any]: + return get_properties(self) + + +def _flatten_runs(runs: Union[list, Dict]) -> list: + """展开分组后的实验数据,返回一个包含所有实验的列表。""" + if isinstance(runs, dict): + return [item for v in runs.values() for item in _flatten_runs(v)] + if isinstance(runs, list): + return list(runs) + return [runs] + + +class Experiments(BaseEntity): + """ + 项目下实验集合的迭代器。 + + 支持两种模式: + - POST 模式(默认):通过 /runs/shows 接口获取,支持复杂过滤,不支持分页 + - GET 模式:通过 /runs 接口获取,支持标准分页,返回精简信息 + + 用法:: + + # POST 复杂过滤 + for run in api.runs(path="username/project"): + print(run.name) + + # GET 分页 + for run in api.list_runs_simple(path="username/project"): + print(run.name, run.state) + """ + + def __init__( + self, + ctx: ApiClientContext, + *, + path: str, + filters: Optional[List[Dict[str, Any]]] = None, + groups: Optional[List[Dict[str, Any]]] = None, + sorts: Optional[List[Dict[str, Any]]] = None, + query: Optional[PaginatedQuery] = None, + mode: str = "post", + ) -> None: + super().__init__(ctx) + self._proj_path = path + self._filters = filters + self._groups = groups + self._sorts = sorts + self._query = query or PaginatedQuery() + self._mode = mode + self._page_info: Dict[str, Any] = { + "page": self._query.page, + "size": self._query.size, + "total": 0, + "pages": 0, + "list": [], + } + + def __iter__(self) -> Iterator[Experiment]: + if self._mode == "get": + yield from self._iter_paginated() + else: + yield from self._iter_filtered() + + def _iter_filtered(self) -> Iterator[Experiment]: + """POST /runs/shows 模式:复杂过滤,不支持分页。""" + resp = self._post( + f"/project/{self._proj_path}/runs/shows", + data={ + "filters": validate_update_active(self._filters, validate_filter, label="filters"), + "groups": validate_update_active(self._groups, validate_group, label="groups"), + "sorts": validate_update_active(self._sorts, validate_sort, label="sorts"), + }, + ) + if not resp.ok: + return + body = resp.data + runs: list = [] + if isinstance(body, list): + runs = body + elif isinstance(body, dict): + runs = _flatten_runs(body) + + total = len(runs) + self._page_info.update({"total": total, "page": 1, "size": total}) + + for run_data in runs: + cuid = run_data.get("cuid", "") + full_path = f"{self._proj_path}/{cuid}" + yield Experiment(self._ctx, path=full_path, data=run_data) + + def _iter_paginated(self) -> Iterator[Experiment]: + """GET /runs 模式:标准分页,返回精简信息。""" + for item in self._paginate( + f"/project/{self._proj_path}/runs", + self._query, + page_info=self._page_info, + ): + cuid = item.get("cuid", "") + full_path = f"{self._proj_path}/{cuid}" + yield Experiment( + self._ctx, + path=full_path, + data=cast(ApiExperimentType, item), + ) + + def json(self) -> Dict[str, Any]: + self._page_info["list"] = [r.json() for r in self] + return self._page_info diff --git a/swanlab/api/metric.py b/swanlab/api/metric.py new file mode 100644 index 000000000..8b2bff884 --- /dev/null +++ b/swanlab/api/metric.py @@ -0,0 +1,541 @@ +""" +@author: caddiesnew +@file: metric.py +@time: 2026/4/20 +@description: Metric 实体类 — 指标序列的查询与操作 +""" + +from __future__ import annotations + +from typing import Any, Dict, Iterator, List, Optional + +from swanlab.api.base import ApiClientContext, BaseEntity +from swanlab.api.typings import ApiColumnCsvExportType, ApiResponseType +from swanlab.api.typings.common import ApiMetricColumnTypeLiteral, ApiMetricLogLevelLiteral +from swanlab.api.typings.metric import ( + ApiLogSeriesType, + ApiMediaItemDataType, + ApiMediaSeriesType, + ApiScalarSeriesType, +) +from swanlab.api.utils import get_properties, validate_metric_keys, validate_metric_log_level, validate_metric_type +from swanlab.sdk.internal.pkg import console + +_SCALAR_STATISTIC_FIELDS = ("min", "max", "avg", "median", "latest") +_METRIC_SHARED_KEYS = frozenset({"project_id", "run_id", "metric_type"}) + + +def _extract_csv_url(data: Any) -> str: + if isinstance(data, list) and data: + return data[0].get("url", "") + if isinstance(data, dict): + return data.get("url", "") + return "" + + +class Metric(BaseEntity): + """ + 表示一个 SwanLab 指标列(非单个数值,而是一组序列)。 + + 支持 SCALAR / MEDIA / LOG 三种类型,按需 Lazy Loading。 + """ + + def __init__( + self, + ctx: ApiClientContext, + *, + project_id: str, + run_id: str, + key: Optional[str] = "", + sample: int = 1000, + log_offset: Optional[int] = 0, # 标记第几个分片,仅对 Log metric_type 有效 + log_level: ApiMetricLogLevelLiteral = "INFO", + metric_type: str = "SCALAR", + data: Optional[Dict[str, Any]] = None, + ignore_timestamp: bool = False, + media_step: Optional[int] = None, + all: bool = False, + ) -> None: + super().__init__(ctx) + validate_metric_type(metric_type, key) + if metric_type == "LOG": + validate_metric_log_level(log_level) + self._project_id = project_id + self._run_id = run_id + self._key = key + self._data: Optional[Dict[str, Any]] = data + self._metric_type = metric_type + self._ignore_timestamp = ignore_timestamp + # 采样值, scalar 时生效 + self._sample = sample + # 偏移量,仅对 Log metric_type 有效, 默认为 0 + self._offset = log_offset + self._log_level = log_level + self._media_step = media_step + self._all = all + + # 类型 → 加载方法 的分发表,新增类型只需在此注册 + _FETCH_DISPATCH = { + "SCALAR": "_fetch_scalar", + "MEDIA": "_fetch_media", + "LOG": "_fetch_logs", + } + + def _ensure_data(self) -> Dict[str, Any]: + if self._data is None: + if self._metric_type == "MEDIA" and self._all: + method_name = "_fetch_media_all" + else: + method_name = self._FETCH_DISPATCH.get(self._metric_type, "_fetch_scalar") + self._data = getattr(self, method_name)() + assert self._data is not None + return self._data + + @property + def project_id(self) -> str: + return self._project_id + + @property + def run_id(self) -> str: + return self._run_id + + @property + def key(self) -> str: + return self._key or "" + + @property + def metric_type(self) -> str: + return self._metric_type + + @property + def metrics(self) -> List[Any]: + return self._ensure_data().get("metrics", []) + + @property + def logs(self) -> List[Any]: + return self._ensure_data().get("logs", []) + + @property + def count(self) -> int: + return self._ensure_data().get("count", 0) + + @property + def steps(self) -> List[int]: + return self._ensure_data().get("steps", []) + + # only available for media type + @property + def step(self) -> Optional[int]: + return self._ensure_data().get("step") + + # ------------------------------------------------------------------ + # 请求辅助函数 + # ------------------------------------------------------------------ + + @staticmethod + def _extract_first(resp: ApiResponseType) -> Optional[Dict[str, Any]]: + """从列表型 API 响应中提取第一个元素,失败返回 None。""" + if resp.ok and isinstance(resp.data, list) and resp.data: + return resp.data[0] + return None + + @staticmethod + def _build_scalar_payload(project_id: str, run_id: str, keys: List[str], sample: int = 1500) -> Dict[str, Any]: + return { + "projectId": project_id, + "xType": "step", + "range": [0, 0], + "columns": [{"experimentId": run_id, "key": key} for key in keys], + "num": sample if sample <= 1500 else 1500, + } + + @staticmethod + def _build_media_payload( + project_id: str, run_id: str, keys: List[str], step: Optional[int] = None + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "projectId": project_id, + "columns": [{"experimentId": run_id, "key": key} for key in keys], + } + if step is not None: + payload["step"] = step + return payload + + def _build_log_params(self) -> Dict[str, Any]: + return { + "projectId": self.project_id, + "experimentId": self.run_id, + "size": 1000, # 硬编码为 1000 + "epoch": self._offset, + "level": self._log_level, + } + + # ------------------------------------------------------------------ + # 类型专属加载 + # ------------------------------------------------------------------ + + def _fetch_scalar(self) -> ApiScalarSeriesType: + res = ApiScalarSeriesType(projectId=self.project_id, experimentId=self.run_id, key=self.key) + payload = self._build_scalar_payload(self.project_id, self.run_id, [self.key], self._sample) + + # 1. 获取折线数据 + raw_data = self._extract_first(self._post("/house/metrics/scalar", data=payload)) + if raw_data is None: + return res + res["metrics"] = raw_data.get("metrics", []) + + # 2. 获取统计值 + stat_data = self._extract_first(self._post("/house/metrics/scalar/value", data=payload)) + if stat_data is None: + return res + for field in _SCALAR_STATISTIC_FIELDS: + res[field] = stat_data.get(field, {}) + return res + + @staticmethod + def _fetch_presigned_urls(entity: BaseEntity, prefix: str, paths: List[str]) -> Dict[str, str]: + """批量获取预签名下载链接,返回 path → url 映射。""" + if not paths: + return {} + resp = entity._post("/resources/presigned/get", data={"prefix": prefix, "paths": paths}) + if not resp.ok or not isinstance(resp.data, dict): + return {} + urls = resp.data.get("urls", []) + return dict(zip(paths, urls)) if urls else {} + + @staticmethod + def _build_media_items( + entry: Dict[str, Any], + url_map: Dict[str, str], + ) -> List[ApiMediaItemDataType]: + """将单个 metric entry 的 data/more 合并为 items,注入预签名 url。""" + paths = entry.get("data", []) + mores = entry.get("more", []) + items: List[ApiMediaItemDataType] = [] + for i, path in enumerate(paths): + item: ApiMediaItemDataType = {} + if path in url_map: + item["url"] = url_map[path] + if i < len(mores) and isinstance(mores[i], dict): + item.update(mores[i]) + items.append(item) + return items + + def _fetch_media(self) -> ApiMediaSeriesType: + res = ApiMediaSeriesType(projectId=self.project_id, experimentId=self.run_id, key=self.key) + payload = self._build_media_payload(self.project_id, self.run_id, [self.key], step=self._media_step) + raw_resp = self._post("/house/metrics/media", data=payload) + if not raw_resp.ok or not raw_resp.data: + return res + data = raw_resp.data + if not isinstance(data, dict): + return res + + res["steps"] = data.get("steps", []) + step_val = data.get("step") + if step_val is not None: + res["step"] = step_val + + metrics_raw: List[Dict[str, Any]] = data.get("metrics", []) + metric_entry = next((m for m in metrics_raw if m.get("key") == self.key), None) + if metric_entry is None: + return res + + prefix = f"{self.project_id}/{self.run_id}" + all_paths = metric_entry.get("data", []) + url_map = self._fetch_presigned_urls(self, prefix, all_paths) if all_paths else {} + if all_paths: + console.debug( + f"Media fetched: run_id[{self.run_id}], key[{self.key}] - {len(all_paths)} items, requesting presigned urls..." + ) + items = self._build_media_items(metric_entry, url_map) + res["metrics"] = [{"index": data.get("step", 0), "items": items}] + return res + + def _fetch_media_all(self) -> ApiMediaSeriesType: + res = ApiMediaSeriesType(projectId=self.project_id, experimentId=self.run_id, key=self.key) + payload = self._build_media_payload(self.project_id, self.run_id, [self.key]) + raw_resp = self._post("/house/metrics/f_media", data=payload) + raw_data = self._extract_first(raw_resp) + if raw_data is None: + return res + + prefix = f"{self.project_id}/{self.run_id}" + all_paths = [p for entry in raw_data.get("metrics", []) for p in entry.get("data", [])] + url_map = self._fetch_presigned_urls(self, prefix, all_paths) if all_paths else {} + if all_paths: + console.debug( + f"Media fetched (all): run_id[{self.run_id}], key[{self.key}] - {len(all_paths)} items, requesting presigned urls..." + ) + res["metrics"] = [ + {"index": entry.get("index", 0), "items": self._build_media_items(entry, url_map)} + for entry in raw_data.get("metrics", []) + ] + return res + + def _fetch_logs(self) -> ApiLogSeriesType: + res = ApiLogSeriesType(projectId=self.project_id, experimentId=self.run_id, key="LOG") + params = self._build_log_params() + raw_resp = self._get("/house/metrics/log", params=params) + if not raw_resp.ok or not raw_resp.data: + return res + data = raw_resp.data + if isinstance(data, dict): + res["logs"] = data.get("logs", []) + res["count"] = data.get("count", 0) + return res + + # ------------------------------------------------------------------ + # 导出 + # ------------------------------------------------------------------ + + def export_csv(self) -> ApiResponseType: + """导出列数据为 CSV。""" + if self.metric_type != "SCALAR": + return ApiResponseType(ok=False, errmsg="export_csv() only support SCALAR metric_type", data=None) + resp = self._get(f"/experiment/{self._run_id}/column/csv", params={"key": self.key}) + if not resp.ok: + return resp + url = _extract_csv_url(resp.data) + if not url: + return ApiResponseType(ok=False, errmsg="Invalid response format", data=None) + return ApiResponseType(ok=True, data=ApiColumnCsvExportType(url=url)) + + def json(self) -> Dict[str, Any]: + result = get_properties(self) + data = self._ensure_data() + + if self._metric_type == "SCALAR": + if "url" in data: + result.pop("metrics", None) + result["url"] = data["url"] + for field in _SCALAR_STATISTIC_FIELDS: + val = data.get(field) + if val: + result[field] = val + + if self._metric_type == "LOG": + result.pop("metrics", None) + else: + result.pop("logs", None) + result.pop("count", None) + + if self._metric_type != "MEDIA" or "steps" not in data: + result.pop("steps", None) + result.pop("step", None) + + if self._ignore_timestamp: + items = result.get("metrics", []) or result.get("logs", []) + if isinstance(items, list): + for item in items: + if isinstance(item, dict): + item.pop("timestamp", None) + + return result + + +class Metrics(BaseEntity): + """ + 批量指标数据的迭代器。 + + 一次 metrics 查询只支持一种 metric_type(SCALAR 或 MEDIA),不支持 LOG。 + 通过 payload 的 columns 数组一次性传递多个 key,减少网络请求。 + + 用法:: + + for m in experiment.metrics(keys=["loss", "acc"], metric_type="SCALAR"): + print(m.key, m.metrics) + """ + + def __init__( + self, + ctx: ApiClientContext, + *, + project_id: str, + run_id: str, + keys: List[str], + metric_type: ApiMetricColumnTypeLiteral, + sample: int = 1500, + ignore_timestamp: bool = False, + media_step: Optional[int] = None, + all: bool = False, + ) -> None: + super().__init__(ctx) + validate_metric_keys(keys) + validate_metric_type(metric_type, keys[0]) + if metric_type == "LOG": + raise ValueError("Metrics does not support LOG metric_type, use Experiment.logs() instead") + self._project_id = project_id + self._run_id = run_id + self._keys = keys + self._metric_type = metric_type + + self._ignore_timestamp = ignore_timestamp + self._media_step = media_step + self._all = all + self._page_info: Dict[str, Any] = { + "keys": keys, + "metricType": metric_type, + "projectId": project_id, + "experimentId": run_id, + "list": [], + } + self._sample = sample + if sample > 1500: + console.warning(f"Get sample = [{sample}], expected <= 1500, will be constrainted automatically..") + self._sample = 1500 + + def __iter__(self) -> Iterator[Metric]: + if self._metric_type == "SCALAR": + if self._all: + yield from self._fetch_scalars_all() + else: + yield from self._fetch_scalars() + else: + if self._all: + yield from self._fetch_medias_all() + else: + yield from self._fetch_medias() + + def _build_metric(self, key: str, data: Dict[str, Any]) -> Metric: + return Metric( + ctx=self._ctx, + project_id=self._project_id, + run_id=self._run_id, + key=key, + metric_type=self._metric_type, + sample=self._sample, + ignore_timestamp=self._ignore_timestamp, + media_step=self._media_step, + data=data, + all=self._all, + ) + + def _fetch_scalars(self) -> Iterator[Metric]: + payload = Metric._build_scalar_payload(self._project_id, self._run_id, self._keys, self._sample) + + # 1. 获取折线数据 + scalar_resp = self._post("/house/metrics/scalar", data=payload) + scalar_list: List[Dict[str, Any]] = ( + scalar_resp.data if scalar_resp.ok and isinstance(scalar_resp.data, list) else [] + ) + + # 2. 获取统计值 + value_resp = self._post("/house/metrics/scalar/value", data=payload) + value_list: List[Dict[str, Any]] = value_resp.ok and isinstance(value_resp.data, list) and value_resp.data or [] + + for i, key in enumerate(self._keys): + data: Dict[str, Any] = { + "projectId": self._project_id, + "experimentId": self._run_id, + "key": key, + "metrics": [], + } + if i < len(scalar_list): + data["metrics"] = scalar_list[i].get("metrics", []) + if i < len(value_list): + for field in _SCALAR_STATISTIC_FIELDS: + val = value_list[i].get(field) + if val is not None: + data[field] = val + yield self._build_metric(key, data) + + def _fetch_scalars_all(self) -> Iterator[Metric]: + urls: Dict[str, str] = {} + for key in self._keys: + resp = self._get(f"/experiment/{self._run_id}/column/csv", params={"key": key}) + if resp.ok and resp.data: + urls[key] = _extract_csv_url(resp.data) + + payload = Metric._build_scalar_payload(self._project_id, self._run_id, self._keys, self._sample) + value_resp = self._post("/house/metrics/scalar/value", data=payload) + value_list: List[Dict[str, Any]] = value_resp.ok and isinstance(value_resp.data, list) and value_resp.data or [] + + for i, key in enumerate(self._keys): + data: Dict[str, Any] = { + "projectId": self._project_id, + "experimentId": self._run_id, + "key": key, + "url": urls.get(key, ""), + } + if i < len(value_list): + for field in _SCALAR_STATISTIC_FIELDS: + val = value_list[i].get(field) + if val is not None: + data[field] = val + yield self._build_metric(key, data) + + def _fetch_medias(self) -> Iterator[Metric]: + payload = Metric._build_media_payload(self._project_id, self._run_id, self._keys, step=self._media_step) + raw_resp = self._post("/house/metrics/media", data=payload) + if not raw_resp.ok or not raw_resp.data: + return + resp_data = raw_resp.data + if not isinstance(resp_data, dict): + return + + steps = resp_data.get("steps", []) + current_step = resp_data.get("step") + metrics_raw: List[Dict[str, Any]] = resp_data.get("metrics", []) + + prefix = f"{self._project_id}/{self._run_id}" + all_paths = [p for entry in metrics_raw for p in entry.get("data", [])] + url_map = Metric._fetch_presigned_urls(self, prefix, all_paths) if all_paths else {} + if all_paths: + console.debug( + f"Media fetched: run_id[{self._run_id}] - {len(all_paths)} items across {len(self._keys)} keys, requesting presigned urls..." + ) + + key_to_entry: Dict[str, Dict[str, Any]] = {e.get("key", ""): e for e in metrics_raw} + for key in self._keys: + data: Dict[str, Any] = { + "projectId": self._project_id, + "experimentId": self._run_id, + "key": key, + "steps": steps, + "step": current_step, + "metrics": [], + } + entry = key_to_entry.get(key) + if entry: + items = Metric._build_media_items(entry, url_map) + data["metrics"] = [{"index": current_step or 0, "items": items}] + yield self._build_metric(key, data) + + def _fetch_medias_all(self) -> Iterator[Metric]: + payload = Metric._build_media_payload(self._project_id, self._run_id, self._keys) + raw_resp = self._post("/house/metrics/f_media", data=payload) + if not raw_resp.ok or not raw_resp.data: + return + raw_list = raw_resp.data + if not isinstance(raw_list, list): + return + + prefix = f"{self._project_id}/{self._run_id}" + all_paths = [p for entry in raw_list for m in entry.get("metrics", []) for p in m.get("data", [])] + url_map = Metric._fetch_presigned_urls(self, prefix, all_paths) if all_paths else {} + if all_paths: + console.debug( + f"Media fetched (all): run_id[{self._run_id}] - {len(all_paths)} items across {len(self._keys)} keys, requesting presigned urls..." + ) + + key_to_entry: Dict[str, Dict[str, Any]] = {e.get("key", ""): e for e in raw_list} + for key in self._keys: + data: Dict[str, Any] = { + "projectId": self._project_id, + "experimentId": self._run_id, + "key": key, + "metrics": [], + } + entry = key_to_entry.get(key) + if entry: + metrics_list: List[Dict[str, Any]] = [] + for m in entry.get("metrics", []): + items = Metric._build_media_items(m, url_map) + metrics_list.append({"index": m.get("index", 0), "items": items}) + data["metrics"] = metrics_list + yield self._build_metric(key, data) + + def json(self) -> Dict[str, Any]: + self._page_info["list"] = [{k: v for k, v in m.json().items() if k not in _METRIC_SHARED_KEYS} for m in self] + return self._page_info diff --git a/swanlab/api/project.py b/swanlab/api/project.py new file mode 100644 index 000000000..e27dd92d7 --- /dev/null +++ b/swanlab/api/project.py @@ -0,0 +1,169 @@ +""" +@author: caddiesnew +@file: project.py +@time: 2026/4/20 +@description: Project 实体类 — 单个项目的查询与操作 +""" + +from typing import Any, Dict, Iterator, List, Optional, cast + +from swanlab.api.base import ApiClientContext, BaseEntity +from swanlab.api.typings.common import PaginatedQuery +from swanlab.api.typings.project import ApiProjectCountType, ApiProjectLabelType, ApiProjectType +from swanlab.api.utils import get_properties + + +class Project(BaseEntity): + """ + 表示一个 SwanLab 项目。 + + 支持双模式:构造时传入 data(列表迭代注入),或 data=None(按需懒加载)。 + """ + + def __init__( + self, + ctx: ApiClientContext, + *, + path: str, + data: Optional[ApiProjectType] = None, + ) -> None: + super().__init__(ctx) + self._path = path + self._data = data + + def _ensure_data(self) -> ApiProjectType: + if self._data is None: + resp = self._get(f"/project/{self._path}") + self._data = resp.data if resp.ok and resp.data else cast(ApiProjectType, {}) + return self._data + + @property + def project_id(self) -> str: + return self._ensure_data().get("cuid", "") + + @property + def name(self) -> str: + return self._ensure_data().get("name", "") + + @property + def path(self) -> str: + return self._ensure_data().get("path", "") + + @property + def url(self) -> str: + return self._build_web_url(f"@{self.path}") + + @property + def description(self) -> str: + return self._ensure_data().get("description", "") + + @property + def visibility(self) -> str: + return self._ensure_data().get("visibility", "PRIVATE") + + @property + def created_at(self) -> str: + return self._ensure_data().get("createdAt", "") + + @property + def updated_at(self) -> str: + return self._ensure_data().get("updatedAt", "") + + @property + def labels(self) -> List[ApiProjectLabelType]: + return [label for label in self._ensure_data().get("projectLabels", [])] + + @property + def count(self) -> ApiProjectCountType: + return cast(ApiProjectCountType, self._ensure_data().get("_count", {})) + + def runs( + self, + filters: Optional[List[Dict[str, Any]]] = None, + groups: Optional[List[Dict[str, Any]]] = None, + sorts: Optional[List[Dict[str, Any]]] = None, + ): + """ + 获取项目下的实验列表(POST 模式,支持复杂过滤)。 + + :param filters: 过滤规则列表,每项为 {key, type, op, value} + :param groups: 分组规则列表,每项为 {key, type} + :param sorts: 排序规则列表,每项为 {key, type, order} + """ + from swanlab.api.experiment import Experiments + + return Experiments(self._ctx, path=self.path, filters=filters, groups=groups, sorts=sorts, mode="post") + + def runs_get( + self, + page: int = 1, + size: int = 20, + all: bool = False, + ): + """ + 获取项目下的实验列表(GET 模式,标准分页,返回精简信息)。 + + :param page: 起始页码,默认 1 + :param size: 每页数量,默认 20 + :param all: 是否获取全部数据,默认 False + """ + from swanlab.api.experiment import Experiments + + query = PaginatedQuery(page=page, size=size, all=all) + return Experiments(self._ctx, path=self.path, query=query, mode="get") + + def delete(self) -> bool: + """删除此项目。""" + resp = self._delete(f"/project/{self.path}") + return resp.ok + + def json(self) -> Dict[str, Any]: + return get_properties(self) + + +class Projects(BaseEntity): + """ + 工作空间下项目集合的分页迭代器。 + + 用法:: + + for project in api.projects("username"): + print(project.name) + """ + + def __init__( + self, + ctx: ApiClientContext, + *, + path: str, + query: Optional[PaginatedQuery] = None, + detail: Optional[bool] = True, + ) -> None: + super().__init__(ctx) + self._path = path + self._query = query or PaginatedQuery() + self._detail = detail + self._page_info: Dict[str, Any] = { + "page": self._query.page, + "size": self._query.size, + "total": 0, + "pages": 0, + "list": [], + } + + def __iter__(self) -> Iterator[Project]: + for item in self._paginate( + f"/project/{self._path}", + self._query, + page_info=self._page_info, + extra={"detail": self._detail}, + ): + yield Project( + self._ctx, + path=str(item.get("path", "")), + data=cast(ApiProjectType, item), + ) + + def json(self) -> Dict[str, Any]: + self._page_info["list"] = [p.json() for p in self] + return self._page_info diff --git a/swanlab/api/selfhosted.py b/swanlab/api/selfhosted.py new file mode 100644 index 000000000..2280defa9 --- /dev/null +++ b/swanlab/api/selfhosted.py @@ -0,0 +1,104 @@ +""" +@author: caddiesnew +@file: selfhosted.py +@time: 2026/4/20 +@description: SelfHosted 实体类 — 私有化部署实例的查询与管理 +""" + +from typing import Any, Dict, Iterator, Optional, cast + +from swanlab.api.base import ApiClientContext, BaseEntity +from swanlab.api.typings.common import ApiResponseType, PaginatedQuery +from swanlab.api.typings.selfhosted import ApiLicensePlanLiteral, ApiSelfHostedInfoType +from swanlab.api.utils import get_properties, validate_non_empty_string + + +class SelfHosted(BaseEntity): + """ + 表示一个 SwanLab 私有化部署实例。 + + 支持双模式:构造时传入 data,或 data=None(按需懒加载)。 + """ + + def __init__( + self, + ctx: ApiClientContext, + *, + data: Optional[ApiSelfHostedInfoType] = None, + ) -> None: + super().__init__(ctx) + self._data = data + + def _ensure_data(self) -> ApiSelfHostedInfoType: + if self._data is None: + resp = self._get("/self_hosted/info") + self._data = resp.data if resp.ok and resp.data else cast(ApiSelfHostedInfoType, {}) + return self._data + + @property + def enabled(self) -> bool: + return self._ensure_data().get("enabled", False) + + @property + def expired(self) -> bool: + return self._ensure_data().get("expired", False) + + @property + def root(self) -> bool: + return self._ensure_data().get("root", False) + + @property + def plan(self) -> ApiLicensePlanLiteral: + return self._ensure_data().get("plan", "free") + + @property + def seats(self) -> int: + return self._ensure_data().get("seats", 0) + + # ================================ + # 权限校验 + # ================================ + + @staticmethod + def validate_expire(info: ApiSelfHostedInfoType) -> None: + if info.get("expired", True): + raise ValueError("SwanLab self-hosted instance has expired.") + + @staticmethod + def validate_root(info: ApiSelfHostedInfoType) -> None: + SelfHosted.validate_expire(info) + if not info.get("root", False): + raise ValueError("You don't have permission to perform this action. Please login as a root user.") + + # ================================ + # 管理操作(root 限定) + # ================================ + + def create_user(self, username: str, password: str) -> ApiResponseType: + """ + 添加用户(私有化管理员限定)。 + + :param username: 待创建用户名 + :param password: 待创建用户密码 + """ + SelfHosted.validate_root(self._ensure_data()) + validate_non_empty_string(username, label="username") + validate_non_empty_string(password, label="password") + data = {"users": [{"username": username, "password": password}]} + return self._post("/self_hosted/users", data=data) + + def get_users(self, page: int = 1, size: int = 20, all: bool = False) -> Iterator[dict]: + """ + 分页获取用户(管理员限定)。 + + :param page: 起始页码,默认 1 + :param size: 每页大小,默认 20 + :param all: 是否获取全部数据,默认 False + """ + SelfHosted.validate_root(self._ensure_data()) + query = PaginatedQuery(page=page, size=size, all=all) + page_info: Dict[str, Any] = {"total": 0, "pages": 0} + yield from self._paginate("/self_hosted/users", query, page_info=page_info) + + def json(self) -> Dict[str, Any]: + return get_properties(self) diff --git a/swanlab/api/typings/__init__.py b/swanlab/api/typings/__init__.py new file mode 100644 index 000000000..5b60c1c9e --- /dev/null +++ b/swanlab/api/typings/__init__.py @@ -0,0 +1,75 @@ +""" +@author: caddiesnew +@file: __init__.py +@time: 2026/4/21 18:40 +@description: SwanLab OpenAPI 类型提示, 以 Api 前缀区分 +""" + +from .column import ApiColumnCsvExportType, ApiColumnErrorType, ApiColumnType +from .common import ( + ApiIdentityLiteral, + ApiLicensePlanLiteral, + ApiMetricAllTypeLiteral, + ApiMetricColumnTypeLiteral, + ApiMetricLogLevelLiteral, + ApiMetricXAxisLiteral, + ApiPaginationType, + ApiResponseType, + ApiRoleLiteral, + ApiRunStateLiteral, + ApiSidebarLiteral, + ApiVisibilityLiteral, + ApiWorkspaceLiteral, +) +from .experiment import ApiExperimentLabelType, ApiExperimentType +from .metric import ( + ApiLogSeriesType, + ApiMediaSeriesType, + ApiScalarSeriesType, +) +from .project import ApiProjectCountType, ApiProjectLabelType, ApiProjectType +from .selfhosted import ApiApiKeyType, ApiSelfHostedInfoType +from .user import ApiUserProfileType, ApiUserType +from .workspace import ApiWorkspaceProfileType, ApiWorkspaceType + +__all__ = [ + # Literal Definition + "ApiSidebarLiteral", + "ApiRunStateLiteral", + "ApiVisibilityLiteral", + "ApiWorkspaceLiteral", + "ApiRoleLiteral", + "ApiIdentityLiteral", + "ApiLicensePlanLiteral", + "ApiMetricLogLevelLiteral", + "ApiMetricAllTypeLiteral", + "ApiMetricColumnTypeLiteral", + "ApiMetricXAxisLiteral", + # General TypedDicts + "ApiPaginationType", + "ApiResponseType", + # Experiment/Run + "ApiExperimentLabelType", + "ApiExperimentType", + # Project + "ApiProjectCountType", + "ApiProjectLabelType", + "ApiProjectType", + # User + "ApiUserType", + "ApiUserProfileType", + # Worksapce/Group + "ApiWorkspaceType", + "ApiWorkspaceProfileType", + # Misc + "ApiApiKeyType", + "ApiSelfHostedInfoType", + # Column + "ApiColumnErrorType", + "ApiColumnType", + "ApiColumnCsvExportType", + # Metric + "ApiLogSeriesType", + "ApiMediaSeriesType", + "ApiScalarSeriesType", +] diff --git a/swanlab/api/typings/column.py b/swanlab/api/typings/column.py new file mode 100644 index 000000000..f119a3696 --- /dev/null +++ b/swanlab/api/typings/column.py @@ -0,0 +1,51 @@ +""" +@author: caddiesnew +@file: column.py +@time: 2026/4/20 +@description: 公共查询 API 实验列类型定义 +""" + +from typing import Any, Dict, Optional, TypedDict + +from .common import ApiColumnClassLiteral, ApiColumnDataTypeLiteral + + +class ApiColumnErrorType(TypedDict, total=False): + """列错误信息""" + + message: str + code: str + + +class ApiColumnType(TypedDict, total=False): + """ + 实验列数据类型 + + 注意:后端响应使用以下字段名: + - class: 列的分类 (CUSTOM/SYSTEM) + - type: 列的数据类型 (FLOAT/STRING/IMAGE等) + - createdAt: 创建时间戳(蛇峰命名) + """ + + # 每个 column 与一个项目和实验绑定 + project_id: str + run_id: str + # 列的分类:CUSTOM 或 SYSTEM + column_class: ApiColumnClassLiteral + # 列的数据类型 + column_type: ApiColumnDataTypeLiteral + # 列的键名(唯一标识) + key: str + # 列的显示名称,默认为 key 的值 + name: str + # 创建时间戳 + createdAt: int + # 错误信息 + error: Optional[Dict[str, Any]] + + +class ApiColumnCsvExportType(TypedDict): + """列 CSV 导出响应类型""" + + # 临时下载 URL + url: str diff --git a/swanlab/api/typings/common.py b/swanlab/api/typings/common.py new file mode 100644 index 000000000..0b1a07195 --- /dev/null +++ b/swanlab/api/typings/common.py @@ -0,0 +1,217 @@ +""" +@author: caddiesnew +@file: common.py +@time: 2026/4/20 +@description: 公共查询 API 通用类型定义 +""" + +from dataclasses import dataclass +from typing import Any, Dict, List, Literal, Optional, TypedDict + +# 启用/停用 +ApiStatusLiteral = Literal["ENABLED", "DISABLED"] + +# 侧边列类型 +# STABLE: Experiment 的固有字段,如 state, name, labels, colors 等 +# CONFIG: 动态生成的实验配置字段,如 learning_rate, batch_size 等 +# SCALAR: 动态生成的标量字段,一般用于标量图展示,如 train/loss 等 +ApiSidebarLiteral = Literal["SCALAR", "CONFIG", "STABLE"] + +# 实验类型: 运行中/总览 +ApiExperimentTypeLiteral = Literal["CHAPTER", "SUMMARY"] + +# 实验状态类型 +ApiRunStateLiteral = Literal["RUNNING", "FINISHED", "CRASHED", "ABORTED", "OFFLINE"] + +# 可见性类型 +ApiVisibilityLiteral = Literal["PUBLIC", "PRIVATE"] + +# 工作空间类型 +ApiWorkspaceLiteral = Literal["TEAM", "PERSON"] + +# 工作空间成员类型 +ApiRoleLiteral = Literal["VISITOR", "VIEWER", "MEMBER", "OWNER"] + +# 列种类 +ApiColumnClassLiteral = Literal["CUSTOM", "SYSTEM"] +# 列数据类型 +ApiColumnDataTypeLiteral = Literal[ + "FLOAT", + "BOOLEAN", + "STRING", + # media 类型 + "IMAGE", + "AUDIO", + "VIDEO", + # 3D点云 (json) + "OBJECT3D", + # 生物化学分子 + "MOLECULE", + # (js/ ts 文件) + "ECHARTS", + # 表格类型 + "TABLE", + "TEXT", +] + +# 列数据非 media 类型,方便过滤 +ApiColumnScalarTypeLiteral = Literal["FLOAT", "BOOLEAN", "STRING"] + +# Self-Hosted 身份类型 +ApiIdentityLiteral = Literal["root", "user"] + +# License 许可证类型 +ApiLicensePlanLiteral = Literal["free", "commercial"] + +# 指标类型(log 不属于 column-backed metrics,使用独立查询方法) +ApiMetricColumnTypeLiteral = Literal["SCALAR", "MEDIA"] + +# 指标扩展类型(包含 LOG,用于内部 Metric 调度) +ApiMetricAllTypeLiteral = Literal["SCALAR", "MEDIA", "LOG"] + +# 指标日志级别 +ApiMetricLogLevelLiteral = Literal["DEBUG", "INFO", "WARN", "ERROR"] + +# X 轴类型 +ApiMetricXAxisLiteral = Literal["step", "time", "relative_time"] + + +# --------------------------------------------------------------------------- +# STABLE 字段 key 枚举 +# 对应 experiment 表的直接字段或嵌套字段,来自 sidebar.js stableFieldSelect。 +# --------------------------------------------------------------------------- +ApiFilterStableKeyLiteral = Literal[ + # 实验状态 RUNNING / FINISHED / CRASHED / ABORTED + "state", + # 实验名称 + "name", + # 实验描述 + "description", + # 是否可见 + "show", + # TODO: experiment 被设置为 pin 时强制返回 + # "pin", + # 是否为基线 + "baseline", + # 颜色 + "colors", + # 实验分组名 + "cluster", + # 分布式任务类型 + "job", + # 创建时间 + "createdAt", + # 更新时间 + "updatedAt", + # 完成时间 + "finishedAt", + # 收藏时间 + "pinnedAt", + # 标签名数组 + "labels", +] + +# --------------------------------------------------------------------------- +# 过滤操作符 +# --------------------------------------------------------------------------- +# EQ : 等于 +# NEQ : 不等于 +# GTE : 大于等于(数值 / 日期 / 字符串) +# LTE : 小于等于(数值 / 日期 / 字符串) +# IN : 在给定值列表中 +# NOT IN : 不在给定值列表中 +# CONTAIN : 模糊包含 +# +# 注意: +# - 数组类型(如 labels)仅支持 EQ / NEQ / IN / NOT IN / CONTAIN +# - 日期类型 GTE/LTE 用 Date 对象比较;其余用 ISO 字符串比较 +# - 数值类型优先数值比较,失败回退字符串比较 +# --------------------------------------------------------------------------- +ApiFilterOpLiteral = Literal["EQ", "NEQ", "GTE", "LTE", "IN", "NOT IN", "CONTAIN"] + +# --------------------------------------------------------------------------- +# 排序方向 +# --------------------------------------------------------------------------- +ApiSortOrderLiteral = Literal["ASC", "DESC"] + +# 后端允许的每页条数 +_VALID_PAGE_SIZES = (10, 12, 15, 20, 24, 27, 50, 100) + + +@dataclass(frozen=True) +class PaginatedQuery: + """ + 通用分页查询参数,与后端 pagination_query 对齐。 + + page: 当前页码,≥1 + size: 每页条数,必须为后端允许值之一 + search: 搜索关键词 + sort: 排序字段 + all: 是否拉取全部分页(客户端侧自动翻页) + """ + + page: int = 1 + size: int = 20 + search: Optional[str] = None + sort: Optional[str] = None + all: bool = False + + def __post_init__(self) -> None: + if self.page < 1: + raise ValueError(f"page must be >= 1, got {self.page}") + if self.size not in _VALID_PAGE_SIZES: + raise ValueError(f"size must be one of {_VALID_PAGE_SIZES}, got {self.size}") + + def to_params(self, **extra: Optional[Any]) -> Dict[str, Any]: + """转换为查询参数字典,自动过滤 None 值。""" + params: Dict[str, Any] = {"page": self.page, "size": self.size} + if self.search is not None: + params["search"] = self.search + if self.sort is not None: + params["sort"] = self.sort + params.update({k: v for k, v in extra.items() if v is not None}) + return params + + +class ApiPaginationType(TypedDict): + list: List + size: int + pages: int + total: int + + +class ApiResponseType: + """ + API 响应的统一封装,保证任何异常都不会导致程序 crash。 + + - ok=True 时 data 持有正常返回值 + - ok=False 时 data 为 None,errmsg 描述失败原因 + """ + + __slots__ = ("ok", "errmsg", "data") + + def __init__(self, *, ok: bool, errmsg: str = "", data: Any = None) -> None: + self.ok = ok + self.errmsg = errmsg + self.data = data + + def json(self) -> Dict[str, Any]: + """返回 JSON 可序列化的字典,自动将实体 data 转为 dict。""" + data = self.data + errors: list[str] = [] + if not self.ok and self.errmsg: + errors.append(self.errmsg) + if data is not None and hasattr(data, "json"): + data = data.json() + entity_errors = getattr(self.data, "_errors", []) + errors.extend(entity_errors) + ok = self.ok and not errors + return {"ok": ok, "errmsg": "; ".join(errors) if errors else "", "data": data} + + def __repr__(self) -> str: + if self.ok: + return f"ApiResponse(ok=True, data={self.data!r})" + return f"ApiResponse(ok=False, errmsg={self.errmsg!r})" + + +__all__ = ["ApiPaginationType", "ApiResponseType", "PaginatedQuery"] diff --git a/swanlab/api/typings/experiment.py b/swanlab/api/typings/experiment.py new file mode 100644 index 000000000..a20eeec4e --- /dev/null +++ b/swanlab/api/typings/experiment.py @@ -0,0 +1,95 @@ +""" +@author: caddiesnew +@file: experiment.py +@time: 2026/4/20 +@description: 公共查询 API 实验类型定义 + +POST /runs/shows 接口支持三个维度的筛选和组织:过滤 (filters)、分组 (groups)、排序 (sorts)。 +每项都有一个 type 字段,取值取决于数据来源: + - STABLE: 实验表固有字段(固定枚举) + - CONFIG: experimentProfile.config 中用户定义的超参(动态 key,如 learning_rate) + - SCALAR: 训练过程中记录的标量指标最新值(动态 key,如 train/loss) +""" + +from typing import Any, Dict, List, Optional, TypedDict + +from .common import ( + ApiExperimentTypeLiteral, + ApiFilterOpLiteral, + ApiRunStateLiteral, + ApiSidebarLiteral, + ApiSortOrderLiteral, +) +from .user import ApiUserType + + +# --------------------------------------------------------------------------- +# filter / group / sort item +# POST /runs/shows 请求体中 filters / groups / sorts 数组的元素类型。 +# 用户传入时不需要 active 字段,由 SDK 内部自动补充 active: True。 +# --------------------------------------------------------------------------- +class ApiFilterItem(TypedDict): + """POST /runs/shows 请求体中的过滤项。 + + 多个 filter 之间为 AND 关系。 + 收藏的实验(pin: true)永远不会被过滤掉。 + """ + + key: str # STABLE 时为 ApiStableKeyLiteral 枚举;CONFIG/SCALAR 时为动态字段名 + type: ApiSidebarLiteral # STABLE | CONFIG | SCALAR + op: ApiFilterOpLiteral # 过滤操作符 + value: List[str] # 过滤值列表(空值统一视为空字符串 "") + + +class ApiGroupItem(TypedDict): + """POST /runs/shows 请求体中的分组项。 + + 多个 group 形成多层嵌套(外层 group 为第一层)。 + 数组类型值(如 labels)会排序后用 ", " 连接成字符串作为分组 key。 + """ + + key: str # STABLE 时为 ApiStableKeyLiteral 枚举;CONFIG/SCALAR 时为动态字段名 + type: ApiSidebarLiteral # STABLE | CONFIG | SCALAR + + +class ApiSortItem(TypedDict): + """POST /runs/shows 请求体中的排序项。 + + 排序与分组联动:有 order 的字段按方向排序后平铺,无 order 的保留嵌套结构。 + 后端自动追加兜底排序:pin DESC > pinnedAt DESC > createdAt DESC。 + """ + + key: str # STABLE 时为 ApiStableKeyLiteral 枚举;CONFIG/SCALAR 时为动态字段名 + type: ApiSidebarLiteral # STABLE | CONFIG | SCALAR + order: ApiSortOrderLiteral # ASC | DESC + + +# --------------------------------------------------------------------------- +# 实验实体 +# --------------------------------------------------------------------------- +class ApiExperimentLabelType(TypedDict, total=False): + name: str + colors: List[str] + + +class ApiExperimentProfileType(TypedDict): + config: Dict[str, Any] + metadata: Dict[str, Any] + requirements: str + conda: str + + +class ApiExperimentType(TypedDict, total=False): + project_id: str + cuid: str + slug: Optional[str] + name: str + type: ApiExperimentTypeLiteral + description: str + labels: List[ApiExperimentLabelType] + profile: ApiExperimentProfileType + show: bool + state: ApiRunStateLiteral + cluster: str + job: str + user: ApiUserType diff --git a/swanlab/api/typings/metric.py b/swanlab/api/typings/metric.py new file mode 100644 index 000000000..e4a6fb3b8 --- /dev/null +++ b/swanlab/api/typings/metric.py @@ -0,0 +1,101 @@ +""" +@author: caddiesnew +@file: metric.py +@time: 2026/4/23 +@description: 指标数据类型定义(用于 column 采样值) +""" + +from typing import Any, List, TypedDict, Union + +# --------------------------------------------------------------------------- +# Common — 通用指标类型定义 +# --------------------------------------------------------------------------- + + +# 指标值类型("NaN", "INF", "-INF") +ApiMetricValueType = Union[int, float, str] + + +# --------------------------------------------------------------------------- +# Column Reference — 指标列引用,标识要查询的指标列 +# --------------------------------------------------------------------------- +class ApiMetricColumnRefType(TypedDict, total=False): + projectId: str + experimentId: str + key: str + rootProId: str + rootExpId: str + + +# --------------------------------------------------------------------------- +# Scalar — 标量指标类型 +# --------------------------------------------------------------------------- +# 使用 index 因为 x 轴可以是 step / time / relative_time / 自定义列 +class ApiScalarType(TypedDict, total=False): + index: float + data: ApiMetricValueType + timestamp: int + + +# 组合 /metrics/scalar 和 /metrics/scalar/value 的标量序列 +class ApiScalarSeriesType(ApiMetricColumnRefType, total=False): + """标量指标序列,包含折线数据和聚合值""" + + metrics: List[ApiScalarType] + url: str + min: ApiScalarType + max: ApiScalarType + avg: ApiScalarType + median: ApiScalarType + latest: ApiScalarType + + +# 指标概要 +# summary[run_id][key] 为下面一个 item 项 +class ApiScalarSummaryItemType(TypedDict, total=False): + step: int + value: Any + minMax: List[Any] + min: Any + max: Any + avg: Any + median: Any + stdDev: Any + + +# --------------------------------------------------------------------------- +# Media — 媒体数据 +# --------------------------------------------------------------------------- +class ApiMediaItemDataType(TypedDict, total=False): + url: str + + +class ApiMediaType(TypedDict, total=False): + index: int + items: List[ApiMediaItemDataType] + + +class ApiMediaSeriesType(ApiMetricColumnRefType, total=False): + steps: List[int] + step: int + metrics: List[ApiMediaType] + + +# --------------------------------------------------------------------------- +# Log — 日志数据 +# --------------------------------------------------------------------------- +class ApiLogType(TypedDict, total=False): + epoch: int + level: str + message: str + tag: str + timestamp: str + + +class ApiLogSeriesType(ApiMetricColumnRefType, total=False): + logs: List[ApiLogType] + count: int + + +# 统一数据类型定义用于类型提示 +ApiMetricType = Union[ApiScalarType, ApiMediaType, ApiLogType] diff --git a/swanlab/api/typings/project.py b/swanlab/api/typings/project.py new file mode 100644 index 000000000..1012299da --- /dev/null +++ b/swanlab/api/typings/project.py @@ -0,0 +1,38 @@ +""" +@author: caddiesnew +@file: project.py +@time: 2026/4/20 +@description: 公共查询 API 项目类型定义 +""" + +from typing import Dict, List, TypedDict + +from .common import ApiVisibilityLiteral + + +class ApiProjectLabelType(TypedDict, total=False): + name: str + colors: List[str] + cuid: str + + +class ApiProjectCountType(TypedDict): + experiments: int + contributors: int + collaborators: int + clones: int + + +class ApiProjectType(TypedDict, total=False): + cuid: str + name: str + username: str + path: str + visibility: ApiVisibilityLiteral + description: str + group: Dict[str, str] + projectLabels: List[ApiProjectLabelType] + _count: ApiProjectCountType + createdAt: str + updatedAt: str + role: str diff --git a/swanlab/api/typings/selfhosted.py b/swanlab/api/typings/selfhosted.py new file mode 100644 index 000000000..05956d363 --- /dev/null +++ b/swanlab/api/typings/selfhosted.py @@ -0,0 +1,24 @@ +""" +@author: caddiesnew +@file: user.py +@time: 2026/4/20 +@description: 公共查询 API 私有化实例类型定义 +""" + +from typing import TypedDict + +from .common import ApiLicensePlanLiteral + + +class ApiApiKeyType(TypedDict): + id: int + name: str + key: str + + +class ApiSelfHostedInfoType(TypedDict): + enabled: bool + expired: bool + root: bool + plan: ApiLicensePlanLiteral + seats: int diff --git a/swanlab/api/typings/user.py b/swanlab/api/typings/user.py new file mode 100644 index 000000000..b9e11d4c2 --- /dev/null +++ b/swanlab/api/typings/user.py @@ -0,0 +1,28 @@ +""" +@author: caddiesnew +@file: user.py +@time: 2026/4/20 +@description: 公共查询 API 用户类型定义 +""" + +from typing import TypedDict + + +class ApiUserProfileType(TypedDict): + # 简介 + bio: str + # 个人链接 + url: str + # 机构 + institution: str + # 学校 + school: str + # 邮箱 + email: str + # 地址 + location: str + + +class ApiUserType(TypedDict): + name: str + username: str diff --git a/swanlab/api/typings/workspace.py b/swanlab/api/typings/workspace.py new file mode 100644 index 000000000..5ee279885 --- /dev/null +++ b/swanlab/api/typings/workspace.py @@ -0,0 +1,29 @@ +""" +@author: caddiesnew +@file: workspace.py +@time: 2026/4/20 +@description: 公共查询 API 工作空间类型定义 +""" + +from typing import TypedDict + +from .common import ApiRoleLiteral, ApiWorkspaceLiteral + + +# 工作空间即 Group 组织 +class ApiWorkspaceProfileType(TypedDict): + bio: str + url: str + institution: str + school: str + email: str + location: str + + +class ApiWorkspaceType(TypedDict): + username: str + name: str + type: ApiWorkspaceLiteral + comment: str + role: ApiRoleLiteral + profile: ApiWorkspaceProfileType diff --git a/swanlab/api/user.py b/swanlab/api/user.py new file mode 100644 index 000000000..67f6afd07 --- /dev/null +++ b/swanlab/api/user.py @@ -0,0 +1,67 @@ +""" +@author: caddiesnew +@file: user.py +@time: 2026/4/20 +@description: User 实体类 — 用户信息的查询 +""" + +from typing import Any, Dict, Optional + +from swanlab.api.base import ApiClientContext, BaseEntity +from swanlab.api.typings.user import ApiUserProfileType +from swanlab.api.utils import get_properties, strip_dict + + +class User(BaseEntity): + """ + 表示一个 SwanLab 用户, 限定为通过 sdk 登录的用户。 + """ + + def __init__( + self, + ctx: ApiClientContext, + data: Optional[Dict[str, Any]] = None, + ) -> None: + super().__init__(ctx) + self._data = data + + def _ensure_data(self) -> Dict[str, Any]: + if self._data is None: + resp = self._get("/user/profile") + self._data = strip_dict(resp.data, ApiUserProfileType) if resp.ok and resp.data else {} + return self._data + + @property + def name(self) -> str: + return self._ctx.name + + @property + def username(self) -> str: + return self._ctx.username + + @property + def bio(self) -> str: + return self._ensure_data().get("bio", "") + + @property + def institution(self) -> str: + return self._ensure_data().get("institution", "") + + @property + def school(self) -> str: + return self._ensure_data().get("school", "") or "" + + @property + def email(self) -> str: + return self._ensure_data().get("email", "") or "" + + @property + def location(self) -> str: + return self._ensure_data().get("location", "") + + @property + def url(self) -> str: + return self._ensure_data().get("url", "") + + def json(self) -> Dict[str, Any]: + return get_properties(self) diff --git a/swanlab/api/utils.py b/swanlab/api/utils.py new file mode 100644 index 000000000..27869cf57 --- /dev/null +++ b/swanlab/api/utils.py @@ -0,0 +1,228 @@ +""" +@author: caddiesnew +@file: utils.py +@time: 2026/4/20 +@description: swanlab/api 实体层工具函数 +""" + +import re +from typing import Any, Dict, List, Optional, Set, Tuple, Type, get_args, get_type_hints + +from swanlab.api.typings.common import ( + ApiColumnClassLiteral, + ApiColumnDataTypeLiteral, + ApiColumnScalarTypeLiteral, + ApiFilterOpLiteral, + ApiFilterStableKeyLiteral, + ApiMetricAllTypeLiteral, + ApiMetricLogLevelLiteral, + ApiSidebarLiteral, + ApiSortOrderLiteral, + ApiVisibilityLiteral, +) + + +def strip_dict(data: Any, typed_cls: Type) -> Dict[str, Any]: + """将原始 API 响应字典裁剪为只保留 TypedDict 中声明的字段。""" + if not data: + return {} + hints = get_type_hints(typed_cls) if hasattr(typed_cls, "__annotations__") else {} + return {k: data[k] for k in hints if k in data} + + +def get_properties(obj: object, _visited: Optional[Set[int]] = None) -> Dict[str, object]: + """递归获取实例中所有 property 的值,用于 json() 默认实现。""" + if _visited is None: + _visited = set() + obj_id = id(obj) + if obj_id in _visited: + return {} + _visited = _visited | {obj_id} + + result = {} + for name in dir(obj): + if name.startswith("_"): + continue + if isinstance(getattr(type(obj), name, None), property): + value = getattr(obj, name, None) + result[name] = value if type(value).__module__ == "builtins" else get_properties(value, _visited) + return result + + +# 路径解析 +def resolve_run_path(path: str) -> Tuple[str, str]: + """ "path like: user/proj_name/run_slug""" + proj_path, run_slug = "", "" + parts = path.split("/") + if len(parts) != 3: + return proj_path, run_slug + run_slug = parts[-1] + proj_path = path.rsplit("/", 1)[0] + return ( + proj_path, + run_slug, + ) + + +def validate_api_path(path: str, *, segments: int, label: str) -> None: + """校验公开 API 入口 path 参数的段数和空白字符。""" + if not isinstance(path, str): + raise ValueError(f"{label} path must be a string") + parts = path.split("/") + if path != path.strip() or len(parts) != segments or any(part != part.strip() or not part for part in parts): + raise ValueError(f"{label} path must contain {segments} non-empty segment(s), got {path!r}") + + +def validate_non_empty_string(value: str, *, label: str) -> None: + """校验公开 API 入口中的非空字符串参数。""" + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{label} must be a non-empty string") + + +# --------------------------------------------------------------------------- +# POST /runs/shows 参数校验常量(从 typings 中的 Literal 类型提取,避免重复定义) +# --------------------------------------------------------------------------- + +_VALID_SIDEBAR_TYPES = frozenset(get_args(ApiSidebarLiteral)) +_VALID_OPS = frozenset(get_args(ApiFilterOpLiteral)) +_VALID_ORDERS = frozenset(get_args(ApiSortOrderLiteral)) +_STABLE_KEYS = frozenset(get_args(ApiFilterStableKeyLiteral)) +_VALID_VISIBILITIES = frozenset(get_args(ApiVisibilityLiteral)) + +_PROJECT_NAME_RE = re.compile(r"^[0-9a-zA-Z\-_.+]+$") + +# 列相关校验常量 +_VALID_COLUMN_CLASSES = frozenset(get_args(ApiColumnClassLiteral)) +_VALID_COLUMN_DATA_TYPES = frozenset(get_args(ApiColumnDataTypeLiteral)) +_VALID_COLUMN_SCALAR_TYPES = frozenset(get_args(ApiColumnScalarTypeLiteral)) + +# 指标相关校验常量 +_VALID_METRIC_ALL_TYPES = frozenset(get_args(ApiMetricAllTypeLiteral)) +_VALID_METRIC_LOG_LEVELS = frozenset(get_args(ApiMetricLogLevelLiteral)) + + +def _check_required(item: Dict[str, Any], keys: Set[str]) -> None: + if not isinstance(item, dict): + raise ValueError(f"Expected dict item, got {type(item).__name__}") + missing = keys - item.keys() + if missing: + raise ValueError(f"Missing required fields: {sorted(missing)}, got {sorted(item.keys())}") + + +def _check_type_field(item: Dict[str, Any]) -> None: + t = item.get("type", "") + if t not in _VALID_SIDEBAR_TYPES: + raise ValueError(f"Invalid type: {t!r}, expected one of {sorted(_VALID_SIDEBAR_TYPES)}") + + +def _check_stable_key(item: Dict[str, Any]) -> None: + if item.get("type") == "STABLE" and item["key"] not in _STABLE_KEYS: + raise ValueError(f"Invalid STABLE key: {item['key']!r}, expected one of {sorted(_STABLE_KEYS)}") + + +def validate_filter(item: Dict[str, Any]) -> None: + """校验单个 filter item 的合法性。""" + _check_required(item, {"key", "type", "op", "value"}) + _check_type_field(item) + _check_stable_key(item) + if item["op"] not in _VALID_OPS: + raise ValueError(f"Invalid filter op: {item['op']!r}, expected one of {sorted(_VALID_OPS)}") + if not isinstance(item["value"], list): + raise ValueError(f"filter value must be a list, got {type(item['value']).__name__}") + + +def validate_metric_type(metric_type: str, key: Optional[str] = None) -> None: + """校验 metric_type 的合法性。非 LOG 类型必须提供非空 key。""" + if metric_type not in _VALID_METRIC_ALL_TYPES: + raise ValueError(f"Invalid metric_type: {metric_type!r}, expected one of {sorted(_VALID_METRIC_ALL_TYPES)}") + if metric_type != "LOG" and (not isinstance(key, str) or not key.strip()): + raise ValueError(f"key is required for metric_type {metric_type!r}, got key={key!r}") + + +def validate_metric_log_level(level: str) -> None: + """校验 metric log level 的合法性。""" + if level not in _VALID_METRIC_LOG_LEVELS: + raise ValueError(f"Invalid metric log level: {level!r}, expected one of {sorted(_VALID_METRIC_LOG_LEVELS)}") + + +def validate_group(item: Dict[str, Any]) -> None: + """校验单个 group item 的合法性。""" + _check_required(item, {"key", "type"}) + _check_type_field(item) + _check_stable_key(item) + + +def validate_sort(item: Dict[str, Any]) -> None: + """校验单个 sort item 的合法性。""" + _check_required(item, {"key", "type", "order"}) + _check_type_field(item) + _check_stable_key(item) + if item["order"] not in _VALID_ORDERS: + raise ValueError(f"Invalid sort order: {item['order']!r}, expected one of {sorted(_VALID_ORDERS)}") + + +def validate_update_active( + items: Optional[List[Dict[str, Any]]], + validator, + *, + label: str = "items", +) -> List[Dict[str, Any]]: + """校验每个 item 并补充 active: True,返回可直接发送的列表。""" + if items is None: + return [] + if not isinstance(items, list): + raise ValueError(f"{label} must be a list") + if not items: + return [] + for item in items: + if not isinstance(item, dict): + raise ValueError(f"{label} items must be dicts") + validator(item) + return [{**item, "active": True} for item in items] + + +def validate_column_params(column_type: Optional[str] = None, column_class: Optional[str] = None) -> None: + """ + 校验列查询参数的合法性。 + + :param column_type: 列的数据类型 + :param column_class: 列的分类 + :raises ValueError: 当参数不在允许的枚举值中时 + """ + if column_type is not None and column_type not in _VALID_COLUMN_DATA_TYPES: + raise ValueError(f"Invalid column_type: {column_type!r}, expected one of {sorted(_VALID_COLUMN_DATA_TYPES)}") + if column_class is not None and column_class not in _VALID_COLUMN_CLASSES: + raise ValueError(f"Invalid column_class: {column_class!r}, expected one of {sorted(_VALID_COLUMN_CLASSES)}") + + +def parse_column_data_type(column_type: str): + """解析列类型。""" + validate_column_params(column_type=column_type) + if column_type in _VALID_COLUMN_SCALAR_TYPES: + return "SCALAR" + # 新加入的类型默认指定为 media + return "MEDIA" + + +# --------------------------------------------------------------------------- +# 创建项目 / 实验的参数校验 +# --------------------------------------------------------------------------- + + +def validate_project_name(name: str) -> None: + if not 1 <= len(name) <= 100: + raise ValueError("Project name must be between 1 and 100 characters.") + if not _PROJECT_NAME_RE.match(name): + raise ValueError("Project name can only contain 0-9, a-z, A-Z, -, _, ., +") + + +def validate_visibility(visibility: str) -> None: + """校验 visibility 的合法性。""" + if visibility not in _VALID_VISIBILITIES: + raise ValueError(f"Invalid visibility: {visibility!r}, expected one of {sorted(_VALID_VISIBILITIES)}") + + +def validate_metric_keys(keys: List[str]) -> None: + """校验 metric keys 列表的合法性。""" + if not isinstance(keys, list) or not keys or any(not isinstance(key, str) or not key.strip() for key in keys): + raise ValueError("keys must be a non-empty list of non-empty strings") diff --git a/swanlab/api/workspace.py b/swanlab/api/workspace.py new file mode 100644 index 000000000..15238008a --- /dev/null +++ b/swanlab/api/workspace.py @@ -0,0 +1,151 @@ +""" +@author: caddiesnew +@file: workspace.py +@time: 2026/4/20 +@description: Workspace 实体类 — 工作空间的查询 +""" + +from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, cast + +from swanlab.api.base import ApiClientContext, BaseEntity +from swanlab.api.typings.common import ApiVisibilityLiteral, PaginatedQuery +from swanlab.api.typings.project import ApiProjectType +from swanlab.api.typings.workspace import ApiWorkspaceLiteral, ApiWorkspaceProfileType, ApiWorkspaceType +from swanlab.api.utils import get_properties, strip_dict, validate_project_name, validate_visibility +from swanlab.sdk.internal.pkg import safe + +if TYPE_CHECKING: + from swanlab.api.project import Project + + +class Workspace(BaseEntity): + """ + 表示一个 SwanLab 工作空间(个人或团队)。 + """ + + def __init__( + self, + ctx: ApiClientContext, + *, + username: str, + data: Optional[ApiWorkspaceType] = None, + ) -> None: + super().__init__(ctx) + self._username = username + self._data = data + + def _ensure_data(self) -> ApiWorkspaceType: + if self._data is None: + resp = self._get(f"/group/{self._username}") + self._data = resp.data if resp.ok and resp.data else cast(ApiWorkspaceType, {}) + return self._data + + @property + def name(self) -> str: + return self._ensure_data().get("name", "") + + @property + def username(self) -> str: + return self._ensure_data().get("username", "") + + @property + def workspace_type(self) -> ApiWorkspaceLiteral: + return self._ensure_data().get("type", "PERSON") + + @property + def profile(self) -> Dict[str, Any]: + return strip_dict(self._ensure_data().get("profile", {}), ApiWorkspaceProfileType) + + @property + def comment(self) -> str: + return self._ensure_data().get("comment", "") + + @property + def role(self) -> str: + return self._ensure_data().get("role", "") + + def projects( + self, + sort: Optional[str] = None, + search: Optional[str] = None, + detail: Optional[bool] = True, + page: int = 1, + size: int = 20, + all: bool = False, + ): + from swanlab.api.project import Projects + + query = PaginatedQuery(page=page, size=size, search=search, sort=sort, all=all) + return Projects( + self._ctx, + path=self.username, + query=query, + detail=detail, + ) + + def create_project( + self, + name: str, + *, + visibility: ApiVisibilityLiteral = "PRIVATE", + description: Optional[str] = None, + ) -> Optional["Project"]: + """ + 在此工作空间下创建项目。 + + :param name: 项目名称 (1-100 字符,仅支持 0-9a-zA-Z-_.+) + :param visibility: 可见性,PUBLIC 或 PRIVATE,默认 PRIVATE + :param description: 项目描述 + """ + from swanlab.api.project import Project + + with safe.block(message=None): + validate_project_name(name) + validate_visibility(visibility) + + body: Dict[str, Any] = {"name": name, "visibility": visibility, "username": self.username} + if description: + body["description"] = description + resp = self._post("/project", data=body) + if not resp.ok: + return None + data = resp.data + path = data.get("path", "") + return Project(self._ctx, path=path, data=cast(ApiProjectType, data)) + return None + + def json(self) -> Dict[str, Any]: + return get_properties(self) + + +class Workspaces(BaseEntity): + """ + 用户工作空间集合的分页迭代器。 + + 用法:: + + for ws in api.workspaces("username"): + print(ws.name) + """ + + def __init__(self, ctx: ApiClientContext, *, username: str) -> None: + super().__init__(ctx) + self._username = username + self._data: Optional[List[ApiWorkspaceType]] = None + + def _ensure_data(self) -> List[ApiWorkspaceType]: + if self._data is None: + resp = self._get(f"/user/{self._username}/groups") + self._data = resp.data if resp.ok and resp.data else [] + assert self._data is not None + return self._data + + def __iter__(self) -> Iterator[Workspace]: + for item in self._ensure_data(): + yield Workspace(self._ctx, username=item["username"], data=item) + + def __len__(self) -> int: + return len(self._ensure_data()) + + def json(self) -> Dict[str, Any]: + return {"username": self._username} diff --git a/swanlab/cli/__init__.py b/swanlab/cli/__init__.py index 97fe8aa8d..ba8bce93b 100644 --- a/swanlab/cli/__init__.py +++ b/swanlab/cli/__init__.py @@ -9,6 +9,7 @@ from swanlab.sdk.internal.pkg.helper import get_swanlab_version +from .api import api_cli from .auth import login, logout, verify from .converter import convert from .dashboard import watch @@ -54,5 +55,8 @@ def cli(): # noinspection PyTypeChecker cli.add_command(disabled) +# Api Cli +# noinspection PyTypeChecker +cli.add_command(api_cli) __all__ = ["cli"] diff --git a/swanlab/cli/api/__init__.py b/swanlab/cli/api/__init__.py new file mode 100644 index 000000000..08e92a779 --- /dev/null +++ b/swanlab/cli/api/__init__.py @@ -0,0 +1,28 @@ +""" +@author: caddiesnew +@file: __init__.py +@time: 2026/4/20 +@description: CLI API 子命令 — 通过命令行调用 SwanLab 公共查询 API +""" + +import click + +from .experiment import experiment_cli +from .project import project_cli +from .selfhosted import selfhosted_cli +from .workspace import workspace_cli + + +@click.group("api") +def api_cli(): + """Generic SwanLab API requests.""" + pass + + +api_cli.add_command(project_cli) +api_cli.add_command(experiment_cli) +api_cli.add_command(workspace_cli) +api_cli.add_command(selfhosted_cli) + + +__all__ = ["api_cli"] diff --git a/swanlab/cli/api/experiment.py b/swanlab/cli/api/experiment.py new file mode 100644 index 000000000..bcea5e83b --- /dev/null +++ b/swanlab/cli/api/experiment.py @@ -0,0 +1,31 @@ +import click +import orjson + +from swanlab.api import Api +from swanlab.cli.api.helper import format_output, save_output + + +@click.group("run") +def experiment_cli(): + """Experiment(Run) management commands.""" + pass + + +@experiment_cli.command("info") +@click.argument("path", required=True) +@click.option( + "--save", + "-s", + "name", + is_flag=False, + flag_value=".", + default=None, + help="Save output as JSON to current directory.", +) +def get_experiment(path: str, name): + """Get Experiment(Run) info by path (username/project/run_id).""" + api = Api() + resp = api.run(path).wrapper() + format_output(resp) + if resp.ok and name is not None: + save_output(orjson.dumps(resp.json(), option=orjson.OPT_INDENT_2), name=name) diff --git a/swanlab/cli/api/helper.py b/swanlab/cli/api/helper.py new file mode 100644 index 000000000..8b3b921fc --- /dev/null +++ b/swanlab/cli/api/helper.py @@ -0,0 +1,32 @@ +import enum +from datetime import datetime +from typing import Optional + +import click +import nanoid +import orjson + +from swanlab.api.typings.common import ApiResponseType + + +class _SaveFormatEnum(enum.Enum): + JSON = "json" + + +def format_output(resp: ApiResponseType, fmt: _SaveFormatEnum = _SaveFormatEnum.JSON) -> None: + if fmt == _SaveFormatEnum.JSON: + click.echo(orjson.dumps(resp.json(), option=orjson.OPT_INDENT_2).decode()) + + +def save_output(content: bytes, name: Optional[str] = None, fmt: _SaveFormatEnum = _SaveFormatEnum.JSON) -> None: + if name and name != ".": + ext = name.rsplit(".", 1)[-1].lower() if "." in name else None + if ext and ext not in {f.value for f in _SaveFormatEnum}: + click.echo(f"Warning: unsupported file extension .{ext}, skipped saving.") + return + filename = name + else: + filename = f"swanlab-{datetime.now().strftime('%Y%m%d_%H%M%S')}-{nanoid.generate(size=4)}.{fmt.value}" + with open(filename, "wb") as f: + f.write(content) + click.echo(f"Saved to {filename}") diff --git a/swanlab/cli/api/project.py b/swanlab/cli/api/project.py new file mode 100644 index 000000000..86771321c --- /dev/null +++ b/swanlab/cli/api/project.py @@ -0,0 +1,31 @@ +import click +import orjson + +from swanlab.api import Api +from swanlab.cli.api.helper import format_output, save_output + + +@click.group("project") +def project_cli(): + """Project management commands.""" + pass + + +@project_cli.command("info") +@click.argument("path", required=True) +@click.option( + "--save", + "-s", + "name", + is_flag=False, + flag_value=".", + default=None, + help="Save output as JSON to current directory.", +) +def get_project(path: str, name): + """Get project info by path (username/project).""" + api = Api() + resp = api.project(path).wrapper() + format_output(resp) + if resp.ok and name is not None: + save_output(orjson.dumps(resp.json(), option=orjson.OPT_INDENT_2), name=name) diff --git a/swanlab/cli/api/selfhosted.py b/swanlab/cli/api/selfhosted.py new file mode 100644 index 000000000..306252d4a --- /dev/null +++ b/swanlab/cli/api/selfhosted.py @@ -0,0 +1,11 @@ +import click + +from swanlab.api import Api +from swanlab.api.typings.common import ApiResponseType +from swanlab.cli.api.helper import format_output + + +@click.group("selfhosted") +def selfhosted_cli(): + """Self-hosted deployment management commands.""" + pass diff --git a/swanlab/cli/api/user.py b/swanlab/cli/api/user.py new file mode 100644 index 000000000..9a8286e14 --- /dev/null +++ b/swanlab/cli/api/user.py @@ -0,0 +1,10 @@ +import click + +from swanlab.api.typings.common import ApiResponseType +from swanlab.cli.api.helper import format_output + + +@click.group("user") +def user_cli(): + """User management commands.""" + pass diff --git a/swanlab/cli/api/workspace.py b/swanlab/cli/api/workspace.py new file mode 100644 index 000000000..ab1723560 --- /dev/null +++ b/swanlab/cli/api/workspace.py @@ -0,0 +1,31 @@ +import click +import orjson + +from swanlab.api import Api +from swanlab.cli.api.helper import format_output, save_output + + +@click.group("workspace") +def workspace_cli(): + """Workspace management commands.""" + pass + + +@workspace_cli.command("info") +@click.argument("username", required=True) +@click.option( + "--save", + "-s", + "name", + is_flag=False, + flag_value=".", + default=None, + help="Save output as JSON to current directory.", +) +def get_workspace(username: str, name): + """Get Workspace info.""" + api = Api() + resp = api.workspace(username).wrapper() + format_output(resp) + if resp.ok and name is not None: + save_output(orjson.dumps(resp.json(), option=orjson.OPT_INDENT_2), name=name) diff --git a/tests/unit/api/__init__.py b/tests/unit/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/api/conftest.py b/tests/unit/api/conftest.py new file mode 100644 index 000000000..ffb871ccc --- /dev/null +++ b/tests/unit/api/conftest.py @@ -0,0 +1,17 @@ +from unittest.mock import MagicMock + +import pytest + +from swanlab.api.base import ApiClientContext + + +@pytest.fixture +def mock_ctx(): + client = MagicMock() + return ApiClientContext( + client=client, + web_host="https://swanlab.cn", + api_host="https://api.swanlab.cn", + username="testuser", + name="Test User", + ) diff --git a/tests/unit/api/test_api.py b/tests/unit/api/test_api.py new file mode 100644 index 000000000..01d57c39e --- /dev/null +++ b/tests/unit/api/test_api.py @@ -0,0 +1,384 @@ +""" +@author: caddiesnew +@time: 2026/4/27 +@description: swanlab/api 实体类 4xx / 错误场景单测 +""" + +import importlib +from types import SimpleNamespace +from typing import Any, List, cast +from unittest.mock import MagicMock + +import pytest +import requests + +from swanlab.api import Api +from swanlab.api.base import ApiClientContext, BaseEntity +from swanlab.api.column import Column, Columns +from swanlab.api.experiment import Experiment, Experiments +from swanlab.api.metric import Metric, Metrics +from swanlab.api.project import Project +from swanlab.api.selfhosted import SelfHosted +from swanlab.api.typings.common import PaginatedQuery +from swanlab.api.typings.selfhosted import ApiSelfHostedInfoType +from swanlab.api.workspace import Workspace +from swanlab.exceptions import AuthenticationError + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +MockResponse = MagicMock + + +def _api_response(data=None): + """构造 Client.get/post 返回值。""" + r = MagicMock() + r.data = data + r.raw = MagicMock() + return r + + +@pytest.fixture +def ctx(): + client = MagicMock() + return ApiClientContext( + client=client, + web_host="https://swanlab.cn", + api_host="https://api.swanlab.cn", + username="testuser", + name="Test User", + ) + + +@pytest.fixture +def ctx_404(ctx): + """Client 所有 HTTP 方法均抛出 HTTPError(模拟 4xx)。""" + err = requests.exceptions.HTTPError("404 Not Found") + ctx.client.get.side_effect = err + ctx.client.post.side_effect = err + ctx.client.put.side_effect = err + ctx.client.delete.side_effect = err + return ctx + + +@pytest.fixture +def api(ctx): + instance = Api.__new__(Api) + BaseEntity.__init__(instance, ctx) + return instance + + +# --------------------------------------------------------------------------- +# Api 入口 — 参数校验 +# --------------------------------------------------------------------------- +class TestApiEntryValidation: + def test_missing_api_key_raises(self, monkeypatch): + api_module = importlib.import_module("swanlab.api") + monkeypatch.setattr( + api_module, + "global_settings", + SimpleNamespace(api_key=None, api_host="https://api.swanlab.cn", web_host="https://swanlab.cn"), + ) + + with pytest.raises(AuthenticationError, match="No API key"): + Api._resolve_credentials(None, None, None) + + @pytest.mark.parametrize("api_key", ["", " "]) + def test_blank_api_key_raises(self, api_key): + with pytest.raises(AuthenticationError, match="No API key"): + Api._resolve_credentials(api_key, "https://api.swanlab.cn", "https://swanlab.cn") + + def test_blank_host_raises(self): + with pytest.raises(ValueError, match="Host cannot be empty"): + Api._resolve_credentials("test-key", " ", "https://swanlab.cn") + + def test_projects_invalid_page_raises(self, api): + with pytest.raises(ValueError, match="page must be >= 1"): + api.projects("testuser", page=0) + + @pytest.mark.parametrize( + ("method_name", "path"), + [ + ("project", "testuser"), + ("run", "testuser/project"), + ("runs", "testuser/project/run1"), + ("columns", "testuser/project"), + ], + ) + def test_factory_methods_reject_invalid_path_shapes(self, api, method_name, path): + with pytest.raises(ValueError, match="path"): + getattr(api, method_name)(path) + + def test_column_rejects_empty_key(self, api): + with pytest.raises(ValueError, match="key"): + api.column("testuser/project/run1", key="") + + @pytest.mark.parametrize("column_type", ["STRING", "IMAGE"]) + def test_columns_accept_documented_column_types(self, api, column_type): + columns = api.columns("testuser/project/run1", column_type=column_type) + assert isinstance(columns, Columns) + + +# --------------------------------------------------------------------------- +# SelfHosted — 权限拒绝 +# --------------------------------------------------------------------------- +def _sh_info(**overrides) -> ApiSelfHostedInfoType: + base = {"enabled": True, "expired": False, "root": True, "plan": "free", "seats": 10} + base.update(overrides) + return cast(ApiSelfHostedInfoType, base) + + +class TestSelfHostedPermission: + def test_create_user_expired(self, ctx): + sh = SelfHosted(ctx, data=_sh_info(expired=True)) + with pytest.raises(ValueError, match="expired"): + sh.create_user("newuser", "pass123") + + def test_create_user_not_root(self, ctx): + sh = SelfHosted(ctx, data=_sh_info(root=False)) + with pytest.raises(ValueError, match="root"): + sh.create_user("newuser", "pass123") + + def test_get_users_not_root(self, ctx): + sh = SelfHosted(ctx, data=_sh_info(root=False)) + with pytest.raises(ValueError, match="root"): + list(sh.get_users()) + + def test_create_user_4xx(self, ctx): + sh = SelfHosted(ctx, data=_sh_info()) + ctx.client.post.side_effect = requests.exceptions.HTTPError("400 Bad Request") + resp = sh.create_user("newuser", "pass123") + assert not resp.ok + + @pytest.mark.parametrize(("username", "password"), [("", "pass123"), ("newuser", "")]) + def test_create_user_rejects_blank_credentials(self, ctx, username, password): + sh = SelfHosted(ctx, data=_sh_info()) + with pytest.raises(ValueError, match="username|password"): + sh.create_user(username, password) + + def test_get_users_4xx_yields_nothing(self, ctx): + sh = SelfHosted(ctx, data=_sh_info()) + ctx.client.get.side_effect = requests.exceptions.HTTPError("500 Internal") + result = list(sh.get_users()) + assert result == [] + + +# --------------------------------------------------------------------------- +# Workspace — create_project 错误 +# --------------------------------------------------------------------------- +class TestWorkspaceCreateProject: + def test_invalid_name_returns_none(self, ctx): + ws = Workspace(ctx, username="testuser") + result = ws.create_project("bad name!") + assert result is None + + def test_empty_name_returns_none(self, ctx): + ws = Workspace(ctx, username="testuser") + result = ws.create_project("") + assert result is None + + def test_invalid_visibility_returns_none(self, ctx): + ws = Workspace(ctx, username="testuser") + result = ws.create_project("valid-name", visibility=cast(Any, "SECRET")) + assert result is None + ctx.client.post.assert_not_called() + + def test_api_error_returns_none(self, ctx): + ws = Workspace(ctx, username="testuser") + ctx.client.post.side_effect = requests.exceptions.HTTPError("500") + result = ws.create_project("valid-name") + assert result is None + + def test_success(self, ctx): + ws = Workspace(ctx, username="testuser") + ctx.client.post.return_value = _api_response( + {"path": "testuser/valid-name", "name": "valid-name", "cuid": "cuid123"} + ) + proj = ws.create_project("valid-name") + assert proj is not None + assert proj.name == "valid-name" + + +# --------------------------------------------------------------------------- +# Entity lazy-load 4xx — 确保不 crash,返回空默认值 +# --------------------------------------------------------------------------- +class TestEntityLazyLoad4xx: + def test_workspace_returns_empty(self, ctx_404): + ws = Workspace(ctx_404, username="testuser") + assert ws.name == "" + assert ws.username == "" + + def test_project_returns_empty(self, ctx_404): + proj = Project(ctx_404, path="user/proj") + assert proj.name == "" + assert proj.path == "" + + def test_experiment_returns_empty(self, ctx_404): + exp = Experiment(ctx_404, path="user/proj/run123") + assert exp.name == "" + assert exp.state == "" + + def test_selfhosted_returns_defaults(self, ctx_404): + sh = SelfHosted(ctx_404) + assert sh.enabled is False + assert sh.expired is False + + def test_project_delete_4xx_returns_false(self, ctx_404): + proj = Project(ctx_404, path="user/proj") + assert proj.delete() is False + + def test_experiment_delete_4xx_returns_false(self, ctx_404): + exp = Experiment(ctx_404, path="user/proj/run123") + assert exp.delete() is False + + +# --------------------------------------------------------------------------- +# Experiment — run slug / cuid 解析 +# --------------------------------------------------------------------------- +class TestExperimentRunIdResolution: + def test_run_id_resolves_slug_to_cuid(self, ctx): + ctx.client.get.side_effect = [ + _api_response({"cuid": "run-cuid", "slug": "run-slug", "name": "test-run"}), + ] + + exp = Experiment(ctx, path="user/proj/run-slug") + + assert exp.run_id == "run-cuid" + assert [call.args[0] for call in ctx.client.get.call_args_list] == [ + "/project/user/proj/runs/run-slug", + ] + + def test_column_created_from_experiment_uses_run_cuid(self, ctx): + ctx.client.get.side_effect = [ + _api_response({"cuid": "run-cuid", "slug": "run-slug", "name": "test-run"}), + ] + exp = Experiment(ctx, path="user/proj/run-slug") + + column = exp.column("loss") + + assert column.run_id == "run-cuid" + + +# --------------------------------------------------------------------------- +# Column / Columns — 校验 + 4xx +# --------------------------------------------------------------------------- +class TestColumnValidation: + def test_columns_invalid_type_raises(self, ctx): + with pytest.raises(ValueError, match="Invalid column_type"): + Columns(ctx, path="user/proj/run1", query=PaginatedQuery(), column_type="INVALID") + + def test_columns_invalid_class_raises(self, ctx): + with pytest.raises(ValueError, match="Invalid column_class"): + Columns(ctx, path="user/proj/run1", query=PaginatedQuery(), column_class="INVALID") + + def test_iterated_columns_resolve_run_cuid_once_for_local_fields(self, ctx): + ctx.client.get.side_effect = [ + _api_response({"cuid": "run-cuid", "slug": "run-slug"}), + _api_response( + { + "list": [ + {"key": "loss", "name": "loss", "type": "FLOAT", "class": "CUSTOM"}, + {"key": "acc", "name": "acc", "type": "FLOAT", "class": "CUSTOM"}, + ], + "total": 2, + "pages": 1, + } + ), + ] + + columns = Columns(ctx, path="user/proj/run-slug", query=PaginatedQuery()) + + assert [column.name for column in columns] == ["loss", "acc"] + assert [call.args[0] for call in ctx.client.get.call_args_list] == [ + "/project/user/proj/runs/run-slug", + "/experiment/run-cuid/column", + ] + + def test_column_resolves_run_cuid_before_fetching_column_data(self, ctx): + ctx.client.get.side_effect = [ + _api_response({"cuid": "run-cuid", "slug": "run-slug"}), + _api_response( + { + "list": [{"key": "loss", "name": "loss", "type": "FLOAT", "class": "CUSTOM"}], + "total": 1, + "pages": 1, + } + ), + ] + + col = Column(ctx, path="user/proj/run-slug", key="loss") + + assert col.name == "loss" + assert col.run_id == "run-cuid" + assert [call.args[0] for call in ctx.client.get.call_args_list] == [ + "/project/user/proj/runs/run-slug", + "/experiment/run-cuid/column", + ] + + def test_column_project_id_fetches_project_lazily(self, ctx): + item = {"key": "loss", "name": "loss", "type": "FLOAT", "class": "CUSTOM"} + col = Column(ctx, path="user/proj/run1", key="loss", data=cast(Any, item)) + ctx.client.get.return_value = _api_response({"cuid": "project-cuid"}) + + assert col.project_id == "project-cuid" + assert [call.args[0] for call in ctx.client.get.call_args_list] == ["/project/user/proj"] + + +# --------------------------------------------------------------------------- +# Metric / Metrics — 校验 +# --------------------------------------------------------------------------- +class TestMetricValidation: + def test_metric_invalid_type_raises(self, ctx): + with pytest.raises(ValueError, match="Invalid metric_type"): + Metric(ctx, project_id="p1", run_id="r1", key="loss", metric_type="INVALID") + + def test_metric_invalid_log_level_raises(self, ctx): + with pytest.raises(ValueError, match="Invalid metric log level"): + Metric(ctx, project_id="p1", run_id="r1", key="LOG", metric_type="LOG", log_level="VERBOSE") + + def test_metric_scalar_no_key_raises(self, ctx): + with pytest.raises(ValueError, match="key is required"): + Metric(ctx, project_id="p1", run_id="r1", key="", metric_type="SCALAR") + + def test_metrics_empty_keys_raises(self, ctx): + with pytest.raises(ValueError, match="non-empty"): + Metrics(ctx, project_id="p1", run_id="r1", keys=[], metric_type="SCALAR") + + def test_metrics_invalid_type_raises(self, ctx): + with pytest.raises(ValueError, match="Invalid metric_type"): + Metrics(ctx, project_id="p1", run_id="r1", keys=["loss"], metric_type=cast(Any, "INVALID")) + + @pytest.mark.parametrize("keys", ["loss", [""], None]) + def test_metrics_invalid_keys_raises(self, ctx, keys): + with pytest.raises(ValueError, match="keys must be a non-empty list"): + Metrics(ctx, project_id="p1", run_id="r1", keys=cast(List[str], keys), metric_type="SCALAR") + + +# --------------------------------------------------------------------------- +# Experiments POST 过滤 — 校验 +# --------------------------------------------------------------------------- +class TestExperimentsFilterValidation: + def test_invalid_filter_raises_on_iter(self, ctx): + bad_filters = [{"key": "name"}] # missing type, op, value + exps = Experiments(ctx, path="user/proj", filters=bad_filters, mode="post") + with pytest.raises(ValueError, match="Missing required"): + list(exps) + + def test_non_list_filters_raise_on_iter(self, ctx): + bad_filters = {"key": "name", "type": "STABLE", "op": "EQ", "value": ["test"]} + exps = Experiments(ctx, path="user/proj", filters=cast(Any, bad_filters), mode="post") + with pytest.raises(ValueError, match="filters must be a list"): + list(exps) + + def test_invalid_group_raises_on_iter(self, ctx): + bad_groups = [{"key": "cluster", "type": "INVALID"}] + exps = Experiments(ctx, path="user/proj", groups=bad_groups, mode="post") + with pytest.raises(ValueError, match="Invalid type"): + list(exps) + + def test_invalid_sort_raises_on_iter(self, ctx): + bad_sorts = [{"key": "name", "type": "STABLE", "order": "RANDOM"}] + exps = Experiments(ctx, path="user/proj", sorts=bad_sorts, mode="post") + with pytest.raises(ValueError, match="Invalid sort order"): + list(exps) diff --git a/tests/unit/api/test_utils.py b/tests/unit/api/test_utils.py new file mode 100644 index 000000000..12378758f --- /dev/null +++ b/tests/unit/api/test_utils.py @@ -0,0 +1,193 @@ +""" +@author: caddiesnew +@time: 2026/4/27 +@description: swanlab/api 校验函数单测 +""" + +from typing import cast + +import pytest + +from swanlab.api.selfhosted import SelfHosted +from swanlab.api.typings.common import PaginatedQuery +from swanlab.api.typings.selfhosted import ApiSelfHostedInfoType +from swanlab.api.utils import ( + validate_column_params, + validate_filter, + validate_group, + validate_metric_log_level, + validate_metric_type, + validate_project_name, + validate_sort, +) + + +# --------------------------------------------------------------------------- +# validate_project_name +# --------------------------------------------------------------------------- +class TestValidateProjectName: + def test_valid(self): + validate_project_name("my-project_1.0+beta") + + @pytest.mark.parametrize("name", ["", "x" * 101]) + def test_length_invalid(self, name): + with pytest.raises(ValueError, match="1 and 100"): + validate_project_name(name) + + @pytest.mark.parametrize("name", ["hello world", "中文项目", "a/b", "a@b"]) + def test_invalid_chars(self, name): + with pytest.raises(ValueError, match="0-9"): + validate_project_name(name) + + +# --------------------------------------------------------------------------- +# validate_column_params +# --------------------------------------------------------------------------- +class TestValidateColumnParams: + def test_valid_type_and_class(self): + validate_column_params(column_type="FLOAT", column_class="CUSTOM") + + def test_invalid_type(self): + with pytest.raises(ValueError, match="Invalid column_type"): + validate_column_params(column_type="INVALID") + + def test_invalid_class(self): + with pytest.raises(ValueError, match="Invalid column_class"): + validate_column_params(column_class="INVALID") + + +# --------------------------------------------------------------------------- +# validate_metric_type / validate_metric_log_level +# --------------------------------------------------------------------------- +class TestValidateMetricType: + def test_valid_scalar(self): + validate_metric_type("SCALAR", key="loss") + + def test_log_no_key_ok(self): + validate_metric_type("LOG") + + def test_scalar_without_key_raises(self): + with pytest.raises(ValueError, match="key is required"): + validate_metric_type("SCALAR", key="") + + def test_invalid_type(self): + with pytest.raises(ValueError, match="Invalid metric_type"): + validate_metric_type("INVALID", key="x") + + +class TestValidateMetricLogLevel: + def test_valid(self): + validate_metric_log_level("INFO") + + def test_invalid(self): + with pytest.raises(ValueError, match="Invalid metric log level"): + validate_metric_log_level("VERBOSE") + + +# --------------------------------------------------------------------------- +# validate_filter / validate_group / validate_sort +# --------------------------------------------------------------------------- +class TestValidateFilter: + def test_valid(self): + validate_filter({"key": "name", "type": "STABLE", "op": "EQ", "value": ["test"]}) + + def test_missing_fields(self): + with pytest.raises(ValueError, match="Missing required"): + validate_filter({"key": "name"}) + + def test_invalid_type(self): + with pytest.raises(ValueError, match="Invalid type"): + validate_filter({"key": "name", "type": "INVALID", "op": "EQ", "value": ["x"]}) + + def test_invalid_op(self): + with pytest.raises(ValueError, match="Invalid filter op"): + validate_filter({"key": "name", "type": "STABLE", "op": "LIKE", "value": ["x"]}) + + def test_value_not_list(self): + with pytest.raises(ValueError, match="must be a list"): + validate_filter({"key": "name", "type": "STABLE", "op": "EQ", "value": "not_list"}) + + def test_invalid_stable_key(self): + with pytest.raises(ValueError, match="Invalid STABLE key"): + validate_filter({"key": "invalid_key", "type": "STABLE", "op": "EQ", "value": ["x"]}) + + +class TestValidateGroup: + def test_valid(self): + validate_group({"key": "cluster", "type": "STABLE"}) + + def test_missing_fields(self): + with pytest.raises(ValueError, match="Missing required"): + validate_group({"key": "name"}) + + +class TestValidateSort: + def test_valid(self): + validate_sort({"key": "name", "type": "STABLE", "order": "ASC"}) + + def test_invalid_order(self): + with pytest.raises(ValueError, match="Invalid sort order"): + validate_sort({"key": "name", "type": "STABLE", "order": "RANDOM"}) + + +# --------------------------------------------------------------------------- +# PaginatedQuery +# --------------------------------------------------------------------------- +class TestPaginatedQuery: + def test_valid_defaults(self): + q = PaginatedQuery() + assert q.page == 1 and q.size == 20 + + def test_page_less_than_1(self): + with pytest.raises(ValueError, match="page must be >= 1"): + PaginatedQuery(page=0) + + def test_invalid_size(self): + with pytest.raises(ValueError, match="size must be one of"): + PaginatedQuery(size=42) + + def test_to_params_filters_none(self): + q = PaginatedQuery() + params = q.to_params(search=None, sort=None) + assert "search" not in params + assert "sort" not in params + + def test_to_params_includes_extras(self): + q = PaginatedQuery() + params = q.to_params(detail=True, extra_key="val") + assert params["detail"] is True + assert params["extra_key"] == "val" + + +# --------------------------------------------------------------------------- +# SelfHosted validation +# --------------------------------------------------------------------------- +class TestSelfHostedValidation: + def _make_info(self, **overrides) -> ApiSelfHostedInfoType: + base: dict = { + "enabled": True, + "expired": False, + "root": True, + "plan": "free", + "seats": 10, + } + base.update(overrides) + return cast(ApiSelfHostedInfoType, base) + + def test_validate_expire_ok(self): + SelfHosted.validate_expire(self._make_info(expired=False)) + + def test_validate_expire_raises(self): + with pytest.raises(ValueError, match="expired"): + SelfHosted.validate_expire(self._make_info(expired=True)) + + def test_validate_root_ok(self): + SelfHosted.validate_root(self._make_info(expired=False, root=True)) + + def test_validate_root_not_root(self): + with pytest.raises(ValueError, match="root"): + SelfHosted.validate_root(self._make_info(expired=False, root=False)) + + def test_validate_root_expired(self): + with pytest.raises(ValueError, match="expired"): + SelfHosted.validate_root(self._make_info(expired=True, root=True)) diff --git a/uv.lock b/uv.lock index 745120723..ed4c9363d 100644 --- a/uv.lock +++ b/uv.lock @@ -2790,6 +2790,201 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, ] +[[package]] +name = "orjson" +version = "3.11.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and sys_platform == 'linux'", + "python_full_version < '3.10' and sys_platform != 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/19/b22cf9dad4db20c8737041046054cbd4f38bb5a2d0e4bb60487832ce3d76/orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1", size = 245719, upload-time = "2025-12-06T15:53:43.877Z" }, + { url = "https://files.pythonhosted.org/packages/03/2e/b136dd6bf30ef5143fbe76a4c142828b55ccc618be490201e9073ad954a1/orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870", size = 132467, upload-time = "2025-12-06T15:53:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fc/ae99bfc1e1887d20a0268f0e2686eb5b13d0ea7bbe01de2b566febcd2130/orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09", size = 130702, upload-time = "2025-12-06T15:53:46.659Z" }, + { url = "https://files.pythonhosted.org/packages/6e/43/ef7912144097765997170aca59249725c3ab8ef6079f93f9d708dd058df5/orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd", size = 135907, upload-time = "2025-12-06T15:53:48.487Z" }, + { url = "https://files.pythonhosted.org/packages/3f/da/24d50e2d7f4092ddd4d784e37a3fa41f22ce8ed97abc9edd222901a96e74/orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac", size = 139935, upload-time = "2025-12-06T15:53:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/02/4a/b4cb6fcbfff5b95a3a019a8648255a0fac9b221fbf6b6e72be8df2361feb/orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e", size = 137541, upload-time = "2025-12-06T15:53:51.226Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/a11bd129f18c2377c27b2846a9d9be04acec981f770d711ba0aaea563984/orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f", size = 139031, upload-time = "2025-12-06T15:53:52.309Z" }, + { url = "https://files.pythonhosted.org/packages/64/29/d7b77d7911574733a036bb3e8ad7053ceb2b7d6ea42208b9dbc55b23b9ed/orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18", size = 141622, upload-time = "2025-12-06T15:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/93/41/332db96c1de76b2feda4f453e91c27202cd092835936ce2b70828212f726/orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a", size = 413800, upload-time = "2025-12-06T15:53:54.866Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/5a0d148dd1f89ad2f9651df67835b209ab7fcb1118658cf353425d7563e9/orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7", size = 151198, upload-time = "2025-12-06T15:53:56.383Z" }, + { url = "https://files.pythonhosted.org/packages/0d/96/8db67430d317a01ae5cf7971914f6775affdcfe99f5bff9ef3da32492ecc/orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401", size = 141984, upload-time = "2025-12-06T15:53:57.746Z" }, + { url = "https://files.pythonhosted.org/packages/71/49/40d21e1aa1ac569e521069228bb29c9b5a350344ccf922a0227d93c2ed44/orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8", size = 135272, upload-time = "2025-12-06T15:53:59.769Z" }, + { url = "https://files.pythonhosted.org/packages/c4/7e/d0e31e78be0c100e08be64f48d2850b23bcb4d4c70d114f4e43b39f6895a/orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167", size = 133360, upload-time = "2025-12-06T15:54:01.25Z" }, + { url = "https://files.pythonhosted.org/packages/fd/68/6b3659daec3a81aed5ab47700adb1a577c76a5452d35b91c88efee89987f/orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8", size = 245318, upload-time = "2025-12-06T15:54:02.355Z" }, + { url = "https://files.pythonhosted.org/packages/e9/00/92db122261425f61803ccf0830699ea5567439d966cbc35856fe711bfe6b/orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc", size = 129491, upload-time = "2025-12-06T15:54:03.877Z" }, + { url = "https://files.pythonhosted.org/packages/94/4f/ffdcb18356518809d944e1e1f77589845c278a1ebbb5a8297dfefcc4b4cb/orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968", size = 132167, upload-time = "2025-12-06T15:54:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/97/c6/0a8caff96f4503f4f7dd44e40e90f4d14acf80d3b7a97cb88747bb712d3e/orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7", size = 130516, upload-time = "2025-12-06T15:54:06.274Z" }, + { url = "https://files.pythonhosted.org/packages/4d/63/43d4dc9bd9954bff7052f700fdb501067f6fb134a003ddcea2a0bb3854ed/orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd", size = 135695, upload-time = "2025-12-06T15:54:07.702Z" }, + { url = "https://files.pythonhosted.org/packages/87/6f/27e2e76d110919cb7fcb72b26166ee676480a701bcf8fc53ac5d0edce32f/orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9", size = 139664, upload-time = "2025-12-06T15:54:08.828Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/5966153a5f1be49b5fbb8ca619a529fde7bc71aa0a376f2bb83fed248bcd/orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef", size = 137289, upload-time = "2025-12-06T15:54:09.898Z" }, + { url = "https://files.pythonhosted.org/packages/a7/34/8acb12ff0299385c8bbcbb19fbe40030f23f15a6de57a9c587ebf71483fb/orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9", size = 138784, upload-time = "2025-12-06T15:54:11.022Z" }, + { url = "https://files.pythonhosted.org/packages/ee/27/910421ea6e34a527f73d8f4ee7bdffa48357ff79c7b8d6eb6f7b82dd1176/orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125", size = 141322, upload-time = "2025-12-06T15:54:12.427Z" }, + { url = "https://files.pythonhosted.org/packages/87/a3/4b703edd1a05555d4bb1753d6ce44e1a05b7a6d7c164d5b332c795c63d70/orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814", size = 413612, upload-time = "2025-12-06T15:54:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1b/36/034177f11d7eeea16d3d2c42a1883b0373978e08bc9dad387f5074c786d8/orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5", size = 150993, upload-time = "2025-12-06T15:54:15.189Z" }, + { url = "https://files.pythonhosted.org/packages/44/2f/ea8b24ee046a50a7d141c0227c4496b1180b215e728e3b640684f0ea448d/orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880", size = 141774, upload-time = "2025-12-06T15:54:16.451Z" }, + { url = "https://files.pythonhosted.org/packages/8a/12/cc440554bf8200eb23348a5744a575a342497b65261cd65ef3b28332510a/orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d", size = 135109, upload-time = "2025-12-06T15:54:17.73Z" }, + { url = "https://files.pythonhosted.org/packages/a3/83/e0c5aa06ba73a6760134b169f11fb970caa1525fa4461f94d76e692299d9/orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1", size = 133193, upload-time = "2025-12-06T15:54:19.426Z" }, + { url = "https://files.pythonhosted.org/packages/cb/35/5b77eaebc60d735e832c5b1a20b155667645d123f09d471db0a78280fb49/orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c", size = 126830, upload-time = "2025-12-06T15:54:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" }, + { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" }, + { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" }, + { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" }, + { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" }, + { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" }, + { url = "https://files.pythonhosted.org/packages/10/43/61a77040ce59f1569edf38f0b9faadc90c8cf7e9bec2e0df51d0132c6bb7/orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629", size = 245271, upload-time = "2025-12-06T15:54:40.878Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/0f79be617388227866d50edd2fd320cb8fb94dc1501184bb1620981a0aba/orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3", size = 129422, upload-time = "2025-12-06T15:54:42.403Z" }, + { url = "https://files.pythonhosted.org/packages/77/42/f1bf1549b432d4a78bfa95735b79b5dac75b65b5bb815bba86ad406ead0a/orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39", size = 132060, upload-time = "2025-12-06T15:54:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/25/49/825aa6b929f1a6ed244c78acd7b22c1481fd7e5fda047dc8bf4c1a807eb6/orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f", size = 130391, upload-time = "2025-12-06T15:54:45.059Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/de55391858b49e16e1aa8f0bbbb7e5997b7345d8e984a2dec3746d13065b/orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51", size = 135964, upload-time = "2025-12-06T15:54:46.576Z" }, + { url = "https://files.pythonhosted.org/packages/1c/40/820bc63121d2d28818556a2d0a09384a9f0262407cf9fa305e091a8048df/orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8", size = 139817, upload-time = "2025-12-06T15:54:48.084Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/3a445ca9a84a0d59d26365fd8898ff52bdfcdcb825bcc6519830371d2364/orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706", size = 137336, upload-time = "2025-12-06T15:54:49.426Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b3/dc0d3771f2e5d1f13368f56b339c6782f955c6a20b50465a91acb79fe961/orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f", size = 138993, upload-time = "2025-12-06T15:54:50.939Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a2/65267e959de6abe23444659b6e19c888f242bf7725ff927e2292776f6b89/orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863", size = 141070, upload-time = "2025-12-06T15:54:52.414Z" }, + { url = "https://files.pythonhosted.org/packages/63/c9/da44a321b288727a322c6ab17e1754195708786a04f4f9d2220a5076a649/orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228", size = 413505, upload-time = "2025-12-06T15:54:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/7f/17/68dc14fa7000eefb3d4d6d7326a190c99bb65e319f02747ef3ebf2452f12/orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2", size = 151342, upload-time = "2025-12-06T15:54:55.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c5/ccee774b67225bed630a57478529fc026eda33d94fe4c0eac8fe58d4aa52/orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05", size = 141823, upload-time = "2025-12-06T15:54:56.331Z" }, + { url = "https://files.pythonhosted.org/packages/67/80/5d00e4155d0cd7390ae2087130637671da713959bb558db9bac5e6f6b042/orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef", size = 135236, upload-time = "2025-12-06T15:54:57.507Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/792cc06a84808dbdc20ac6eab6811c53091b42f8e51ecebf14b540e9cfe4/orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583", size = 133167, upload-time = "2025-12-06T15:54:58.71Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/d158bd8b50e3b1cfdcf406a7e463f6ffe3f0d167b99634717acdaf5e299f/orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287", size = 126712, upload-time = "2025-12-06T15:54:59.892Z" }, + { url = "https://files.pythonhosted.org/packages/c2/60/77d7b839e317ead7bb225d55bb50f7ea75f47afc489c81199befc5435b50/orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0", size = 245252, upload-time = "2025-12-06T15:55:01.127Z" }, + { url = "https://files.pythonhosted.org/packages/f1/aa/d4639163b400f8044cef0fb9aa51b0337be0da3a27187a20d1166e742370/orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81", size = 129419, upload-time = "2025-12-06T15:55:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/94/9eabf94f2e11c671111139edf5ec410d2f21e6feee717804f7e8872d883f/orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f", size = 132050, upload-time = "2025-12-06T15:55:03.918Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c8/ca10f5c5322f341ea9a9f1097e140be17a88f88d1cfdd29df522970d9744/orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e", size = 130370, upload-time = "2025-12-06T15:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/25/d4/e96824476d361ee2edd5c6290ceb8d7edf88d81148a6ce172fc00278ca7f/orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7", size = 136012, upload-time = "2025-12-06T15:55:06.402Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/9bc3423308c425c588903f2d103cfcfe2539e07a25d6522900645a6f257f/orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb", size = 139809, upload-time = "2025-12-06T15:55:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/b404e94e0b02a232b957c54643ce68d0268dacb67ac33ffdee24008c8b27/orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4", size = 137332, upload-time = "2025-12-06T15:55:08.961Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/cc2d69d5ce0ad9b84811cdf4a0cd5362ac27205a921da524ff42f26d65e0/orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad", size = 138983, upload-time = "2025-12-06T15:55:10.595Z" }, + { url = "https://files.pythonhosted.org/packages/0e/87/de3223944a3e297d4707d2fe3b1ffb71437550e165eaf0ca8bbe43ccbcb1/orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829", size = 141069, upload-time = "2025-12-06T15:55:11.832Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/81d5087ae74be33bcae3ff2d80f5ccaa4a8fedc6d39bf65a427a95b8977f/orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac", size = 413491, upload-time = "2025-12-06T15:55:13.314Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6f/f6058c21e2fc1efaf918986dbc2da5cd38044f1a2d4b7b91ad17c4acf786/orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d", size = 151375, upload-time = "2025-12-06T15:55:14.715Z" }, + { url = "https://files.pythonhosted.org/packages/54/92/c6921f17d45e110892899a7a563a925b2273d929959ce2ad89e2525b885b/orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439", size = 141850, upload-time = "2025-12-06T15:55:15.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/86/cdecb0140a05e1a477b81f24739da93b25070ee01ce7f7242f44a6437594/orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499", size = 135278, upload-time = "2025-12-06T15:55:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/e4/97/b638d69b1e947d24f6109216997e38922d54dcdcdb1b11c18d7efd2d3c59/orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310", size = 133170, upload-time = "2025-12-06T15:55:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/7b682849dd4c9fb701a981669b964ea700516ecbd8e88f62aae07c6852bd/orjson-3.11.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1b280e2d2d284a6713b0cfec7b08918ebe57df23e3f76b27586197afca3cb1e9", size = 245298, upload-time = "2025-12-06T15:55:20.984Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3f/194355a9335707a15fdc79ddc670148987b43d04712dd26898a694539ce6/orjson-3.11.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d8a112b274fae8c5f0f01954cb0480137072c271f3f4958127b010dfefaec", size = 132150, upload-time = "2025-12-06T15:55:22.364Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/d74b3a986d37e6c2e04b8821c62927620c9a1924bb49ea51519a87751b86/orjson-3.11.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0a2ae6f09ac7bd47d2d5a5305c1d9ed08ac057cda55bb0a49fa506f0d2da00", size = 130490, upload-time = "2025-12-06T15:55:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/b2/16/ebd04c38c1db01e493a68eee442efdffc505a43112eccd481e0146c6acc2/orjson-3.11.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0d87bd1896faac0d10b4f849016db81a63e4ec5df38757ffae84d45ab38aa71", size = 135726, upload-time = "2025-12-06T15:55:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/06/64/2ce4b2c09a099403081c37639c224bdcdfe401138bd66fed5c96d4f8dbd3/orjson-3.11.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:801a821e8e6099b8c459ac7540b3c32dba6013437c57fdcaec205b169754f38c", size = 139640, upload-time = "2025-12-06T15:55:26.535Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e2/425796df8ee1d7cea3a7edf868920121dd09162859dbb76fffc9a5c37fd3/orjson-3.11.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a0f6ac618c98c74b7fbc8c0172ba86f9e01dbf9f62aa0b1776c2231a7bffe5", size = 137289, upload-time = "2025-12-06T15:55:27.78Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/88e482eb8e899a037dcc9eff85ef117a568e6ca1ffa1a2b2be3fcb51b7bb/orjson-3.11.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea7339bdd22e6f1060c55ac31b6a755d86a5b2ad3657f2669ec243f8e3b2bdb", size = 138761, upload-time = "2025-12-06T15:55:29.388Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fd/131dd6d32eeb74c513bfa487f434a2150811d0fbd9cb06689284f2f21b34/orjson-3.11.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4dad582bc93cef8f26513e12771e76385a7e6187fd713157e971c784112aad56", size = 141357, upload-time = "2025-12-06T15:55:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/7a/90/e4a0abbcca7b53e9098ac854f27f5ed9949c796f3c760bc04af997da0eb2/orjson-3.11.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0522003e9f7fba91982e83a97fec0708f5a714c96c4209db7104e6b9d132f111", size = 413638, upload-time = "2025-12-06T15:55:32.344Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c2/df91e385514924120001ade9cd52d6295251023d3bfa2c0a01f38cfc485a/orjson-3.11.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7403851e430a478440ecc1258bcbacbfbd8175f9ac1e39031a7121dd0de05ff8", size = 150972, upload-time = "2025-12-06T15:55:33.725Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ff/c76cc5a30a4451191ff1b868a331ad1354433335277fc40931f5fc3cab9d/orjson-3.11.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5f691263425d3177977c8d1dd896cde7b98d93cbf390b2544a090675e83a6a0a", size = 141729, upload-time = "2025-12-06T15:55:35.317Z" }, + { url = "https://files.pythonhosted.org/packages/27/c3/7830bf74389ea1eaab2b017d8b15d1cab2bb0737d9412dfa7fb8644f7d78/orjson-3.11.5-cp39-cp39-win32.whl", hash = "sha256:61026196a1c4b968e1b1e540563e277843082e9e97d78afa03eb89315af531f1", size = 135100, upload-time = "2025-12-06T15:55:36.57Z" }, + { url = "https://files.pythonhosted.org/packages/69/e6/babf31154e047e465bc194eb72d1326d7c52ad4d7f50bf92b02b3cacda5c/orjson-3.11.5-cp39-cp39-win_amd64.whl", hash = "sha256:09b94b947ac08586af635ef922d69dc9bc63321527a3a04647f4986a73f4bd30", size = 133189, upload-time = "2025-12-06T15:55:38.143Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version == '3.10.*' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.10.*' and sys_platform != 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/90/5d81f61fe3e4270da80c71442864c091cee3003cc8984c75f413fe742a07/orjson-3.11.8-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e6693ff90018600c72fd18d3d22fa438be26076cd3c823da5f63f7bab28c11cb", size = 229663, upload-time = "2026-03-31T16:14:30.708Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/85e06b0eb11de6fb424120fd5788a07035bd4c5e6bb7841ae9972a0526d1/orjson-3.11.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93de06bc920854552493c81f1f729fab7213b7db4b8195355db5fda02c7d1363", size = 132321, upload-time = "2026-03-31T16:14:32.317Z" }, + { url = "https://files.pythonhosted.org/packages/86/71/089338ee51b3132f050db0864a7df9bdd5e94c2a03820ab8a91e8f655618/orjson-3.11.8-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe0b8c83e0f36247fc9431ce5425a5d95f9b3a689133d494831bdbd6f0bceb13", size = 130658, upload-time = "2026-03-31T16:14:33.935Z" }, + { url = "https://files.pythonhosted.org/packages/10/0d/f39d8802345d0ad65f7fd4374b29b9b59f98656dc30f21ca5c773265b2f0/orjson-3.11.8-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97d823831105c01f6c8029faf297633dbeb30271892bd430e9c24ceae3734744", size = 135708, upload-time = "2026-03-31T16:14:35.224Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b5/40aae576b3473511696dcffea84fde638b2b64774eb4dcb8b2c262729f8a/orjson-3.11.8-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60c0423f15abb6cf78f56dff00168a1b582f7a1c23f114036e2bfc697814d5f", size = 147047, upload-time = "2026-03-31T16:14:36.489Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f0/778a84458d1fdaa634b2e572e51ce0b354232f580b2327e1f00a8d88c38c/orjson-3.11.8-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01928d0476b216ad2201823b0a74000440360cef4fed1912d297b8d84718f277", size = 133072, upload-time = "2026-03-31T16:14:37.715Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/1bbf2fc3ffcc4b829ade554b574af68cec898c9b5ad6420a923c75a073d3/orjson-3.11.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a4a639049c44d36a6d1ae0f4a94b271605c745aee5647fa8ffaabcdc01b69a6", size = 133867, upload-time = "2026-03-31T16:14:39.356Z" }, + { url = "https://files.pythonhosted.org/packages/08/94/6413da22edc99a69a8d0c2e83bf42973b8aa94d83ef52a6d39ac85da00bc/orjson-3.11.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3222adff1e1ff0dce93c16146b93063a7793de6c43d52309ae321234cdaf0f4d", size = 142268, upload-time = "2026-03-31T16:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/aa5dbaa6136d7ba55f5461ac2e885efc6e6349424a428927fd46d68f4396/orjson-3.11.8-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3223665349bbfb68da234acd9846955b1a0808cbe5520ff634bf253a4407009b", size = 424008, upload-time = "2026-03-31T16:14:42.637Z" }, + { url = "https://files.pythonhosted.org/packages/fa/aa/2c1962d108c7fe5e27aa03a354b378caf56d8eafdef15fd83dec081ce45a/orjson-3.11.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:61c9d357a59465736022d5d9ba06687afb7611dfb581a9d2129b77a6fcf78e59", size = 147942, upload-time = "2026-03-31T16:14:44.256Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/65f404f4c47eb1b0b4476f03ec838cac0c4aa933920ff81e5dda4dee14e7/orjson-3.11.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58fb9b17b4472c7b1dcf1a54583629e62e23779b2331052f09a9249edf81675b", size = 136640, upload-time = "2026-03-31T16:14:45.884Z" }, + { url = "https://files.pythonhosted.org/packages/90/5f/7b784aea98bdb125a2f2da7c27d6c2d2f6d943d96ef0278bae596d563f85/orjson-3.11.8-cp310-cp310-win32.whl", hash = "sha256:b43dc2a391981d36c42fa57747a49dae793ef1d2e43898b197925b5534abd10a", size = 132066, upload-time = "2026-03-31T16:14:47.397Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/2e284af8d6c9478df5ef938917743f61d68f4c70d17f1b6e82f7e3b8dba1/orjson-3.11.8-cp310-cp310-win_amd64.whl", hash = "sha256:c98121237fea2f679480765abd566f7713185897f35c9e6c2add7e3a9900eb61", size = 127609, upload-time = "2026-03-31T16:14:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/67/41/5aa7fa3b0f4dc6b47dcafc3cea909299c37e40e9972feabc8b6a74e2730d/orjson-3.11.8-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:003646067cc48b7fcab2ae0c562491c9b5d2cbd43f1e5f16d98fd118c5522d34", size = 229229, upload-time = "2026-03-31T16:14:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d7/57e7f2458e0a2c41694f39fc830030a13053a84f837a5b73423dca1f0938/orjson-3.11.8-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ed193ce51d77a3830cad399a529cd4ef029968761f43ddc549e1bc62b40d88f8", size = 128871, upload-time = "2026-03-31T16:14:51.888Z" }, + { url = "https://files.pythonhosted.org/packages/53/4a/e0fdb9430983e6c46e0299559275025075568aad5d21dd606faee3703924/orjson-3.11.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30491bc4f862aa15744b9738517454f1e46e56c972a2be87d70d727d5b2a8f8", size = 132104, upload-time = "2026-03-31T16:14:53.142Z" }, + { url = "https://files.pythonhosted.org/packages/08/4a/2025a60ff3f5c8522060cda46612d9b1efa653de66ed2908591d8d82f22d/orjson-3.11.8-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eda5b8b6be91d3f26efb7dc6e5e68ee805bc5617f65a328587b35255f138bf4", size = 130483, upload-time = "2026-03-31T16:14:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3c/b9cde05bdc7b2385c66014e0620627da638d3d04e4954416ab48c31196c5/orjson-3.11.8-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee8db7bfb6fe03581bbab54d7c4124a6dd6a7f4273a38f7267197890f094675f", size = 135481, upload-time = "2026-03-31T16:14:55.901Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f2/a8238e7734de7cb589fed319857a8025d509c89dc52fdcc88f39c6d03d5a/orjson-3.11.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d8b5231de76c528a46b57010bbd83fb51e056aa0220a372fd5065e978406f1c", size = 146819, upload-time = "2026-03-31T16:14:57.548Z" }, + { url = "https://files.pythonhosted.org/packages/db/10/dbf1e2a3cafea673b1b4350e371877b759060d6018a998643b7040e5de48/orjson-3.11.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58a4a208a6fbfdb7a7327b8f201c6014f189f721fd55d047cafc4157af1bc62a", size = 132846, upload-time = "2026-03-31T16:14:58.91Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fc/55e667ec9c85694038fcff00573d221b085d50777368ee3d77f38668bf3c/orjson-3.11.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f8952d6d2505c003e8f0224ff7858d341fa4e33fef82b91c4ff0ef070f2393c", size = 133580, upload-time = "2026-03-31T16:15:00.519Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a6/c08c589a9aad0cb46c4831d17de212a2b6901f9d976814321ff8e69e8785/orjson-3.11.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0022bb50f90da04b009ce32c512dc1885910daa7cb10b7b0cba4505b16db82a8", size = 142042, upload-time = "2026-03-31T16:15:01.906Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/2f78ea241d52b717d2efc38878615fe80425bf2beb6e68c984dde257a766/orjson-3.11.8-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ff51f9d657d1afb6f410cb435792ce4e1fe427aab23d2fcd727a2876e21d4cb6", size = 423845, upload-time = "2026-03-31T16:15:03.703Z" }, + { url = "https://files.pythonhosted.org/packages/70/07/c17dcf05dd8045457538428a983bf1f1127928df5bf328cb24d2b7cddacb/orjson-3.11.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6dbe9a97bdb4d8d9d5367b52a7c32549bba70b2739c58ef74a6964a6d05ae054", size = 147729, upload-time = "2026-03-31T16:15:05.203Z" }, + { url = "https://files.pythonhosted.org/packages/90/6c/0fb6e8a24e682e0958d71711ae6f39110e4b9cd8cab1357e2a89cb8e1951/orjson-3.11.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5c370674ebabe16c6ccac33ff80c62bf8a6e59439f5e9d40c1f5ab8fd2215b7", size = 136425, upload-time = "2026-03-31T16:15:07.052Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/4d3cc3a3d616035beb51b24a09bb872942dc452cf2df0c1d11ab35046d9f/orjson-3.11.8-cp311-cp311-win32.whl", hash = "sha256:0e32f7154299f42ae66f13488963269e5eccb8d588a65bc839ed986919fc9fac", size = 131870, upload-time = "2026-03-31T16:15:08.678Z" }, + { url = "https://files.pythonhosted.org/packages/13/26/9fe70f81d16b702f8c3a775e8731b50ad91d22dacd14c7599b60a0941cd1/orjson-3.11.8-cp311-cp311-win_amd64.whl", hash = "sha256:25e0c672a2e32348d2eb33057b41e754091f2835f87222e4675b796b92264f06", size = 127440, upload-time = "2026-03-31T16:15:09.994Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c6/b038339f4145efd2859c1ca53097a52c0bb9cbdd24f947ebe146da1ad067/orjson-3.11.8-cp311-cp311-win_arm64.whl", hash = "sha256:9185589c1f2a944c17e26c9925dcdbc2df061cc4a145395c57f0c51f9b5dbfcd", size = 127399, upload-time = "2026-03-31T16:15:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/01/f6/8d58b32ab32d9215973a1688aebd098252ee8af1766c0e4e36e7831f0295/orjson-3.11.8-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1cd0b77e77c95758f8e1100139844e99f3ccc87e71e6fc8e1c027e55807c549f", size = 229233, upload-time = "2026-03-31T16:15:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/2ffe35e71f6b92622e8ea4607bf33ecf7dfb51b3619dcfabfd36cbe2d0a5/orjson-3.11.8-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6a3d159d5ffa0e3961f353c4b036540996bf8b9697ccc38261c0eac1fd3347a6", size = 128772, upload-time = "2026-03-31T16:15:14.237Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/1f8682ae50d5c6897a563cb96bc106da8c9cb5b7b6e81a52e4cc086679b9/orjson-3.11.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76070a76e9c5ae661e2d9848f216980d8d533e0f8143e6ed462807b242e3c5e8", size = 131946, upload-time = "2026-03-31T16:15:15.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/5500f76f0eece84226e0689cb48dcde081104c2fa6e2483d17ca13685ffb/orjson-3.11.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54153d21520a71a4c82a0dbb4523e468941d549d221dc173de0f019678cf3813", size = 130368, upload-time = "2026-03-31T16:15:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/da/4e/58b927e08fbe9840e6c920d9e299b051ea667463b1f39a56e668669f8508/orjson-3.11.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:469ac2125611b7c5741a0b3798cd9e5786cbad6345f9f400c77212be89563bec", size = 135540, upload-time = "2026-03-31T16:15:18.404Z" }, + { url = "https://files.pythonhosted.org/packages/56/7c/ba7cb871cba1bcd5cd02ee34f98d894c6cea96353ad87466e5aef2429c60/orjson-3.11.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14778ffd0f6896aa613951a7fbf4690229aa7a543cb2bfbe9f358e08aafa9546", size = 146877, upload-time = "2026-03-31T16:15:19.833Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/eb9c25fc1386696c6a342cd361c306452c75e0b55e86ad602dd4827a7fd7/orjson-3.11.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea56a955056a6d6c550cf18b3348656a9d9a4f02e2d0c02cabf3c73f1055d506", size = 132837, upload-time = "2026-03-31T16:15:21.282Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/5ddeb7fc1fbd9004aeccab08426f34c81a5b4c25c7061281862b015fce2b/orjson-3.11.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53a0f57e59a530d18a142f4d4ba6dfc708dc5fdedce45e98ff06b44930a2a48f", size = 133624, upload-time = "2026-03-31T16:15:22.641Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/90048793db94ee4b2fcec4ac8e5ddb077367637d6650be896b3494b79bb7/orjson-3.11.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b48e274f8824567d74e2158199e269597edf00823a1b12b63d48462bbf5123e", size = 141904, upload-time = "2026-03-31T16:15:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cf/eb284847487821a5d415e54149a6449ba9bfc5872ce63ab7be41b8ec401c/orjson-3.11.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3f262401086a3960586af06c054609365e98407151f5ea24a62893a40d80dbbb", size = 423742, upload-time = "2026-03-31T16:15:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/44/09/e12423d327071c851c13e76936f144a96adacfc037394dec35ac3fc8d1e8/orjson-3.11.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e8c6218b614badf8e229b697865df4301afa74b791b6c9ade01d19a9953a942", size = 147806, upload-time = "2026-03-31T16:15:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6d/37c2589ba864e582ffe7611643314785c6afb1f83c701654ef05daa8fcc7/orjson-3.11.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093d489fa039ddade2db541097dbb484999fcc65fc2b0ff9819141e2ab364f25", size = 136485, upload-time = "2026-03-31T16:15:29.749Z" }, + { url = "https://files.pythonhosted.org/packages/be/c9/135194a02ab76b04ed9a10f68624b7ebd238bbe55548878b11ff15a0f352/orjson-3.11.8-cp312-cp312-win32.whl", hash = "sha256:e0950ed1bcb9893f4293fd5c5a7ee10934fbf82c4101c70be360db23ce24b7d2", size = 131966, upload-time = "2026-03-31T16:15:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/9796f8fbe3cf30ce9cb696748dbb535e5c87be4bf4fe2e9ca498ef1fa8cf/orjson-3.11.8-cp312-cp312-win_amd64.whl", hash = "sha256:3cf17c141617b88ced4536b2135c552490f07799f6ad565948ea07bef0dcb9a6", size = 127441, upload-time = "2026-03-31T16:15:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/cc/47/5aaf54524a7a4a0dd09dd778f3fa65dd2108290615b652e23d944152bc8e/orjson-3.11.8-cp312-cp312-win_arm64.whl", hash = "sha256:48854463b0572cc87dac7d981aa72ed8bf6deedc0511853dc76b8bbd5482d36d", size = 127364, upload-time = "2026-03-31T16:15:34.748Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, + { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, + { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, + { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, + { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -4419,6 +4614,8 @@ dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "nvidia-ml-py" }, + { name = "orjson", version = "3.11.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "orjson", version = "3.11.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "platformdirs", version = "4.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "protobuf" }, @@ -4499,6 +4696,8 @@ requires-dist = [ { name = "moviepy", marker = "extra == 'media'" }, { name = "numpy", marker = "extra == 'media'" }, { name = "nvidia-ml-py" }, + { name = "orjson", marker = "python_full_version == '3.9.*'", specifier = "<=3.11.5" }, + { name = "orjson", marker = "python_full_version >= '3.10'" }, { name = "pillow", marker = "extra == 'media'" }, { name = "platformdirs", specifier = ">=4.2.0" }, { name = "protobuf", marker = "sys_platform != 'linux'", specifier = ">=3.19.0,!=4.21.0,!=5.28.0,<7" },