refactor: rename package from mytoolkit to bin

- Move all source files from mytoolkit/ to bin/
- Update entry point and build config in pyproject.toml
- Add pillow and pypdf dependencies
- Update README and .gitignore paths
- Remove unused subprocess import in pdf.py
- Clean up duplicate imports in pdf merge command
This commit is contained in:
Zhengshou Lai
2026-04-26 09:56:29 +08:00
parent 3aeaf7cb64
commit c1df4098b3
20 changed files with 374 additions and 23 deletions
+9
View File
@@ -0,0 +1,9 @@
"""MyCLI subcommands."""
from . import pdf, image, latex, video, bib, utils
from .env import env_cmd
from .ssh import ssh_cmd
from .git import git_cmd
from .self_mgmt import self_cmd, update_cmd, uninstall_cmd
__all__ = ["pdf", "image", "latex", "video", "bib", "utils", "env_cmd", "ssh_cmd", "git_cmd", "self_cmd", "update_cmd", "uninstall_cmd"]
+56
View File
@@ -0,0 +1,56 @@
"""BibTeX utilities."""
import subprocess
from pathlib import Path
import click
from bin.config import config
from bin.utils import handle_errors, run_command
@click.group()
def bib():
"""BibTeX and bibliography commands."""
pass
@bib.command("to-markdown")
@click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def to_markdown(files, dry_run):
"""Convert BibTeX files to Markdown."""
for pattern in files:
for bib_file in Path(".").glob(pattern):
if bib_file.suffix.lower() != ".bib":
continue
output = bib_file.with_suffix(".md")
if dry_run:
click.echo(f"Would convert: {bib_file} -> {output}")
continue
click.echo(f"Converting: {bib_file} -> {output}")
content = bib_file.read_text()
md_content = f"# Bibliography\n\n```bibtex\n{content}\n```\n"
output.write_text(md_content)
@bib.command("cv-update")
@handle_errors
def cv_update():
"""Update CV bibliography using biber."""
cv_path_str = config.get("path_cv")
if cv_path_str:
cv_path = Path(cv_path_str).expanduser()
else:
# Fallback to hardcoded path
cv_path = Path.home() / "Documents/Personal/resume_cv/cv_lai_maintaining_latex/cv_lai_maintaining"
if not cv_path.exists():
click.echo(f"CV file not found: {cv_path}")
return
click.echo(f"Updating CV bibliography: {cv_path}")
run_command(["biber", str(cv_path)], check=False)
+65
View File
@@ -0,0 +1,65 @@
"""Environment variables management."""
import click
from bin.config import config
@click.group(name="env")
def env_cmd():
"""Manage environment variables."""
pass
@env_cmd.command("list")
def env_list():
"""List all vars."""
vars = config.get_all()
if not vars:
click.echo("No vars configured.")
return
for name, value in sorted(vars.items()):
masked = "***" if any(x in name.lower() for x in ["key", "secret", "token", "pass"]) else value
click.echo(f"{name:20} = {masked}")
@env_cmd.command("get")
@click.argument("name")
def env_get(name):
"""Get a var value."""
value = config.get(name)
if value is None:
click.echo(f"Var not found: {name}", err=True)
raise click.Exit(1)
click.echo(value)
@env_cmd.command("set")
@click.argument("name")
@click.argument("value", required=False)
@click.option("--secret", is_flag=True, help="Hide input for secrets")
def env_set(name, value, secret):
"""Set a var. Prompts if value not provided."""
if value is None:
value = click.prompt("Value", hide_input=secret)
config.set(name, value)
click.secho(f"Set: {name}", fg="green")
@env_cmd.command("remove")
@click.argument("name")
@click.confirmation_option(prompt="Remove this var?")
def env_remove(name):
"""Remove a var."""
if config.remove(name):
click.secho(f"Removed: {name}", fg="green")
else:
click.echo(f"Var not found: {name}", err=True)
raise click.Exit(1)
@env_cmd.command("export")
def env_export():
"""Print export statements for shell eval."""
for name, value in config.export().items():
click.echo(f"export {name}={value}")
+116
View File
@@ -0,0 +1,116 @@
"""Git utilities including proxy management."""
import subprocess
import click
from bin.config import config
def _get_proxies() -> dict[str, str]:
"""Get git proxies from config."""
proxies = {}
for key, value in config.get_all().items():
if key.startswith("proxy_"):
name = key[6:] # Remove 'proxy_' prefix
proxies[name] = value
return proxies
def _get_git_proxy(key: str) -> str:
"""Get a global git proxy config value."""
result = subprocess.run(
["git", "config", "--global", key],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip() if result.returncode == 0 else "(not set)"
def _unset_git_proxy(key: str) -> None:
"""Unset a global git proxy config value (no-op if not set)."""
subprocess.run(
["git", "config", "--global", "--unset", key],
capture_output=True,
check=False,
)
@click.group(name="git")
def git_cmd() -> None:
"""Git utilities."""
pass
@git_cmd.group(name="proxy")
def proxy_cmd() -> None:
"""Manage git proxy settings."""
pass
def _complete_proxy_names(_ctx, _param, incomplete):
"""Shell completion for proxy names."""
return [
name for name in _get_proxies() if name.startswith(incomplete)
]
@proxy_cmd.command("set")
@click.argument("name", shell_complete=_complete_proxy_names)
def proxy_set(name: str) -> None:
"""Set git proxy by name."""
proxies = _get_proxies()
if name not in proxies:
click.echo(f"Unknown proxy: {name}", err=True)
click.echo(f"Available: {', '.join(proxies.keys())}", err=True)
raise click.Exit(1)
proxy_url = proxies[name]
subprocess.run(
["git", "config", "--global", "http.proxy", proxy_url], check=True
)
subprocess.run(
["git", "config", "--global", "https.proxy", proxy_url], check=True
)
click.secho(f"Git proxy set to {name}: {proxy_url}", fg="green")
@proxy_cmd.command("unset")
def proxy_unset() -> None:
"""Unset git proxy."""
_unset_git_proxy("http.proxy")
_unset_git_proxy("https.proxy")
click.secho("Git proxy unset", fg="green")
@proxy_cmd.command("status")
def proxy_status() -> None:
"""Show current git proxy status."""
http_proxy = _get_git_proxy("http.proxy")
https_proxy = _get_git_proxy("https.proxy")
proxies = _get_proxies()
current_name = next(
(p for p, url in proxies.items() if url == http_proxy), None
)
click.secho("Git proxy status:", fg="cyan")
if current_name:
click.echo(f" Active: {current_name}")
click.echo(f" http.proxy: {http_proxy}")
click.echo(f" https.proxy: {https_proxy}")
click.secho("\nAvailable proxies:", fg="cyan")
for proxy_name, url in proxies.items():
marker = "*" if proxy_name == current_name else " "
click.echo(f" [{marker}] {proxy_name:12} {url}")
@proxy_cmd.command("list")
def proxy_list_cmd() -> None:
"""List available git proxies."""
proxies = _get_proxies()
click.secho("Available proxies:", fg="cyan")
for name, url in proxies.items():
click.echo(f" {name:12} {url}")
+183
View File
@@ -0,0 +1,183 @@
"""Image utilities."""
import subprocess
from pathlib import Path
import click
from bin.utils import handle_errors, run_command
@click.group()
def image():
"""Image manipulation commands."""
pass
@image.command("eps-to-pdf")
@click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def eps_to_pdf(files, dry_run):
"""Convert EPS files to PDF."""
for pattern in files:
for eps_file in Path(".").glob(pattern):
if eps_file.suffix.lower() not in (".eps", ".ps"):
continue
output = eps_file.with_suffix(".pdf")
if dry_run:
click.echo(f"Would convert: {eps_file} -> {output}")
continue
click.echo(f"Converting: {eps_file} -> {output}")
run_command(["epstopdf", str(eps_file)], check=False)
@image.command("eps-fix")
@click.argument("file", type=click.Path(exists=True))
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def eps_fix(file, dry_run):
"""Fix EPS files created by print command."""
path = Path(file)
content = path.read_text()
if "/f/fill" not in content:
click.echo("File already fixed or not created by print command")
return
if dry_run:
click.echo(f"Would fix: {file}")
return
lines = content.split("\n")
new_lines = []
for line in lines:
if "/f/fill" in line:
line = line.replace("/f/fill", "")
new_lines.append(line)
path.write_text("\n".join(new_lines))
click.echo(f"Fixed: {file}")
@image.command("jpg-to-pdf")
@click.argument("files", nargs=-1, required=True)
@click.option("--output", "-o", help="Output PDF file")
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def jpg_to_pdf(files, output, dry_run):
"""Convert JPG files to PDF."""
images = []
for pattern in files:
for jpg_file in Path(".").glob(pattern):
if jpg_file.suffix.lower() in (".jpg", ".jpeg"):
images.append(str(jpg_file))
if not images:
click.echo("No JPG files found")
return
output = output or "output.pdf"
if dry_run:
click.echo(f"Would convert {len(images)} images to {output}")
return
click.echo(f"Converting {len(images)} images to {output}")
run_command(["convert"] + images + [output], check=False)
@image.command("tiff-to-pdf")
@click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def tiff_to_pdf(files, dry_run):
"""Convert TIFF files to PDF."""
for pattern in files:
for tiff_file in Path(".").glob(pattern):
if tiff_file.suffix.lower() not in (".tif", ".tiff"):
continue
output = tiff_file.with_suffix(".pdf")
if dry_run:
click.echo(f"Would convert: {tiff_file} -> {output}")
continue
click.echo(f"Converting: {tiff_file} -> {output}")
run_command(["convert", str(tiff_file), str(output)], check=False)
@image.command("tiff-compress")
@click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def tiff_compress(files, dry_run):
"""Compress TIFF files."""
for pattern in files:
for tiff_file in Path(".").glob(pattern):
if tiff_file.suffix.lower() not in (".tif", ".tiff"):
continue
if dry_run:
click.echo(f"Would compress: {tiff_file}")
continue
click.echo(f"Compressing: {tiff_file}")
run_command(
["convert", str(tiff_file), "-compress", "zip", str(tiff_file)],
check=False,
)
@image.command("compress")
@click.argument("files", nargs=-1, required=True)
@click.option("--target-mb", "-t", default=1.0, help="Target max file size in MB")
@click.option("--max-width", "-w", default=1600, help="Max width in pixels if resize needed")
@click.option("--quality", "-q", default=75, help="JPEG quality for resized images")
@click.option("--suffix", "-s", default="_compressed", help="Output filename suffix")
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def compress_images(files, target_mb, max_width, quality, suffix, dry_run):
"""Compress PNG/JPG images to fit under target size."""
from PIL import Image
for pattern in files:
for img_file in Path(".").glob(pattern):
if img_file.suffix.lower() not in (".png", ".jpg", ".jpeg"):
continue
output = img_file.with_stem(f"{img_file.stem}{suffix}")
if img_file.suffix.lower() == ".png":
output = output.with_suffix(".jpg")
if dry_run:
click.echo(f"Would compress: {img_file} -> {output}")
continue
img = Image.open(img_file)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# Try full-res first with q80
img.save(output, "JPEG", quality=80, optimize=True)
size_mb = output.stat().st_size / (1024 * 1024)
if size_mb > target_mb:
w, h = img.size
if w > max_width:
h = int(h * max_width / w)
w = max_width
try:
resample = Image.Resampling.LANCZOS
except AttributeError:
resample = Image.LANCZOS # type: ignore[attr-defined]
img = img.resize((w, h), resample)
img.save(output, "JPEG", quality=quality, optimize=True)
size_mb = output.stat().st_size / (1024 * 1024)
if size_mb > target_mb:
img.save(output, "JPEG", quality=quality - 5, optimize=True)
size_mb = output.stat().st_size / (1024 * 1024)
click.echo(f"Compressed: {img_file} -> {output} ({size_mb:.2f} MB)")
+49
View File
@@ -0,0 +1,49 @@
"""LaTeX utilities."""
import subprocess
from pathlib import Path
import click
from bin.utils import handle_errors, run_command
@click.group()
def latex():
"""LaTeX compilation and utilities."""
pass
@latex.command("compile")
@click.argument("files", nargs=-1, required=True)
@click.option("--engine", "-e", default="pdflatex", help="LaTeX engine")
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def compile(files, engine, dry_run):
"""Compile LaTeX files."""
for pattern in files:
for tex_file in Path(".").glob(pattern):
if tex_file.suffix.lower() != ".tex":
continue
if dry_run:
click.echo(f"Would compile: {tex_file}")
continue
click.echo(f"Compiling: {tex_file}")
run_command([engine, str(tex_file)], check=False)
@latex.command("count")
@click.argument("file", type=click.Path(exists=True))
@handle_errors
def count(file):
"""Count words in a LaTeX file (excluding commands)."""
result = run_command(
["detex", file],
capture_output=True,
text=True,
check=False,
)
words = len(result.stdout.split())
click.echo(f"Words: {words}")
+147
View File
@@ -0,0 +1,147 @@
"""PDF utilities."""
from pathlib import Path
import click
from pypdf import PdfReader, PdfWriter
from PIL import Image
from bin.utils import handle_errors, run_command
@click.group()
def pdf():
"""PDF manipulation commands."""
pass
@pdf.command("compress")
@click.argument("files", nargs=-1, required=True)
@click.option(
"--quality",
"-q",
type=click.Choice(["screen", "ebook", "printer", "prepress"]),
default="screen",
help="Compression quality level",
)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def compress(files, quality, dry_run):
"""Compress PDF files using ghostscript."""
for pattern in files:
for pdf_file in Path(".").glob(pattern):
if pdf_file.suffix.lower() != ".pdf":
continue
output = pdf_file.with_suffix(".compressed.pdf")
cmd = [
"gs",
"-sDEVICE=pdfwrite",
"-dNOPAUSE",
"-dQUIET",
"-dBATCH",
f"-dPDFSETTINGS=/{quality}",
"-dCompatibilityLevel=1.4",
f"-sOutputFile={output}",
str(pdf_file),
]
if dry_run:
click.echo(f"Would compress: {pdf_file} -> {output}")
continue
click.echo(f"Compressing: {pdf_file}")
run_command(cmd, check=True)
output.replace(pdf_file)
click.echo(f" Done: {pdf_file}")
@pdf.command("crop")
@click.argument("files", nargs=-1, required=True)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def crop(files, dry_run):
"""Crop PDF files."""
for pattern in files:
for pdf_file in Path(".").glob(pattern):
if pdf_file.suffix.lower() != ".pdf":
continue
if dry_run:
click.echo(f"Would crop: {pdf_file}")
continue
click.echo(f"Cropping: {pdf_file}")
run_command(["pdfcrop", str(pdf_file), str(pdf_file)], check=False)
@pdf.command("to-tiff")
@click.argument("files", nargs=-1, required=True)
@click.option("--density", "-d", default="300", help="DPI density")
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def to_tiff(files, density, dry_run):
"""Convert PDF to TIFF."""
for pattern in files:
for pdf_file in Path(".").glob(pattern):
if pdf_file.suffix.lower() != ".pdf":
continue
output = pdf_file.with_suffix(".tiff")
if dry_run:
click.echo(f"Would convert: {pdf_file} -> {output}")
continue
click.echo(f"Converting: {pdf_file} -> {output}")
run_command(
["convert", "-density", density, str(pdf_file), "-compress", "zip", str(output)],
check=False,
)
@pdf.command("merge")
@click.argument("files", nargs=-1, required=True)
@click.option("--output", "-o", required=True, help="Output PDF file")
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def merge_pdfs(files, output, dry_run):
"""Merge PDFs and/or images into a single PDF."""
writer = PdfWriter()
processed = []
for pattern in files:
for path in sorted(Path(".").glob(pattern)):
if path.suffix.lower() == ".pdf":
if dry_run:
click.echo(f"Would add PDF: {path}")
continue
reader = PdfReader(str(path))
for page in reader.pages:
writer.add_page(page)
processed.append(str(path))
elif path.suffix.lower() in (".png", ".jpg", ".jpeg"):
if dry_run:
click.echo(f"Would add image: {path}")
continue
img = Image.open(path)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# Save to temp PDF
temp_pdf = path.with_suffix(".temp.pdf")
img.save(temp_pdf, "PDF", resolution=150.0)
reader = PdfReader(str(temp_pdf))
writer.add_page(reader.pages[0])
temp_pdf.unlink()
processed.append(str(path))
if dry_run:
click.echo(f"Would write merged PDF to: {output}")
return
if not processed:
click.echo("No files found to merge")
return
with open(output, "wb") as f:
writer.write(f)
click.echo(f"Merged {len(processed)} files into {output}")
+69
View File
@@ -0,0 +1,69 @@
"""Self-management commands for bin."""
import subprocess
from pathlib import Path
import click
def _get_project_root() -> Path:
"""Get mytoolkit project root."""
# This file is at bin/commands/self_mgmt.py
return Path(__file__).parent.parent.parent
@click.command(name="update")
def update_cmd():
"""Update mytoolkit from the local repository."""
root = _get_project_root()
click.secho("Updating mytoolkit...", fg="cyan")
# Run make install
result = subprocess.run(
["make", "install"],
cwd=str(root),
capture_output=True,
text=True,
)
if result.returncode == 0:
click.secho("mytoolkit updated successfully.", fg="green")
else:
click.secho("Update failed:", fg="red", err=True)
click.echo(result.stderr, err=True)
raise click.Exit(1)
@click.command(name="uninstall")
def uninstall_cmd():
"""Uninstall mytoolkit."""
root = _get_project_root()
click.secho("Uninstalling mytoolkit...", fg="cyan")
result = subprocess.run(
["make", "uninstall"],
cwd=str(root),
capture_output=True,
text=True,
)
if result.returncode == 0:
click.secho("mytoolkit uninstalled.", fg="green")
else:
click.secho("Uninstall failed:", fg="red", err=True)
click.echo(result.stderr, err=True)
raise click.Exit(1)
# Backward compatibility: keep self_cmd group for any existing scripts
@click.group(name="self")
def self_cmd():
"""Manage mytoolkit itself (legacy, use 'update'/'uninstall' directly)."""
pass
# Register commands in the group for backward compatibility
self_cmd.add_command(update_cmd, name="update")
self_cmd.add_command(uninstall_cmd, name="uninstall")
+111
View File
@@ -0,0 +1,111 @@
"""SSH connection utilities."""
import subprocess
from pathlib import Path
import click
from bin.config import config
from bin.utils import handle_errors
def _get_hosts() -> dict:
"""Get SSH hosts from config."""
hosts = {}
for key in config.get_all().keys():
if key.startswith("ssh_") and not key.endswith("_port") and not key.endswith("_key"):
name = key[4:] # Remove 'ssh_' prefix
host = config.get(f"ssh_{name}")
port = config.get(f"ssh_{name}_port") or "22"
key_file = config.get(f"ssh_{name}_key")
if host:
hosts[name] = {"host": host, "port": int(port)}
if key_file:
hosts[name]["key"] = key_file
return hosts
def _build_ssh_base_cmd(host_config: dict) -> list[str]:
"""Build base SSH command with host and port."""
cmd = ["ssh", "-p", str(host_config["port"])]
if "key" in host_config:
key_path = Path(host_config["key"]).expanduser()
cmd.extend(["-i", str(key_path)])
cmd.append(host_config["host"])
return cmd
@click.group(name="ssh")
def ssh_cmd():
"""SSH connection utilities."""
pass
@ssh_cmd.command("connect")
@click.argument("name")
@handle_errors
def connect(name):
"""Connect to a configured host."""
hosts = _get_hosts()
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)
cmd = _build_ssh_base_cmd(hosts[name])
click.secho(f"Connecting to {name}...", fg="cyan")
subprocess.run(cmd)
@ssh_cmd.command("tunnel")
@click.argument("name")
@click.option("--local-port", "-l", default=11111, help="Local port")
@click.option("--remote-port", "-r", default=11111, help="Remote port")
@handle_errors
def tunnel(name, local_port, remote_port):
"""Create SSH tunnel with port forwarding."""
hosts = _get_hosts()
if name not in hosts:
click.echo(f"Unknown host: {name}", err=True)
raise click.Exit(1)
host_config = hosts[name]
cmd = ["ssh", "-p", str(host_config["port"]), "-L", f"{local_port}:localhost:{remote_port}"]
if "key" in host_config:
key_path = Path(host_config["key"]).expanduser()
cmd.extend(["-i", str(key_path)])
cmd.append(host_config["host"])
click.secho(f"Creating tunnel {local_port} -> {remote_port} on {name}...", fg="cyan")
click.secho(f"Command: {' '.join(cmd)}", fg="dim")
subprocess.run(cmd)
@ssh_cmd.command("list")
def list_hosts():
"""List configured SSH hosts."""
hosts = _get_hosts()
click.secho("Configured hosts:", fg="cyan")
for name, host_config in sorted(hosts.items()):
click.echo(f" {name:12} {host_config['host']}:{host_config['port']}")
@ssh_cmd.command("cmd")
@click.argument("name")
@click.argument("command")
@handle_errors
def run_cmd(name, command):
"""Run a command on remote host."""
hosts = _get_hosts()
if name not in hosts:
click.echo(f"Unknown host: {name}", err=True)
raise click.Exit(1)
cmd = _build_ssh_base_cmd(hosts[name])
cmd.append(command)
subprocess.run(cmd)
+32
View File
@@ -0,0 +1,32 @@
"""Utility commands."""
from pathlib import Path
import click
from bin.utils import handle_errors
@click.group()
def utils():
"""Miscellaneous utilities."""
pass
@utils.command("albany-cleanup")
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def albany_cleanup(dry_run):
"""Clean up Albany temporary files (phalanx_*)."""
count = 0
for file in Path(".").glob("phalanx_*"):
if dry_run:
click.echo(f"Would remove: {file}")
else:
file.unlink()
count += 1
if dry_run:
click.echo("Dry run completed")
else:
click.echo(f"Removed {count} Albany temporary files")
+37
View File
@@ -0,0 +1,37 @@
"""Video utilities."""
import subprocess
from pathlib import Path
import click
from bin.utils import handle_errors, run_command
@click.group()
def video():
"""Video conversion commands."""
pass
@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")
@handle_errors
def avi_to_mp4(files, dry_run):
"""Convert AVI files to MP4 using ffmpeg."""
for pattern in files:
for avi_file in Path(".").glob(pattern):
if avi_file.suffix.lower() != ".avi":
continue
output = avi_file.with_suffix(".mp4")
if dry_run:
click.echo(f"Would convert: {avi_file} -> {output}")
continue
click.echo(f"Converting: {avi_file} -> {output}")
run_command(
["ffmpeg", "-i", str(avi_file), "-c:v", "libx264", "-c:a", "aac", str(output)],
check=False,
)