refactor(config): unify secrets in ~/.mytoolkit/config.json and migrate skill wrappers
This commit is contained in:
@@ -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 <key>
|
||||
mytoolkit env set volc_appid <appid>
|
||||
mytoolkit env set volc_access_token <token>
|
||||
mytoolkit env set secrets.api_keys.ark <key>
|
||||
mytoolkit env set secrets.volcengine.app_id <appid>
|
||||
mytoolkit env set secrets.volcengine.access_token <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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+21
-23
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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 <appid>\n"
|
||||
" mytoolkit env set volc_access_token <token>"
|
||||
" mytoolkit env set secrets.volcengine.app_id <appid>\n"
|
||||
" mytoolkit env set secrets.volcengine.access_token <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).")
|
||||
|
||||
+308
-45
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user