259 lines
5.4 KiB
Python
259 lines
5.4 KiB
Python
#!/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",
|
|
}
|
|
|
|
KNOWN_CONFIG_FILES = {
|
|
".babelrc",
|
|
".eslintrc",
|
|
".eslintrc.json",
|
|
".eslintrc.yaml",
|
|
".eslintrc.yml",
|
|
".prettierrc",
|
|
".prettierrc.json",
|
|
".prettierrc.yaml",
|
|
".prettierrc.yml",
|
|
".swcrc",
|
|
"angular.json",
|
|
"app.json",
|
|
"astro.config.mjs",
|
|
"astro.config.ts",
|
|
"babel.config.js",
|
|
"babel.config.json",
|
|
"Cargo.lock",
|
|
"Cargo.toml",
|
|
"composer.json",
|
|
"docker-compose.yaml",
|
|
"docker-compose.yml",
|
|
"eslint.config.cjs",
|
|
"eslint.config.js",
|
|
"eslint.config.mjs",
|
|
"go.mod",
|
|
"go.sum",
|
|
"jsconfig.json",
|
|
"lerna.json",
|
|
"manifest.json",
|
|
"next.config.js",
|
|
"next.config.mjs",
|
|
"next.config.ts",
|
|
"nuxt.config.js",
|
|
"nuxt.config.ts",
|
|
"nx.json",
|
|
"package-lock.json",
|
|
"package.json",
|
|
"playwright.config.js",
|
|
"playwright.config.ts",
|
|
"postcss.config.cjs",
|
|
"postcss.config.js",
|
|
"prettier.config.cjs",
|
|
"prettier.config.js",
|
|
"prettier.config.mjs",
|
|
"project.json",
|
|
"pyproject.toml",
|
|
"rollup.config.js",
|
|
"rollup.config.mjs",
|
|
"rollup.config.ts",
|
|
"svelte.config.js",
|
|
"stylelintrc.json",
|
|
"tailwind.config.js",
|
|
"tailwind.config.ts",
|
|
"tsconfig.app.json",
|
|
"tsconfig.json",
|
|
"tsconfig.node.json",
|
|
"tslint.json",
|
|
"turbo.json",
|
|
"vite.config.js",
|
|
"vite.config.mjs",
|
|
"vite.config.ts",
|
|
"vitest.config.js",
|
|
"vitest.config.ts",
|
|
"webpack.config.js",
|
|
"webpack.config.ts",
|
|
"workspace.json",
|
|
}
|
|
|
|
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",
|
|
".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, skip_blank: bool = True) -> int:
|
|
try:
|
|
text = read_text(path)
|
|
except Exception:
|
|
return 0
|
|
if not text:
|
|
return 0
|
|
if skip_blank:
|
|
return sum(1 for line in text.splitlines() if line.strip())
|
|
return len(text.splitlines())
|
|
|
|
|
|
def is_known_config_file(path: Path) -> bool:
|
|
"""Return True for well-known config files that shouldn't count as source code."""
|
|
return path.name in KNOWN_CONFIG_FILES
|
|
|
|
|
|
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
|