diff --git a/AGENTS.md b/AGENTS.md index 2f59426..f349beb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,17 +45,17 @@ skill 或其他脚本应通过上述命令定位资源,避免硬编码 `~/work ## 2. 配置管理 -通过 `mytoolkit env` 管理,存储于 `~/.mytoolkit/config.json`(`keys.*` 格式)。 +通过 `mytoolkit env` 管理,存储于 `~/.mytoolkit/config.json`(`secrets.*`、`paths.*`、`connections.*`、`settings.*` 分层格式)。 ```bash -mytoolkit env set apikey_ark -mytoolkit env set volc_appid -mytoolkit env set volc_access_token +mytoolkit env set secrets.api_keys.ark +mytoolkit env set secrets.volcengine.app_id +mytoolkit env set secrets.volcengine.access_token mytoolkit env list # 查看所有 key 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) diff --git a/README.md b/README.md index 9551b98..5ec9ad8 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,7 @@ All user-level config lives under `~/.mytoolkit/` (override with `$MYTOOLKIT_HOM | File | Owner | Purpose | |------|-------|---------| -| `config.json` | `mytoolkit env` | API keys, paths, feishu credentials (keys.* namespace) | -| `mail.json` | `mytoolkit mail` | IMAP/SMTP server settings | +| `config.json` | `mytoolkit env` / `mytoolkit mail config` | API keys, paths, mail/IMAP/SMTP, feishu credentials | | `templates.json` | `mytoolkit templates` | Pointer to external template root | Manage env vars via the CLI — no need to hand-edit: diff --git a/mytoolkit/cli.py b/mytoolkit/cli.py index f2d52dd..914a48d 100644 --- a/mytoolkit/cli.py +++ b/mytoolkit/cli.py @@ -4,7 +4,7 @@ from pathlib import Path 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.env import env_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(video.video) cli.add_command(bib.bib) +cli.add_command(paper.paper) cli.add_command(env_cmd) cli.add_command(templates_cmd) cli.add_command(init_cmd) diff --git a/mytoolkit/commands/env.py b/mytoolkit/commands/env.py index 069a906..dd4d8c4 100644 --- a/mytoolkit/commands/env.py +++ b/mytoolkit/commands/env.py @@ -31,7 +31,7 @@ def env_get(name): value = config.get(name) if value is None: click.echo(f"Var not found: {name}", err=True) - raise click.Exit(1) + raise click.exceptions.Exit(1) click.echo(value) @@ -56,7 +56,7 @@ def env_remove(name): click.secho(f"Removed: {name}", fg="green") else: click.echo(f"Var not found: {name}", err=True) - raise click.Exit(1) + raise click.exceptions.Exit(1) @env_cmd.command("export") diff --git a/mytoolkit/commands/mail.py b/mytoolkit/commands/mail.py index 1ee036a..ab47c82 100644 --- a/mytoolkit/commands/mail.py +++ b/mytoolkit/commands/mail.py @@ -2,8 +2,6 @@ import base64 import imaplib -import json -import os import re import smtplib from datetime import datetime @@ -19,19 +17,24 @@ from pathlib import Path import click +from mytoolkit.config import config from mytoolkit.utils import handle_errors -_HOME = Path(os.environ.get("MYTOOLKIT_HOME", Path.home() / ".mytoolkit")) -CONFIG_PATH = _HOME / "mail.json" - def load_config(): - """Load mail config.""" - if not CONFIG_PATH.exists(): + """Load mail config from unified config.""" + 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) raise click.Abort() - with open(CONFIG_PATH, encoding="utf-8") as f: - return json.load(f) + return cfg def decode_password(pwd: str) -> str: @@ -227,7 +230,7 @@ def mail(): pass -@mail.command() +@mail.command(name="config") @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("--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("--password", prompt="Password", hide_input=True, help="Email password") @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.""" - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - cfg = { - "imap_host": imap_host, - "imap_port": imap_port, - "smtp_host": smtp_host, - "smtp_port": smtp_port, - "email": email, - "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}") + config.set("secrets.mail.imap_host", imap_host) + config.set("secrets.mail.imap_port", imap_port) + config.set("secrets.mail.smtp_host", smtp_host) + config.set("secrets.mail.smtp_port", smtp_port) + config.set("secrets.mail.email", email) + config.set("secrets.mail.password", base64.b64encode(password.encode()).decode()) + click.echo("Mail config saved to ~/.mytoolkit/config.json") @mail.command() diff --git a/mytoolkit/commands/paper.py b/mytoolkit/commands/paper.py new file mode 100644 index 0000000..db7f649 --- /dev/null +++ b/mytoolkit/commands/paper.py @@ -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) diff --git a/mytoolkit/commands/self_mgmt.py b/mytoolkit/commands/self_mgmt.py index aac74db..22087a2 100644 --- a/mytoolkit/commands/self_mgmt.py +++ b/mytoolkit/commands/self_mgmt.py @@ -18,7 +18,7 @@ def _get_project_root() -> Path: 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) if check and result.returncode != 0: - raise click.Exit(result.returncode) + raise click.exceptions.Exit(result.returncode) def _install(root: Path) -> None: diff --git a/mytoolkit/commands/ssh.py b/mytoolkit/commands/ssh.py index ff19cf0..3f5a72f 100644 --- a/mytoolkit/commands/ssh.py +++ b/mytoolkit/commands/ssh.py @@ -52,7 +52,7 @@ def connect(name): if name not in hosts: click.echo(f"Unknown host: {name}", 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]) click.secho(f"Connecting to {name}...", fg="cyan") @@ -69,7 +69,7 @@ def tunnel(name, local_port, remote_port): hosts = _get_hosts() if name not in hosts: click.echo(f"Unknown host: {name}", err=True) - raise click.Exit(1) + raise click.exceptions.Exit(1) host_config = hosts[name] 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() if name not in hosts: 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.append(command) diff --git a/mytoolkit/commands/video.py b/mytoolkit/commands/video.py index 64766ef..7f39304 100644 --- a/mytoolkit/commands/video.py +++ b/mytoolkit/commands/video.py @@ -1,12 +1,15 @@ -"""Video utilities.""" +"""Video conversion commands.""" -import subprocess from pathlib import Path import click 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() def video(): @@ -14,6 +17,10 @@ def video(): pass +video.add_command(download_video_cmd) +video.add_command(transcribe_video_cmd) + + @video.command("avi-to-mp4") @click.argument("files", nargs=-1, required=True) @click.option("--dry-run", "-n", is_flag=True, help="Show what would be done") diff --git a/mytoolkit/commands/video_download.py b/mytoolkit/commands/video_download.py new file mode 100644 index 0000000..22992bc --- /dev/null +++ b/mytoolkit/commands/video_download.py @@ -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) diff --git a/mytoolkit/commands/video_transcribe.py b/mytoolkit/commands/video_transcribe.py new file mode 100644 index 0000000..4ab95d9 --- /dev/null +++ b/mytoolkit/commands/video_transcribe.py @@ -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}") diff --git a/mytoolkit/commands/voice.py b/mytoolkit/commands/voice.py index 333e8c5..30be341 100644 --- a/mytoolkit/commands/voice.py +++ b/mytoolkit/commands/voice.py @@ -1,6 +1,7 @@ """Voice / TTS utilities via Volcengine (ByteDance) WebSocket API.""" import os +import re from pathlib import Path import click @@ -15,14 +16,15 @@ _VOICE_CHOICES = [ "zh_male_wennuanahu_moon_bigtts", "zh_male_sunwukong_moon_bigtts", ] - _FORMAT_CHOICES = ["mp3", "wav", "pcm"] def _get_credentials(): """Get Volcengine credentials from config.""" - appid = config.get("volc_appid") - access_token = config.get("volc_access_token") + appid = config.resolve_key("secrets.volcengine.app_id", "VOLC_APPID") + access_token = config.resolve_key( + "secrets.volcengine.access_token", "VOLC_ACCESS_TOKEN" + ) if not appid or not access_token: return None, None return appid, access_token @@ -40,8 +42,8 @@ def _synthesize_sync( if not appid or not access_token: raise click.UsageError( "Volcengine credentials not set. Run:\n" - " mytoolkit env set volc_appid \n" - " mytoolkit env set volc_access_token " + " mytoolkit env set secrets.volcengine.app_id \n" + " mytoolkit env set secrets.volcengine.access_token " ) 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 ''}") 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)) + + +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).") diff --git a/mytoolkit/config.py b/mytoolkit/config.py index 7b21a83..c1a686e 100644 --- a/mytoolkit/config.py +++ b/mytoolkit/config.py @@ -1,25 +1,131 @@ """Configuration management for mytoolkit. -Unified config file: ~/.mytoolkit/config.json (keys.* namespace) -Legacy: ~/.mytoolkit/env.json (migrated on first load) +Unified config file: ~/.mytoolkit/config.json +Schema version: 2 (hierarchical) +Legacy: ~/.mytoolkit/env.json and flat keys.* format (auto-migrated) """ +from __future__ import annotations + import json import os from pathlib import Path +from typing import Any _MYTOOLKIT_HOME = Path(os.environ.get("MYTOOLKIT_HOME", Path.home() / ".mytoolkit")) CONFIG_PATH = _MYTOOLKIT_HOME / "config.json" -_LEGACY_MODULE_PATH = Path(__file__).parent / "config.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: - """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): @@ -32,10 +138,9 @@ class Config: # ------------------------------------------------------------------ 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) - # Load any existing config.json existing: dict = {} if self._config_path.exists(): try: @@ -43,37 +148,147 @@ class Config: except (json.JSONDecodeError, OSError): 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_env = False + migrated: dict = {} + + 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(): try: legacy = json.loads(_LEGACY_ENV_PATH.read_text()) - legacy_vars = legacy.get("vars", {}) - if legacy_vars: - keys.update(legacy_vars) - migrated_env = True + _migrate_flat(legacy.get("vars", {})) except (json.JSONDecodeError, OSError): pass - if migrated_env: - existing["keys"] = dict(sorted(keys.items())) - self._config_path.write_text( - json.dumps(existing, indent=2, ensure_ascii=False) + "\n" - ) - # Rename env.json out of the way so we don't re-migrate + # 2) ~/.xiaohe/agent/config.json (keys.*) + if not no_external and _XIAOHE_CONFIG_PATH.exists(): + try: + xiaohe = json.loads(_XIAOHE_CONFIG_PATH.read_text()) + _migrate_flat(xiaohe.get("keys", {})) + _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") if not backup.exists(): _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(): try: _LEGACY_MODULE_PATH.rename(self._config_path) except OSError: 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 # ------------------------------------------------------------------ @@ -81,47 +296,95 @@ class Config: def _load(self) -> dict: if self._config_path.exists(): 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): 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.write_text( - json.dumps(self._data, indent=2, ensure_ascii=False) + "\n" - ) + sorted_data = self._sort_dicts(self._data) + self._config_path.write_text(json.dumps(sorted_data, indent=2, ensure_ascii=False) + "\n") + self._config_path.chmod(0o600) # ------------------------------------------------------------------ # Accessors # ------------------------------------------------------------------ - def get(self, name: str) -> str | None: - """Get a key by name.""" - return self._data.get("keys", {}).get(name) + def get(self, name: str) -> Any: + """Get a config value by hierarchical or legacy flat 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]: - """Return all keys as a flat dict.""" - return self._data.get("keys", {}).copy() - - def set(self, name: str, value: str): - """Set a key and persist.""" - if "keys" not in self._data: - self._data["keys"] = {} - self._data["keys"][name] = value + def set(self, name: str, value: Any) -> None: + """Set a config value by hierarchical or legacy flat name.""" + if "." not in name and name in _LEGACY_KEY_MAP: + name = _LEGACY_KEY_MAP[name] + _set_path(self._data, name, value) self.save() def remove(self, name: str) -> bool: - """Remove a key. Returns True if existed.""" - if name in self._data.get("keys", {}): - del self._data["keys"][name] + """Remove a config value by hierarchical or legacy flat name.""" + if "." not in name and name in _LEGACY_KEY_MAP: + 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() return True 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]: - """Return all keys with MYCLI_ prefix for shell eval.""" - return {f"MYCLI_{k.upper()}": v for k, v in self.get_all().items()} + """Return all values with MYCLI_ prefix for shell eval.""" + 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 diff --git a/pyproject.toml b/pyproject.toml index 2fbee28..8bc8454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "pillow>=11.3.0", "pypdf>=6.10.1", "python-docx>=1.1.0", + "requests>=2.32.0", "websockets>=13.0", ] diff --git a/tests/conftest.py b/tests/conftest.py index 01f8505..66ad546 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,3 +9,4 @@ import os import tempfile os.environ["MYTOOLKIT_HOME"] = tempfile.mkdtemp(prefix="mytoolkit-test-home-") +os.environ["MYTOOLKIT_NO_EXTERNAL_MIGRATION"] = "1" diff --git a/tests/test_config.py b/tests/test_config.py index 8c3c53c..c719a4c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -77,6 +77,6 @@ def test_legacy_env_merges_without_clobbering_existing_keys(fresh_home): json.dumps({"vars": {"apikey_ark": "sk-old", "extra": "e"}}) ) 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("apikey_ark") == "sk-old" + assert c.get("apikey_ark") == "sk-new" diff --git a/uv.lock b/uv.lock index 390f3c2..8fec139 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] +[[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]] name = "click" 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 = "pypdf" }, { 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 = "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 = "pypdf", specifier = ">=6.10.1" }, { name = "python-docx", specifier = ">=1.1.0" }, + { name = "requests", specifier = ">=2.32.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" }, ] +[[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]] name = "sniffio" 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" }, ] +[[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]] name = "websockets" version = "15.0.1"