#!/usr/bin/env python3 import json import os import shlex import shutil import subprocess import sys from helpers.gitea import GiteaCommand from helpers.renderdoc import RenderDocCommand from helpers.shaders import ShadersCommand from helpers.tracy import TracyCommand 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 DevCommand(GiteaCommand, RenderDocCommand, ShadersCommand, TracyCommand): PROJECT_NAME = "sophia" DEFAULT_PRESET = "debug" 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 ## # Configures and builds a CMake preset (see CMakePresets.json). # dev build [preset] [--fresh] # The preset defaults to debug and builds into build/. --fresh # discards the preset's CMake cache before configuring. def build(self, *args, **kwargs): preset, fresh = self._parse_preset_args(args, "build") self._build_preset(preset, fresh) ## # Builds the debug preset (incrementally), then runs the editor from # build/debug. Any arguments are passed through to the editor. # dev editor [args...] def editor(self, *args, **kwargs): preset = self.DEFAULT_PRESET self._build_preset(preset, fresh=False) preset_dir = os.path.join(self._project_root(), "build", preset) binary = os.path.join(preset_dir, "bin", "editor") print(f"Running: {shlex.join([binary, *args])}") os.chdir(preset_dir) os.execv(binary, [binary, *args]) ## # Builds the debug preset (incrementally), then runs the testbed from # build/debug. Any arguments are passed through to the testbed. # dev testbed [args...] def testbed(self, *args, **kwargs): preset = self.DEFAULT_PRESET self._build_preset(preset, fresh=False) preset_dir = os.path.join(self._project_root(), "build", preset) binary = os.path.join(preset_dir, "bin", "testbed") print(f"Running: {shlex.join([binary, *args])}") os.chdir(preset_dir) os.execv(binary, [binary, *args]) # 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 dir(DevCommand) if not name.startswith("_") and name != "call" and callable(getattr(DevCommand, name)) ) def _parse_preset_args(self, args, command): preset = None fresh = False for arg in args: if arg == "--fresh": fresh = True elif arg.startswith("-"): raise ValueError(f"Unknown option: {arg}") elif preset is None: preset = arg else: raise ValueError(f"Usage: dev {command} [preset] [--fresh]") preset = preset or self.DEFAULT_PRESET presets = self._configure_presets() if preset not in presets: raise ValueError(f"Unknown preset: {preset} (available: {', '.join(presets)})") return preset, fresh def _configure_presets(self): with open(os.path.join(self._project_root(), "CMakePresets.json")) as f: presets = json.load(f)["configurePresets"] return [preset["name"] for preset in presets if not preset.get("hidden")] def _build_preset(self, preset, fresh): configure = ["cmake", "--preset", preset] if fresh: configure.append("--fresh") self._run_in_root(configure) self._copy_compile_commands(preset) self._run_in_root(["cmake", "--build", "--preset", preset]) # Editor tooling reads build/compile_commands.json, so point it at the # most recently configured preset. def _copy_compile_commands(self, preset): build_dir = os.path.join(self._project_root(), "build") source = os.path.join(build_dir, preset, "compile_commands.json") if os.path.isfile(source): shutil.copyfile(source, os.path.join(build_dir, "compile_commands.json")) def _run_in_root(self, command): print(f"Running: {shlex.join(command)}") result = subprocess.run(command, cwd=self._project_root()) if result.returncode != 0: sys.exit(result.returncode) 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__": DevCommand().call(*sys.argv[1:])