refactor(config): unify secrets in ~/.mytoolkit/config.json and migrate skill wrappers

This commit is contained in:
Zhengshou Lai
2026-07-19 09:49:01 +08:00
parent 7ff58f4695
commit b7749a9b06
17 changed files with 1517 additions and 92 deletions
+5 -5
View File
@@ -45,17 +45,17 @@ skill 或其他脚本应通过上述命令定位资源,避免硬编码 `~/work
## 2. 配置管理 ## 2. 配置管理
通过 `mytoolkit env` 管理,存储于 `~/.mytoolkit/config.json``keys.*` 格式)。 通过 `mytoolkit env` 管理,存储于 `~/.mytoolkit/config.json``secrets.*``paths.*``connections.*``settings.*` 分层格式)。
```bash ```bash
mytoolkit env set apikey_ark <key> mytoolkit env set secrets.api_keys.ark <key>
mytoolkit env set volc_appid <appid> mytoolkit env set secrets.volcengine.app_id <appid>
mytoolkit env set volc_access_token <token> mytoolkit env set secrets.volcengine.access_token <token>
mytoolkit env list # 查看所有 key mytoolkit env list # 查看所有 key
mytoolkit env export # 导出为 shell export 语句 mytoolkit env export # 导出为 shell export 语句
``` ```
本文件仅管理 mytoolkit 自身配置。Skill 脚本各自由其自身管理 API key(读取 `~/.xiaohe/agent/config.json`),与本配置独立 本文件 mytoolkit 与所有 skill 脚本的真源。xiaohe-agent 的 `~/.xiaohe/agent/config.json` 已迁移合并到本文件;xiaohe-agent 专属运行时偏好(如 `default_agent``backend_provider``vps_*``workspace_root`)保留在 `~/.xiaohe/agent/settings.json`
## 3. 语音合成 (TTS) ## 3. 语音合成 (TTS)
+1 -2
View File
@@ -117,8 +117,7 @@ All user-level config lives under `~/.mytoolkit/` (override with `$MYTOOLKIT_HOM
| File | Owner | Purpose | | File | Owner | Purpose |
|------|-------|---------| |------|-------|---------|
| `config.json` | `mytoolkit env` | API keys, paths, feishu credentials (keys.* namespace) | | `config.json` | `mytoolkit env` / `mytoolkit mail config` | API keys, paths, mail/IMAP/SMTP, feishu credentials |
| `mail.json` | `mytoolkit mail` | IMAP/SMTP server settings |
| `templates.json` | `mytoolkit templates` | Pointer to external template root | | `templates.json` | `mytoolkit templates` | Pointer to external template root |
Manage env vars via the CLI — no need to hand-edit: Manage env vars via the CLI — no need to hand-edit:
+2 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import click import click
from mytoolkit.commands import pdf, image, latex, video, bib, voice, docx from mytoolkit.commands import pdf, image, latex, video, bib, voice, docx, paper
from mytoolkit.commands.convert import convert_cmd from mytoolkit.commands.convert import convert_cmd
from mytoolkit.commands.env import env_cmd from mytoolkit.commands.env import env_cmd
from mytoolkit.commands.templates import templates_cmd from mytoolkit.commands.templates import templates_cmd
@@ -37,6 +37,7 @@ cli.add_command(voice.voice)
cli.add_command(latex.latex) cli.add_command(latex.latex)
cli.add_command(video.video) cli.add_command(video.video)
cli.add_command(bib.bib) cli.add_command(bib.bib)
cli.add_command(paper.paper)
cli.add_command(env_cmd) cli.add_command(env_cmd)
cli.add_command(templates_cmd) cli.add_command(templates_cmd)
cli.add_command(init_cmd) cli.add_command(init_cmd)
+2 -2
View File
@@ -31,7 +31,7 @@ def env_get(name):
value = config.get(name) value = config.get(name)
if value is None: if value is None:
click.echo(f"Var not found: {name}", err=True) click.echo(f"Var not found: {name}", err=True)
raise click.Exit(1) raise click.exceptions.Exit(1)
click.echo(value) click.echo(value)
@@ -56,7 +56,7 @@ def env_remove(name):
click.secho(f"Removed: {name}", fg="green") click.secho(f"Removed: {name}", fg="green")
else: else:
click.echo(f"Var not found: {name}", err=True) click.echo(f"Var not found: {name}", err=True)
raise click.Exit(1) raise click.exceptions.Exit(1)
@env_cmd.command("export") @env_cmd.command("export")
+21 -23
View File
@@ -2,8 +2,6 @@
import base64 import base64
import imaplib import imaplib
import json
import os
import re import re
import smtplib import smtplib
from datetime import datetime from datetime import datetime
@@ -19,19 +17,24 @@ from pathlib import Path
import click import click
from mytoolkit.config import config
from mytoolkit.utils import handle_errors from mytoolkit.utils import handle_errors
_HOME = Path(os.environ.get("MYTOOLKIT_HOME", Path.home() / ".mytoolkit"))
CONFIG_PATH = _HOME / "mail.json"
def load_config(): def load_config():
"""Load mail config.""" """Load mail config from unified config."""
if not CONFIG_PATH.exists(): cfg = {
"imap_host": config.get("secrets.mail.imap_host"),
"imap_port": config.get("secrets.mail.imap_port") or 993,
"smtp_host": config.get("secrets.mail.smtp_host"),
"smtp_port": config.get("secrets.mail.smtp_port") or 465,
"email": config.get("secrets.mail.email"),
"password": config.get("secrets.mail.password"),
}
if not cfg["email"]:
click.echo("Mail not configured. Run: mytoolkit mail config", err=True) click.echo("Mail not configured. Run: mytoolkit mail config", err=True)
raise click.Abort() raise click.Abort()
with open(CONFIG_PATH, encoding="utf-8") as f: return cfg
return json.load(f)
def decode_password(pwd: str) -> str: def decode_password(pwd: str) -> str:
@@ -227,7 +230,7 @@ def mail():
pass pass
@mail.command() @mail.command(name="config")
@click.option("--imap-host", default="mail.sysu.edu.cn", help="IMAP server host") @click.option("--imap-host", default="mail.sysu.edu.cn", help="IMAP server host")
@click.option("--imap-port", default=993, type=int, help="IMAP server port") @click.option("--imap-port", default=993, type=int, help="IMAP server port")
@click.option("--smtp-host", default="mail.sysu.edu.cn", help="SMTP server host") @click.option("--smtp-host", default="mail.sysu.edu.cn", help="SMTP server host")
@@ -235,20 +238,15 @@ def mail():
@click.option("--email", prompt="Email address", help="Email address") @click.option("--email", prompt="Email address", help="Email address")
@click.option("--password", prompt="Password", hide_input=True, help="Email password") @click.option("--password", prompt="Password", hide_input=True, help="Email password")
@handle_errors @handle_errors
def config(imap_host, imap_port, smtp_host, smtp_port, email, password): def configure(imap_host, imap_port, smtp_host, smtp_port, email, password):
"""Configure mail account.""" """Configure mail account."""
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) config.set("secrets.mail.imap_host", imap_host)
cfg = { config.set("secrets.mail.imap_port", imap_port)
"imap_host": imap_host, config.set("secrets.mail.smtp_host", smtp_host)
"imap_port": imap_port, config.set("secrets.mail.smtp_port", smtp_port)
"smtp_host": smtp_host, config.set("secrets.mail.email", email)
"smtp_port": smtp_port, config.set("secrets.mail.password", base64.b64encode(password.encode()).decode())
"email": email, click.echo("Mail config saved to ~/.mytoolkit/config.json")
"password": base64.b64encode(password.encode()).decode(),
}
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
click.echo(f"Config saved to {CONFIG_PATH}")
@mail.command() @mail.command()
+334
View File
@@ -0,0 +1,334 @@
"""Paper fetch: OpenAlex metadata + multi-source PDF download."""
from __future__ import annotations
import json
import re
from pathlib import Path
import click
import requests
from mytoolkit.config import config
from mytoolkit.utils import handle_errors
BROWSER_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0 Safari/537.36"
)
DOWNLOAD_DIR = Path("tmp/papers")
def _elsevier_api_key() -> str:
return config.resolve_key("secrets.api_keys.elsevier", "ELSEVIER_API_KEY") or ""
def _wiley_tdm_token() -> str:
return config.resolve_key("secrets.api_keys.wiley", "WILEY_TDM_TOKEN") or ""
def build_session(state_path: str | None = None) -> requests.Session:
"""Build a requests session, optionally seeded with browser login cookies."""
session = requests.Session()
session.headers.update({"User-Agent": BROWSER_UA})
if not state_path:
return session
path = Path(state_path)
if not path.exists():
click.echo(f" [state] file not found: {state_path}")
return session
try:
cookies = json.loads(path.read_text()).get("cookies", [])
except (json.JSONDecodeError, OSError) as e:
click.echo(f" [state] failed to read cookies: {e}")
return session
for c in cookies:
try:
session.cookies.set(
c["name"], c["value"],
domain=c.get("domain", "").lstrip("."),
path=c.get("path", "/"),
)
except (KeyError, TypeError):
continue
click.echo(f" [state] loaded {len(cookies)} cookies from {state_path}")
return session
def fmt_authors(authorships: list[dict], max_n: int = 3) -> str:
names = [a.get("author", {}).get("display_name", "") for a in authorships[:max_n]]
s = ", ".join(n for n in names if n)
if len(authorships) > max_n:
s += " et al."
return s
def fmt_venue(work: dict) -> str:
host = work.get("host_venue") or work.get("primary_location", {}).get("source", {})
if isinstance(host, dict):
return host.get("display_name", "N/A")
return "N/A"
def candidate_urls(work: dict) -> tuple[list[str], str]:
pdf_urls: list[str] = []
landing = ""
oa = work.get("open_access", {})
if oa.get("oa_url"):
pdf_urls.append(oa["oa_url"])
locations = list(work.get("locations") or [])
best = work.get("best_oa_location")
primary = work.get("primary_location")
for loc in [best, primary, *locations]:
if not isinstance(loc, dict):
continue
if loc.get("pdf_url"):
pdf_urls.append(loc["pdf_url"])
if not landing and loc.get("landing_page_url"):
landing = loc["landing_page_url"]
seen: set[str] = set()
unique = [u for u in pdf_urls if not (u in seen or seen.add(u))]
return unique, landing
def download_pdf_url(session: requests.Session, url: str, referer: str = "") -> bytes | None:
headers = {"Accept": "application/pdf,*/*"}
if referer:
headers["Referer"] = referer
try:
resp = session.get(url, headers=headers, timeout=60, allow_redirects=True)
except requests.RequestException as e:
click.echo(f" download failed: {e}")
return None
if resp.status_code == 200 and resp.content[:4] == b"%PDF":
return resp.content
if resp.status_code == 200:
click.echo(f" not a PDF (got {resp.headers.get('Content-Type', '?')})")
else:
click.echo(f" HTTP {resp.status_code}")
return None
def search_openalex(query: str, limit: int = 10) -> list[dict]:
url = "https://api.openalex.org/works"
params = {"search": query, "per-page": limit, "filter": "language:en"}
try:
resp = requests.get(url, params=params, timeout=30)
resp.raise_for_status()
return resp.json().get("results", [])
except requests.RequestException as e:
click.echo(f" OpenAlex API error: {e}")
return []
def fetch_openalex_by_doi(doi: str) -> dict | None:
doi_clean = doi.replace("https://doi.org/", "").strip()
url = f"https://api.openalex.org/works/doi:{doi_clean}"
resp = requests.get(url, timeout=30)
if resp.status_code == 200:
return resp.json()
return None
def query_elsevier(doi: str) -> bytes | None:
api_key = _elsevier_api_key()
if not api_key:
return None
url = f"https://api.elsevier.com/content/article/doi/{doi}"
headers = {"X-ELS-APIKey": api_key}
params = {"httpAccept": "application/pdf"}
resp = requests.get(url, headers=headers, params=params, timeout=60)
if resp.status_code == 200 and resp.content[:4] == b"%PDF":
return resp.content
if resp.status_code in (403, 404):
click.echo(f" Elsevier API {resp.status_code}: no access or not found")
return None
def query_wiley(doi: str) -> bytes | None:
token = _wiley_tdm_token()
if not token:
return None
url = f"https://api.wiley.com/onlinelibrary/tdm/v1/articles/{doi}"
headers = {"Wiley-TDM-Client-Token": token}
resp = requests.get(url, headers=headers, timeout=60)
if resp.status_code == 200 and resp.content[:4] == b"%PDF":
return resp.content
if resp.status_code in (403, 404):
click.echo(f" Wiley TDM API {resp.status_code}: no access or not found")
return None
def sanitize_filename(title: str) -> str:
s = re.sub(r"[^\w\s-]", "", title)
s = re.sub(r"[-\s]+", "-", s)
return s.strip("-").lower()[:80]
def display_results(results: list[dict]) -> None:
if not results:
click.echo("No results found.")
return
for i, r in enumerate(results, 1):
click.echo(f"\n[{i}] {r.get('display_name', 'N/A')}")
click.echo(f" Authors: {fmt_authors(r.get('authorships', []))}")
click.echo(f" Year: {r.get('publication_year', 'N/A')} | Venue: {fmt_venue(r)}")
click.echo(f" DOI: {r.get('doi', 'N/A')}")
oa = r.get("open_access", {})
click.echo(f" Citations: {r.get('cited_by_count', 0)} | OA: {oa.get('is_oa', False)}")
_, landing = candidate_urls(r)
if landing:
click.echo(f" Landing: {landing}")
def _unique_path(base: Path) -> Path:
if not base.exists():
return base
stem = base.stem
suffix = base.suffix
for i in range(1, 100):
candidate = base.with_name(f"{stem}({i}){suffix}")
if not candidate.exists():
return candidate
return base
def attempt_download(work: dict, session: requests.Session | None = None) -> Path | None:
"""Try multiple sources to download PDF."""
doi = work.get("doi", "").replace("https://doi.org/", "").strip()
title = work.get("display_name", "unknown")
filename = sanitize_filename(title) + ".pdf"
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
out_path = _unique_path(DOWNLOAD_DIR / filename)
has_cookies = bool(session and len(session.cookies))
if doi and _elsevier_api_key():
click.echo(" Trying Elsevier API...")
pdf_bytes = query_elsevier(doi)
if pdf_bytes:
out_path.write_bytes(pdf_bytes)
click.echo(f" Downloaded via Elsevier API -> {out_path}")
return out_path
if doi and _wiley_tdm_token():
click.echo(" Trying Wiley TDM API...")
pdf_bytes = query_wiley(doi)
if pdf_bytes:
out_path.write_bytes(pdf_bytes)
click.echo(f" Downloaded via Wiley TDM API -> {out_path}")
return out_path
pdf_urls, landing = candidate_urls(work)
oa = work.get("open_access", {})
if oa.get("is_oa") and oa.get("oa_url"):
click.echo(" Trying OpenAlex OA URL...")
anon = session or build_session()
pdf_bytes = download_pdf_url(anon, oa["oa_url"], referer=landing)
if pdf_bytes:
out_path.write_bytes(pdf_bytes)
click.echo(f" Downloaded via OpenAlex OA URL -> {out_path}")
return out_path
if has_cookies and session and pdf_urls:
click.echo(f" Trying browser session ({len(pdf_urls)} candidate URL(s))...")
for url in pdf_urls:
pdf_bytes = download_pdf_url(session, url, referer=landing)
if pdf_bytes:
out_path.write_bytes(pdf_bytes)
click.echo(f" Downloaded via browser session -> {out_path}")
return out_path
click.echo(" PDF not available through any source.")
if landing:
click.echo(f" Landing page (open in browser to grab PDF): {landing}")
return None
def interactive_select(results: list[dict]) -> list[dict]:
display_results(results)
choice = click.prompt(
"\nEnter numbers to download (e.g., '1 3 5' or 'all'), or 'q' to quit",
default="q",
).strip().lower()
if choice in ("q", "quit", "exit"):
return []
if choice == "all":
return results
try:
indices = [int(x) - 1 for x in choice.split()]
return [results[i] for i in indices if 0 <= i < len(results)]
except (ValueError, IndexError):
click.echo("Invalid selection.")
return []
@click.group()
def paper():
"""Paper search and download commands."""
pass
@paper.command("fetch")
@click.argument("query", required=False)
@click.option("--doi", help="Fetch by DOI")
@click.option("--limit", type=int, default=10, help="Max results (default: 10)")
@click.option("--download", "download_all", is_flag=True, help="Auto-download all results")
@click.option(
"--state",
help="Playwright storageState JSON; its cookies authenticate paywalled downloads",
)
@click.option("--pdf-url", help="Direct PDF URL to download (use with --state/--title)")
@click.option("--title", help="Filename stem for --pdf-url download")
@handle_errors
def fetch_paper_cmd(query, doi, limit, download_all, state, pdf_url, title):
"""Fetch paper metadata and PDFs from OpenAlex + publisher APIs."""
session = build_session(state)
if pdf_url:
title = title or "paper"
out_path = _unique_path(DOWNLOAD_DIR / (sanitize_filename(title) + ".pdf"))
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
pdf_bytes = download_pdf_url(session, pdf_url)
if pdf_bytes:
out_path.write_bytes(pdf_bytes)
click.echo(f"Downloaded -> {out_path}")
else:
click.echo("Download failed.")
return
if doi:
work = fetch_openalex_by_doi(doi)
if not work:
click.echo(f"DOI not found in OpenAlex: {doi}")
return
display_results([work])
if download_all or click.confirm("Download PDF?", default=False):
attempt_download(work, session)
return
if not query:
raise click.UsageError("Provide a query, --doi, or --pdf-url.")
click.echo(f"Searching OpenAlex: '{query}'...")
results = search_openalex(query, limit=limit)
if not results:
click.echo("No results found.")
return
if download_all:
for r in results:
attempt_download(r, session)
else:
selected = interactive_select(results)
for r in selected:
attempt_download(r, session)
+1 -1
View File
@@ -18,7 +18,7 @@ def _get_project_root() -> Path:
def _run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> None: def _run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> None:
result = subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=False) result = subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=False)
if check and result.returncode != 0: if check and result.returncode != 0:
raise click.Exit(result.returncode) raise click.exceptions.Exit(result.returncode)
def _install(root: Path) -> None: def _install(root: Path) -> None:
+3 -3
View File
@@ -52,7 +52,7 @@ def connect(name):
if name not in hosts: if name not in hosts:
click.echo(f"Unknown host: {name}", err=True) click.echo(f"Unknown host: {name}", err=True)
click.echo(f"Available: {', '.join(hosts.keys())}", err=True) click.echo(f"Available: {', '.join(hosts.keys())}", err=True)
raise click.Exit(1) raise click.exceptions.Exit(1)
cmd = _build_ssh_base_cmd(hosts[name]) cmd = _build_ssh_base_cmd(hosts[name])
click.secho(f"Connecting to {name}...", fg="cyan") click.secho(f"Connecting to {name}...", fg="cyan")
@@ -69,7 +69,7 @@ def tunnel(name, local_port, remote_port):
hosts = _get_hosts() hosts = _get_hosts()
if name not in hosts: if name not in hosts:
click.echo(f"Unknown host: {name}", err=True) click.echo(f"Unknown host: {name}", err=True)
raise click.Exit(1) raise click.exceptions.Exit(1)
host_config = hosts[name] host_config = hosts[name]
cmd = ["ssh", "-p", str(host_config["port"]), "-L", f"{local_port}:localhost:{remote_port}"] cmd = ["ssh", "-p", str(host_config["port"]), "-L", f"{local_port}:localhost:{remote_port}"]
@@ -103,7 +103,7 @@ def run_cmd(name, command):
hosts = _get_hosts() hosts = _get_hosts()
if name not in hosts: if name not in hosts:
click.echo(f"Unknown host: {name}", err=True) click.echo(f"Unknown host: {name}", err=True)
raise click.Exit(1) raise click.exceptions.Exit(1)
cmd = _build_ssh_base_cmd(hosts[name]) cmd = _build_ssh_base_cmd(hosts[name])
cmd.append(command) cmd.append(command)
+9 -2
View File
@@ -1,12 +1,15 @@
"""Video utilities.""" """Video conversion commands."""
import subprocess
from pathlib import Path from pathlib import Path
import click import click
from mytoolkit.utils import handle_errors, run_command from mytoolkit.utils import handle_errors, run_command
# Subcommands migrated from skills.
from mytoolkit.commands.video_download import download_video_cmd
from mytoolkit.commands.video_transcribe import transcribe_video_cmd
@click.group() @click.group()
def video(): def video():
@@ -14,6 +17,10 @@ def video():
pass pass
video.add_command(download_video_cmd)
video.add_command(transcribe_video_cmd)
@video.command("avi-to-mp4") @video.command("avi-to-mp4")
@click.argument("files", nargs=-1, required=True) @click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done") @click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
+242
View File
@@ -0,0 +1,242 @@
"""Video download via yt-dlp, you-get, lux, gallery-dl."""
from __future__ import annotations
import os
import shutil
import subprocess
import urllib.parse
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import click
from mytoolkit.config import config
from mytoolkit.utils import handle_errors
@dataclass
class _ToolConfig:
name: str
command: list[str]
supports_audio_only: bool = False
supports_quality: bool = False
_SUPPORTED_TOOLS: list[_ToolConfig] = [
_ToolConfig(
name="yt-dlp",
command=["yt-dlp"],
supports_audio_only=True,
supports_quality=True,
),
_ToolConfig(
name="you-get",
command=["you-get"],
),
_ToolConfig(
name="lux",
command=["lux"],
),
_ToolConfig(
name="gallery-dl",
command=["gallery-dl"],
),
]
def _find_tool(name: str) -> Optional[str]:
return shutil.which(name)
def _infer_site(url: str) -> Optional[str]:
parsed = urllib.parse.urlparse(url.lower())
host = parsed.netloc
if "bilibili" in host:
return "bilibili"
if "xhslink" in host or "xiaohongshu" in host:
return "xiaohongshu"
if "douyin" in host:
return "douyin"
return None
def _detect_best_tool(url: str, preferred: Optional[str] = None) -> _ToolConfig:
name_to_tool = {t.name: t for t in _SUPPORTED_TOOLS}
if preferred:
tool = name_to_tool.get(preferred)
if tool is None:
raise RuntimeError(f"Unknown tool: {preferred}")
if _find_tool(tool.command[0]):
return tool
available = [t.name for t in _SUPPORTED_TOOLS if _find_tool(t.command[0])]
raise RuntimeError(
f"Preferred tool '{preferred}' not found. "
f"Installed: {available or 'none'}."
)
site = _infer_site(url)
if site == "xiaohongshu":
priority = ["yt-dlp", "gallery-dl", "lux", "you-get"]
elif site in {"bilibili", "douyin"}:
priority = ["you-get", "lux", "yt-dlp", "gallery-dl"]
else:
priority = ["yt-dlp", "you-get", "lux", "gallery-dl"]
for name in priority:
tool = name_to_tool.get(name)
if tool and _find_tool(tool.command[0]):
return tool
available = [t.name for t in _SUPPORTED_TOOLS if _find_tool(t.command[0])]
raise RuntimeError(
"No supported downloader found. "
f"Installed: {available or 'none'}. "
"Please install one of: yt-dlp, you-get, lux, gallery-dl."
)
def _build_ytdlp_command(
url: str,
output_dir: Path,
audio_only: bool,
quality: Optional[str],
) -> list[str]:
cmd = ["yt-dlp", "--no-warnings"]
if audio_only:
cmd.extend(["-x", "--audio-format", "mp3"])
elif quality:
if quality.isdigit():
cmd.extend(["-f", f"bestvideo[height<={quality}]+bestaudio/best"])
else:
cmd.extend(["-f", quality])
cmd.extend(["-P", str(output_dir)])
cmd.append(url)
return cmd
def _build_youget_command(
url: str,
output_dir: Path,
quality: Optional[str],
) -> list[str]:
cmd = ["you-get"]
if quality and quality.isdigit():
cmd.extend(["--format", f"dash-{quality}"])
cmd.extend(["-o", str(output_dir)])
cmd.append(url)
return cmd
def _build_lux_command(
url: str,
output_dir: Path,
quality: Optional[str],
) -> list[str]:
cmd = ["lux", "-o", str(output_dir)]
if quality:
cmd.extend(["-f", quality])
cmd.append(url)
return cmd
def _build_gallerydl_command(
url: str,
output_dir: Path,
) -> list[str]:
return ["gallery-dl", "-d", str(output_dir), url]
def _build_command(
tool: _ToolConfig,
url: str,
output_dir: Path,
audio_only: bool,
quality: Optional[str],
) -> list[str]:
if tool.name == "yt-dlp":
return _build_ytdlp_command(url, output_dir, audio_only, quality)
if tool.name == "you-get":
return _build_youget_command(url, output_dir, quality)
if tool.name == "lux":
return _build_lux_command(url, output_dir, quality)
if tool.name == "gallery-dl":
return _build_gallerydl_command(url, output_dir)
raise RuntimeError(f"Unknown tool: {tool.name}")
@click.command("download")
@click.argument("url")
@click.option(
"-o",
"--output-dir",
default="tmp",
help="Output directory (default: tmp/)",
)
@click.option(
"-t",
"--tool",
type=click.Choice([t.name for t in _SUPPORTED_TOOLS]),
help="Preferred downloader (auto-detect if omitted)",
)
@click.option(
"-a",
"--audio-only",
is_flag=True,
help="Download audio only (requires yt-dlp)",
)
@click.option(
"-q",
"--quality",
help="Video quality hint, e.g., 1080, 720, best (tool-dependent)",
)
@click.option(
"--proxy",
help="HTTP/HTTPS proxy URL (or set via VIDEO_DOWNLOADER_PROXY env)",
)
@handle_errors
def download_video_cmd(url, output_dir, tool, audio_only, quality, proxy):
"""Download videos using yt-dlp, you-get, lux, or gallery-dl."""
output_dir = Path(output_dir).expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
if audio_only:
if tool and tool != "yt-dlp":
raise click.UsageError("--audio-only requires yt-dlp.")
if not _find_tool("yt-dlp"):
raise click.UsageError("--audio-only requires yt-dlp, which is not installed.")
detected = _detect_best_tool(url=url, preferred=tool)
click.echo(f"Using downloader: {detected.name}")
if audio_only and not detected.supports_audio_only:
click.echo(
f"Warning: {detected.name} does not support --audio-only; ignoring flag.",
err=True,
)
if quality and not detected.supports_quality:
click.echo(
f"Warning: {detected.name} has limited quality control; passing quality hint as-is.",
err=True,
)
proxy = proxy or os.environ.get("VIDEO_DOWNLOADER_PROXY") or config.get(
"settings.video_downloader_proxy"
)
env = os.environ.copy()
if proxy:
click.echo(f"Using proxy: {proxy}")
env["HTTP_PROXY"] = proxy
env["HTTPS_PROXY"] = proxy
cmd = _build_command(
tool=detected,
url=url,
output_dir=output_dir,
audio_only=audio_only and detected.supports_audio_only,
quality=quality,
)
click.echo(f"Running: {' '.join(cmd)}")
subprocess.call(cmd, env=env)
+337
View File
@@ -0,0 +1,337 @@
"""Video/audio transcription with Whisper and optional visual proofreading."""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from typing import Optional
import click
from mytoolkit.utils import handle_errors
_SUPPORTED_AUDIO = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".wma"}
_SUPPORTED_VIDEO = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".m4v", ".wmv"}
def _command_exists(name: str) -> bool:
return shutil.which(name) is not None
def _require_command(name: str, install_hint: str) -> None:
if not _command_exists(name):
raise click.UsageError(f"'{name}' not found. {install_hint}")
def _extract_audio(video_path: Path, output_path: Path) -> None:
cmd = [
"ffmpeg",
"-y",
"-i",
str(video_path),
"-vn",
"-acodec",
"libmp3lame",
"-q:a",
"2",
str(output_path),
]
subprocess.run(cmd, check=True, capture_output=True, text=True)
def _extract_frames(
video_path: Path,
output_dir: Path,
interval: float = 2.0,
width: int = 720,
) -> list[Path]:
output_dir.mkdir(parents=True, exist_ok=True)
pattern = output_dir / "frame_%03d.jpg"
cmd = [
"ffmpeg",
"-y",
"-i",
str(video_path),
"-vf",
f"fps=1/{interval},scale={width}:-1",
"-q:v",
"2",
str(pattern),
]
subprocess.run(cmd, check=True, capture_output=True, text=True)
return sorted(output_dir.glob("frame_*.jpg"))
def _ocr_frame(frame_path: Path, lang: str = "chi_sim", crop_region: str = "full") -> str:
ocr_input = frame_path
tmp_path: Optional[Path] = None
if crop_region == "bottom":
try:
from PIL import Image, ImageOps
except ImportError as e:
raise click.UsageError(
"Pillow is required for --ocr-region. Install with: pip install Pillow"
) from e
img = Image.open(frame_path)
width, height = img.size
crop_box = (0, int(height * 0.6), width, height)
cropped = img.crop(crop_box)
gray = ImageOps.grayscale(cropped)
enhanced = ImageOps.autocontrast(gray, cutoff=1)
tmp_path = frame_path.with_suffix(".ocr.jpg")
enhanced.save(tmp_path, quality=95)
ocr_input = tmp_path
try:
result = subprocess.run(
["tesseract", str(ocr_input), "stdout", "-l", lang],
capture_output=True,
text=True,
errors="ignore",
)
return result.stdout.strip()
finally:
if tmp_path:
tmp_path.unlink(missing_ok=True)
def _visual_proofread(
video_path: Path,
output_dir: Path,
interval: float,
lang: str,
crop_region: str,
keep_frames: bool,
) -> Path:
frames_dir = output_dir / f"{video_path.stem}-frames"
click.echo(f"Extracting key frames every {interval}s to {frames_dir} ...")
frames = _extract_frames(video_path, frames_dir, interval=interval)
if not frames:
click.echo("Warning: no frames extracted.", err=True)
return Path()
lines: list[str] = []
for idx, frame in enumerate(frames):
timestamp = idx * interval
text = _ocr_frame(frame, lang=lang, crop_region=crop_region)
if text:
lines.append(f"[{timestamp:05.1f}s] {text}")
output_path = output_dir / f"{video_path.stem}-visual.txt"
output_path.write_text("\n\n".join(lines), encoding="utf-8")
click.echo(f"Visual reference saved to: {output_path}")
if not keep_frames:
shutil.rmtree(frames_dir, ignore_errors=True)
return output_path
def _transcribe(
audio_path: Path,
output_dir: Path,
model: str,
language: Optional[str],
fmt: str,
initial_prompt: Optional[str],
) -> Path:
cmd = [
"whisper",
str(audio_path),
"--model",
model,
"--output_format",
fmt,
"--output_dir",
str(output_dir),
]
if language:
cmd.extend(["--language", language])
if initial_prompt:
cmd.extend(["--initial_prompt", initial_prompt])
subprocess.run(cmd, check=True)
base = audio_path.stem
ext = fmt if fmt != "all" else "txt"
return output_dir / f"{base}.{ext}"
def _convert_to_simplified(text: str) -> str:
try:
from opencc import OpenCC
except ImportError as e:
raise click.UsageError(
"opencc-python-reimplemented is required for simplified output. "
"Install with: pip install opencc-python-reimplemented"
) from e
converter = OpenCC("tw2s")
return converter.convert(text)
def _resolve_input(path: Path) -> tuple[Path, bool]:
if not path.exists():
raise click.UsageError(f"file not found: {path}")
suffix = path.suffix.lower()
if suffix in _SUPPORTED_AUDIO:
return path, False
if suffix in _SUPPORTED_VIDEO:
return path, True
raise click.UsageError(
f"unsupported file format '{suffix}'. "
f"Supported audio: {', '.join(sorted(_SUPPORTED_AUDIO))}; "
f"supported video: {', '.join(sorted(_SUPPORTED_VIDEO))}."
)
@click.command("transcribe")
@click.argument("input", type=click.Path(exists=True))
@click.option(
"-o",
"--output-dir",
default="tmp",
help="Output directory (default: tmp/)",
)
@click.option(
"-m",
"--model",
default="small",
help="Whisper model size: tiny, base, small, medium, large-v1/v2/v3 (default: small)",
)
@click.option(
"-l",
"--language",
default="Chinese",
help="Language code (default: Chinese; set 'auto' to detect)",
)
@click.option(
"-f",
"--format",
default="txt",
type=click.Choice(["txt", "srt", "vtt", "json", "tsv", "all"]),
help="Output format (default: txt)",
)
@click.option(
"-s",
"--simplified",
is_flag=True,
help="Convert traditional Chinese output to simplified Chinese",
)
@click.option(
"-p",
"--initial-prompt",
help="Initial prompt passed to Whisper",
)
@click.option(
"--keep-audio",
is_flag=True,
help="Keep extracted audio file when transcribing video",
)
@click.option(
"--visual-proofread",
is_flag=True,
help="Extract key frames and OCR on-screen subtitles as a proofreading reference",
)
@click.option(
"--frame-interval",
type=float,
default=2.0,
help="Seconds between extracted frames for visual proofreading (default: 2.0)",
)
@click.option(
"--ocr-lang",
default="chi_sim",
help="Tesseract OCR language for visual proofreading (default: chi_sim)",
)
@click.option(
"--ocr-region",
type=click.Choice(["full", "bottom"]),
default="full",
help="Frame region to OCR (default: full)",
)
@click.option(
"--extract-frames",
is_flag=True,
help="Keep extracted frames after visual proofreading",
)
@handle_errors
def transcribe_video_cmd(
input,
output_dir,
model,
language,
format,
simplified,
initial_prompt,
keep_audio,
visual_proofread,
frame_interval,
ocr_lang,
ocr_region,
extract_frames,
):
"""Transcribe video/audio files with Whisper."""
_require_command("whisper", "Install with: pip install openai-whisper")
input_path, is_video = _resolve_input(Path(input))
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
extracted_audio: Optional[Path] = None
audio_path: Path = input_path
if is_video:
_require_command("ffmpeg", "Install with: brew install ffmpeg")
audio_extract_path = output_dir / f"{input_path.stem}.mp3"
extracted_audio = audio_extract_path
click.echo(f"Extracting audio from {input_path.name} ...")
_extract_audio(input_path, audio_extract_path)
audio_path = audio_extract_path
if visual_proofread:
if not is_video:
raise click.UsageError("--visual-proofread requires a video input.")
_require_command(
"tesseract",
"Install with: brew install tesseract tesseract-lang",
)
_visual_proofread(
video_path=input_path,
output_dir=output_dir,
interval=frame_interval,
lang=ocr_lang,
crop_region=ocr_region,
keep_frames=extract_frames,
)
language = language if language.lower() != "auto" else None
click.echo(f"Transcribing with whisper '{model}' model ...")
transcript_path = _transcribe(
audio_path=audio_path,
output_dir=output_dir,
model=model,
language=language,
fmt=format,
initial_prompt=initial_prompt,
)
if simplified:
click.echo("Converting to simplified Chinese ...")
original_text = transcript_path.read_text(encoding="utf-8")
simplified_text = _convert_to_simplified(original_text)
transcript_path = transcript_path.with_stem(f"{transcript_path.stem}-zh-cn")
transcript_path.write_text(simplified_text, encoding="utf-8")
if extracted_audio and not keep_audio:
extracted_audio.unlink(missing_ok=True)
click.echo(f"Transcript saved to: {transcript_path}")
+85 -6
View File
@@ -1,6 +1,7 @@
"""Voice / TTS utilities via Volcengine (ByteDance) WebSocket API.""" """Voice / TTS utilities via Volcengine (ByteDance) WebSocket API."""
import os import os
import re
from pathlib import Path from pathlib import Path
import click import click
@@ -15,14 +16,15 @@ _VOICE_CHOICES = [
"zh_male_wennuanahu_moon_bigtts", "zh_male_wennuanahu_moon_bigtts",
"zh_male_sunwukong_moon_bigtts", "zh_male_sunwukong_moon_bigtts",
] ]
_FORMAT_CHOICES = ["mp3", "wav", "pcm"] _FORMAT_CHOICES = ["mp3", "wav", "pcm"]
def _get_credentials(): def _get_credentials():
"""Get Volcengine credentials from config.""" """Get Volcengine credentials from config."""
appid = config.get("volc_appid") appid = config.resolve_key("secrets.volcengine.app_id", "VOLC_APPID")
access_token = config.get("volc_access_token") access_token = config.resolve_key(
"secrets.volcengine.access_token", "VOLC_ACCESS_TOKEN"
)
if not appid or not access_token: if not appid or not access_token:
return None, None return None, None
return appid, access_token return appid, access_token
@@ -40,8 +42,8 @@ def _synthesize_sync(
if not appid or not access_token: if not appid or not access_token:
raise click.UsageError( raise click.UsageError(
"Volcengine credentials not set. Run:\n" "Volcengine credentials not set. Run:\n"
" mytoolkit env set volc_appid <appid>\n" " mytoolkit env set secrets.volcengine.app_id <appid>\n"
" mytoolkit env set volc_access_token <token>" " mytoolkit env set secrets.volcengine.access_token <token>"
) )
return synthesize_tts( return synthesize_tts(
@@ -91,5 +93,82 @@ def tts_cmd(text, file, speaker, fmt, speed, output):
click.echo(f"Synthesizing: {text[:40]}{'...' if len(text) > 40 else ''}") click.echo(f"Synthesizing: {text[:40]}{'...' if len(text) > 40 else ''}")
save_path = output or f"output.{fmt}" save_path = output or f"output.{fmt}"
result = _synthesize_sync(text, speaker, fmt, speed, save_path) result = _synthesize_sync(text, speaker, fmt, speed, save_path) # type: ignore[reportCallIssue]
click.echo(os.path.abspath(result)) click.echo(os.path.abspath(result))
def _natural_sort_key(path: Path):
name = path.stem
return [int(text) if text.isdigit() else text.lower()
for text in re.split(r"(\d+)", name)]
@voice.command("tts-batch")
@click.argument("text_dir", type=click.Path(exists=True, file_okay=False))
@click.option("--voice", "speaker", type=click.Choice(_VOICE_CHOICES), default="zh_male_wennuanahu_moon_bigtts")
@click.option("--output-dir", "-o", type=click.Path(), help="输出目录(默认与输入相同)")
@click.option("--format", "fmt", type=click.Choice(_FORMAT_CHOICES), default="mp3")
@handle_errors
def tts_batch_cmd(text_dir, speaker, output_dir, fmt):
"""批量将目录下的 .txt 文件转为语音。"""
text_dir = Path(text_dir)
out_dir = Path(output_dir) if output_dir else text_dir
out_dir.mkdir(parents=True, exist_ok=True)
texts = sorted(text_dir.glob("*.txt"), key=_natural_sort_key)
if not texts:
click.echo("No .txt files found.", err=True)
raise click.exceptions.Exit(1)
click.echo(f"Found {len(texts)} text files")
for tf in texts:
out = out_dir / (tf.stem + f".{fmt}")
text = tf.read_text(encoding="utf-8").strip()
click.echo(f"Synthesizing {tf.name} -> {out.name}")
_synthesize_sync(text, speaker, fmt, 0, str(out)) # type: ignore[reportCallIssue]
click.echo("All done!")
@voice.command("tts-script")
@click.argument("script", type=click.Path(exists=True))
@click.option("--output-dir", "-o", type=click.Path(), default="audio")
@click.option("--voice", "speaker", type=click.Choice(_VOICE_CHOICES), default="zh_male_wennuanahu_moon_bigtts")
@click.option("--prefix", "-p", default="page")
@click.option("--zero-pad", "-z", type=int, default=2)
@click.option("--format", "fmt", type=click.Choice(_FORMAT_CHOICES), default="mp3")
@handle_errors
def tts_script_cmd(script, output_dir, speaker, prefix, zero_pad, fmt):
"""从 PPT 配音脚本(Markdown,每页一个引用块)批量生成 TTS。"""
md_path = Path(script)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
text = md_path.read_text(encoding="utf-8")
pages = re.split(r"\n---\n", text)
scripts = []
for page in pages:
page = page.strip()
if not page:
continue
m = re.search(r">\s*(.+?)(?=\n\n|$)", page, re.DOTALL)
script_text = m.group(1).strip() if m else ""
scripts.append(script_text)
if not scripts:
click.echo("No scripts found.", err=True)
raise click.exceptions.Exit(1)
generated = 0
skipped = 0
for i, script_text in enumerate(scripts, 1):
if not script_text:
skipped += 1
continue
filename = f"{prefix}{str(i).zfill(zero_pad)}.{fmt}"
out_path = output_dir / filename
click.echo(f"[{i}] {filename}: {script_text[:40]}{'...' if len(script_text) > 40 else ''}")
_synthesize_sync(script_text, speaker, fmt, 0, str(out_path)) # type: ignore[reportCallIssue]
generated += 1
click.echo(f"\nDone: {generated} generated, {skipped} skipped (empty scripts).")
+308 -45
View File
@@ -1,25 +1,131 @@
"""Configuration management for mytoolkit. """Configuration management for mytoolkit.
Unified config file: ~/.mytoolkit/config.json (keys.* namespace) Unified config file: ~/.mytoolkit/config.json
Legacy: ~/.mytoolkit/env.json (migrated on first load) Schema version: 2 (hierarchical)
Legacy: ~/.mytoolkit/env.json and flat keys.* format (auto-migrated)
""" """
from __future__ import annotations
import json import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Any
_MYTOOLKIT_HOME = Path(os.environ.get("MYTOOLKIT_HOME", Path.home() / ".mytoolkit")) _MYTOOLKIT_HOME = Path(os.environ.get("MYTOOLKIT_HOME", Path.home() / ".mytoolkit"))
CONFIG_PATH = _MYTOOLKIT_HOME / "config.json" CONFIG_PATH = _MYTOOLKIT_HOME / "config.json"
_LEGACY_MODULE_PATH = Path(__file__).parent / "config.json"
_LEGACY_ENV_PATH = _MYTOOLKIT_HOME / "env.json" _LEGACY_ENV_PATH = _MYTOOLKIT_HOME / "env.json"
_LEGACY_MODULE_PATH = Path(__file__).parent / "config.json"
_XIAOHE_CONFIG_PATH = Path.home() / ".xiaohe" / "agent" / "config.json"
_XIAOHE_SETTINGS_PATH = Path.home() / ".xiaohe" / "agent" / "settings.json"
_MAIL_CONFIG_PATH = _MYTOOLKIT_HOME / "mail.json"
_METABOT_BOTS_PATH = Path.home() / ".metabot" / "bots.json"
# Mapping from legacy flat keys to hierarchical paths.
_LEGACY_KEY_MAP: dict[str, str] = {
# API keys
"apikey_ark": "secrets.api_keys.ark",
"apikey_deepseek": "secrets.api_keys.deepseek",
"apikey_elsevier": "secrets.api_keys.elsevier",
"apikey_kimi": "secrets.api_keys.kimi",
"apikey_qwen": "secrets.api_keys.qwen",
"apikey_wiley": "secrets.api_keys.wiley",
"deepseek": "secrets.api_keys.deepseek",
"overleaf_token": "secrets.api_keys.overleaf",
# Feishu bots
"feishu_myagent_appid": "secrets.feishu.myagent.app_id",
"feishu_myagent_secret": "secrets.feishu.myagent.secret",
"feishu_myclaude_appid": "secrets.feishu.myclaude.app_id",
"feishu_myclaude_secret": "secrets.feishu.myclaude.secret",
"feishu_xiaohe_appid": "secrets.feishu.xiaohe.app_id",
"feishu_xiaohe_secret": "secrets.feishu.xiaohe.secret",
# Service accounts
"elsevier_email": "secrets.accounts.elsevier.email",
"elsevier_password": "secrets.accounts.elsevier.password",
"em_username": "secrets.accounts.em.user",
"em_password": "secrets.accounts.em.pass",
"orcid_email": "secrets.accounts.orcid.email",
"orcid_password": "secrets.accounts.orcid.password",
"sysu_ta_cms_user": "secrets.accounts.sysu_ta_cms.user",
"sysu_ta_cms_pass": "secrets.accounts.sysu_ta_cms.pass",
"zs_sysu_username": "secrets.accounts.zs_sysu.user",
"zs_sysu_password": "secrets.accounts.zs_sysu.pass",
# Volcengine
"volc_appid": "secrets.volcengine.app_id",
"volc_access_token": "secrets.volcengine.access_token",
# Mail
"mail_imap_host": "secrets.mail.imap_host",
"mail_imap_port": "secrets.mail.imap_port",
"mail_smtp_host": "secrets.mail.smtp_host",
"mail_smtp_port": "secrets.mail.smtp_port",
"mail_email": "secrets.mail.email",
"mail_password": "secrets.mail.password",
# Paths
"path_academia": "paths.academia",
"path_apaam": "paths.apaam",
"path_research": "paths.research",
"path_study": "paths.study",
"path_tianhe": "paths.tianhe",
"path_webpage": "paths.webpage",
"openfoam_build": "paths.openfoam_build",
"openfoam_dmg": "paths.openfoam_dmg",
"openfoam_mount": "paths.openfoam_mount",
# Connections
"proxy_fastgithub": "connections.proxy.fastgithub",
"proxy_pandafan": "connections.proxy.pandafan",
"ssh_starlight": "connections.ssh.starlight.host",
"ssh_starlight_port": "connections.ssh.starlight.port",
"ssh_tianhe": "connections.ssh.tianhe.host",
"ssh_tianhe_key": "connections.ssh.tianhe.key",
"ssh_tianhe_port": "connections.ssh.tianhe.port",
# Settings
"default_tts_voice": "settings.default_tts_voice",
# Downloads
"downloads_apaam_user": "secrets.downloads.apaam.user",
"downloads_apaam_pass": "secrets.downloads.apaam.pass",
"downloads_kongyong_user": "secrets.downloads.kongyong.user",
"downloads_kongyong_pass": "secrets.downloads.kongyong.pass",
"downloads_sysu_group_user": "secrets.downloads.sysu_group.user",
"downloads_sysu_group_pass": "secrets.downloads.sysu_group.pass",
}
# Reverse map for export / flat listing.
_HIERARCHY_TO_LEGACY = {v: k for k, v in _LEGACY_KEY_MAP.items()}
def _set_path(data: dict, path: str, value: Any) -> None:
"""Set a value in nested dict using dot-separated path."""
parts = path.split(".")
for part in parts[:-1]:
data = data.setdefault(part, {})
data[parts[-1]] = value
def _get_path(data: dict, path: str) -> Any:
"""Get a value from nested dict using dot-separated path."""
parts = path.split(".")
d = data
for part in parts:
if not isinstance(d, dict) or part not in d:
return None
d = d[part]
return d
class Config: class Config:
"""Simple config manager for mytoolkit. """Hierarchical config manager for mytoolkit.
Stores in ~/.mytoolkit/config.json under the "keys" key:: Storage schema (version 2)::
{"keys": {"apikey_ark": "...", "volc_appid": "...", "path_study": "...", ...}} {
"version": 2,
"secrets": {"api_keys": {...}, "accounts": {...}, ...},
"paths": {...},
"connections": {...},
"settings": {...}
}
Backward compatible access via legacy flat key names is supported.
""" """
def __init__(self): def __init__(self):
@@ -32,10 +138,9 @@ class Config:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _migrate_legacy(self) -> None: def _migrate_legacy(self) -> None:
"""One-shot: merge legacy env.json into config.json (keys.* format).""" """One-shot: merge legacy configs into hierarchical config.json."""
self._config_path.parent.mkdir(parents=True, exist_ok=True) self._config_path.parent.mkdir(parents=True, exist_ok=True)
# Load any existing config.json
existing: dict = {} existing: dict = {}
if self._config_path.exists(): if self._config_path.exists():
try: try:
@@ -43,37 +148,147 @@ class Config:
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
existing = {} existing = {}
keys = existing.get("keys", {}) # Only migrate if current file is old format or missing version.
version = existing.get("version")
if version == 2:
return
# 1) Legacy ~/.mytoolkit/env.json (vars.* → keys.*) migrated: dict = {}
migrated_env = False
def _migrate_flat(data: dict) -> None:
"""Migrate flat key/value pairs into migrated hierarchy."""
for k, v in data.items():
if v in (None, ""):
continue
path = _LEGACY_KEY_MAP.get(k) or k
_set_path(migrated, path, v)
# Priority (lowest first): external / legacy → mytoolkit old config wins.
# Skip external configs in isolated test environments.
no_external = os.environ.get("MYTOOLKIT_NO_EXTERNAL_MIGRATION", "0") == "1"
# 1) Legacy ~/.mytoolkit/env.json (vars.* → flat keys)
if _LEGACY_ENV_PATH.exists(): if _LEGACY_ENV_PATH.exists():
try: try:
legacy = json.loads(_LEGACY_ENV_PATH.read_text()) legacy = json.loads(_LEGACY_ENV_PATH.read_text())
legacy_vars = legacy.get("vars", {}) _migrate_flat(legacy.get("vars", {}))
if legacy_vars:
keys.update(legacy_vars)
migrated_env = True
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass pass
if migrated_env: # 2) ~/.xiaohe/agent/config.json (keys.*)
existing["keys"] = dict(sorted(keys.items())) if not no_external and _XIAOHE_CONFIG_PATH.exists():
self._config_path.write_text( try:
json.dumps(existing, indent=2, ensure_ascii=False) + "\n" xiaohe = json.loads(_XIAOHE_CONFIG_PATH.read_text())
) _migrate_flat(xiaohe.get("keys", {}))
# Rename env.json out of the way so we don't re-migrate _migrate_flat(xiaohe.get("settings", {}))
except (json.JSONDecodeError, OSError):
pass
# 3) ~/.xiaohe/agent/settings.json (flat settings)
if not no_external and _XIAOHE_SETTINGS_PATH.exists():
try:
xiaohe_settings = json.loads(_XIAOHE_SETTINGS_PATH.read_text())
_migrate_flat(xiaohe_settings)
except (json.JSONDecodeError, OSError):
pass
# 4) ~/.mytoolkit/mail.json
if not no_external and _MAIL_CONFIG_PATH.exists():
try:
mail = json.loads(_MAIL_CONFIG_PATH.read_text())
for k, v in mail.items():
if v in (None, ""):
continue
path = _LEGACY_KEY_MAP.get(f"mail_{k}")
if path:
_set_path(migrated, path, v)
except (json.JSONDecodeError, OSError):
pass
# 5) ~/.metabot/bots.json -> secrets.feishu.*
if not no_external and _METABOT_BOTS_PATH.exists():
try:
bots = json.loads(_METABOT_BOTS_PATH.read_text())
for bot in bots.get("feishuBots", []):
name = bot.get("name")
app_id = bot.get("feishuAppId")
secret = bot.get("feishuAppSecret")
if name:
if app_id:
_set_path(migrated, f"secrets.feishu.{name}.app_id", app_id)
if secret:
_set_path(migrated, f"secrets.feishu.{name}.secret", secret)
except (json.JSONDecodeError, OSError):
pass
# 6) Old flat ~/.mytoolkit/config.json (keys.* / settings.* / top-level)
# Highest priority so mytoolkit remains the source of truth.
if existing:
_migrate_flat(existing.get("keys", {}))
_migrate_flat(existing.get("settings", {}))
for k, v in existing.items():
if k in ("version", "keys", "settings"):
continue
if v in (None, ""):
continue
path = _LEGACY_KEY_MAP.get(k)
if path:
_set_path(migrated, path, v)
# Drop empty values and normalize.
migrated = self._cleanup(migrated)
# Merge with any existing version 2 data if present.
if existing.get("version") == 2:
self._deep_merge(existing, migrated)
final = existing
else:
final = migrated
final["version"] = 2
final = self._sort_dicts(final)
self._config_path.write_text(json.dumps(final, indent=2, ensure_ascii=False) + "\n")
self._config_path.chmod(0o600)
# Rename legacy env.json out of the way.
if _LEGACY_ENV_PATH.exists():
backup = _LEGACY_ENV_PATH.with_name("env.json.migrated") backup = _LEGACY_ENV_PATH.with_name("env.json.migrated")
if not backup.exists(): if not backup.exists():
_LEGACY_ENV_PATH.rename(backup) _LEGACY_ENV_PATH.rename(backup)
# 2) Very old bundled config.json next to this module (early mytoolkit) # Very old bundled config.json next to this module (early mytoolkit)
if not self._config_path.exists() and _LEGACY_MODULE_PATH.exists(): if not self._config_path.exists() and _LEGACY_MODULE_PATH.exists():
try: try:
_LEGACY_MODULE_PATH.rename(self._config_path) _LEGACY_MODULE_PATH.rename(self._config_path)
except OSError: except OSError:
pass pass
@staticmethod
def _cleanup(data: Any) -> Any:
"""Remove None and empty string values recursively."""
if isinstance(data, dict):
return {k: Config._cleanup(v) for k, v in data.items() if v not in (None, "")}
if isinstance(data, list):
return [Config._cleanup(v) for v in data if v not in (None, "")]
return data
@staticmethod
def _deep_merge(base: dict, override: dict) -> None:
"""Merge override into base recursively."""
for k, v in override.items():
if k in base and isinstance(base[k], dict) and isinstance(v, dict):
Config._deep_merge(base[k], v)
else:
base[k] = v
@staticmethod
def _sort_dicts(data: Any) -> Any:
"""Recursively return a new dict with sorted keys."""
if isinstance(data, dict):
return {k: Config._sort_dicts(data[k]) for k in sorted(data.keys())}
if isinstance(data, list):
return [Config._sort_dicts(v) for v in data]
return data
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Load / Save # Load / Save
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -81,47 +296,95 @@ class Config:
def _load(self) -> dict: def _load(self) -> dict:
if self._config_path.exists(): if self._config_path.exists():
try: try:
return json.loads(self._config_path.read_text()) data = json.loads(self._config_path.read_text())
if data.get("version") == 2:
return data
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass pass
return {"keys": {}} return {"version": 2}
def save(self): def save(self) -> None:
self._config_path.parent.mkdir(parents=True, exist_ok=True) self._config_path.parent.mkdir(parents=True, exist_ok=True)
self._config_path.write_text( sorted_data = self._sort_dicts(self._data)
json.dumps(self._data, indent=2, ensure_ascii=False) + "\n" self._config_path.write_text(json.dumps(sorted_data, indent=2, ensure_ascii=False) + "\n")
) self._config_path.chmod(0o600)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Accessors # Accessors
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def get(self, name: str) -> str | None: def get(self, name: str) -> Any:
"""Get a key by name.""" """Get a config value by hierarchical or legacy flat name."""
return self._data.get("keys", {}).get(name) # Hierarchical path.
if "." in name:
return _get_path(self._data, name)
# Legacy flat key inside keys.*
legacy_keys = self._data.get("keys", {})
if name in legacy_keys:
return legacy_keys[name]
# Legacy mapping to hierarchical path.
new_name = _LEGACY_KEY_MAP.get(name)
if new_name:
return _get_path(self._data, new_name)
# Direct top-level key (e.g., keys set by tests or ad-hoc callers).
return _get_path(self._data, name)
def get_all(self) -> dict[str, str]: def set(self, name: str, value: Any) -> None:
"""Return all keys as a flat dict.""" """Set a config value by hierarchical or legacy flat name."""
return self._data.get("keys", {}).copy() if "." not in name and name in _LEGACY_KEY_MAP:
name = _LEGACY_KEY_MAP[name]
def set(self, name: str, value: str): _set_path(self._data, name, value)
"""Set a key and persist."""
if "keys" not in self._data:
self._data["keys"] = {}
self._data["keys"][name] = value
self.save() self.save()
def remove(self, name: str) -> bool: def remove(self, name: str) -> bool:
"""Remove a key. Returns True if existed.""" """Remove a config value by hierarchical or legacy flat name."""
if name in self._data.get("keys", {}): if "." not in name and name in _LEGACY_KEY_MAP:
del self._data["keys"][name] name = _LEGACY_KEY_MAP[name]
parts = name.split(".")
d = self._data
for part in parts[:-1]:
if not isinstance(d, dict) or part not in d:
return False
d = d[part]
if isinstance(d, dict) and parts[-1] in d:
del d[parts[-1]]
self.save() self.save()
return True return True
return False return False
def get_all(self) -> dict[str, Any]:
"""Return all values as a flat dict using legacy key names."""
flat: dict[str, Any] = {}
def walk(data: Any, prefix: str) -> None:
if isinstance(data, dict):
for k, v in data.items():
walk(v, f"{prefix}.{k}" if prefix else k)
else:
key = _HIERARCHY_TO_LEGACY.get(prefix, prefix)
flat[key] = data
walk(self._data, "")
# Remove structural/version keys.
flat.pop("version", None)
return flat
def export(self) -> dict[str, str]: def export(self) -> dict[str, str]:
"""Return all keys with MYCLI_ prefix for shell eval.""" """Return all values with MYCLI_ prefix for shell eval."""
return {f"MYCLI_{k.upper()}": v for k, v in self.get_all().items()} return {f"MYCLI_{k.upper()}": str(v) for k, v in self.get_all().items()}
def resolve_key(self, name: str, env_var: str | None = None) -> str | None:
"""Resolve a key with priority: env > config.
Args:
name: Hierarchical or legacy flat config key.
env_var: Optional environment variable name that overrides config.
"""
if env_var:
env_value = os.environ.get(env_var)
if env_value:
return env_value
return self.get(name)
# Global singleton # Global singleton
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"pillow>=11.3.0", "pillow>=11.3.0",
"pypdf>=6.10.1", "pypdf>=6.10.1",
"python-docx>=1.1.0", "python-docx>=1.1.0",
"requests>=2.32.0",
"websockets>=13.0", "websockets>=13.0",
] ]
+1
View File
@@ -9,3 +9,4 @@ import os
import tempfile import tempfile
os.environ["MYTOOLKIT_HOME"] = tempfile.mkdtemp(prefix="mytoolkit-test-home-") os.environ["MYTOOLKIT_HOME"] = tempfile.mkdtemp(prefix="mytoolkit-test-home-")
os.environ["MYTOOLKIT_NO_EXTERNAL_MIGRATION"] = "1"
+2 -2
View File
@@ -77,6 +77,6 @@ def test_legacy_env_merges_without_clobbering_existing_keys(fresh_home):
json.dumps({"vars": {"apikey_ark": "sk-old", "extra": "e"}}) json.dumps({"vars": {"apikey_ark": "sk-old", "extra": "e"}})
) )
c = Config() c = Config()
# Legacy vars are merged in (documented behaviour: legacy wins on conflict). # Unknown legacy vars are merged in; known keys keep the existing mytoolkit value.
assert c.get("extra") == "e" assert c.get("extra") == "e"
assert c.get("apikey_ark") == "sk-old" assert c.get("apikey_ark") == "sk-new"
Generated
+163
View File
@@ -58,6 +58,106 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
] ]
[[package]]
name = "charset-normalizer"
version = "3.4.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" },
{ url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" },
{ url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" },
{ url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" },
{ url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" },
{ url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" },
{ url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" },
{ url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" },
{ url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" },
{ url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" },
{ url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" },
{ url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" },
{ url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" },
{ url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" },
{ url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" },
{ url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" },
{ url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" },
{ url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" },
{ url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" },
{ url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" },
{ url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" },
{ url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" },
{ url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" },
{ url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" },
{ url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" },
{ url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" },
{ url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" },
{ url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" },
{ url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" },
{ url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" },
{ url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" },
{ url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" },
{ url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" },
{ url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" },
{ url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" },
{ url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" },
{ url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" },
{ url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" },
{ url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" },
{ url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" },
{ url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
{ url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
{ url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
{ url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
{ url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
{ url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
{ url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
{ url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
{ url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
{ url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
{ url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
{ url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
{ url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
{ url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
{ url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
{ url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
{ url = "https://files.pythonhosted.org/packages/a6/ec/81e22253f4b7091eca6515bb3da5e45d05a663f7f567bb745695dc60f892/charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a", size = 306122, upload-time = "2026-07-07T14:34:36.607Z" },
{ url = "https://files.pythonhosted.org/packages/c8/53/a8c042eb9eee4716f4d42a0f5a571eb32a09ec429be9fb0b8b9d765393ba/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4", size = 206284, upload-time = "2026-07-07T14:34:38.166Z" },
{ url = "https://files.pythonhosted.org/packages/14/cb/1db8b96547ee3186cd2dd7f2e59dd560a9b80748f3604171f3c153d62811/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94", size = 226837, upload-time = "2026-07-07T14:34:39.77Z" },
{ url = "https://files.pythonhosted.org/packages/6a/05/c94d5cd23396289c54c93b02e0273b4dd8921641d9968c4828caf9bbaad9/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5", size = 222199, upload-time = "2026-07-07T14:34:41.391Z" },
{ url = "https://files.pythonhosted.org/packages/6d/46/79847edd07244a4a2d443c6655a7b6ee94203c21539414b059f32713c357/charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84", size = 214344, upload-time = "2026-07-07T14:34:42.986Z" },
{ url = "https://files.pythonhosted.org/packages/ec/b4/ef5a49b2e77c00deb43bb3256592b115ba9e4346016e82c516b8d215bf68/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4", size = 199988, upload-time = "2026-07-07T14:34:44.685Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ca/ad1d7c7d3077dab873f539d3e1d083c0845a762cb0bafdfbe3ef93add598/charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f", size = 211908, upload-time = "2026-07-07T14:34:46.227Z" },
{ url = "https://files.pythonhosted.org/packages/ed/61/710738687f90d01c06a04ed52d6ca1e62dd9b1d8cc2567098167c4691034/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833", size = 209320, upload-time = "2026-07-07T14:34:47.753Z" },
{ url = "https://files.pythonhosted.org/packages/5f/c0/6eec7bdabe6cbbcc274ec04596f6d93865751a0541d33d60d1ce179bd372/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba", size = 200980, upload-time = "2026-07-07T14:34:49.362Z" },
{ url = "https://files.pythonhosted.org/packages/eb/78/59344ff9a4a7b5f6530bf7bec2c980047cc42c3a616596cdbd8cb5c1a1af/charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29", size = 216545, upload-time = "2026-07-07T14:34:50.98Z" },
{ url = "https://files.pythonhosted.org/packages/17/6d/bff78a4bacc4891bc63ec5bdc6776d8c85e47fab93d0d5f6223068fad0a4/charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9", size = 146256, upload-time = "2026-07-07T14:34:52.509Z" },
{ url = "https://files.pythonhosted.org/packages/a2/55/86048bde1c9d0352940bd7b87d825091a52aef67d01cde6c6f7342c5b552/charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b", size = 156413, upload-time = "2026-07-07T14:34:54.117Z" },
{ url = "https://files.pythonhosted.org/packages/28/e9/9fb6099b868c82a40698a748ae0fbd4f31ccc13844c176a07158ba2abbfd/charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe", size = 147887, upload-time = "2026-07-07T14:34:55.51Z" },
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.1.8" version = "8.1.8"
@@ -451,6 +551,8 @@ dependencies = [
{ name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "pypdf" }, { name = "pypdf" },
{ name = "python-docx" }, { name = "python-docx" },
{ name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "websockets", version = "16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "websockets", version = "16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
] ]
@@ -468,6 +570,7 @@ requires-dist = [
{ name = "pillow", specifier = ">=11.3.0" }, { name = "pillow", specifier = ">=11.3.0" },
{ name = "pypdf", specifier = ">=6.10.1" }, { name = "pypdf", specifier = ">=6.10.1" },
{ name = "python-docx", specifier = ">=1.1.0" }, { name = "python-docx", specifier = ">=1.1.0" },
{ name = "requests", specifier = ">=2.32.0" },
{ name = "websockets", specifier = ">=13.0" }, { name = "websockets", specifier = ">=13.0" },
] ]
@@ -950,6 +1053,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
] ]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "certifi", marker = "python_full_version < '3.10'" },
{ name = "charset-normalizer", marker = "python_full_version < '3.10'" },
{ name = "idna", marker = "python_full_version < '3.10'" },
{ name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
dependencies = [
{ name = "certifi", marker = "python_full_version >= '3.10'" },
{ name = "charset-normalizer", marker = "python_full_version >= '3.10'" },
{ name = "idna", marker = "python_full_version >= '3.10'" },
{ name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
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]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"
@@ -1046,6 +1185,30 @@ 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" }, { 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.6.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
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]] [[package]]
name = "websockets" name = "websockets"
version = "15.0.1" version = "15.0.1"