#!/usr/bin/env python3 """Shared helpers for the software copyright materials skill.""" from __future__ import annotations import json import os import re from pathlib import Path from typing import Any, Iterable EXCLUDE_DIRS = { ".git", ".hg", ".svn", ".idea", ".vscode", "__pycache__", "node_modules", "dist", "build", ".next", ".nuxt", ".output", "coverage", "target", "vendor", "软件著作权申请资料", "software-copyright-materials", } CODE_EXTS = { ".vue", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css", ".scss", ".sass", ".less", ".html", ".svelte", ".astro", ".json", ".md", } FRONTEND_EXTS = { ".vue", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".css", ".scss", ".sass", ".less", ".html", ".svelte", ".astro", } SUPPLEMENT_CODE_EXTS = { ".py", ".java", ".go", ".rs", ".cs", ".php", ".rb", ".kt", ".swift", ".sql", ".sh", ".toml", ".yml", ".yaml", ".json", } COPYRIGHT_CODE_EXTS = FRONTEND_EXTS | SUPPLEMENT_CODE_EXTS LOCK_FILES = { "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock", } def repo_root_from_script() -> Path: return Path(__file__).resolve().parents[3] def is_excluded(path: Path) -> bool: parts = set(path.parts) if parts & EXCLUDE_DIRS: return True name = path.name if name.startswith(".") and name not in {".env.example"}: return True if name in LOCK_FILES: return True if name.endswith(".map") or name.endswith(".min.js") or name.endswith(".min.css"): return True return False def iter_project_files(project: Path, exts: set[str] | None = None) -> Iterable[Path]: project = project.resolve() for root, dirs, files in os.walk(project): root_path = Path(root) dirs[:] = [d for d in dirs if not is_excluded(root_path / d)] for filename in files: path = root_path / filename if is_excluded(path): continue if exts is not None and path.suffix.lower() not in exts: continue yield path def rel(path: Path, root: Path) -> str: return path.resolve().relative_to(root.resolve()).as_posix() def read_text(path: Path, limit: int | None = None) -> str: data = path.read_bytes() if limit is not None: data = data[:limit] for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"): try: return data.decode(encoding) except UnicodeDecodeError: continue return data.decode("utf-8", errors="replace") def read_json(path: Path) -> dict[str, Any]: return json.loads(read_text(path)) def write_json(path: Path, data: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") def count_text_lines(path: Path) -> int: try: text = read_text(path) except Exception: return 0 if not text: return 0 return len(text.splitlines()) def looks_binary(path: Path) -> bool: try: chunk = path.read_bytes()[:4096] except Exception: return True return b"\x00" in chunk def normalize_title(value: str) -> str: value = re.sub(r"[-_]+", " ", value).strip() value = re.sub(r"\s+", " ", value) return value or "待命名软件" def safe_filename(value: str) -> str: value = re.sub(r'[\\/:*?"<>|]+', "_", value).strip() return value or "软件" def ensure_dir(path: Path) -> Path: path.mkdir(parents=True, exist_ok=True) return path