restructuing dev command

This commit is contained in:
2026-09-30 02:42:45 -07:00
parent 9f3ad5f372
commit 7d87503eba
6 changed files with 365 additions and 272 deletions
+9 -272
View File
@@ -2,13 +2,16 @@
import json
import os
import re
import shlex
import shutil
import struct
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",
@@ -17,8 +20,7 @@ METHOD_TO_COMMAND = {method: command for command, method in COMMAND_TO_METHOD.it
WAIT_FOR_PROCESS = "wait_for_process"
class DevHelper:
SHADER_EXTENSIONS = (".vert", ".frag", ".comp", ".geom", ".tesc", ".tese")
class DevCommand(GiteaCommand, RenderDocCommand, ShadersCommand, TracyCommand):
PROJECT_NAME = "sophia"
DEFAULT_PRESET = "debug"
@@ -78,111 +80,6 @@ class DevHelper:
os.chdir(preset_dir)
os.execv(binary, [binary, *args])
##
# 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-<issue_number>/<issue_title>
# If the issue is from another repo (presumably upstream), the branch name will be:
# <issue_owner>-issue-<issue_number>/<issue_title>
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 <title> [-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)
##
# Builds the Tracy profiler (viewer) from the third_party/tracy submodule,
# then runs it. Building from the submodule keeps the viewer's version in
# sync with the client compiled into the engine.
# dev tracy [--fresh] [args...]
# Configures only when there is no build yet (or --fresh), then builds
# incrementally. Other arguments are passed through to the profiler.
def tracy(self, *args, **kwargs):
fresh = "--fresh" in args
args = [arg for arg in args if arg != "--fresh"]
source_dir = os.path.join("third_party", "tracy", "profiler")
build_dir = os.path.join(source_dir, "build")
if fresh or not os.path.isfile(os.path.join(self._project_root(), build_dir, "CMakeCache.txt")):
configure = ["cmake", "-B", build_dir, "-S", source_dir, "-DCMAKE_BUILD_TYPE=Release"]
if fresh:
configure.append("--fresh")
self._run_in_root(configure)
self._run_in_root(["cmake", "--build", build_dir, "--parallel"])
binary = os.path.join(self._project_root(), build_dir, "tracy-profiler")
print(f"Running: {shlex.join([binary, *args])}")
os.execv(binary, [binary, *args])
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):
@@ -204,8 +101,8 @@ class DevHelper:
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))
for name in dir(DevCommand)
if not name.startswith("_") and name != "call" and callable(getattr(DevCommand, name))
)
def _parse_preset_args(self, args, command):
@@ -254,166 +151,6 @@ class DevHelper:
if result.returncode != 0:
sys.exit(result.returncode)
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:
@@ -457,4 +194,4 @@ class DevHelper:
if __name__ == "__main__":
DevHelper().call(*sys.argv[1:])
DevCommand().call(*sys.argv[1:])