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
+7
View File
@@ -32,6 +32,13 @@ pids
*.pid.lock *.pid.lock
imgui.ini imgui.ini
# Python bytecode (bin/dev and bin/dev_commands)
__pycache__/
# Tracy captures
traces/
*.tracy
# dotenv environment variables file # dotenv environment variables file
.envrc .envrc
.env .env
+9 -272
View File
@@ -2,13 +2,16 @@
import json import json
import os import os
import re
import shlex import shlex
import shutil import shutil
import struct
import subprocess import subprocess
import sys 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 = { COMMAND_TO_METHOD = {
"branch-from": "branch_from", "branch-from": "branch_from",
"bash-completions": "bash_completions", "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" WAIT_FOR_PROCESS = "wait_for_process"
class DevHelper: class DevCommand(GiteaCommand, RenderDocCommand, ShadersCommand, TracyCommand):
SHADER_EXTENSIONS = (".vert", ".frag", ".comp", ".geom", ".tesc", ".tese")
PROJECT_NAME = "sophia" PROJECT_NAME = "sophia"
DEFAULT_PRESET = "debug" DEFAULT_PRESET = "debug"
@@ -78,111 +80,6 @@ class DevHelper:
os.chdir(preset_dir) os.chdir(preset_dir)
os.execv(binary, [binary, *args]) 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 # Custom helpers
def ownit(self, *args, **kwargs): def ownit(self, *args, **kwargs):
@@ -204,8 +101,8 @@ class DevHelper:
def _commands(self): def _commands(self):
return sorted( return sorted(
METHOD_TO_COMMAND.get(name, name) METHOD_TO_COMMAND.get(name, name)
for name in vars(DevHelper) for name in dir(DevCommand)
if not name.startswith("_") and name != "call" and callable(getattr(DevHelper, name)) if not name.startswith("_") and name != "call" and callable(getattr(DevCommand, name))
) )
def _parse_preset_args(self, args, command): def _parse_preset_args(self, args, command):
@@ -254,166 +151,6 @@ class DevHelper:
if result.returncode != 0: if result.returncode != 0:
sys.exit(result.returncode) 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): def _wait_for_process_with_logging(self, command):
process = self._exec(command, capture=True) process = self._exec(command, capture=True)
for line in process.stdout: for line in process.stdout:
@@ -457,4 +194,4 @@ class DevHelper:
if __name__ == "__main__": if __name__ == "__main__":
DevHelper().call(*sys.argv[1:]) DevCommand().call(*sys.argv[1:])
+156
View File
@@ -0,0 +1,156 @@
import json
import re
import shlex
import shutil
import subprocess
import sys
# Mixed into DevCommand (bin/dev), which provides PROJECT_NAME, _project_root
# and _exec_in_root.
class GiteaCommand:
##
# 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 _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
+16
View File
@@ -0,0 +1,16 @@
import shutil
# Mixed into DevCommand (bin/dev), which provides _exec_in_root.
class RenderDocCommand:
def renderdoc(self, *args, **kwargs):
qrenderdoc = self._find_qrenderdoc()
self._exec_in_root(qrenderdoc, replace=True)
def _find_qrenderdoc(self):
qrenderdoc = shutil.which("qrenderdoc")
if qrenderdoc is None:
raise RuntimeError(
"qrenderdoc not found on PATH (install RenderDoc)"
)
return qrenderdoc
+92
View File
@@ -0,0 +1,92 @@
import os
import shutil
import struct
import subprocess
# Mixed into DevCommand (bin/dev), which provides _project_root.
class ShadersCommand:
SHADER_EXTENSIONS = (".vert", ".frag", ".comp", ".geom", ".tesc", ".tese")
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)
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 _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)")
+85
View File
@@ -0,0 +1,85 @@
import os
import re
import shlex
import time
# Mixed into DevCommand (bin/dev), which provides _project_root and
# _run_in_root.
class TracyCommand:
##
# Builds a Tracy tool from the third_party/tracy submodule, then runs it.
# Building from the submodule keeps the tool's version in sync with the
# client compiled into the engine.
# dev tracy [--fresh] [args...] Opens the profiler (viewer).
# dev tracy --save [name] [--fresh] [args...]
# Captures a trace headlessly to
# traces/<name>.tracy (default
# name: a timestamp) until the app
# exits or Ctrl+C.
# The profiler uses the X11 backend unless WAYLAND_DISPLAY is set. Tools
# are configured on first use (or --fresh, or when the backend changes),
# then built incrementally. Other arguments are passed through to the tool
# (e.g. a .tracy file to open, or `-s 10` to capture for 10 seconds).
def tracy(self, *args, **kwargs):
fresh, save, name, args = self._parse_tracy_args(args)
if save:
binary = self._build_tracy_tool("capture", "tracy-capture", {}, fresh)
traces_dir = os.path.join(self._project_root(), "traces")
os.makedirs(traces_dir, exist_ok=True)
name = name or time.strftime("%Y-%m-%d-%H%M%S")
if not name.endswith(".tracy"):
name += ".tracy"
args = ["-o", os.path.join(traces_dir, name), *args]
else:
legacy = "OFF" if os.environ.get("WAYLAND_DISPLAY") else "ON"
binary = self._build_tracy_tool("profiler", "tracy-profiler", {"LEGACY": legacy}, fresh)
print(f"Running: {shlex.join([binary, *args])}")
os.execv(binary, [binary, *args])
def _parse_tracy_args(self, args):
fresh = False
save = False
name = None
rest = []
args = list(args)
while args:
arg = args.pop(0)
if arg == "--fresh":
fresh = True
elif arg == "--save":
save = True
if args and not args[0].startswith("-"):
name = args.pop(0)
else:
rest.append(arg)
return fresh, save, name, rest
# Configures third_party/tracy/<tool> into <tool>/build when it has no
# build yet, --fresh is given, or a cached define differs from `defines`.
def _build_tracy_tool(self, tool, binary_name, defines, fresh):
source_dir = os.path.join("third_party", "tracy", tool)
build_dir = os.path.join(source_dir, "build")
cache_path = os.path.join(self._project_root(), build_dir, "CMakeCache.txt")
stale = not os.path.isfile(cache_path)
if not stale:
with open(cache_path) as f:
cache = f.read()
stale = any(
re.search(rf"^{key}:BOOL={value}$", cache, re.MULTILINE) is None
for key, value in defines.items()
)
if fresh or stale:
configure = ["cmake", "-B", build_dir, "-S", source_dir, "-DCMAKE_BUILD_TYPE=Release"]
configure += [f"-D{key}={value}" for key, value in defines.items()]
if fresh:
configure.append("--fresh")
self._run_in_root(configure)
self._run_in_root(["cmake", "--build", build_dir, "--parallel"])
return os.path.join(self._project_root(), build_dir, binary_name)