SDT/scripts/script_common.py
2026-03-01 20:52:56 -06:00

316 lines
9.2 KiB
Python

#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
from typing import Dict, Iterable, List, Sequence
PROXY_VARS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"GIT_HTTP_PROXY",
"GIT_HTTPS_PROXY",
"PIP_NO_INDEX",
]
def resolve_repo_root(start: str | None = None) -> pathlib.Path:
base = pathlib.Path(start or os.getcwd()).resolve()
# Preferred marker for SDT-managed projects.
for cur in [base, *base.parents]:
cfg = cur / "devtool.json"
if cfg.exists():
hints = load_project_root_hints(cur)
if not hints:
return cur
if any(_hint_matches(cur, hint) for hint in hints):
return cur
# Fall back to git root when available.
try:
proc = subprocess.run(
["git", "-C", str(base), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if proc.returncode == 0:
git_root = proc.stdout.strip()
if git_root:
return pathlib.Path(git_root).resolve()
except Exception:
pass
return base
def load_project_root_hints(repo_root: pathlib.Path) -> list[str]:
cfg = repo_root / "devtool.json"
if not cfg.exists():
return []
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
hints = data.get("project", {}).get("rootHints", [])
return [str(x) for x in hints if isinstance(x, str) and x.strip()]
except Exception:
return []
def ensure_dirs(paths: List[pathlib.Path]) -> None:
for p in paths:
p.mkdir(parents=True, exist_ok=True)
def clean_proxy_env(env: Dict[str, str]) -> None:
for k in PROXY_VARS:
env.pop(k, None)
def dotnet_env(repo_root: pathlib.Path) -> Dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
dotnet_cli_home = repo_root / ".dotnet_home"
nuget_packages = repo_root / ".nuget" / "packages"
nuget_http_cache = repo_root / ".nuget" / "http-cache"
ensure_dirs([dotnet_cli_home, nuget_packages, nuget_http_cache])
env["DOTNET_CLI_HOME"] = str(dotnet_cli_home)
env["NUGET_PACKAGES"] = str(nuget_packages)
env["NUGET_HTTP_CACHE_PATH"] = str(nuget_http_cache)
env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
env["DOTNET_ADD_GLOBAL_TOOLS_TO_PATH"] = "0"
env["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "0"
env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
env["NUGET_CERT_REVOCATION_MODE"] = "offline"
return env
def pip_env(repo_root: pathlib.Path) -> Dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
pip_cache = repo_root / ".pip" / "cache"
pip_tmp = repo_root / ".tmp" / "pip-temp"
ensure_dirs([pip_cache, pip_tmp])
env["PIP_CACHE_DIR"] = str(pip_cache)
env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
env["PIP_DEFAULT_TIMEOUT"] = "30"
env["PIP_RETRIES"] = "2"
env["TEMP"] = str(pip_tmp)
env["TMP"] = str(pip_tmp)
return env
def run(command: str, args: List[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> int:
resolved = resolve_command(command)
try:
proc = subprocess.run([resolved, *args], cwd=str(cwd), env=env, check=False)
return proc.returncode
except FileNotFoundError:
print(f"Command not found: {resolved}", file=sys.stderr)
return 127
def run_capture(command: str, args: Sequence[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> tuple[int, str, str]:
resolved = resolve_command(command)
try:
proc = subprocess.run(
[resolved, *args],
cwd=str(cwd),
env=env,
capture_output=True,
text=True,
check=False,
)
return proc.returncode, proc.stdout, proc.stderr
except FileNotFoundError:
return 127, "", f"Command not found: {resolved}"
def resolve_command(command: str) -> str:
if not command:
return command
if os.name != "nt":
return command
if any(sep in command for sep in ("\\", "/")):
return command
if pathlib.Path(command).suffix:
found = shutil.which(command)
return found or command
candidates = []
lowered = command.lower()
if lowered in ("npm", "npx", "pnpm", "yarn", "tauri"):
candidates.extend([f"{command}.cmd", f"{command}.exe", f"{command}.bat", command])
else:
candidates.append(command)
for c in candidates:
found = _which_windows(c)
if found:
name = pathlib.Path(found).name.lower()
if name in ("npm", "npx", "pnpm", "yarn", "tauri"):
shim = pathlib.Path(found).with_name(name + ".cmd")
if shim.exists():
return str(shim)
return found
if lowered in ("npm", "npx", "pnpm", "yarn"):
node = _which_windows("node.exe") or _which_windows("node")
if node:
node_dir = pathlib.Path(node).parent
shim = node_dir / f"{lowered}.cmd"
if shim.exists():
return str(shim)
return candidates[-1]
def _hint_matches(root: pathlib.Path, hint: str) -> bool:
h = hint.strip()
if not h:
return False
has_glob = any(ch in h for ch in ("*", "?", "["))
if has_glob:
# Match both anywhere in root and directly at root-level for common hints like "*.sln".
if any(root.glob(h)):
return True
return any(root.rglob(h))
marker = root / h
if marker.exists():
return True
# If hint is just a filename marker, look bounded in tree.
if not any(sep in h for sep in ("\\", "/")):
return any(p.name == h for p in root.rglob(h))
return False
def _expand_windows_path_segment(segment: str) -> str:
expanded = segment
# Expand %VAR% tokens repeatedly for nested references.
for _ in range(4):
next_value = os.path.expandvars(expanded)
if next_value == expanded:
break
expanded = next_value
return expanded
def _which_windows(command: str) -> str | None:
found = shutil.which(command)
if found:
return found
if os.name != "nt":
return None
path_value = os.environ.get("PATH", "")
pathext = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD")
exts = [e.lower() for e in pathext.split(";") if e]
has_ext = pathlib.Path(command).suffix != ""
names = [command] if has_ext else [command, *(command + e.lower() for e in exts)]
for raw_segment in path_value.split(os.pathsep):
segment = _expand_windows_path_segment(raw_segment.strip())
if not segment:
continue
base = pathlib.Path(segment)
for name in names:
candidate = base / name
if candidate.exists():
return str(candidate)
return None
def sha256_files(paths: Iterable[pathlib.Path]) -> str:
h = hashlib.sha256()
for p in paths:
if not p.exists():
continue
h.update(p.read_bytes())
return h.hexdigest()
def first_existing(paths: Iterable[pathlib.Path]) -> pathlib.Path | None:
for p in paths:
if p.exists():
return p
return None
def find_csproj(repo_root: pathlib.Path, hints: Sequence[str] | None = None) -> pathlib.Path | None:
if hints:
for hint in hints:
candidate = (repo_root / hint).resolve()
if candidate.exists() and candidate.suffix.lower() == ".csproj":
return candidate
csprojs = sorted(repo_root.rglob("*.csproj"))
if not csprojs:
return None
if len(csprojs) == 1:
return csprojs[0]
return None
def find_csproj_by_keyword(repo_root: pathlib.Path, keywords: Sequence[str]) -> pathlib.Path | None:
kws = [k.lower() for k in keywords]
matches: list[pathlib.Path] = []
for p in repo_root.rglob("*.csproj"):
text = str(p).lower()
if any(k in text for k in kws):
matches.append(p)
if len(matches) == 1:
return matches[0]
return None
def find_node_app_root(repo_root: pathlib.Path, preferred: str | None = None) -> pathlib.Path | None:
if preferred:
p = (repo_root / preferred).resolve()
if (p / "package.json").exists():
return p
direct = repo_root / "package.json"
if direct.exists():
return repo_root
tauri_candidates = []
for package_json in repo_root.rglob("package.json"):
d = package_json.parent
if (d / "src-tauri" / "tauri.conf.json").exists():
tauri_candidates.append(d)
if len(tauri_candidates) == 1:
return tauri_candidates[0]
all_candidates = [p.parent for p in repo_root.rglob("package.json")]
if len(all_candidates) == 1:
return all_candidates[0]
return None
def newest_file(search_root: pathlib.Path, pattern: str) -> pathlib.Path | None:
if not search_root.exists():
return None
files = [p for p in search_root.rglob(pattern) if p.is_file() and "\\obj\\" not in str(p).replace("/", "\\")]
if not files:
return None
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return files[0]