chore: import upstream snapshot with attribution
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:26 +08:00
commit 1443d3fdf9
732 changed files with 196602 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# yuxi-cli
Yuxi command line client.
First-stage scope:
- remote management through `~/.yuxi/config.toml`
- browser login
- API Key import through `--api-key`
- `whoami`, `status`, and `logout`
- server discovery and compatibility check for Yuxi `>=0.7.1`
- `yuxi kb upload` for knowledge base file uploads
- `yuxi agent eval` for running existing Langfuse dataset experiments with a logged-in remote
+50
View File
@@ -0,0 +1,50 @@
[build-system]
requires = ["setuptools>=80", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "yuxi-cli"
version = "0.1.2"
description = "Yuxi command line client"
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "Wenjie Zhang" }]
license = "MIT"
keywords = ["yuxi", "cli", "rag", "knowledge-base", "agent"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Utilities",
]
dependencies = [
"httpx>=0.27",
"langfuse>=4.0.0",
"packaging>=24",
"questionary>=2.1.0",
"rich>=13.7",
"typer>=0.16",
]
[project.urls]
Homepage = "https://github.com/xerrors/Yuxi"
Repository = "https://github.com/xerrors/Yuxi"
Issues = "https://github.com/xerrors/Yuxi/issues"
[project.scripts]
yuxi = "yuxi_cli.main:app"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[dependency-groups]
test = [
"pytest>=8",
]
@@ -0,0 +1,3 @@
"""Yuxi CLI package."""
__version__ = "0.1.2"
@@ -0,0 +1,3 @@
from yuxi_cli.main import app
app()
@@ -0,0 +1,138 @@
from __future__ import annotations
import os
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from langfuse import Langfuse
from rich.console import Console
from yuxi_cli.client import YuxiClient
from yuxi_cli.config import ConfigStore
class AgentEvalError(Exception):
pass
@dataclass
class AgentEvalOptions:
dataset_name: str
agent_slug: str
experiment_name: str | None = None
max_concurrency: int = 1
timeout_seconds: float = 900
def _env(name: str, default: str | None = None) -> str | None:
value = os.getenv(name)
return value if value else default
def build_langfuse_client() -> Langfuse:
kwargs: dict[str, Any] = {
"public_key": _env("LANGFUSE_PUBLIC_KEY"),
"secret_key": _env("LANGFUSE_SECRET_KEY"),
}
host = _env("LANGFUSE_BASE_URL")
if host:
kwargs["host"] = host
return Langfuse(**kwargs)
def extract_query(item_input: Any) -> str:
if isinstance(item_input, str):
return item_input
if isinstance(item_input, dict):
for key in ("input", "query", "question", "prompt"):
value = item_input.get(key)
if isinstance(value, str) and value.strip():
return value
raise AgentEvalError(f"无法从 Langfuse dataset item input 中提取 query: {item_input!r}")
def run_langfuse_agent_experiment(
store: ConfigStore,
remote_name: str | None,
options: AgentEvalOptions,
console: Console,
*,
langfuse_factory=build_langfuse_client,
client_factory=YuxiClient,
) -> None:
config = store.load()
remote = config.get_remote(remote_name)
if not remote.api_key:
raise AgentEvalError(f"remote 尚未登录: {remote.name}。请先运行 yuxi login。")
if options.max_concurrency < 1:
raise AgentEvalError("--max-concurrency 必须大于等于 1")
if options.timeout_seconds <= 0:
raise AgentEvalError("--timeout-seconds 必须大于 0")
experiment_name = options.experiment_name or f"yuxi-agent-eval-{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}"
langfuse_client = langfuse_factory()
dataset = langfuse_client.get_dataset(options.dataset_name)
def task(*, item, **_kwargs):
return _run_agent_eval_item(
remote=remote,
agent_slug=options.agent_slug,
dataset_name=options.dataset_name,
experiment_name=experiment_name,
item=item,
timeout_seconds=options.timeout_seconds,
client_factory=client_factory,
)
result = dataset.run_experiment(
name=experiment_name,
task=task,
max_concurrency=options.max_concurrency,
metadata={
"source": "agent_evaluation",
"agent_slug": options.agent_slug,
"dataset_name": options.dataset_name,
"remote": remote.name,
},
)
console.print(result.format(include_item_results=True))
langfuse_client.flush()
processed_count = len(result.item_results)
total_count = len(dataset.items)
if processed_count != total_count:
raise AgentEvalError(f"Langfuse experiment 部分失败: {processed_count}/{total_count} 个 item 成功写入")
def _run_agent_eval_item(
*,
remote,
agent_slug: str,
dataset_name: str,
experiment_name: str,
item: Any,
timeout_seconds: float,
client_factory,
) -> str:
query = extract_query(item.input)
item_id = str(getattr(item, "id", "") or "")
request_id = f"eval-{uuid.uuid4()}"
evaluation = {
"dataset_name": dataset_name,
"dataset_item_id": item_id,
"experiment_name": experiment_name,
}
with client_factory(remote, timeout=timeout_seconds) as client:
result = client.run_agent_eval(
query=query,
agent_slug=agent_slug,
evaluation=evaluation,
meta={"request_id": request_id},
timeout_seconds=timeout_seconds,
)
if result.get("status") != "completed":
raise AgentEvalError(f"Agent eval run failed for dataset item {item_id}: {result}")
return str(result.get("output") or "")
+203
View File
@@ -0,0 +1,203 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
import httpx
from yuxi_cli.config import Remote, build_url
class ClientError(Exception):
def __init__(self, message: str, *, error_code: str | None = None, status_code: int | None = None):
super().__init__(message)
self.error_code = error_code
self.status_code = status_code
@dataclass
class CLIAuthSession:
device_code: str
user_code: str
verification_uri: str
expires_in: int
interval: int
@property
def authorize_path(self) -> str:
params = urlencode({"user_code": self.user_code})
separator = "&" if "?" in self.verification_uri else "?"
return f"{self.verification_uri}{separator}{params}"
class YuxiClient:
def __init__(self, remote: Remote, timeout: float = 30.0):
self.remote = remote
self.client = httpx.Client(timeout=timeout)
def close(self) -> None:
self.client.close()
def __enter__(self) -> YuxiClient:
return self
def __exit__(self, *_exc) -> None:
self.close()
def health(self) -> dict:
return self._request("GET", "/system/health", auth=False)
def discovery(self) -> dict:
return self._request("GET", "/system/discovery", auth=False)
def me(self, api_key: str | None = None) -> dict:
return self._request("GET", "/auth/me", api_key=api_key)
def create_cli_session(self) -> CLIAuthSession:
data = self._request("POST", "/auth/cli/sessions", json={}, auth=False)
return CLIAuthSession(
device_code=data["device_code"],
user_code=data["user_code"],
verification_uri=data["verification_uri"],
expires_in=int(data.get("expires_in") or 600),
interval=int(data.get("interval") or 2),
)
def exchange_cli_token(self, device_code: str) -> dict:
return self._request("POST", "/auth/cli/sessions/token", json={"device_code": device_code}, auth=False)
def delete_api_key(self, api_key_id: str) -> dict:
return self._request("DELETE", f"/user/apikey/{api_key_id}")
def get_database(self, kb_id: str) -> dict:
return self._request("GET", f"/knowledge/databases/{kb_id}")
def list_databases(self) -> dict:
return self._request("GET", "/knowledge/databases")
def get_knowledge_base_types(self) -> dict:
return self._request("GET", "/knowledge/types")
def get_supported_file_types(self) -> dict:
return self._request("GET", "/knowledge/files/supported-types")
def knowledge_document_exists(self, kb_id: str, filename: str) -> bool:
data = self._request(
"GET",
f"/knowledge/databases/{kb_id}/documents/exists",
params={"filename": filename},
)
return bool(data.get("exists"))
def upload_knowledge_file(self, kb_id: str, path: Path, *, timeout_seconds: float = 300) -> dict:
with path.open("rb") as fp:
return self._request(
"POST",
"/knowledge/files/upload",
params={"kb_id": kb_id},
files={"file": (path.name, fp, "application/octet-stream")},
timeout=timeout_seconds,
)
def add_uploaded_documents(self, kb_id: str, items: list[str], params: dict) -> dict:
return self._request(
"POST",
f"/knowledge/databases/{kb_id}/documents/add",
json={"items": items, "params": params},
)
def run_agent_eval(
self,
*,
query: str,
agent_slug: str,
evaluation: dict,
meta: dict | None = None,
image_content: str | None = None,
model_spec: str | None = None,
timeout_seconds: float = 900,
) -> dict:
payload = {
"query": query,
"agent_slug": agent_slug,
"evaluation": evaluation,
"meta": meta or {},
"image_content": image_content,
"model_spec": model_spec,
}
return self._request("POST", "/agent-invocation/eval/runs", json=payload, timeout=timeout_seconds)
def authorize_url(self, session: CLIAuthSession) -> str:
return build_url(self.remote.url, session.authorize_path)
def _request(
self,
method: str,
path: str,
*,
auth: bool = True,
api_key: str | None = None,
json: Any | None = None,
params: dict | None = None,
files: dict | None = None,
data: dict | None = None,
timeout: float | None = None,
) -> dict:
headers = {}
token = api_key if api_key is not None else self.remote.api_key
if auth and token:
headers["Authorization"] = f"Bearer {token}"
url = f"{self.remote.api_base_url}{path if path.startswith('/') else f'/{path}'}"
request_kwargs: dict[str, Any] = {"headers": headers}
if params is not None:
request_kwargs["params"] = params
if files is not None:
request_kwargs["files"] = files
if data is not None:
request_kwargs["data"] = data
if json is not None:
request_kwargs["json"] = json
if timeout is not None:
request_kwargs["timeout"] = timeout
try:
response = self.client.request(method, url, **request_kwargs)
except httpx.HTTPError as exc:
# 网络层错误(连接失败、超时等)没有 HTTP 状态码,视为可重试的瞬时错误。
raise ClientError(f"请求远程失败: {exc}") from exc
if response.status_code >= 400:
error_code, error_message = _parse_http_error(response)
raise ClientError(error_message, error_code=error_code, status_code=response.status_code)
if not response.content:
return {}
try:
data = response.json()
except ValueError as exc:
raise ClientError("远程响应不是 JSON") from exc
if not isinstance(data, dict):
raise ClientError("远程响应格式无效")
return data
def _parse_http_error(response: httpx.Response) -> tuple[str | None, str]:
"""解析远程错误,返回 (机器可读 error code, 人类可读 message)。"""
try:
detail = response.json().get("detail")
except ValueError:
detail = response.text.strip()
if isinstance(detail, dict):
error = detail.get("error")
message = detail.get("message")
if error and message:
return str(error), f"{error}: {message}"
if error:
return str(error), str(error)
if message:
return None, str(message)
if detail:
return None, str(detail)
return None, f"HTTP {response.status_code}"
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
import sys
import time
import webbrowser
from collections.abc import Callable
from rich.console import Console
from rich.table import Table
from yuxi_cli.client import ClientError, YuxiClient
from yuxi_cli.config import ConfigStore, Remote
from yuxi_cli.discovery import ServerCompatibilityError, ensure_server_compatible
PENDING_ERRORS = ("authorization_pending", "slow_down")
class CommandError(Exception):
pass
def remote_add(store: ConfigStore, name: str, url: str) -> Remote:
config = store.load()
remote = config.set_remote(name, url)
store.save(config)
return remote
def remote_use(store: ConfigStore, name: str) -> Remote:
config = store.load()
remote = config.use_remote(name)
store.save(config)
return remote
def remote_list(store: ConfigStore, console: Console) -> None:
config = store.load()
table = Table(show_header=True, header_style="bold")
table.add_column("Current", width=7)
table.add_column("Name")
table.add_column("URL")
table.add_column("Auth")
for name, remote in config.remotes.items():
table.add_row("*" if name == config.current else "", name, remote.url, "API Key" if remote.has_api_key else "-")
console.print(table)
def remote_ping(store: ConfigStore, name: str | None, console: Console, client_factory=YuxiClient) -> None:
config = store.load()
remote = config.get_remote(name)
with client_factory(remote) as client:
data = client.health()
console.print(f"{remote.name}: {data.get('status', 'ok')} {data.get('version', '')}".rstrip())
def login_with_api_key(
store: ConfigStore,
remote_name: str | None,
api_key: str,
console: Console,
client_factory=YuxiClient,
) -> Remote:
if not api_key.startswith("yxkey_"):
raise CommandError("API Key 格式无效,应以 yxkey_ 开头")
config = store.load()
remote = config.get_remote(remote_name)
with client_factory(remote) as client:
_ensure_server_compatible(client, "cli.api_key_auth")
client.me(api_key=api_key) # 校验 Key 是否可用
remote.api_key = api_key
remote.api_key_id = ""
store.save(config)
console.print(f"已保存 {remote.name} 的 API Key。")
return remote
def login_with_browser(
store: ConfigStore,
remote_name: str | None,
no_open: bool,
console: Console,
*,
client_factory=YuxiClient,
open_browser: Callable[[str], bool] = webbrowser.open,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
) -> Remote:
config = store.load()
remote = config.get_remote(remote_name)
with client_factory(remote) as client:
_ensure_server_compatible(client, "cli.browser_login")
session = client.create_cli_session()
authorize_url = client.authorize_url(session)
console.print(f"授权码: {session.user_code}")
console.print(f"浏览器授权地址: {authorize_url}")
if not no_open:
open_browser(authorize_url)
deadline = monotonic() + session.expires_in
while monotonic() < deadline:
try:
data = client.exchange_cli_token(session.device_code)
api_key = data.get("secret") or ""
api_key_meta = data.get("api_key") or {}
if not api_key:
raise CommandError("远程未返回 API Key secret")
remote.api_key = api_key
remote.api_key_id = str(api_key_meta.get("id") or "")
store.save(config)
console.print(f"已完成 {remote.name} 的浏览器登录。")
return remote
except ClientError as exc:
if not _should_keep_polling(exc):
raise
sleep(max(1, session.interval))
raise CommandError("浏览器授权超时")
def _should_keep_polling(exc: ClientError) -> bool:
"""轮询期间是否应继续重试:等待授权、限流,或瞬时网络/服务端错误。"""
if exc.error_code in PENDING_ERRORS or str(exc).startswith(PENDING_ERRORS):
return True
# status_code 为 None 表示网络层错误;5xx 为服务端瞬时错误,均可重试。
return exc.status_code is None or exc.status_code >= 500
def whoami(store: ConfigStore, remote_name: str | None, console: Console, client_factory=YuxiClient) -> None:
config = store.load()
remote = config.get_remote(remote_name)
if not remote.api_key:
raise CommandError(f"remote 尚未登录: {remote.name}")
with client_factory(remote) as client:
user = client.me()
console.print(f"{user.get('username')} ({user.get('uid')}) - {user.get('role')}")
def status(store: ConfigStore, remote_name: str | None, console: Console, client_factory=YuxiClient) -> None:
config = store.load()
remote = config.get_remote(remote_name)
with client_factory(remote) as client:
health = client.health()
auth = "未登录"
if remote.api_key:
try:
user = client.me()
auth = f"{user.get('username')} ({user.get('uid')})"
except ClientError:
auth = "API Key 无效"
table = Table(show_header=False)
table.add_row("Remote", remote.name)
table.add_row("URL", remote.url)
table.add_row("Health", f"{health.get('status', 'ok')} {health.get('version', '')}".rstrip())
table.add_row("Auth", auth)
console.print(table)
def logout(
store: ConfigStore,
remote_name: str | None,
local_only: bool,
console: Console,
client_factory=YuxiClient,
) -> Remote:
config = store.load()
remote = config.get_remote(remote_name)
if remote.api_key and remote.api_key_id and not local_only:
with client_factory(remote) as client:
client.delete_api_key(remote.api_key_id)
remote.api_key = ""
remote.api_key_id = ""
store.save(config)
console.print(f"已退出 {remote.name}")
return remote
def select_login_mode(console: Console) -> str:
console.print("选择登录方式:")
console.print("> 1. 浏览器登录(推荐)")
console.print(" 2. API Key")
if not sys.stdin.isatty():
return "browser"
value = input("直接回车使用浏览器登录,输入 2 使用 API Key: ").strip()
return "api_key" if value == "2" else "browser"
def _ensure_server_compatible(client: YuxiClient, required_capability: str) -> None:
try:
discovery = client.discovery()
except ClientError as exc:
raise CommandError(f"无法读取服务端 discovery,请确认远程是 Yuxi 0.7.1 或更高版本: {exc}") from exc
try:
ensure_server_compatible(discovery, required_capability)
except ServerCompatibilityError as exc:
raise CommandError(str(exc)) from exc
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import os
import re
import stat
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
DEFAULT_REMOTE_NAME = "local"
DEFAULT_REMOTE_URL = "http://localhost:5173"
class ConfigError(Exception):
pass
@dataclass
class Remote:
name: str
url: str
api_key: str = ""
api_key_id: str = ""
@classmethod
def from_dict(cls, name: str, data: dict) -> Remote:
return cls(
name=name,
url=normalize_remote_url(str(data.get("url") or "")),
api_key=str(data.get("api_key") or ""),
api_key_id=str(data.get("api_key_id") or ""),
)
@property
def api_base_url(self) -> str:
return f"{self.url.rstrip('/')}/api"
@property
def has_api_key(self) -> bool:
return bool(self.api_key)
@dataclass
class Config:
current: str = DEFAULT_REMOTE_NAME
remotes: dict[str, Remote] = field(default_factory=dict)
@classmethod
def default(cls) -> Config:
return cls(
current=DEFAULT_REMOTE_NAME,
remotes={DEFAULT_REMOTE_NAME: Remote(name=DEFAULT_REMOTE_NAME, url=DEFAULT_REMOTE_URL)},
)
def get_remote(self, name: str | None = None) -> Remote:
remote_name = name or self.current
remote = self.remotes.get(remote_name)
if remote is None:
raise ConfigError(f"remote 不存在: {remote_name}")
return remote
def set_remote(self, name: str, url: str) -> Remote:
remote = self.remotes.get(name)
normalized_url = normalize_remote_url(url)
if remote is None:
remote = Remote(name=name, url=normalized_url)
self.remotes[name] = remote
else:
if remote.url != normalized_url:
remote.api_key = ""
remote.api_key_id = ""
remote.url = normalized_url
if not self.current:
self.current = name
return remote
def use_remote(self, name: str) -> Remote:
remote = self.get_remote(name)
self.current = remote.name
return remote
class ConfigStore:
def __init__(self, path: Path | None = None):
self.path = path or default_config_path()
def load(self) -> Config:
if not self.path.exists():
return Config.default()
try:
data = tomllib.loads(self.path.read_text(encoding="utf-8"))
except tomllib.TOMLDecodeError as exc:
raise ConfigError(f"配置文件 TOML 格式无效: {self.path}") from exc
raw_remotes = data.get("remotes") or {}
remotes: dict[str, Remote] = {}
for name, raw_remote in raw_remotes.items():
if isinstance(raw_remote, dict):
remotes[name] = Remote.from_dict(name, raw_remote)
if not remotes:
remotes = Config.default().remotes
current = str(data.get("current") or DEFAULT_REMOTE_NAME)
if current not in remotes:
current = next(iter(remotes))
return Config(current=current, remotes=remotes)
def save(self, config: Config) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
# 以 0600 创建文件,避免新文件在 umask 下短暂出现可被他人读取的窗口(凭据明文存放)。
def _opener(path, flags):
return os.open(path, flags, stat.S_IRUSR | stat.S_IWUSR)
with open(self.path, "w", encoding="utf-8", opener=_opener) as fp:
fp.write(_render_toml(config))
# 已存在的文件不会被 _opener 重置权限,这里再收敛一次。
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
def default_config_path() -> Path:
return Path.home() / ".yuxi" / "config.toml"
def normalize_remote_url(raw_url: str) -> str:
value = raw_url.strip()
if not value:
raise ConfigError("remote URL 不能为空")
if "://" not in value:
value = f"http://{value}"
parts = urlsplit(value)
if not parts.scheme or not parts.netloc:
raise ConfigError(f"remote URL 无效: {raw_url}")
if parts.scheme not in {"http", "https"}:
raise ConfigError("remote URL 仅支持 http 或 https")
path = parts.path.rstrip("/")
if path == "/api":
path = ""
elif path.endswith("/api"):
path = path[: -len("/api")]
normalized = urlunsplit((parts.scheme, parts.netloc, path, "", "")).rstrip("/")
return normalized or f"{parts.scheme}://{parts.netloc}"
def build_url(remote_url: str, path: str) -> str:
base = normalize_remote_url(remote_url).rstrip("/")
suffix = path if path.startswith("/") else f"/{path}"
return f"{base}{suffix}"
def _render_toml(config: Config) -> str:
lines = [f'current = "{_escape(config.current)}"', ""]
for name, remote in config.remotes.items():
lines.append(f"[remotes.{_format_key(name)}]")
lines.append(f'url = "{_escape(remote.url)}"')
lines.append(f'api_key = "{_escape(remote.api_key)}"')
lines.append(f'api_key_id = "{_escape(remote.api_key_id)}"')
lines.append("")
return "\n".join(lines)
def _format_key(value: str) -> str:
if re.match(r"^[A-Za-z0-9_-]+$", value):
return value
return f'"{_escape(value)}"'
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
@@ -0,0 +1,43 @@
from __future__ import annotations
from packaging.version import InvalidVersion, Version
MIN_SERVER_VERSION = "0.7.1"
class ServerCompatibilityError(Exception):
pass
def is_server_version_supported(version: str, minimum: str = MIN_SERVER_VERSION) -> bool:
try:
parsed = Version(version)
required = Version(minimum)
except InvalidVersion:
return False
if parsed >= required:
return True
if parsed.is_devrelease:
return parsed.release >= required.release
return False
def ensure_server_compatible(discovery: dict, required_capability: str) -> None:
version = str(discovery.get("version") or "")
if not is_server_version_supported(version):
raise ServerCompatibilityError(f"当前 Yuxi 服务版本 {version or 'unknown'} 低于 CLI 要求 {MIN_SERVER_VERSION}")
if not _capability_enabled(discovery, required_capability):
raise ServerCompatibilityError(f"当前 Yuxi 服务未声明支持 {required_capability}")
def _capability_enabled(discovery: dict, capability: str) -> bool:
current = discovery.get("capabilities")
for part in capability.split("."):
if not isinstance(current, dict):
return False
current = current.get(part)
return current is True
+693
View File
@@ -0,0 +1,693 @@
from __future__ import annotations
import os
import sys
import time
from collections import Counter
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import questionary
import typer
from questionary import Choice
from rich.console import Console
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
from yuxi_cli.client import ClientError, YuxiClient
from yuxi_cli.config import ConfigStore, Remote
from yuxi_cli.discovery import ServerCompatibilityError, ensure_server_compatible
ALREADY_UPLOADED_MESSAGE = "File with the same content already exists in this database"
ALREADY_EXISTS_MESSAGE = "File with the same filename already exists in this database"
DEFAULT_INCLUDE_EXTENSIONS = {".docx", ".html", ".htm", ".md", ".txt"}
DEFAULT_EXCLUDE_EXTENSIONS = {".bmp", ".jpeg", ".jpg", ".pdf", ".png", ".tif", ".tiff"}
MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024
MAX_CONCURRENCY = 300
DEFAULT_CONCURRENCY = 10
UPLOAD_RETRY_ATTEMPTS = 2
PROMPT_STYLE = questionary.Style(
[
("qmark", "fg:#5f6fff bold"),
("question", "bold"),
("pointer", "fg:#5f6fff bold"),
("highlighted", "fg:#5f6fff bold"),
("selected", "fg:#3a7d44"),
("instruction", "fg:#6b7280"),
("answer", "fg:#3a7d44 bold"),
]
)
class KbUploadError(Exception):
pass
@dataclass(frozen=True)
class KbUploadOptions:
path: Path
kb_id: str | None = None
yes: bool = False
concurrency: int = DEFAULT_CONCURRENCY
include_ext: str | None = None
exclude_ext: str | None = None
force_upload_file: bool = False
@dataclass(frozen=True)
class LocalFile:
path: Path
relative_path: str
extension: str
size: int
@dataclass(frozen=True)
class SkippedFile:
path: Path
relative_path: str
reason: str
@dataclass(frozen=True)
class ExtensionOption:
extension: str
count: int
@dataclass
class UploadResult:
local_file: LocalFile
file_path: str | None = None
content_hash: str | None = None
size: int | None = None
error: str | None = None
already_uploaded: bool = False
@property
def success(self) -> bool:
return bool(self.file_path and self.content_hash)
@dataclass
class KbUploadSummary:
scanned: int
skipped: list[SkippedFile] = field(default_factory=list)
selected: list[LocalFile] = field(default_factory=list)
uploaded: list[UploadResult] = field(default_factory=list)
upload_failed: list[UploadResult] = field(default_factory=list)
add_response: dict | None = None
@property
def add_failed_count(self) -> int:
if not self.add_response:
return 0
return int(self.add_response.get("failed") or 0)
@property
def already_uploaded_count(self) -> int:
return sum(1 for result in self.upload_failed if result.already_uploaded)
@property
def real_upload_failed_count(self) -> int:
return len(self.upload_failed) - self.already_uploaded_count
def run_kb_upload(
store: ConfigStore,
remote_name: str | None,
options: KbUploadOptions,
console: Console,
*,
client_factory=YuxiClient,
) -> KbUploadSummary:
if options.concurrency < 1 or options.concurrency > MAX_CONCURRENCY:
raise KbUploadError(f"--concurrency 必须在 1 到 {MAX_CONCURRENCY} 之间")
config = store.load()
remote = config.get_remote(remote_name)
if not remote.api_key:
raise KbUploadError(f"remote 尚未登录: {remote.name}")
with client_factory(remote) as client:
_ensure_kb_upload_supported(client)
kb_types = _load_kb_types(client)
database = _resolve_database(client, options.kb_id, kb_types, console)
kb_id = str(database.get("kb_id") or "")
if not kb_id:
raise KbUploadError("知识库详情缺少 kb_id")
supported_extensions = _load_supported_extensions(client)
scanned_files, initial_skipped = scan_local_files(options.path)
selected_extensions = None
if not options.yes:
selected_extensions = _prompt_select_extensions(
scanned_files,
supported_extensions=supported_extensions,
include_ext=options.include_ext,
exclude_ext=options.exclude_ext,
)
selected, skipped_by_type = select_upload_files(
scanned_files,
supported_extensions=supported_extensions,
include_ext=options.include_ext,
exclude_ext=options.exclude_ext,
selected_extensions=selected_extensions,
)
summary = KbUploadSummary(
scanned=len(scanned_files) + len(initial_skipped),
skipped=[*initial_skipped, *skipped_by_type],
selected=selected,
)
if not selected:
_print_selection_summary(summary, console)
raise KbUploadError("没有可上传的文件")
_print_selection_summary(summary, console)
if not options.yes and not typer.confirm("确认上传?", default=True):
raise KbUploadError("已取消")
uploaded, failed, add_response = upload_files(
remote,
client_factory,
kb_id,
selected,
concurrency=options.concurrency,
console=console,
force_upload_file=options.force_upload_file,
)
summary.uploaded = uploaded
summary.upload_failed = failed
summary.add_response = add_response
if not uploaded and not summary.already_uploaded_count:
_print_final_summary(summary, console)
raise KbUploadError("所有文件上传失败,未添加文档记录")
_print_final_summary(summary, console)
if any(not result.already_uploaded for result in failed) or summary.add_failed_count:
raise KbUploadError("部分文件处理失败,请查看摘要")
return summary
def scan_local_files(path: Path) -> tuple[list[LocalFile], list[SkippedFile]]:
root = path.expanduser()
if not root.exists():
raise KbUploadError(f"路径不存在: {path}")
if root.is_file():
return _local_file_from_path(root, root.name)
if not root.is_dir():
raise KbUploadError(f"路径不是文件或目录: {path}")
files: list[LocalFile] = []
skipped: list[SkippedFile] = []
for current in sorted((p for p in root.rglob("*") if p.is_file()), key=lambda p: p.relative_to(root).as_posix()):
relative_path = current.relative_to(root).as_posix()
if _has_hidden_part(relative_path):
skipped.append(SkippedFile(current, relative_path, "hidden"))
continue
item_files, item_skipped = _local_file_from_path(current, relative_path)
files.extend(item_files)
skipped.extend(item_skipped)
return files, skipped
def _has_hidden_part(relative_path: str) -> bool:
return any(part.startswith(".") for part in Path(relative_path).parts)
def select_upload_files(
files: list[LocalFile],
*,
supported_extensions: set[str],
include_ext: str | None,
exclude_ext: str | None,
selected_extensions: set[str] | None = None,
) -> tuple[list[LocalFile], list[SkippedFile]]:
if selected_extensions is None:
include_extensions = parse_extension_list(include_ext) if include_ext else set(DEFAULT_INCLUDE_EXTENSIONS)
exclude_extensions = (
parse_extension_list(exclude_ext)
if exclude_ext
else (set() if include_ext else set(DEFAULT_EXCLUDE_EXTENSIONS))
)
else:
include_extensions = selected_extensions
exclude_extensions = set()
selected: list[LocalFile] = []
skipped: list[SkippedFile] = []
for item in files:
if item.extension not in supported_extensions:
skipped.append(SkippedFile(item.path, item.relative_path, "unsupported"))
continue
if item.extension not in include_extensions:
reason = "not-selected" if selected_extensions is not None else "not-included"
skipped.append(SkippedFile(item.path, item.relative_path, reason))
continue
if item.extension in exclude_extensions:
skipped.append(SkippedFile(item.path, item.relative_path, "excluded"))
continue
selected.append(item)
return selected, skipped
def parse_extension_list(raw: str | None) -> set[str]:
if not raw:
return set()
extensions = set()
for part in raw.split(","):
value = part.strip().lower()
if not value:
continue
if not value.startswith("."):
value = f".{value}"
extensions.add(value)
return extensions
def _prompt_select_extensions(
files: list[LocalFile],
*,
supported_extensions: set[str],
include_ext: str | None,
exclude_ext: str | None,
) -> set[str]:
options = _extension_options(files, supported_extensions)
if not options:
return set()
if not sys.stdin.isatty():
raise KbUploadError("非交互环境请传 --yes 或 --include-ext")
selected_extensions = _initial_selected_extensions(
{option.extension for option in options},
include_ext=include_ext,
exclude_ext=exclude_ext,
)
selected = _ask_question(
questionary.checkbox(
"选择要上传的文件类型",
choices=_extension_choices(options, selected_extensions),
pointer="",
instruction="↑/↓ 移动 · Space 选择/取消 · Enter 确认",
style=PROMPT_STYLE,
)
)
if selected is None:
raise KbUploadError("已取消")
return set(selected)
def _extension_options(files: list[LocalFile], supported_extensions: set[str]) -> list[ExtensionOption]:
counts = Counter(item.extension for item in files if item.extension in supported_extensions)
return [ExtensionOption(extension, count) for extension, count in sorted(counts.items())]
def _initial_selected_extensions(
available_extensions: set[str], *, include_ext: str | None, exclude_ext: str | None
) -> set[str]:
selected = parse_extension_list(include_ext) if include_ext else set(DEFAULT_INCLUDE_EXTENSIONS)
if exclude_ext:
selected -= parse_extension_list(exclude_ext)
return selected & available_extensions
def upload_files(
remote: Remote,
client_factory,
kb_id: str,
files: list[LocalFile],
*,
concurrency: int,
console: Console,
force_upload_file: bool = False,
) -> tuple[list[UploadResult], list[UploadResult], dict | None]:
uploaded: list[UploadResult] = []
failed: list[UploadResult] = []
add_response: dict | None = None
def upload_and_add_one(item: LocalFile) -> tuple[UploadResult, dict | None]:
if not force_upload_file and _remote_document_exists(remote, client_factory, kb_id, item):
return UploadResult(item, error=ALREADY_EXISTS_MESSAGE, already_uploaded=True), None
result = _upload_one_with_retry(remote, client_factory, kb_id, item)
if not result.success:
return result, None
with client_factory(remote) as client:
response = add_uploaded_documents(client, kb_id, [result])
return result, response
def record_result(result: UploadResult, response: dict | None, completed: int) -> None:
nonlocal add_response
if result.success:
uploaded.append(result)
if response:
add_response = _merge_add_response(add_response, response)
return
failed.append(result)
if result.already_uploaded:
console.print(
f"[yellow]-[/yellow] {result.local_file.relative_path} ({completed}/{len(files)}): 已上传过,跳过"
)
return
console.print(f"[red]✗[/red] {result.local_file.relative_path} ({completed}/{len(files)}): {result.error}")
console.print(f"开始上传并添加: {len(files)} 个文件,并发 {concurrency}")
try:
with ThreadPoolExecutor(max_workers=concurrency) as executor:
pending: set[Future] = set()
file_iter = iter(files)
def submit_next() -> None:
try:
item = next(file_iter)
except StopIteration:
return
pending.add(executor.submit(upload_and_add_one, item))
for _ in range(min(concurrency, len(files))):
submit_next()
def completed_results():
while pending:
done, still_pending = wait(pending, return_when=FIRST_COMPLETED)
pending.clear()
pending.update(still_pending)
for future in done:
yield future.result()
submit_next()
if console.is_terminal:
progress = Progress(
TextColumn("[progress.description]{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TextColumn("{task.percentage:>3.0f}%"),
TimeElapsedColumn(),
console=console,
)
completed = 0
with progress:
task_id = progress.add_task("处理进度", total=len(files))
for result, response in completed_results():
completed += 1
record_result(result, response, completed)
progress.advance(task_id)
else:
completed = 0
progress_step = max(1, len(files) // 10)
for result, response in completed_results():
completed += 1
record_result(result, response, completed)
if completed == len(files) or completed % progress_step == 0:
console.print(f"处理进度: {completed}/{len(files)}")
except KeyboardInterrupt as exc:
raise KbUploadError("已取消上传队列,请查看已完成的文档记录") from exc
uploaded.sort(key=lambda item: item.local_file.relative_path)
failed.sort(key=lambda item: item.local_file.relative_path)
return uploaded, failed, add_response
def _remote_document_exists(remote: Remote, client_factory, kb_id: str, item: LocalFile) -> bool:
try:
with client_factory(remote) as client:
return client.knowledge_document_exists(kb_id, item.relative_path)
except Exception:
# 存在性检查只是上传前优化;失败时保留原上传路径的行为。
return False
def add_uploaded_documents(client: YuxiClient, kb_id: str, uploaded: list[UploadResult]) -> dict:
items = [result.file_path for result in uploaded if result.file_path]
params = {
"content_type": "file",
"content_hashes": {result.file_path: result.content_hash for result in uploaded if result.file_path},
"file_sizes": {result.file_path: result.size for result in uploaded if result.file_path},
"source_paths": {result.file_path: result.local_file.relative_path for result in uploaded if result.file_path},
}
return client.add_uploaded_documents(kb_id, items, params)
def _merge_add_response(current: dict | None, response: dict) -> dict:
if current is None:
current = {"items": [], "failed_items": [], "added": 0, "failed": 0}
current["items"].extend(response.get("items") or [])
current["failed_items"].extend(response.get("failed_items") or [])
current["added"] += int(response.get("added") or 0)
current["failed"] += int(response.get("failed") or 0)
added = int(current["added"])
failed = int(current["failed"])
if failed == 0:
current["status"] = "success"
current["message"] = f"已添加 {added} 个文件"
elif added == 0:
current["status"] = "failed"
current["message"] = f"文件添加失败,失败 {failed}"
else:
current["status"] = "partial_failed"
current["message"] = f"已添加 {added} 个文件,失败 {failed}"
return current
def _local_file_from_path(path: Path, relative_path: str) -> tuple[list[LocalFile], list[SkippedFile]]:
if path.is_symlink():
return [], [SkippedFile(path, relative_path, "symlink")]
try:
stat = path.stat()
except OSError as exc:
return [], [SkippedFile(path, relative_path, f"stat-failed: {exc}")]
if stat.st_size == 0:
return [], [SkippedFile(path, relative_path, "empty")]
if stat.st_size > MAX_UPLOAD_SIZE_BYTES:
return [], [SkippedFile(path, relative_path, "too-large")]
if not os.access(path, os.R_OK):
return [], [SkippedFile(path, relative_path, "unreadable")]
extension = path.suffix.lower()
if not extension:
return [], [SkippedFile(path, relative_path, "no-extension")]
local_file = LocalFile(
path=path,
relative_path=relative_path.replace("\\", "/"),
extension=extension,
size=stat.st_size,
)
return [local_file], []
def _ensure_kb_upload_supported(client: YuxiClient) -> None:
try:
ensure_server_compatible(client.discovery(), "cli.kb_upload")
except ServerCompatibilityError as exc:
raise KbUploadError(str(exc)) from exc
def _load_kb_types(client: YuxiClient) -> dict:
payload = client.get_knowledge_base_types()
kb_types = payload.get("kb_types")
if not isinstance(kb_types, dict):
raise KbUploadError("服务端知识库类型响应格式无效")
return kb_types
def _resolve_database(client: YuxiClient, kb_id: str | None, kb_types: dict, console: Console) -> dict:
if kb_id and kb_id.strip():
database = client.get_database(kb_id.strip())
_ensure_database_supports_documents(database, kb_types)
return database
databases = _list_uploadable_databases(client, kb_types)
if not databases:
raise KbUploadError("当前 remote 没有可用于文档上传的知识库")
if len(databases) == 1:
database = databases[0]
console.print(f"已选择唯一可用知识库: {database.get('name') or database.get('kb_id')}")
return database
if not sys.stdin.isatty():
raise KbUploadError("非交互环境必须显式传入 --kb-id")
return _prompt_select_database(databases)
def _prompt_select_database(databases: list[dict]) -> dict:
selected_index = _ask_question(
questionary.select(
"选择知识库",
choices=_database_choices(databases),
pointer="",
instruction="↑/↓ 移动 · Enter 确认",
style=PROMPT_STYLE,
)
)
if selected_index is None:
raise KbUploadError("已取消")
return databases[int(selected_index)]
def _ask_question(question) -> Any:
try:
return question.ask()
except (EOFError, KeyboardInterrupt) as exc:
raise KbUploadError("已取消") from exc
def _database_choices(databases: list[dict]) -> list[Choice]:
return [Choice(title=_database_option_label(database), value=index) for index, database in enumerate(databases)]
def _database_option_label(database: dict) -> str:
name = str(database.get("name") or database.get("database_name") or "-")
kb_id = str(database.get("kb_id") or "-")
kb_type = str(database.get("kb_type") or "-")
return f"{name} [{kb_type}] {kb_id}"
def _extension_choices(options: list[ExtensionOption], selected_extensions: set[str]) -> list[Choice]:
return [
Choice(
title=_extension_option_label(option),
value=option.extension,
checked=option.extension in selected_extensions,
)
for option in options
]
def _extension_option_label(option: ExtensionOption) -> str:
return f"{option.extension.lstrip('.')} ({option.count})"
def _format_unsupported_summary(unsupported_counts: Counter[str]) -> str:
total = sum(unsupported_counts.values())
extensions = [extension for extension, _count in unsupported_counts.most_common()]
visible = extensions[:8]
remaining = len(extensions) - len(visible)
suffix = f", 等 {remaining}" if remaining else ""
return f"不支持: {total} ({', '.join(visible)}{suffix})"
def _list_uploadable_databases(client: YuxiClient, kb_types: dict) -> list[dict]:
payload = client.list_databases()
databases = payload.get("databases")
if not isinstance(databases, list):
raise KbUploadError("服务端知识库列表响应格式无效")
uploadable = []
for database in databases:
if not isinstance(database, dict):
continue
if _database_supports_documents(database, kb_types):
uploadable.append(database)
uploadable.sort(key=lambda item: (str(item.get("name") or ""), str(item.get("kb_id") or "")))
return uploadable
def _ensure_database_supports_documents(database: dict, kb_types: dict) -> None:
kb_type = str(database.get("kb_type") or "")
if not kb_type:
raise KbUploadError("知识库详情缺少 kb_type,无法确认是否支持文档上传")
type_info = kb_types.get(kb_type)
if not isinstance(type_info, dict):
raise KbUploadError(f"服务端未返回知识库类型信息: {kb_type}")
if type_info.get("supports_documents") is False:
raise KbUploadError(f"{database.get('name') or kb_type} 只支持检索,不支持文档上传")
def _database_supports_documents(database: dict, kb_types: dict) -> bool:
kb_type = str(database.get("kb_type") or "")
type_info = kb_types.get(kb_type)
return isinstance(type_info, dict) and type_info.get("supports_documents") is True
def _load_supported_extensions(client: YuxiClient) -> set[str]:
payload = client.get_supported_file_types()
raw_file_types = payload.get("file_types")
if not isinstance(raw_file_types, list) or not raw_file_types:
raise KbUploadError("服务端支持文件类型响应格式无效")
return {str(item).lower() if str(item).startswith(".") else f".{str(item).lower()}" for item in raw_file_types}
def _upload_one_with_retry(remote: Remote, client_factory, kb_id: str, item: LocalFile) -> UploadResult:
last_error: Exception | None = None
for attempt in range(UPLOAD_RETRY_ATTEMPTS + 1):
try:
with client_factory(remote) as client:
data = client.upload_knowledge_file(kb_id, item.path)
file_path = str(data.get("file_path") or data.get("minio_path") or "")
content_hash = str(data.get("content_hash") or "")
size = int(data.get("size") or item.size)
if not file_path or not content_hash:
return UploadResult(item, error="上传响应缺少 file_path 或 content_hash")
return UploadResult(item, file_path=file_path, content_hash=content_hash, size=size)
except ClientError as exc:
last_error = exc
if _is_already_uploaded(exc):
return UploadResult(item, error=ALREADY_UPLOADED_MESSAGE, already_uploaded=True)
if not _is_retryable(exc) or attempt >= UPLOAD_RETRY_ATTEMPTS:
break
except Exception as exc: # noqa: BLE001
last_error = exc
break
time.sleep(2**attempt)
return UploadResult(item, error=str(last_error) if last_error else "上传失败")
def _is_retryable(exc: ClientError) -> bool:
return exc.status_code is None or exc.status_code == 429 or exc.status_code >= 500
def _is_already_uploaded(exc: ClientError) -> bool:
return ALREADY_UPLOADED_MESSAGE in str(exc)
def _print_selection_summary(summary: KbUploadSummary, console: Console) -> None:
selected_extensions = sorted({item.extension for item in summary.selected})
selected_extension_text = f" ({', '.join(selected_extensions)})" if selected_extensions else ""
not_selected = sum(1 for item in summary.skipped if item.reason in {"not-selected", "not-included", "excluded"})
unsupported_counts = _unsupported_counts_from_skipped(summary.skipped)
unsupported_total = sum(unsupported_counts.values())
other_skipped = len(summary.skipped) - not_selected - unsupported_total
console.print("上传预览")
console.print(f" 扫描文件: {summary.scanned}")
console.print(f" 将上传: {len(summary.selected)}{selected_extension_text}")
if not_selected:
console.print(f" 未选择: {not_selected}")
if unsupported_counts:
console.print(f" {_format_unsupported_summary(unsupported_counts)}", markup=False)
if other_skipped:
console.print(f" 其他跳过: {other_skipped}")
def _unsupported_counts_from_skipped(skipped: list[SkippedFile]) -> Counter[str]:
counts = Counter()
for item in skipped:
if item.reason == "unsupported":
counts[item.path.suffix.lower() or "无扩展名"] += 1
elif item.reason == "no-extension":
counts["无扩展名"] += 1
return counts
def _print_final_summary(summary: KbUploadSummary, console: Console) -> None:
added = int((summary.add_response or {}).get("added") or 0)
add_failed = int((summary.add_response or {}).get("failed") or 0)
console.print("上传结果")
console.print(f" 上传成功: {len(summary.uploaded)}")
if summary.already_uploaded_count:
console.print(f" 已上传过: {summary.already_uploaded_count}")
console.print(f" 上传失败: {summary.real_upload_failed_count}")
console.print(f" 添加成功: {added}")
console.print(f" 添加失败: {add_failed}")
failed_items = (summary.add_response or {}).get("failed_items") or []
if failed_items:
for item in failed_items:
console.print(f"[red]添加失败:[/red] {item.get('item')} - {item.get('error')}")
+216
View File
@@ -0,0 +1,216 @@
from __future__ import annotations
from pathlib import Path
import typer
from rich.console import Console
from yuxi_cli import __version__
from yuxi_cli.agent_eval import AgentEvalError, AgentEvalOptions, run_langfuse_agent_experiment
from yuxi_cli.client import ClientError
from yuxi_cli.commands import (
CommandError,
login_with_api_key,
login_with_browser,
logout as logout_command,
remote_add,
remote_list,
remote_ping,
remote_use,
select_login_mode,
status as status_command,
whoami as whoami_command,
)
from yuxi_cli.config import ConfigError, ConfigStore
from yuxi_cli.kb_upload import DEFAULT_CONCURRENCY, MAX_CONCURRENCY, KbUploadError, KbUploadOptions, run_kb_upload
console = Console()
app = typer.Typer(help="Yuxi command line client.", invoke_without_command=True)
remote_app = typer.Typer(help="Manage Yuxi remotes.")
agent_app = typer.Typer(help="Run and manage Yuxi agents.")
kb_app = typer.Typer(help="Upload and manage knowledge base files.")
app.add_typer(remote_app, name="remote")
app.add_typer(agent_app, name="agent")
app.add_typer(kb_app, name="kb")
def _store() -> ConfigStore:
return ConfigStore()
def _print_remote_context(store: ConfigStore, remote_name: str | None) -> None:
remote = store.load().get_remote(remote_name)
console.print(f"Yuxi CLI {__version__}")
console.print(f"Remote: {remote.name} {remote.url}")
def _handle_error(exc: Exception) -> None:
console.print(f"[red]错误:[/red] {exc}")
raise typer.Exit(1) from exc
@app.callback()
def main(
version: bool = typer.Option(False, "--version", help="Show version and exit.", is_eager=True),
):
if version:
console.print(__version__)
raise typer.Exit()
@remote_app.command("add")
def add_remote(name: str, url: str):
try:
remote = remote_add(_store(), name, url)
console.print(f"已保存 remote {remote.name}: {remote.url}")
except ConfigError as exc:
_handle_error(exc)
@remote_app.command("use")
def use_remote(name: str):
try:
remote = remote_use(_store(), name)
console.print(f"当前 remote: {remote.name}")
except ConfigError as exc:
_handle_error(exc)
@remote_app.command("list")
def list_remotes():
try:
remote_list(_store(), console)
except ConfigError as exc:
_handle_error(exc)
@remote_app.command("ping")
def ping_remote(name: str | None = typer.Argument(None)):
store = _store()
try:
_print_remote_context(store, name)
remote_ping(store, name, console)
except (ConfigError, ClientError) as exc:
_handle_error(exc)
@app.command()
def login(
remote: str | None = typer.Option(None, "--remote", help="Remote name."),
browser: bool = typer.Option(False, "--browser", help="Use browser login."),
api_key: str | None = typer.Option(None, "--api-key", help="Import an existing API Key."),
no_open: bool = typer.Option(False, "--no-open", help="Print browser URL without opening it."),
):
if browser and api_key:
_handle_error(CommandError("--browser 和 --api-key 不能同时使用"))
store = _store()
try:
_print_remote_context(store, remote)
if api_key:
login_with_api_key(store, remote, api_key, console)
return
if browser:
login_with_browser(store, remote, no_open, console)
return
mode = select_login_mode(console)
if mode == "api_key":
typed_key = typer.prompt("API Key", hide_input=True)
login_with_api_key(store, remote, typed_key, console)
else:
login_with_browser(store, remote, no_open, console)
except (ConfigError, ClientError, CommandError) as exc:
_handle_error(exc)
@app.command()
def whoami(remote: str | None = typer.Option(None, "--remote", help="Remote name.")):
store = _store()
try:
_print_remote_context(store, remote)
whoami_command(store, remote, console)
except (ConfigError, ClientError, CommandError) as exc:
_handle_error(exc)
@app.command()
def status(remote: str | None = typer.Option(None, "--remote", help="Remote name.")):
store = _store()
try:
_print_remote_context(store, remote)
status_command(store, remote, console)
except (ConfigError, ClientError) as exc:
_handle_error(exc)
@app.command()
def logout(
remote: str | None = typer.Option(None, "--remote", help="Remote name."),
local_only: bool = typer.Option(False, "--local-only", help="Only remove local credentials."),
):
store = _store()
try:
_print_remote_context(store, remote)
logout_command(store, remote, local_only, console)
except (ConfigError, ClientError) as exc:
_handle_error(exc)
@kb_app.command("upload")
def upload_knowledge_base_files(
path: Path = typer.Argument(..., help="File or directory to upload."),
kb_id: str | None = typer.Option(None, "--kb-id", help="Knowledge base ID. Prompt when omitted."),
remote: str | None = typer.Option(None, "--remote", help="Remote name."),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
concurrency: int = typer.Option(
DEFAULT_CONCURRENCY,
"--concurrency",
help=f"Concurrent upload count, 1-{MAX_CONCURRENCY}.",
),
include_ext: str | None = typer.Option(None, "--include-ext", help="Comma separated extension allowlist."),
exclude_ext: str | None = typer.Option(None, "--exclude-ext", help="Comma separated extension denylist."),
force_upload_file: bool = typer.Option(
False,
"--force-upload-file",
help="Skip remote filename existence check before upload.",
),
):
options = KbUploadOptions(
path=path,
kb_id=kb_id,
yes=yes,
concurrency=concurrency,
include_ext=include_ext,
exclude_ext=exclude_ext,
force_upload_file=force_upload_file,
)
store = _store()
try:
_print_remote_context(store, remote)
run_kb_upload(store, remote, options, console)
except (ConfigError, ClientError, KbUploadError) as exc:
_handle_error(exc)
@agent_app.command("eval")
def eval_agent(
dataset_name: str = typer.Option(..., "--dataset-name", help="Langfuse dataset name."),
agent_slug: str = typer.Option(..., "--agent-slug", help="Yuxi agent slug."),
experiment_name: str | None = typer.Option(None, "--experiment-name", help="Langfuse experiment name."),
remote: str | None = typer.Option(None, "--remote", help="Remote name."),
max_concurrency: int = typer.Option(1, "--max-concurrency", help="Langfuse experiment max concurrency."),
timeout_seconds: float = typer.Option(900, "--timeout-seconds", help="Per item Yuxi API timeout."),
):
options = AgentEvalOptions(
dataset_name=dataset_name,
agent_slug=agent_slug,
experiment_name=experiment_name,
max_concurrency=max_concurrency,
timeout_seconds=timeout_seconds,
)
store = _store()
try:
_print_remote_context(store, remote)
run_langfuse_agent_experiment(store, remote, options, console)
except (ConfigError, ClientError, AgentEvalError) as exc:
_handle_error(exc)
+186
View File
@@ -0,0 +1,186 @@
from __future__ import annotations
import io
from types import SimpleNamespace
import pytest
from rich.console import Console
from yuxi_cli.agent_eval import AgentEvalError, AgentEvalOptions, extract_query, run_langfuse_agent_experiment
from yuxi_cli.config import ConfigStore, Remote
class FakeResult:
def __init__(self, outputs: list[str]):
self.item_results = outputs
self.output = outputs[0] if outputs else ""
@classmethod
def single(cls, output: str):
return cls([output])
def format(self, *, include_item_results: bool) -> str:
assert include_item_results is True
return f"formatted: {self.output}"
class FakeDataset:
def __init__(self, item, *, result: FakeResult | None = None):
self.item = item
self.items = [item]
self.result = result
self.run_kwargs = None
def run_experiment(self, **kwargs):
self.run_kwargs = kwargs
output = kwargs["task"](item=self.item)
return self.result or FakeResult.single(output)
class FakePartialDataset:
def __init__(self):
self.items = [
SimpleNamespace(id="item-1", input="hello"),
SimpleNamespace(id="item-2", input="world"),
]
self.run_kwargs = None
def run_experiment(self, **kwargs):
self.run_kwargs = kwargs
kwargs["task"](item=self.items[0])
return FakeResult.single("final answer")
class FakeLangfuse:
def __init__(self, dataset):
self.dataset = dataset
self.flushed = 0
def get_dataset(self, name: str):
assert name == "agent-eval-smoke"
return self.dataset
def flush(self):
self.flushed += 1
class FakeYuxiClient:
calls = []
def __init__(self, remote: Remote, timeout: float = 30.0):
self.remote = remote
self.timeout = timeout
def __enter__(self):
return self
def __exit__(self, *_exc):
return None
def run_agent_eval(self, **kwargs):
self.calls.append({"remote": self.remote, "client_timeout": self.timeout, "kwargs": kwargs})
return {"status": "completed", "output": "final answer"}
def _console():
return Console(file=io.StringIO(), force_terminal=False)
def test_extract_query_supports_string_and_common_fields():
assert extract_query("hello") == "hello"
assert extract_query({"query": "hello"}) == "hello"
assert extract_query({"question": "hello"}) == "hello"
assert extract_query({"prompt": "hello"}) == "hello"
def test_extract_query_rejects_unrecognized_input():
with pytest.raises(AgentEvalError, match="无法从 Langfuse dataset item input 中提取 query"):
extract_query({"text": "hello"})
def test_run_langfuse_agent_experiment_uses_remote_api_key(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
config = store.load()
remote = config.get_remote("local")
remote.api_key = "yxkey_local"
store.save(config)
dataset = FakeDataset(SimpleNamespace(id="item-1", input={"query": "2+2=?"}))
langfuse = FakeLangfuse(dataset)
console = _console()
FakeYuxiClient.calls = []
run_langfuse_agent_experiment(
store,
None,
AgentEvalOptions(
dataset_name="agent-eval-smoke",
agent_slug="default-chatbot",
experiment_name="exp-1",
max_concurrency=2,
timeout_seconds=123,
),
console,
langfuse_factory=lambda: langfuse,
client_factory=FakeYuxiClient,
)
assert dataset.run_kwargs["name"] == "exp-1"
assert dataset.run_kwargs["max_concurrency"] == 2
assert dataset.run_kwargs["metadata"] == {
"source": "agent_evaluation",
"agent_slug": "default-chatbot",
"dataset_name": "agent-eval-smoke",
"remote": "local",
}
call = FakeYuxiClient.calls[0]
assert call["remote"].name == "local"
assert call["client_timeout"] == 123
assert call["kwargs"]["query"] == "2+2=?"
assert call["kwargs"]["agent_slug"] == "default-chatbot"
assert "api_key" not in call["kwargs"]
assert call["kwargs"]["timeout_seconds"] == 123
assert call["kwargs"]["evaluation"] == {
"dataset_name": "agent-eval-smoke",
"dataset_item_id": "item-1",
"experiment_name": "exp-1",
}
assert call["kwargs"]["meta"]["request_id"].startswith("eval-")
assert "formatted: final answer" in console.file.getvalue()
assert langfuse.flushed == 1
def test_run_langfuse_agent_experiment_requires_login(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
with pytest.raises(AgentEvalError, match="remote 尚未登录"):
run_langfuse_agent_experiment(
store,
None,
AgentEvalOptions(dataset_name="agent-eval-smoke", agent_slug="default-chatbot"),
_console(),
langfuse_factory=lambda: FakeLangfuse(FakeDataset(SimpleNamespace(id="1", input="hello"))),
client_factory=FakeYuxiClient,
)
def test_run_langfuse_agent_experiment_rejects_partial_langfuse_results(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
config = store.load()
config.get_remote("local").api_key = "yxkey_local"
store.save(config)
langfuse = FakeLangfuse(FakePartialDataset())
FakeYuxiClient.calls = []
with pytest.raises(AgentEvalError, match="1/2 个 item 成功写入"):
run_langfuse_agent_experiment(
store,
None,
AgentEvalOptions(
dataset_name="agent-eval-smoke",
agent_slug="default-chatbot",
experiment_name="exp-1",
),
_console(),
langfuse_factory=lambda: langfuse,
client_factory=FakeYuxiClient,
)
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from yuxi_cli.client import YuxiClient
from yuxi_cli.config import Remote
def test_run_agent_eval_uses_invocation_endpoint(monkeypatch):
client = YuxiClient(Remote(name="local", url="http://localhost:5173", api_key="yxkey_test"))
calls: dict[str, object] = {}
def fake_request(method, path, **kwargs):
calls["method"] = method
calls["path"] = path
calls["kwargs"] = kwargs
return {"status": "completed", "output": "final answer"}
monkeypatch.setattr(client, "_request", fake_request)
try:
result = client.run_agent_eval(
query="2+2=?",
agent_slug="default-chatbot",
evaluation={"dataset_name": "dataset-1"},
meta={"request_id": "req-1"},
timeout_seconds=123,
)
finally:
client.close()
assert result == {"status": "completed", "output": "final answer"}
assert calls["method"] == "POST"
assert calls["path"] == "/agent-invocation/eval/runs"
assert calls["kwargs"]["timeout"] == 123
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
import io
import pytest
from rich.console import Console
from yuxi_cli.client import CLIAuthSession, ClientError
from yuxi_cli.commands import CommandError, login_with_api_key, login_with_browser, logout
from yuxi_cli.config import ConfigStore, Remote
class FakeClient:
def __init__(self, remote: Remote):
self.remote = remote
self.exchanges = 0
self.deleted_api_key_id = None
def __enter__(self):
return self
def __exit__(self, *_exc):
return None
def discovery(self):
return {
"version": "0.7.1",
"capabilities": {
"cli": {
"browser_login": True,
"api_key_auth": True,
}
},
}
def me(self, api_key=None):
assert api_key == "yxkey_existing" or self.remote.api_key
return {
"id": 1,
"uid": "admin",
"username": "Admin",
"role": "superadmin",
}
def create_cli_session(self):
return CLIAuthSession(
device_code="yxcli_device",
user_code="ABCD-EFGH",
verification_uri="/auth/cli/authorize",
expires_in=30,
interval=1,
)
def authorize_url(self, session):
return f"{self.remote.url}{session.authorize_path}"
def exchange_cli_token(self, _device_code):
self.exchanges += 1
if self.exchanges == 1:
raise ClientError("authorization_pending: 等待浏览器授权")
return {
"secret": "yxkey_browser",
"api_key": {"id": 42},
"user": {"id": 1, "uid": "admin", "username": "Admin", "role": "superadmin"},
}
def delete_api_key(self, api_key_id):
self.deleted_api_key_id = api_key_id
return {"success": True}
def _console():
return Console(file=io.StringIO(), force_terminal=False)
def test_login_with_api_key_saves_remote_credentials(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
remote = login_with_api_key(store, None, "yxkey_existing", _console(), client_factory=FakeClient)
loaded = store.load().get_remote("local")
assert remote.api_key == "yxkey_existing"
assert loaded.api_key == "yxkey_existing"
def test_login_with_browser_polls_until_token_and_saves_credentials(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
opened = []
sleeps = []
clock = {"value": 0}
def monotonic():
clock["value"] += 1
return clock["value"]
remote = login_with_browser(
store,
None,
no_open=False,
console=_console(),
client_factory=FakeClient,
open_browser=lambda url: opened.append(url) or True,
sleep=lambda seconds: sleeps.append(seconds),
monotonic=monotonic,
)
assert opened == ["http://localhost:5173/auth/cli/authorize?user_code=ABCD-EFGH"]
assert sleeps == [1]
assert remote.api_key == "yxkey_browser"
assert remote.api_key_id == "42"
assert store.load().get_remote("local").api_key == "yxkey_browser"
def test_login_rejects_unsupported_server_version(tmp_path):
class OldServerClient(FakeClient):
def discovery(self):
return {
"version": "0.7.0",
"capabilities": {
"cli": {
"browser_login": True,
"api_key_auth": True,
}
},
}
store = ConfigStore(tmp_path / "config.toml")
with pytest.raises(CommandError, match="低于 CLI 要求 0.7.1"):
login_with_api_key(store, None, "yxkey_existing", _console(), client_factory=OldServerClient)
def test_login_with_browser_retries_transient_errors(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
clock = {"value": 0}
class FlakyClient(FakeClient):
def exchange_cli_token(self, _device_code):
self.exchanges += 1
if self.exchanges == 1:
raise ClientError("请求远程失败: 连接被重置") # 网络层错误,status_code=None
if self.exchanges == 2:
raise ClientError("HTTP 503", status_code=503) # 服务端瞬时错误
return {
"secret": "yxkey_browser",
"api_key": {"id": 42},
"user": {"id": 1, "uid": "admin", "username": "Admin", "role": "superadmin"},
}
remote = login_with_browser(
store,
None,
no_open=True,
console=_console(),
client_factory=FlakyClient,
open_browser=lambda url: True,
sleep=lambda seconds: None,
monotonic=lambda: clock.__setitem__("value", clock["value"] + 1) or clock["value"],
)
assert remote.api_key == "yxkey_browser"
def test_login_with_browser_aborts_on_terminal_error(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
clock = {"value": 0}
class ExpiredClient(FakeClient):
def exchange_cli_token(self, _device_code):
raise ClientError("expired_token: 授权会话已过期", error_code="expired_token", status_code=410)
with pytest.raises(ClientError, match="expired_token"):
login_with_browser(
store,
None,
no_open=True,
console=_console(),
client_factory=ExpiredClient,
open_browser=lambda url: True,
sleep=lambda seconds: None,
monotonic=lambda: clock.__setitem__("value", clock["value"] + 1) or clock["value"],
)
def test_logout_clears_local_credentials(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
config = store.load()
remote = config.get_remote("local")
remote.api_key = "yxkey_browser"
remote.api_key_id = "42"
store.save(config)
logout(store, None, local_only=True, console=_console(), client_factory=FakeClient)
loaded = store.load().get_remote("local")
assert loaded.api_key == ""
assert loaded.api_key_id == ""
+69
View File
@@ -0,0 +1,69 @@
from yuxi_cli.config import ConfigStore, normalize_remote_url
def test_normalize_remote_url_adds_scheme_and_removes_api_suffix():
assert normalize_remote_url("localhost:5173") == "http://localhost:5173"
assert normalize_remote_url("https://example.com/api") == "https://example.com"
assert normalize_remote_url("https://example.com/yuxi/api") == "https://example.com/yuxi"
def test_config_store_uses_implicit_local_default(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
config = store.load()
assert config.current == "local"
assert config.remotes["local"].url == "http://localhost:5173"
def test_config_store_persists_remote_credentials(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
config = store.load()
remote = config.set_remote("prod", "https://example.com/api")
remote.api_key = "yxkey_test"
remote.api_key_id = "12"
config.use_remote("prod")
store.save(config)
loaded = store.load()
assert loaded.current == "prod"
assert loaded.remotes["prod"].url == "https://example.com"
assert loaded.remotes["prod"].api_key == "yxkey_test"
assert loaded.remotes["prod"].api_key_id == "12"
def test_remote_url_change_clears_credentials(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
config = store.load()
remote = config.set_remote("prod", "https://one.example.com")
remote.api_key = "yxkey_old"
remote.api_key_id = "12"
config.set_remote("prod", "https://two.example.com")
assert config.remotes["prod"].api_key == ""
assert config.remotes["prod"].api_key_id == ""
def test_config_escapes_special_chars_in_remote_name(tmp_path):
store = ConfigStore(tmp_path / "config.toml")
config = store.load()
# remote 名称由用户输入,可能包含引号/反斜杠,需转义后仍能往返解析。
name = 'pr"o\\d'
config.set_remote(name, "https://example.com")
config.use_remote(name)
store.save(config)
loaded = store.load()
assert loaded.current == name
assert loaded.remotes[name].url == "https://example.com"
def test_config_file_is_created_with_owner_only_permissions(tmp_path):
import stat as stat_module
path = tmp_path / "config.toml"
store = ConfigStore(path)
store.save(store.load())
mode = stat_module.S_IMODE(path.stat().st_mode)
assert mode == 0o600
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import pytest
from yuxi_cli.discovery import ServerCompatibilityError, ensure_server_compatible, is_server_version_supported
@pytest.mark.parametrize(
("version", "expected"),
[
("0.7.1", True),
("0.7.2", True),
("0.7.1.post1", True),
("0.7.1.dev0", True),
("0.7.2.dev0", True),
("0.7.0", False),
("0.7.0.dev99", False),
("0.7.1rc1", False),
("unknown", False),
],
)
def test_is_server_version_supported_handles_dev_releases(version, expected):
assert is_server_version_supported(version) is expected
def test_ensure_server_compatible_requires_capability():
discovery = {
"version": "0.7.1",
"capabilities": {
"cli": {
"browser_login": False,
"api_key_auth": True,
}
},
}
with pytest.raises(ServerCompatibilityError, match="cli.browser_login"):
ensure_server_compatible(discovery, "cli.browser_login")
+620
View File
@@ -0,0 +1,620 @@
from __future__ import annotations
import io
import threading
import time
from collections import Counter
from concurrent.futures import Future
from pathlib import Path
import pytest
from rich.console import Console
import yuxi_cli.kb_upload as kb_upload_module
from yuxi_cli.client import ClientError
from yuxi_cli.config import ConfigStore, Remote
from yuxi_cli.kb_upload import (
ALREADY_EXISTS_MESSAGE,
ALREADY_UPLOADED_MESSAGE,
DEFAULT_CONCURRENCY,
ExtensionOption,
KbUploadOptions,
KbUploadError,
KbUploadSummary,
LocalFile,
MAX_CONCURRENCY,
SkippedFile,
_database_choices,
_format_unsupported_summary,
_extension_choices,
_extension_option_label,
_print_selection_summary,
run_kb_upload,
upload_files,
)
class FakeKbClient:
uploaded: list[str] = []
add_payload: dict | None = None
add_payloads: list[dict] = []
events: list[tuple[str, str | list[str]]] = []
exists_checks: list[str] = []
existing_files: set[str] = set()
active_uploads = 0
max_active_uploads = 0
lock = threading.Lock()
def __init__(self, remote: Remote):
self.remote = remote
def __enter__(self):
return self
def __exit__(self, *_exc):
return None
@classmethod
def reset(cls) -> None:
cls.uploaded = []
cls.add_payload = None
cls.add_payloads = []
cls.events = []
cls.exists_checks = []
cls.existing_files = set()
cls.active_uploads = 0
cls.max_active_uploads = 0
def discovery(self):
return {
"version": "0.7.1",
"capabilities": {"cli": {"kb_upload": True}},
}
def get_database(self, kb_id: str):
return {"kb_id": kb_id, "name": "Test KB", "kb_type": "milvus"}
def list_databases(self):
return {
"databases": [
{"kb_id": "kb_1", "name": "Milvus KB", "kb_type": "milvus"},
{"kb_id": "dify_1", "name": "Dify KB", "kb_type": "dify"},
]
}
def get_knowledge_base_types(self):
return {
"kb_types": {
"milvus": {"supports_documents": True},
"dify": {"supports_documents": False},
}
}
def get_supported_file_types(self):
return {
"file_types": [
".bmp",
".csv",
".docx",
".html",
".htm",
".jpeg",
".jpg",
".json",
".md",
".pdf",
".png",
".pptx",
".tif",
".tiff",
".txt",
".xls",
".xlsx",
]
}
def knowledge_document_exists(self, kb_id: str, filename: str) -> bool:
with self.lock:
type(self).exists_checks.append(filename)
type(self).events.append(("exists", filename))
return filename in type(self).existing_files
def upload_knowledge_file(self, kb_id: str, path: Path):
with self.lock:
type(self).active_uploads += 1
type(self).max_active_uploads = max(type(self).max_active_uploads, type(self).active_uploads)
try:
time.sleep(0.02)
with self.lock:
type(self).uploaded.append(path.name)
type(self).events.append(("upload", path.name))
return {
"file_path": f"minio://knowledgebases/{kb_id}/upload/{path.name}",
"content_hash": f"hash-{path.name}",
"size": path.stat().st_size,
}
finally:
with self.lock:
type(self).active_uploads -= 1
def add_uploaded_documents(self, kb_id: str, items: list[str], params: dict):
payload = {"kb_id": kb_id, "items": list(items), "params": params}
item_names = [item.rsplit("/", 1)[-1] for item in items]
with self.lock:
type(self).add_payloads.append(payload)
type(self).events.append(("add", item_names))
if type(self).add_payload is None:
type(self).add_payload = {
"kb_id": kb_id,
"items": list(items),
"params": {
"content_type": params.get("content_type"),
"content_hashes": dict(params.get("content_hashes") or {}),
"file_sizes": dict(params.get("file_sizes") or {}),
"source_paths": dict(params.get("source_paths") or {}),
},
}
else:
type(self).add_payload["items"].extend(items)
type(self).add_payload["params"]["content_hashes"].update(params.get("content_hashes") or {})
type(self).add_payload["params"]["file_sizes"].update(params.get("file_sizes") or {})
type(self).add_payload["params"]["source_paths"].update(params.get("source_paths") or {})
return {"status": "success", "added": len(items), "failed": 0, "items": [], "failed_items": []}
def _console():
return Console(file=io.StringIO(), force_terminal=False)
def _store(tmp_path: Path) -> ConfigStore:
store = ConfigStore(tmp_path / "config.toml")
config = store.load()
remote = config.get_remote("local")
remote.api_key = "yxkey_test"
store.save(config)
return store
def test_kb_upload_default_include_excludes_structured_and_presentation_files(tmp_path):
FakeKbClient.reset()
for name in [
"a.md",
"b.txt",
"c.docx",
"d.html",
"e.htm",
"f.json",
"g.csv",
"h.xls",
"i.xlsx",
"j.pptx",
"k.pdf",
]:
(tmp_path / name).write_text("demo", encoding="utf-8")
run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, kb_id="kb_1", yes=True, concurrency=2),
_console(),
client_factory=FakeKbClient,
)
assert sorted(FakeKbClient.uploaded) == ["a.md", "b.txt", "c.docx", "d.html", "e.htm"]
assert FakeKbClient.add_payload is not None
assert len(FakeKbClient.add_payload["items"]) == 5
def test_kb_upload_default_concurrency_is_10():
assert KbUploadOptions(path=Path(".")).concurrency == DEFAULT_CONCURRENCY == 10
def test_kb_upload_allows_concurrency_300(tmp_path):
FakeKbClient.reset()
(tmp_path / "note.md").write_text("demo", encoding="utf-8")
run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, kb_id="kb_1", yes=True, concurrency=MAX_CONCURRENCY),
_console(),
client_factory=FakeKbClient,
)
assert FakeKbClient.uploaded == ["note.md"]
def test_kb_upload_rejects_concurrency_above_300(tmp_path):
(tmp_path / "note.md").write_text("demo", encoding="utf-8")
with pytest.raises(KbUploadError, match="1 到 300"):
run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, kb_id="kb_1", yes=True, concurrency=MAX_CONCURRENCY + 1),
_console(),
client_factory=FakeKbClient,
)
def test_kb_upload_preserves_relative_source_paths(tmp_path):
FakeKbClient.reset()
docs_dir = tmp_path / "docs" / "guide"
docs_dir.mkdir(parents=True)
(docs_dir / "intro.md").write_text("intro", encoding="utf-8")
(tmp_path / "root.txt").write_text("root", encoding="utf-8")
run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, kb_id="kb_1", yes=True, concurrency=2),
_console(),
client_factory=FakeKbClient,
)
assert FakeKbClient.add_payload is not None
source_paths = FakeKbClient.add_payload["params"]["source_paths"]
assert source_paths == {
"minio://knowledgebases/kb_1/upload/intro.md": "docs/guide/intro.md",
"minio://knowledgebases/kb_1/upload/root.txt": "root.txt",
}
def test_kb_upload_without_kb_id_selects_only_uploadable_database(tmp_path):
FakeKbClient.reset()
(tmp_path / "note.md").write_text("demo", encoding="utf-8")
run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, yes=True, concurrency=2),
_console(),
client_factory=FakeKbClient,
)
assert FakeKbClient.add_payload is not None
assert FakeKbClient.add_payload["kb_id"] == "kb_1"
assert FakeKbClient.add_payload["items"] == ["minio://knowledgebases/kb_1/upload/note.md"]
def test_kb_upload_include_ext_allows_non_default_supported_types(tmp_path):
FakeKbClient.reset()
(tmp_path / "data.xlsx").write_text("demo", encoding="utf-8")
(tmp_path / "slides.pptx").write_text("demo", encoding="utf-8")
(tmp_path / "note.md").write_text("demo", encoding="utf-8")
run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, kb_id="kb_1", yes=True, concurrency=2, include_ext="xlsx,pptx"),
_console(),
client_factory=FakeKbClient,
)
assert sorted(FakeKbClient.uploaded) == ["data.xlsx", "slides.pptx"]
def test_kb_upload_limits_upload_concurrency(tmp_path):
FakeKbClient.reset()
for index in range(6):
(tmp_path / f"{index}.md").write_text("demo", encoding="utf-8")
run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, kb_id="kb_1", yes=True, concurrency=2),
_console(),
client_factory=FakeKbClient,
)
assert FakeKbClient.max_active_uploads <= 2
assert FakeKbClient.max_active_uploads > 1
def test_upload_files_limits_pending_submissions(monkeypatch, tmp_path):
FakeKbClient.reset()
files = []
for index in range(5):
path = tmp_path / f"{index}.md"
path.write_text("demo", encoding="utf-8")
files.append(LocalFile(path, path.name, ".md", path.stat().st_size))
state = {"wait_started": False, "submitted_before_wait": 0}
class TrackingExecutor:
def __init__(self, max_workers: int):
self.max_workers = max_workers
def __enter__(self):
return self
def __exit__(self, *_exc):
return None
def submit(self, fn, item):
if not state["wait_started"]:
state["submitted_before_wait"] += 1
future = Future()
future.set_result(fn(item))
return future
def tracking_wait(pending, *, return_when):
assert return_when == kb_upload_module.FIRST_COMPLETED
state["wait_started"] = True
completed = {next(iter(pending))}
return completed, set(pending) - completed
monkeypatch.setattr(kb_upload_module, "ThreadPoolExecutor", TrackingExecutor)
monkeypatch.setattr(kb_upload_module, "wait", tracking_wait)
uploaded, failed, add_response = upload_files(
Remote(name="local", url="http://localhost", api_key="yxkey_test"),
FakeKbClient,
"kb_1",
files,
concurrency=2,
console=_console(),
)
assert len(uploaded) == 5
assert failed == []
assert add_response is not None
assert state["submitted_before_wait"] == 2
def test_kb_upload_treats_duplicate_content_as_already_uploaded(tmp_path):
class DuplicateContentClient(FakeKbClient):
def upload_knowledge_file(self, kb_id: str, path: Path):
raise ClientError(ALREADY_UPLOADED_MESSAGE, status_code=400)
FakeKbClient.reset()
(tmp_path / "note.md").write_text("demo", encoding="utf-8")
buffer = io.StringIO()
console = Console(file=buffer, force_terminal=False)
summary = run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, kb_id="kb_1", yes=True, concurrency=2),
console,
client_factory=DuplicateContentClient,
)
assert summary.uploaded == []
assert summary.already_uploaded_count == 1
assert summary.real_upload_failed_count == 0
assert summary.add_failed_count == 0
output = buffer.getvalue()
assert "已上传过: 1" in output
assert "上传失败: 0" in output
def test_kb_upload_skips_existing_relative_path_before_upload(tmp_path):
FakeKbClient.reset()
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "existing.md").write_text("existing", encoding="utf-8")
(docs_dir / "new.md").write_text("new", encoding="utf-8")
FakeKbClient.existing_files = {"docs/existing.md"}
summary = run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, kb_id="kb_1", yes=True, concurrency=2),
_console(),
client_factory=FakeKbClient,
)
assert sorted(FakeKbClient.exists_checks) == ["docs/existing.md", "docs/new.md"]
assert FakeKbClient.uploaded == ["new.md"]
assert FakeKbClient.add_payload is not None
assert FakeKbClient.add_payload["params"]["source_paths"] == {
"minio://knowledgebases/kb_1/upload/new.md": "docs/new.md"
}
assert summary.already_uploaded_count == 1
assert summary.upload_failed[0].error == ALREADY_EXISTS_MESSAGE
assert ("upload", "existing.md") not in FakeKbClient.events
def test_kb_upload_falls_back_to_upload_when_exists_check_fails(tmp_path):
class ExistsCheckFailedClient(FakeKbClient):
def knowledge_document_exists(self, kb_id: str, filename: str) -> bool:
with self.lock:
type(self).exists_checks.append(filename)
type(self).events.append(("exists-error", filename))
raise ClientError("exists endpoint unavailable", status_code=404)
FakeKbClient.reset()
(tmp_path / "note.md").write_text("demo", encoding="utf-8")
summary = run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, kb_id="kb_1", yes=True, concurrency=2),
_console(),
client_factory=ExistsCheckFailedClient,
)
assert FakeKbClient.exists_checks == ["note.md"]
assert FakeKbClient.uploaded == ["note.md"]
assert summary.already_uploaded_count == 0
assert summary.add_response is not None
assert summary.add_response["added"] == 1
def test_kb_upload_force_upload_file_skips_exists_check(tmp_path):
FakeKbClient.reset()
(tmp_path / "note.md").write_text("demo", encoding="utf-8")
FakeKbClient.existing_files = {"note.md"}
summary = run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, kb_id="kb_1", yes=True, concurrency=2, force_upload_file=True),
_console(),
client_factory=FakeKbClient,
)
assert FakeKbClient.exists_checks == []
assert FakeKbClient.uploaded == ["note.md"]
assert summary.already_uploaded_count == 0
assert summary.add_response is not None
assert summary.add_response["added"] == 1
def test_kb_upload_adds_each_document_after_its_upload(tmp_path):
FakeKbClient.reset()
for index in range(5):
(tmp_path / f"{index}.md").write_text("demo", encoding="utf-8")
summary = run_kb_upload(
_store(tmp_path),
None,
KbUploadOptions(path=tmp_path, kb_id="kb_1", yes=True, concurrency=2),
_console(),
client_factory=FakeKbClient,
)
added_names = [[item.rsplit("/", 1)[-1] for item in payload["items"]] for payload in FakeKbClient.add_payloads]
assert all(len(items) == 1 for items in added_names)
assert sorted(item for items in added_names for item in items) == [f"{index}.md" for index in range(5)]
for index in range(5):
name = f"{index}.md"
upload_index = FakeKbClient.events.index(("upload", name))
add_index = FakeKbClient.events.index(("add", [name]))
assert upload_index < add_index
assert summary.add_response is not None
assert summary.add_response["added"] == 5
def test_upload_files_uses_log_progress_for_non_tty_console(tmp_path):
FakeKbClient.reset()
files = []
for index in range(3):
path = tmp_path / f"{index}.md"
path.write_text("demo", encoding="utf-8")
files.append(LocalFile(path, path.name, ".md", path.stat().st_size))
buffer = io.StringIO()
console = Console(file=buffer, force_terminal=False)
uploaded, failed, add_response = upload_files(
Remote(name="local", url="http://localhost", api_key="yxkey_test"),
FakeKbClient,
"kb_1",
files,
concurrency=2,
console=console,
)
output = buffer.getvalue()
assert len(uploaded) == 3
assert failed == []
assert add_response is not None
assert add_response["added"] == 3
assert "处理进度: 3/3" in output
assert "" not in output
def test_upload_files_uses_progress_bar_for_tty_console(tmp_path):
FakeKbClient.reset()
files = []
for index in range(3):
path = tmp_path / f"{index}.md"
path.write_text("demo", encoding="utf-8")
files.append(LocalFile(path, path.name, ".md", path.stat().st_size))
buffer = io.StringIO()
console = Console(file=buffer, force_terminal=True, color_system=None, width=100)
uploaded, failed, add_response = upload_files(
Remote(name="local", url="http://localhost", api_key="yxkey_test"),
FakeKbClient,
"kb_1",
files,
concurrency=2,
console=console,
)
output = buffer.getvalue()
assert len(uploaded) == 3
assert failed == []
assert add_response is not None
assert add_response["added"] == 3
assert "处理进度" in output
assert "处理进度:" not in output
assert "0.md" not in output
def test_database_choices_use_labels_without_numbering():
choices = _database_choices(
[
{"kb_id": "kb_1", "name": "Alpha", "kb_type": "milvus"},
{"kb_id": "kb_2", "name": "Beta", "kb_type": "milvus"},
]
)
assert choices[0].title == "Alpha [milvus] kb_1"
assert choices[0].value == 0
assert choices[1].title == "Beta [milvus] kb_2"
assert choices[1].value == 1
assert "1" not in str(choices[0].title).split("Alpha", 1)[0]
def test_extension_choices_show_counts_and_default_selection():
choices = _extension_choices(
[
ExtensionOption(".html", 101),
ExtensionOption(".md", 165),
ExtensionOption(".txt", 2),
ExtensionOption(".json", 9),
],
selected_extensions={".html", ".md", ".txt"},
)
assert [choice.title for choice in choices] == ["html (101)", "md (165)", "txt (2)", "json (9)"]
assert [choice.value for choice in choices] == [".html", ".md", ".txt", ".json"]
assert [choice.checked for choice in choices] == [True, True, True, False]
assert _extension_option_label(ExtensionOption(".tar.gz", 3)) == "tar.gz (3)"
def test_unsupported_summary_truncates_extensions_without_per_extension_counts():
summary = _format_unsupported_summary(
Counter(
{
".py": 100,
".json": 90,
".js": 80,
".ts": 70,
".map": 60,
".css": 50,
".yaml": 40,
".lock": 30,
".mjs": 20,
".cjs": 10,
}
)
)
assert summary == "不支持: 550 (.py, .json, .js, .ts, .map, .css, .yaml, .lock, 等 2 类)"
assert ".py 100" not in summary
assert ".json 90" not in summary
def test_selection_summary_shows_compact_selected_type_summary(tmp_path):
selected = [LocalFile(tmp_path / "a.md", "a.md", ".md", 4)]
skipped = [
SkippedFile(tmp_path / "b.json", "b.json", "not-included"),
SkippedFile(tmp_path / "c.py", "c.py", "unsupported"),
]
buffer = io.StringIO()
console = Console(file=buffer, force_terminal=False)
_print_selection_summary(KbUploadSummary(scanned=3, selected=selected, skipped=skipped), console)
output = buffer.getvalue()
assert " 扫描文件: 3" in output
assert " 将上传: 1 (.md)" in output
assert " 未选择: 1" in output
assert " 不支持: 1 (.py)" in output
assert "文件类型:" not in output
assert "[x]" not in output
assert "[ ]" not in output
+53
View File
@@ -0,0 +1,53 @@
from typer.testing import CliRunner
from yuxi_cli import __version__
from yuxi_cli.config import ConfigStore
from yuxi_cli.main import app
def test_version_option_without_command():
result = CliRunner().invoke(app, ["--version"])
assert result.exit_code == 0
assert __version__ in result.output
def test_agent_eval_help_is_registered():
result = CliRunner().invoke(app, ["agent", "eval", "--help"])
assert result.exit_code == 0
assert "--dataset-name" in result.output
assert "--create-smoke-item" not in result.output
assert "--auth-token" not in result.output
def test_kb_upload_help_is_registered():
result = CliRunner().invoke(app, ["kb", "upload", "--help"])
assert result.exit_code == 0
assert "--kb-id" in result.output
assert "--concurrency" in result.output
assert "--force-upload-file" in result.output
assert "1-300" in result.output
def test_remote_command_prints_version_and_remote_context_first(tmp_path, monkeypatch):
store = ConfigStore(tmp_path / "config.toml")
config = store.load()
remote = config.get_remote("local")
remote.url = "https://example.com"
store.save(config)
def fake_remote_ping(store_arg, name, console):
assert store_arg is store
assert name is None
console.print("pong")
monkeypatch.setattr("yuxi_cli.main._store", lambda: store)
monkeypatch.setattr("yuxi_cli.main.remote_ping", fake_remote_ping)
result = CliRunner().invoke(app, ["remote", "ping"])
assert result.exit_code == 0
lines = [line.strip() for line in result.output.splitlines() if line.strip()]
assert lines[:3] == [f"Yuxi CLI {__version__}", "Remote: local https://example.com", "pong"]
+680
View File
@@ -0,0 +1,680 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]
[[package]]
name = "backoff"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "googleapis-common-protos"
version = "1.75.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "langfuse"
version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
{ name = "httpx" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-sdk" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/ba/3a969cad28bcef7f42e0fd052df4cf54004268a3a405108e3e2e03055678/langfuse-4.12.0.tar.gz", hash = "sha256:af4c12ae61f69726e0ac73d14a4ada1a119013d5558598828d382f2a238d7314", size = 348143, upload-time = "2026-06-25T11:57:12.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/f3/da8f6972566cfb9eb701a0d6a48668ab3e5ba6ae4ffeabb85e24a10e02ac/langfuse-4.12.0-py3-none-any.whl", hash = "sha256:55f6231d1e64dc6e9debe776cb3f06eb93ca7afb51322ce54571fa7493ac05c9", size = 609201, upload-time = "2026-06-25T11:57:10.497Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/c1/e8098490ab15abf116dcaf9fa89ededcb35547c7d08d4b5a62f573dc1e63/opentelemetry_exporter_otlp_proto_common-1.43.0.tar.gz", hash = "sha256:c4e32ba6d6b13bdb2b8f6764c4fd28d00192826561aa04f6d14eedfce7ac076f", size = 20197, upload-time = "2026-06-24T15:20:00.247Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/b2/41ebc74ae1d5859901f1b69305de58724bf043381103d6ef413521cbc35a/opentelemetry_exporter_otlp_proto_common-1.43.0-py3-none-any.whl", hash = "sha256:123c3f9cc87218562490c63b36f497bf3a722faf174a515d1443f31ababa6264", size = 17048, upload-time = "2026-06-24T15:19:41.264Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/92/0b9f56412483a8891d4843890294796c9df8ab42417bd9bad8035d840cb3/opentelemetry_exporter_otlp_proto_http-1.43.0.tar.gz", hash = "sha256:fa8a42bb7d00ee5391f4c0b04d8e6a46c03caa437903296ab73a81dc11ba118f", size = 25406, upload-time = "2026-06-24T15:20:01.515Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/20/b685ed7af2e17c29ffc8af56f1fa8bc2033258fc30fb0d2b722f49d13ba0/opentelemetry_exporter_otlp_proto_http-1.43.0-py3-none-any.whl", hash = "sha256:647f603aa8efdbdb4dbff842e0729d0406a6fff26b295a72d3d60e7d963b2610", size = 21795, upload-time = "2026-06-24T15:19:43.164Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/b9/d357faefb40bda1d4799913e6af611171ff22a2dedcb93576bc92242d056/opentelemetry_proto-1.43.0.tar.gz", hash = "sha256:224778df17e1f3fafeaaa21d874236ca5f6ffc2f86e0899298ec7351aac27924", size = 46481, upload-time = "2026-06-24T15:20:07.625Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/a7/3e5308cf548b8f72529c7db1afdb3a404211982376a12927fd7759f77bf3/opentelemetry_proto-1.43.0-py3-none-any.whl", hash = "sha256:c58f1f7ef84bc7dc2834016c0c37fe0081dde7ca9f6339be1970fbf9cdaaa90d", size = 72489, upload-time = "2026-06-24T15:19:51.164Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3e/eb/5041074274ac0956b03637cc039d434569112468e875eddfcc9a0674ce06/opentelemetry_sdk-1.43.0.tar.gz", hash = "sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9", size = 254744, upload-time = "2026-06-24T15:20:08.467Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/e3/b17be23af124201c9f52eececd4cc8ddfed1597d37b4ee771895d325805c/opentelemetry_sdk-1.43.0-py3-none-any.whl", hash = "sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823", size = 178852, upload-time = "2026-06-24T15:19:52.169Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.64b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/30/5f26df29509eccd86b99b481ac9ffa39da49ba9577cc69071c552ae30447/opentelemetry_semantic_conventions-0.64b0.tar.gz", hash = "sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c", size = 148340, upload-time = "2026-06-24T15:20:09.267Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/ca/23ba87a221b574a7c5a99d48849d80bfe8b047624681357e2b002e566187/opentelemetry_semantic_conventions-0.64b0-py3-none-any.whl", hash = "sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6", size = 203713, upload-time = "2026-06-24T15:19:53.339Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "protobuf"
version = "7.35.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" },
{ url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" },
{ url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" },
{ url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" },
{ url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" },
{ url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" },
{ url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "questionary"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "typer"
version = "0.26.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "wcwidth"
version = "0.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
]
[[package]]
name = "wrapt"
version = "2.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" },
{ url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" },
{ url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" },
{ url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" },
{ url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" },
{ url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" },
{ url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" },
{ url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" },
{ url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" },
{ url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" },
{ url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" },
{ url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" },
{ url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" },
{ url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" },
{ url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" },
{ url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" },
{ url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" },
{ url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" },
{ url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" },
{ url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" },
{ url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" },
{ url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" },
{ url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" },
{ url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" },
{ url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" },
{ url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" },
{ url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" },
{ url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" },
{ url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" },
{ url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" },
{ url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" },
{ url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" },
{ url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" },
{ url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" },
{ url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" },
{ url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" },
{ url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" },
{ url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" },
{ url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" },
{ url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" },
{ url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" },
{ url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" },
{ url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" },
{ url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" },
{ url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" },
]
[[package]]
name = "yuxi-cli"
version = "0.1.2"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "langfuse" },
{ name = "packaging" },
{ name = "questionary" },
{ name = "rich" },
{ name = "typer" },
]
[package.dev-dependencies]
test = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "langfuse", specifier = ">=4.0.0" },
{ name = "packaging", specifier = ">=24" },
{ name = "questionary", specifier = ">=2.1.0" },
{ name = "rich", specifier = ">=13.7" },
{ name = "typer", specifier = ">=0.16" },
]
[package.metadata.requires-dev]
test = [{ name = "pytest", specifier = ">=8" }]