#!/usr/bin/env python3 import json import os import re import shlex import shutil import struct import subprocess import sys COMMAND_TO_METHOD = { "branch-from": "branch_from", "bash-completions": "bash_completions", } METHOD_TO_COMMAND = {method: command for command, method in COMMAND_TO_METHOD.items()} WAIT_FOR_PROCESS = "wait_for_process" class DevHelper: SHADER_EXTENSIONS = (".vert", ".frag", ".comp", ".geom", ".tesc", ".tese") PROJECT_NAME = "sophia" def __init__(self): self.last_exit_status = 0 # Core logic def call(self, *args, **kwargs): if not args: print("Usage: dev [args...]\n\nCommands:") for command in self._commands(): print(f" {command}") return command, rest = args[0], args[1:] method_name = COMMAND_TO_METHOD.get(command, command) method = getattr(self, method_name, None) if callable(method) and not method_name.startswith("_") and method_name != "call": return method(*rest, **kwargs) # Primary command wrappers def build(self, *args, **kwargs): command = f"rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug" self._exec_in_root(command, replace=True) ## # Checks out a branch named after a Gitea issue URL. # If the issue is from this project's repo, the branch name will be: # issue-/ # If the issue is from another repo (presumably upstream), the branch name will be: # -issue-/ def branch_from(self, *args, **kwargs): issue_url = args[0] if args else None if issue_url is None: raise ValueError("Must provide a Gitea issue URL.") base_url, issue_repo, issue_number = self._parse_issue_url(issue_url) issue_title = self._fetch_issue_title(base_url, issue_repo, issue_number) branch_name = self._format_branch_name(issue_repo, issue_number, issue_title) exists = subprocess.run( ["git", "-C", self._project_root(), "rev-parse", "--verify", "--quiet", f"refs/heads/{branch_name}"], stdout=subprocess.DEVNULL, ).returncode == 0 flag = "" if exists else "-b " self._exec_in_root(f"git checkout {flag}{shlex.quote(branch_name)}", replace=True) ## # Creates an issue on this project's Gitea repo. # dev issue [-b|--body <text>] [-l|--label <name>]... [-a|--assignee <user>]... [-o|--open] # The body is read from stdin if --body is not given and stdin is piped. # Gitea's API cannot add issues to a project (only the web UI can), so the # issue URL is printed and --open opens it in the browser to do so. def issue(self, *args, **kwargs): title, body, labels, assignees, open_in_browser = self._parse_issue_args(args) repo = self._origin_repo() if repo is None: raise RuntimeError("could not determine the Gitea repo from the origin remote") payload = {"title": title, "body": body} if labels: label_ids = self._fetch_label_ids(repo) unknown = [label for label in labels if label not in label_ids] if unknown: raise ValueError(f"Unknown label(s): {', '.join(unknown)} (available: {', '.join(sorted(label_ids))})") payload["labels"] = [label_ids[label] for label in labels] if assignees: payload["assignees"] = assignees issue = self._tea_api(f"repos/{repo}/issues", payload) print(f"Created issue #{issue['number']}: {issue['title']}") print(issue["html_url"]) print(f"Add it to the project from the issue sidebar: Projects > {self.PROJECT_NAME}") if open_in_browser: opener = shutil.which("xdg-open") or shutil.which("open") if opener is None: raise RuntimeError("xdg-open not found on PATH") subprocess.Popen([opener, issue["html_url"]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def renderdoc(self, *args, **kwargs): qrenderdoc = self._find_qrenderdoc() self._exec_in_root(qrenderdoc, replace=True) def shaders(self, *args, **kwargs): shaders_dir = os.path.join(self._project_root(), "engine", "src", "shaders") if not os.path.isdir(shaders_dir): raise ValueError(f"Shaders directory not found: {shaders_dir}") glslc = self._find_glslc() shader_paths = sorted( os.path.join(root, filename) for root, _dirs, files in os.walk(shaders_dir) for filename in files if filename.endswith(self.SHADER_EXTENSIONS) ) if not shader_paths: print(f"No shaders found in {shaders_dir}") return for shader_path in shader_paths: self._generate_shader_header(glslc, shader_path) # Custom helpers def ownit(self, *args, **kwargs): file_or_directory = args[0] if args else None if file_or_directory is None: raise ValueError("Must provide a file or directory path.") if sys.platform.startswith("linux"): print(f"Take ownership of the file or directory? {file_or_directory}") command = f"sudo chown -R {self._user_id()}:{self._group_id()} {file_or_directory}" self._exec(command, replace=True) else: raise NotImplementedError(f"Not implemented for platform {sys.platform}") def bash_completions(self, *args, **kwargs): print(" ".join(self._commands())) # Private helpers def _commands(self): return sorted( METHOD_TO_COMMAND.get(name, name) for name in vars(DevHelper) if not name.startswith("_") and name != "call" and callable(getattr(DevHelper, name)) ) def _parse_issue_url(self, url): match = re.match(r"^(https?://[^/]+)/([^/]+)/([^/]+)/issues/(\d+)", url) if match is None: raise ValueError(f"Not a Gitea issue URL: {url}") return match[1], f"{match[2]}/{match[3]}", match[4] def _fetch_issue_title(self, base_url, repo, issue_number): if shutil.which("tea") is None: raise RuntimeError("tea not found on PATH (install gitea tea and run `tea login add`)") endpoint = f"{base_url}/api/v1/repos/{repo}/issues/{issue_number}" print(f"Running: tea api {endpoint}") result = subprocess.run(["tea", "api", endpoint], stdout=subprocess.PIPE, text=True) if result.returncode != 0: raise RuntimeError(f"failed to fetch issue title from {endpoint}") return json.loads(result.stdout)["title"] def _parse_issue_args(self, args): title = None body = None labels = [] assignees = [] open_in_browser = False args = list(args) while args: arg = args.pop(0) if arg in ("-b", "--body", "-l", "--label", "-a", "--assignee"): if not args: raise ValueError(f"{arg} requires a value") value = args.pop(0) if arg in ("-b", "--body"): body = value elif arg in ("-l", "--label"): labels.append(value) else: assignees.append(value) elif arg in ("-o", "--open"): open_in_browser = True elif arg.startswith("-"): raise ValueError(f"Unknown option: {arg}") elif title is None: title = arg else: title = f"{title} {arg}" if not title: raise ValueError("Must provide an issue title. Usage: dev issue <title> [-b <body>] [-l <label>]... [-a <user>]... [-o]") if body is None: body = "" if sys.stdin.isatty() else sys.stdin.read() return title, body, labels, assignees, open_in_browser def _tea_api(self, endpoint, payload=None): if shutil.which("tea") is None: raise RuntimeError("tea not found on PATH (install gitea tea and run `tea login add`)") command = ["tea", "api", endpoint] if payload is not None: command += ["-d", "@-"] print(f"Running: {' '.join(command)}") result = subprocess.run( command, input=json.dumps(payload) if payload is not None else None, stdout=subprocess.PIPE, text=True, ) if result.returncode != 0: raise RuntimeError(f"tea api request failed: {endpoint}") return json.loads(result.stdout) def _fetch_label_ids(self, repo): labels = self._tea_api(f"repos/{repo}/labels?limit=100") return {label["name"]: label["id"] for label in labels} def _format_branch_name(self, issue_repo, issue_number, issue_title): formatted_title = re.sub(r"-+", "-", re.sub(r"[^a-z0-9\-]", "", re.sub(r"\s+", "-", issue_title.lower().strip()))) if issue_repo == self._origin_repo(): return f"issue-{issue_number}/{formatted_title}" issue_owner = issue_repo.split("/")[0] return f"{issue_owner}-issue-{issue_number}/{formatted_title}" def _origin_repo(self): result = subprocess.run( ["git", "-C", self._project_root(), "remote", "get-url", "origin"], stdout=subprocess.PIPE, text=True, ) match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", result.stdout.strip()) return match[1] if match else None def _find_glslc(self): glslc = shutil.which("glslc") if glslc is None: raise RuntimeError( "glslc not found on PATH (install the Vulkan SDK)" ) return glslc def _find_qrenderdoc(self): qrenderdoc = shutil.which("qrenderdoc") if qrenderdoc is None: raise RuntimeError( "qrenderdoc not found on PATH (install RenderDoc)" ) return qrenderdoc def _generate_shader_header(self, glslc, shader_path): relative_path = os.path.relpath(shader_path, self._project_root()) print(f"Compiling shader: {relative_path}") result = subprocess.run( [glslc, shader_path, "-o", "-"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) if result.returncode != 0: raise RuntimeError( f"failed to compile {shader_path}: {result.stderr.decode()}" ) spirv = result.stdout if len(spirv) % 4 != 0: raise RuntimeError( f"invalid SPIR-V output for {shader_path} (size not a multiple of 4)" ) word_count = len(spirv) // 4 words = struct.unpack(f"<{word_count}I", spirv) filename = os.path.basename(shader_path) variable_name = f"{filename.replace('.', '_')}_spv" header_path = shader_path + ".spv.h" body_lines = [ " " + ", ".join(f"0x{word:08x}" for word in words[i:i + 4]) + "," for i in range(0, len(words), 4) ] header = "\n".join([ "#pragma once", "", "#include <array>", "#include <cstdint>", "", "namespace sophia::shaders {", "", f"inline constexpr std::array<uint32_t, {word_count}> {variable_name} = {{", *body_lines, "};", "", "} // namespace sophia::shaders", "", ]) with open(header_path, "w") as f: f.write(header) print(f"Wrote {os.path.relpath(header_path, self._project_root())} " f"({word_count * 4} bytes)") def _wait_for_process_with_logging(self, command): process = self._exec(command, capture=True) for line in process.stdout: print(line, end="") process.wait() self.last_exit_status = process.returncode def _exec_in_root(self, command, **kwargs): return self._exec(f"cd {self._project_root()} && {command}", **kwargs) def _exec(self, command, *, replace=False, capture=False): print(f"Running: {command}") if replace: os.execv("/bin/sh", ["/bin/sh", "-c", command]) if capture: return subprocess.Popen( ["/bin/sh", "-c", f"{command} 2>&1"], stdout=subprocess.PIPE, text=True, ) return subprocess.run(["/bin/sh", "-c", command]) def _project_root(self): return os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) def _take_over_needed(self, file_or_directory): result = subprocess.run( ["/bin/sh", "-c", f"find {file_or_directory} -not -user {self._user_id()} -print -quit | grep -q ."], ) return result.returncode == 0 def _user_id(self): if not sys.platform.startswith("linux"): raise NotImplementedError(f"Not implemented for platform {sys.platform}") return subprocess.run(["id", "-u"], stdout=subprocess.PIPE, text=True).stdout.strip() def _group_id(self): if not sys.platform.startswith("linux"): raise NotImplementedError(f"Not implemented for platform {sys.platform}") return subprocess.run(["id", "-g"], stdout=subprocess.PIPE, text=True).stdout.strip() if __name__ == "__main__": DevHelper().call(*sys.argv[1:])