sdt can compile journal and other projects. it using a json config system. this program's Repo exists on the Gitea under stan. Readme included as well.
598 lines
19 KiB
Python
598 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from script_common import resolve_command
|
|
|
|
|
|
def run_step(command, args, cwd):
|
|
resolved = resolve_command(command)
|
|
if shutil.which(resolved) is None and not pathlib.Path(resolved).exists():
|
|
return {
|
|
"command": resolved,
|
|
"args": args,
|
|
"cwd": cwd,
|
|
"exit_code": 127,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "failed",
|
|
"failure_reason": f"command_not_found:{resolved}",
|
|
}
|
|
|
|
started = time.time()
|
|
proc = subprocess.run([resolved, *args], cwd=cwd, check=False)
|
|
elapsed = round(time.time() - started, 3)
|
|
return {
|
|
"command": resolved,
|
|
"args": args,
|
|
"cwd": cwd,
|
|
"exit_code": proc.returncode,
|
|
"elapsed_seconds": elapsed,
|
|
"status": "ok" if proc.returncode == 0 else "failed",
|
|
"failure_reason": None if proc.returncode == 0 else f"non_zero_exit:{proc.returncode}",
|
|
}
|
|
|
|
|
|
def resolve_python_executable():
|
|
candidates = ["py", "python"] if os.name == "nt" else ["python3", "python"]
|
|
for c in candidates:
|
|
if shutil.which(c):
|
|
return c
|
|
return "python"
|
|
|
|
|
|
def parse_common(parser):
|
|
parser.add_argument("--project-root", required=True)
|
|
parser.add_argument("--working-dir", default=".")
|
|
parser.add_argument("--json", action="store_true")
|
|
|
|
|
|
def resolve_cwd(project_root, working_dir):
|
|
return os.path.abspath(os.path.join(project_root, working_dir))
|
|
|
|
|
|
EXCLUDED_SCAN_DIRS = {".git", "node_modules", "bin", "obj", ".venv", "venv", ".sdt", "dist", "build"}
|
|
|
|
|
|
def discover_dotnet_target(project_root: str, cwd: str):
|
|
# Prefer local solution first (.slnx, then .sln), then csproj, then bounded scan from project root.
|
|
local_slnx = sorted(pathlib.Path(cwd).glob("*.slnx"))
|
|
if len(local_slnx) == 1:
|
|
return str(local_slnx[0])
|
|
|
|
local_sln = sorted(pathlib.Path(cwd).glob("*.sln"))
|
|
if len(local_sln) == 1:
|
|
return str(local_sln[0])
|
|
|
|
local_csproj = sorted(pathlib.Path(cwd).glob("*.csproj"))
|
|
if len(local_csproj) == 1:
|
|
return str(local_csproj[0])
|
|
|
|
slnx_hits = bounded_find_files(project_root, ".slnx", max_depth=4)
|
|
if len(slnx_hits) == 1:
|
|
return slnx_hits[0]
|
|
|
|
sln_hits = bounded_find_files(project_root, ".sln", max_depth=4)
|
|
if len(sln_hits) == 1:
|
|
return sln_hits[0]
|
|
|
|
csproj_hits = bounded_find_files(project_root, ".csproj", max_depth=4)
|
|
if len(csproj_hits) == 1:
|
|
return csproj_hits[0]
|
|
|
|
return None
|
|
|
|
|
|
def bounded_find_files(root: str, extension: str, max_depth: int):
|
|
root_path = pathlib.Path(root).resolve()
|
|
results = []
|
|
for current_root, dirs, files in os.walk(root_path):
|
|
rel = pathlib.Path(current_root).resolve().relative_to(root_path)
|
|
depth = len(rel.parts)
|
|
dirs[:] = [d for d in dirs if d not in EXCLUDED_SCAN_DIRS]
|
|
if depth > max_depth:
|
|
dirs[:] = []
|
|
continue
|
|
|
|
for name in files:
|
|
if name.lower().endswith(extension.lower()):
|
|
results.append(str(pathlib.Path(current_root) / name))
|
|
return sorted(results)
|
|
|
|
|
|
def run_dotnet_action(project_root, working_dir, verb):
|
|
cwd = resolve_cwd(project_root, working_dir)
|
|
target = discover_dotnet_target(project_root, cwd)
|
|
if not target:
|
|
return 0, {
|
|
"command": "dotnet",
|
|
"args": [verb],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_no_dotnet_target",
|
|
"message": "No .slnx/.sln/.csproj found for this step. Skipping dotnet action.",
|
|
}
|
|
|
|
args = [verb, target]
|
|
step = run_step("dotnet", args, cwd)
|
|
step["resolved_target"] = target
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def _deps_hash(app_root):
|
|
h = hashlib.sha256()
|
|
for name in ("package.json", "package-lock.json"):
|
|
p = pathlib.Path(app_root) / name
|
|
if p.exists():
|
|
h.update(p.read_bytes())
|
|
return h.hexdigest()
|
|
|
|
|
|
def ensure_npm_dependencies(app_root):
|
|
package_json = pathlib.Path(app_root) / "package.json"
|
|
if not package_json.exists():
|
|
return {"installed": False, "reason": "not_applicable"}
|
|
|
|
node_modules = pathlib.Path(app_root) / "node_modules"
|
|
deps_hash_file = node_modules / ".sdt-deps.sha256"
|
|
expected = _deps_hash(app_root)
|
|
|
|
should_install = not node_modules.exists()
|
|
if not should_install:
|
|
if not deps_hash_file.exists():
|
|
should_install = True
|
|
else:
|
|
current = deps_hash_file.read_text(encoding="utf-8").strip()
|
|
should_install = current != expected
|
|
|
|
if not should_install:
|
|
return {"installed": False, "reason": "deps_unchanged"}
|
|
|
|
lock_exists = (pathlib.Path(app_root) / "package-lock.json").exists()
|
|
install_args = ["ci", "--no-audit", "--fund=false"] if lock_exists else ["install", "--no-audit", "--fund=false"]
|
|
install_step = run_step("npm", install_args, app_root)
|
|
if install_step["exit_code"] != 0:
|
|
if lock_exists and install_args[0] == "ci":
|
|
fallback = run_step("npm", ["install", "--no-audit", "--fund=false"], app_root)
|
|
if fallback["exit_code"] != 0:
|
|
fallback["failure_reason"] = "deps_install_failed_after_ci_fallback"
|
|
return {"installed": True, "reason": "install_failed", "step": fallback}
|
|
install_step = fallback
|
|
else:
|
|
return {"installed": True, "reason": "install_failed", "step": install_step}
|
|
|
|
node_modules.mkdir(parents=True, exist_ok=True)
|
|
deps_hash_file.write_text(expected, encoding="utf-8")
|
|
return {"installed": True, "reason": "installed", "step": install_step}
|
|
|
|
|
|
def read_package_json(cwd: str):
|
|
package_json = pathlib.Path(cwd) / "package.json"
|
|
if not package_json.exists():
|
|
return None
|
|
try:
|
|
return json.loads(package_json.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def has_npm_script(cwd: str, script_name: str) -> bool:
|
|
data = read_package_json(cwd)
|
|
if not isinstance(data, dict):
|
|
return False
|
|
scripts = data.get("scripts")
|
|
if not isinstance(scripts, dict):
|
|
return False
|
|
return script_name in scripts and isinstance(scripts.get(script_name), str)
|
|
|
|
|
|
def action_dotnet_build(args):
|
|
return run_dotnet_action(args.project_root, args.working_dir, "build")
|
|
|
|
|
|
def action_dotnet_restore(args):
|
|
return run_dotnet_action(args.project_root, args.working_dir, "restore")
|
|
|
|
|
|
def action_dotnet_test(args):
|
|
return run_dotnet_action(args.project_root, args.working_dir, "test")
|
|
|
|
|
|
def action_dotnet_publish(args):
|
|
return run_dotnet_action(args.project_root, args.working_dir, "publish")
|
|
|
|
|
|
def action_npm_install(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
if not (pathlib.Path(cwd) / "package.json").exists():
|
|
return 0, {
|
|
"command": "npm",
|
|
"args": ["install"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_no_package_json",
|
|
}
|
|
step = run_step("npm", ["install"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_npm_ci(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
if not (pathlib.Path(cwd) / "package.json").exists():
|
|
return 0, {
|
|
"command": "npm",
|
|
"args": ["ci"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_no_package_json",
|
|
}
|
|
step = run_step("npm", ["ci"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_npm_build(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
if not (pathlib.Path(cwd) / "package.json").exists():
|
|
return 0, {
|
|
"command": "npm",
|
|
"args": ["run", "build"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_no_package_json",
|
|
}
|
|
if not has_npm_script(cwd, "build"):
|
|
return 0, {
|
|
"command": "npm",
|
|
"args": ["run", "build"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_missing_build_script",
|
|
}
|
|
deps = ensure_npm_dependencies(cwd)
|
|
if deps.get("reason") == "not_applicable":
|
|
return 0, {
|
|
"command": "npm",
|
|
"args": ["run", "build"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_no_package_json",
|
|
}
|
|
if deps.get("reason") == "install_failed":
|
|
step = deps["step"]
|
|
step["failure_reason"] = "deps_install_failed"
|
|
return step["exit_code"], step
|
|
step = run_step("npm", ["run", "build"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_npm_test(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
if not (pathlib.Path(cwd) / "package.json").exists():
|
|
return 0, {
|
|
"command": "npm",
|
|
"args": ["test"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_no_package_json",
|
|
}
|
|
if not has_npm_script(cwd, "test"):
|
|
return 0, {
|
|
"command": "npm",
|
|
"args": ["test"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_missing_test_script",
|
|
}
|
|
deps = ensure_npm_dependencies(cwd)
|
|
if deps.get("reason") == "not_applicable":
|
|
return 0, {
|
|
"command": "npm",
|
|
"args": ["test"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_no_package_json",
|
|
}
|
|
if deps.get("reason") == "install_failed":
|
|
step = deps["step"]
|
|
step["failure_reason"] = "deps_install_failed"
|
|
return step["exit_code"], step
|
|
step = run_step("npm", ["test"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_npm_audit(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
if not (pathlib.Path(cwd) / "package.json").exists():
|
|
return 0, {
|
|
"command": "npm",
|
|
"args": ["audit"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_no_package_json",
|
|
}
|
|
step = run_step("npm", ["audit"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_python_venv_create(args):
|
|
cwd = resolve_cwd(args.project_root, ".")
|
|
venv_dir = args.venv_dir or ".venv"
|
|
step = run_step(resolve_python_executable(), ["-m", "venv", venv_dir], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_python_pip_install(args):
|
|
cwd = resolve_cwd(args.project_root, ".")
|
|
req = args.requirements
|
|
step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_python_pip_sync(args):
|
|
cwd = resolve_cwd(args.project_root, ".")
|
|
req = args.requirements
|
|
step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_python_pytest(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
step = run_step(resolve_python_executable(), ["-m", "pytest"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_cargo_build(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
if not (pathlib.Path(cwd) / "Cargo.toml").exists():
|
|
return 0, {
|
|
"command": "cargo",
|
|
"args": ["build"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_no_cargo_toml",
|
|
}
|
|
step = run_step("cargo", ["build"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_cargo_test(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
if not (pathlib.Path(cwd) / "Cargo.toml").exists():
|
|
return 0, {
|
|
"command": "cargo",
|
|
"args": ["test"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_no_cargo_toml",
|
|
}
|
|
step = run_step("cargo", ["test"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_tauri_build(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
tauri_conf = pathlib.Path(cwd) / "src-tauri" / "tauri.conf.json"
|
|
if not tauri_conf.exists():
|
|
tauri_conf = pathlib.Path(cwd) / "tauri.conf.json"
|
|
if not tauri_conf.exists() or not (pathlib.Path(cwd) / "package.json").exists():
|
|
return 0, {
|
|
"command": "npm",
|
|
"args": ["run", "tauri", "build"],
|
|
"cwd": cwd,
|
|
"exit_code": 0,
|
|
"elapsed_seconds": 0.0,
|
|
"status": "skipped",
|
|
"failure_reason": None,
|
|
"skip_reason": "not_applicable_no_tauri_project",
|
|
}
|
|
|
|
deps = ensure_npm_dependencies(cwd)
|
|
if deps.get("reason") == "install_failed":
|
|
step = deps["step"]
|
|
step["failure_reason"] = "deps_install_failed"
|
|
return step["exit_code"], step
|
|
|
|
tauri_args = ["run", "tauri", "build"]
|
|
if args.no_bundle:
|
|
tauri_args.extend(["--", "--no-bundle"])
|
|
step = run_step("npm", tauri_args, cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_git_status(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
step = run_step("git", ["status"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_git_fetch(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
step = run_step("git", ["fetch"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_git_pull(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
step = run_step("git", ["pull"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_git_clean(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
step = run_step("git", ["clean", "-fd"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_docker_build(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
step = run_step("docker", ["build", "."], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_docker_compose_up(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
step = run_step("docker", ["compose", "up", "-d"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def action_docker_compose_down(args):
|
|
cwd = resolve_cwd(args.project_root, args.working_dir)
|
|
step = run_step("docker", ["compose", "down"], cwd)
|
|
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="SDT normalized build actions")
|
|
sub = parser.add_subparsers(dest="action", required=True)
|
|
|
|
p0 = sub.add_parser("dotnet-restore")
|
|
parse_common(p0)
|
|
|
|
p1 = sub.add_parser("dotnet-build")
|
|
parse_common(p1)
|
|
|
|
p1b = sub.add_parser("dotnet-test")
|
|
parse_common(p1b)
|
|
|
|
p1c = sub.add_parser("dotnet-publish")
|
|
parse_common(p1c)
|
|
|
|
p2 = sub.add_parser("npm-install")
|
|
parse_common(p2)
|
|
|
|
p2b = sub.add_parser("npm-ci")
|
|
parse_common(p2b)
|
|
|
|
p3 = sub.add_parser("npm-build")
|
|
parse_common(p3)
|
|
|
|
p3b = sub.add_parser("npm-test")
|
|
parse_common(p3b)
|
|
|
|
p3c = sub.add_parser("npm-audit")
|
|
parse_common(p3c)
|
|
|
|
p4 = sub.add_parser("python-venv-create")
|
|
parse_common(p4)
|
|
p4.add_argument("--venv-dir", default=".venv")
|
|
|
|
p5 = sub.add_parser("python-pip-install")
|
|
parse_common(p5)
|
|
p5.add_argument("--requirements", required=True)
|
|
|
|
p5b = sub.add_parser("python-pip-sync")
|
|
parse_common(p5b)
|
|
p5b.add_argument("--requirements", required=True)
|
|
|
|
p5c = sub.add_parser("python-pytest")
|
|
parse_common(p5c)
|
|
|
|
p6 = sub.add_parser("cargo-build")
|
|
parse_common(p6)
|
|
|
|
p6b = sub.add_parser("cargo-test")
|
|
parse_common(p6b)
|
|
|
|
p7 = sub.add_parser("tauri-build")
|
|
parse_common(p7)
|
|
p7.add_argument("--no-bundle", action="store_true")
|
|
|
|
p8 = sub.add_parser("git-status")
|
|
parse_common(p8)
|
|
|
|
p9 = sub.add_parser("git-fetch")
|
|
parse_common(p9)
|
|
|
|
p10 = sub.add_parser("git-pull")
|
|
parse_common(p10)
|
|
|
|
p11 = sub.add_parser("git-clean")
|
|
parse_common(p11)
|
|
|
|
p12 = sub.add_parser("docker-build")
|
|
parse_common(p12)
|
|
|
|
p13 = sub.add_parser("docker-compose-up")
|
|
parse_common(p13)
|
|
|
|
p14 = sub.add_parser("docker-compose-down")
|
|
parse_common(p14)
|
|
|
|
args = parser.parse_args()
|
|
|
|
handlers = {
|
|
"dotnet-restore": action_dotnet_restore,
|
|
"dotnet-build": action_dotnet_build,
|
|
"dotnet-test": action_dotnet_test,
|
|
"dotnet-publish": action_dotnet_publish,
|
|
"npm-install": action_npm_install,
|
|
"npm-ci": action_npm_ci,
|
|
"npm-build": action_npm_build,
|
|
"npm-test": action_npm_test,
|
|
"npm-audit": action_npm_audit,
|
|
"python-venv-create": action_python_venv_create,
|
|
"python-pip-install": action_python_pip_install,
|
|
"python-pip-sync": action_python_pip_sync,
|
|
"python-pytest": action_python_pytest,
|
|
"cargo-build": action_cargo_build,
|
|
"cargo-test": action_cargo_test,
|
|
"tauri-build": action_tauri_build,
|
|
"git-status": action_git_status,
|
|
"git-fetch": action_git_fetch,
|
|
"git-pull": action_git_pull,
|
|
"git-clean": action_git_clean,
|
|
"docker-build": action_docker_build,
|
|
"docker-compose-up": action_docker_compose_up,
|
|
"docker-compose-down": action_docker_compose_down,
|
|
}
|
|
|
|
code, summary = handlers[args.action](args)
|
|
if args.json:
|
|
print(json.dumps(summary))
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|