Blacksite/scripts/codex/cargo_lane.py

849 lines
30 KiB
Python
Executable File

#!/usr/bin/env python3
"""Resolve and execute Cargo commands in bounded, signature-stable build lanes.
This module intentionally uses only the Python standard library. It is also
imported by build_storage.py, so lane identity and sentinel validation have one
implementation.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping, Sequence
try:
import tomllib
except ModuleNotFoundError as error: # pragma: no cover - Python < 3.11
raise SystemExit("cargo_lane.py requires Python 3.11 or newer") from error
SCHEMA_VERSION = 1
SENTINEL_NAME = ".blacksite-cargo-lane.json"
LANE_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
SEPARATE_BUILD_DIR_MINIMUM = (1, 91, 0)
DEFAULT_BUILD_STORAGE: dict[str, Any] = {
"cache_root": "cargo-cache-home",
"workspace_partition": "canonical-path-hash",
"dev_target_dir": "target",
"exceptional_target_root": "target/lanes",
"persistent_lane": "dev",
"candidate_lane": "candidate",
"hot_reload_lane": "hot-reload",
"full_debug_lane": "full-debug",
"package_lane": "package",
}
class LaneError(RuntimeError):
"""A lane cannot be resolved or safely reused."""
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def canonical_path(path: Path | str) -> Path:
return Path(path).expanduser().resolve(strict=False)
def workspace_root(start: Path | None = None) -> Path:
override = os.environ.get("BLACKSITE_WORKSPACE_ROOT")
if override:
candidate = canonical_path(override)
elif start is not None:
candidate = canonical_path(start)
else:
candidate = canonical_path(Path(__file__).parents[2])
for current in (candidate, *candidate.parents):
if (current / "Cargo.toml").is_file() and (current / ".git").exists():
return current
raise LaneError(f"could not locate a Cargo workspace from {candidate}")
def workspace_hash(root: Path) -> str:
return hashlib.sha256(os.fsencode(str(canonical_path(root)))).hexdigest()[:20]
def cargo_home() -> Path:
return canonical_path(os.environ.get("CARGO_HOME", Path.home() / ".cargo"))
def load_workflow(root: Path) -> dict[str, Any]:
path = root / ".codex" / "workflow.toml"
document: dict[str, Any] = {}
if path.is_file():
with path.open("rb") as handle:
loaded = tomllib.load(handle)
if not isinstance(loaded, dict):
raise LaneError(f"workflow config is not a TOML table: {path}")
document = loaded
storage = dict(DEFAULT_BUILD_STORAGE)
configured = document.get("build_storage", {})
if configured:
if not isinstance(configured, dict):
raise LaneError("[build_storage] must be a TOML table")
storage.update(configured)
verification = document.get("verification", {})
if isinstance(verification, dict):
for source, destination in (
("persistent_lane", "persistent_lane"),
("candidate_lane", "candidate_lane"),
("hot_reload_lane", "hot_reload_lane"),
("full_debug_lane", "full_debug_lane"),
("package_lane", "package_lane"),
):
if source in verification:
storage[destination] = verification[source]
document["build_storage"] = storage
return document
def _configured_path(root: Path, value: str, *, base: Path | None = None) -> Path:
expanded = Path(os.path.expandvars(os.path.expanduser(value)))
if expanded.is_absolute():
return canonical_path(expanded)
return canonical_path((base or root) / expanded)
def cache_root(root: Path, workflow: Mapping[str, Any]) -> Path:
override = os.environ.get("BLACKSITE_BUILD_CACHE_ROOT")
if override:
return canonical_path(override)
storage = workflow["build_storage"]
configured = str(storage.get("cache_root", "cargo-cache-home"))
if configured == "cargo-cache-home":
return cargo_home() / "blacksite-build"
if configured == "workspace-parent-cache":
return canonical_path(root.parent / ".blacksite-build-cache")
return _configured_path(root, configured)
def parse_version(text: str) -> tuple[int, int, int]:
match = re.search(r"\b(\d+)\.(\d+)\.(\d+)", text)
if not match:
return (0, 0, 0)
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
def _capture(command: Sequence[str]) -> str:
try:
completed = subprocess.run(
command,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
except OSError as error:
return f"unavailable: {error}"
return completed.stdout.strip()
def toolchain_info() -> dict[str, Any]:
cargo_text = _capture(["cargo", "-Vv"])
rustc_text = _capture(["rustc", "-Vv"])
host = "unknown"
for line in rustc_text.splitlines():
if line.startswith("host:"):
host = line.partition(":")[2].strip()
break
version = parse_version(cargo_text)
return {
"cargo": cargo_text.splitlines()[0] if cargo_text else "unavailable",
"cargo_version": ".".join(str(part) for part in version),
"rustc": rustc_text.splitlines()[0] if rustc_text else "unavailable",
"host": host,
"separate_build_dir": version >= SEPARATE_BUILD_DIR_MINIMUM,
}
def _repo_target_config(root: Path, target: str) -> dict[str, Any]:
path = root / ".cargo" / "config.toml"
result: dict[str, Any] = {"linker": None, "rustflags": []}
if path.is_file():
with path.open("rb") as handle:
config = tomllib.load(handle)
target_table = config.get("target", {})
if isinstance(target_table, dict):
values = target_table.get(target, {})
if isinstance(values, dict):
result["linker"] = values.get("linker")
flags = values.get("rustflags", [])
if isinstance(flags, list):
result["rustflags"] = [str(flag) for flag in flags]
encoded_flags = os.environ.get("CARGO_ENCODED_RUSTFLAGS")
if encoded_flags:
result["rustflags"] = [flag for flag in encoded_flags.split("\x1f") if flag]
result["rustflags_source"] = "CARGO_ENCODED_RUSTFLAGS"
elif os.environ.get("RUSTFLAGS"):
result["rustflags"] = shlex.split(os.environ["RUSTFLAGS"])
result["rustflags_source"] = "environment"
else:
result["rustflags_source"] = str(path) if path.is_file() else "cargo-default"
result["rustc_wrapper"] = os.environ.get("RUSTC_WRAPPER")
linker_variable = "CARGO_TARGET_" + target.upper().replace("-", "_") + "_LINKER"
if os.environ.get(linker_variable):
result["linker"] = os.environ[linker_variable]
return result
def validate_lane_name(lane: str) -> str:
if not LANE_RE.fullmatch(lane):
raise LaneError(
f"invalid lane {lane!r}; use lowercase letters, digits, and hyphens"
)
return lane
def profile_directory(profile: str) -> str:
if profile in {"dev", "test"}:
return "debug"
if profile in {"release", "bench"}:
return "release"
return profile
@dataclass(frozen=True)
class CommandContext:
profile: str
profile_dir: str
feature_signature: str
cargo_subcommand: str | None
target_triple: str | None
PROJECT_ALIAS_SUBCOMMANDS = {
"bake-navigation",
"package-project",
"process-assets",
"upgrade-project",
"validate-levels",
"validate-samples",
}
def command_context(command: Sequence[str], lane: str) -> CommandContext:
args = list(command)
cargo_index: int | None = None
for index, value in enumerate(args[:2]):
if Path(value).name == "cargo":
cargo_index = index
break
cargo_args = args[cargo_index + 1 :] if cargo_index is not None else []
subcommand = next((arg for arg in cargo_args if not arg.startswith("-")), None)
# Arguments following Blacksite's Cargo aliases are forwarded to the xtask
# binary by the alias's trailing `--`. They are not Cargo options. In
# particular, `cargo package-project --profile package-qa` selects the
# package manifest profile and must not repartition the Cargo build lane.
cargo_options = (
cargo_args[: cargo_args.index(subcommand) + 1]
if subcommand in PROJECT_ALIAS_SUBCOMMANDS
else cargo_args
)
if "--release" in cargo_options:
profile = "release"
else:
profile = "test" if subcommand == "test" else "dev"
for index, value in enumerate(cargo_options):
if value.startswith("--profile="):
profile = value.partition("=")[2]
elif value == "--profile" and index + 1 < len(cargo_options):
profile = cargo_options[index + 1]
features: set[str] = set()
all_features = "--all-features" in cargo_options
no_default = "--no-default-features" in cargo_options
for index, value in enumerate(cargo_options):
raw: str | None = None
if value.startswith("--features="):
raw = value.partition("=")[2]
elif value.startswith("-F") and value != "-F":
raw = value[2:]
elif value in {"--features", "-F"} and index + 1 < len(cargo_options):
raw = cargo_options[index + 1]
if raw:
features.update(part for part in re.split(r"[ ,]+", raw) if part)
target_triple: str | None = None
for index, value in enumerate(cargo_options):
if value.startswith("--target="):
target_triple = value.partition("=")[2]
elif value == "--target" and index + 1 < len(cargo_options):
target_triple = cargo_options[index + 1]
if all_features:
feature_signature = "all-features"
else:
parts = ["no-default" if no_default else "default"]
if features:
parts.append("features=" + ",".join(sorted(features)))
feature_signature = ";".join(parts)
# These disposable lanes intentionally contain a bounded family of
# package-specific feature sets. Hot reload needs the editor's
# `dev,hot-reload` build and `game_hot`'s `dylib` build in one isolated
# runtime lane; package aliases likewise expand to different internal
# features. They must never spill into the persistent default-feature lane.
if lane == "hot-reload":
if all_features:
raise LaneError("all-features belongs in the candidate lane")
feature_signature = "hot-reload-family"
elif lane == "package":
feature_signature = "package-family"
if lane == "dev" and (all_features or "hot-reload" in features):
raise LaneError(
"the persistent dev lane cannot be used for all-features or hot-reload"
)
if lane == "candidate" and subcommand is not None and not all_features:
raise LaneError("the candidate lane requires --all-features")
return CommandContext(
profile=profile,
profile_dir=profile_directory(profile),
feature_signature=feature_signature,
cargo_subcommand=subcommand,
target_triple=target_triple,
)
@dataclass(frozen=True)
class LaneLayout:
workspace: str
workspace_hash: str
lane: str
mode: str
cache_root: str
lane_root: str
target_dir: str
build_dir: str
profile: str
profile_dir: str
runtime_deps: str
feature_signature: str
toolchain: dict[str, Any]
linker: str | None
rustflags: list[str]
rustflags_source: str
rustc_wrapper: str | None
target_triple: str
def lane_layout(
root: Path,
lane: str,
workflow: Mapping[str, Any],
command: Sequence[str] = (),
*,
toolchain: Mapping[str, Any] | None = None,
) -> LaneLayout:
lane = validate_lane_name(lane)
root = canonical_path(root)
info = dict(toolchain or toolchain_info())
context = command_context(command, lane)
storage = workflow["build_storage"]
cache = canonical_path(cache_root(root, workflow))
partition = cache / workspace_hash(root)
lane_root = partition / lane
separate = bool(info.get("separate_build_dir"))
effective_target = context.target_triple or str(info.get("host", "unknown"))
if (
context.target_triple
and context.target_triple != info.get("host")
and not lane.startswith("cross-")
):
raise LaneError("cross-target Cargo commands require a cross-* disposable lane")
persistent = str(storage.get("persistent_lane", "dev"))
if separate:
if lane == persistent:
target = _configured_path(
root, str(storage.get("dev_target_dir", "target"))
)
else:
exceptional = _configured_path(
root, str(storage.get("exceptional_target_root", "target/lanes"))
)
target = exceptional / lane
build = lane_root
mode = "separate-build-dir"
else:
target = lane_root / "target"
build = target
mode = "external-target-dir-fallback"
target_config = _repo_target_config(root, effective_target)
profile_path = Path(context.profile_dir)
if context.target_triple:
profile_path = Path(context.target_triple) / profile_path
return LaneLayout(
workspace=str(root),
workspace_hash=workspace_hash(root),
lane=lane,
mode=mode,
cache_root=str(cache),
lane_root=str(lane_root),
target_dir=str(target),
build_dir=str(build),
profile=context.profile,
profile_dir=context.profile_dir,
runtime_deps=str(build / profile_path / "deps"),
feature_signature=context.feature_signature,
toolchain=info,
linker=target_config.get("linker"),
rustflags=list(target_config.get("rustflags", [])),
rustflags_source=str(target_config.get("rustflags_source")),
rustc_wrapper=target_config.get("rustc_wrapper"),
target_triple=effective_target,
)
def lane_environment(layout: LaneLayout) -> dict[str, str]:
environment = {
"CARGO_TARGET_DIR": layout.target_dir,
"BLACKSITE_CARGO_LANE": layout.lane,
"BLACKSITE_WORKSPACE_HASH": layout.workspace_hash,
}
if layout.mode == "separate-build-dir":
environment["CARGO_BUILD_BUILD_DIR"] = layout.build_dir
if layout.lane in {"candidate", "package", "full-debug"}:
environment["CARGO_INCREMENTAL"] = "0"
if layout.lane == "full-debug":
environment["CARGO_PROFILE_DEV_DEBUG"] = "full"
environment["CARGO_PROFILE_TEST_DEBUG"] = "full"
return environment
def sentinel_path(path: Path) -> Path:
return path / SENTINEL_NAME
def read_sentinel(path: Path) -> dict[str, Any] | None:
marker = sentinel_path(path)
if not marker.is_file() or marker.is_symlink():
return None
try:
with marker.open("r", encoding="utf-8") as handle:
value = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None
def validate_sentinel(
path: Path,
document: Mapping[str, Any],
*,
root: Path,
lane: str,
role: str,
) -> list[str]:
errors: list[str] = []
expected = {
"schema_version": SCHEMA_VERSION,
"workspace": str(canonical_path(root)),
"workspace_hash": workspace_hash(root),
"lane": lane,
"role": role,
"path": str(canonical_path(path)),
}
for key, value in expected.items():
if document.get(key) != value:
errors.append(f"{key}: expected {value!r}, found {document.get(key)!r}")
return errors
def _signature(layout: LaneLayout) -> dict[str, Any]:
# The persistent development and candidate lanes intentionally serve both
# Cargo's `dev` and `test` profiles. Cargo fingerprints those profiles
# independently, while keeping them in one bounded ordinary/candidate lane
# avoids multiplying the Bevy dependency graph into permanent caches.
profile_signature = "dev-test" if layout.profile in {"dev", "test"} else layout.profile
return {
"toolchain": layout.toolchain,
"target_triple": layout.target_triple,
"linker": layout.linker,
"rustflags": layout.rustflags,
"rustc_wrapper": layout.rustc_wrapper,
"feature_signature": layout.feature_signature,
"profile_signature": profile_signature,
"mode": layout.mode,
"lane_environment": lane_environment(layout),
}
def _signature_compatible(existing: object, requested: Mapping[str, Any]) -> bool:
if existing == requested:
return True
if not isinstance(existing, Mapping):
return False
previous = dict(existing)
current = dict(requested)
previous_profile = previous.pop("profile_signature", None)
current_profile = current.pop("profile_signature", None)
return (
previous == current
and previous_profile in {"dev", "test"}
and current_profile == "dev-test"
)
def _atomic_json(path: Path, document: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(document, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def _prepare_marker(
path: Path, layout: LaneLayout, role: str, *, allow_create: bool
) -> dict[str, Any]:
if path.is_symlink():
raise LaneError(f"refusing symlinked lane path: {path}")
if allow_create:
path.mkdir(parents=True, exist_ok=True)
if not path.is_dir():
raise LaneError(f"lane path does not exist: {path}")
existing = read_sentinel(path)
now = utc_now()
signature = _signature(layout)
if existing is not None:
errors = validate_sentinel(
path, existing, root=Path(layout.workspace), lane=layout.lane, role=role
)
if errors:
raise LaneError(
f"invalid lane sentinel at {path}: " + "; ".join(errors)
)
if not _signature_compatible(existing.get("signature"), signature):
raise LaneError(
"lane signature changed; select the correct exceptional lane or reset "
f"the whole lane after a dry run: {layout.lane}"
)
document = dict(existing)
document["signature"] = signature
document["last_use"] = now
document["last_profile"] = layout.profile
else:
document = {
"schema_version": SCHEMA_VERSION,
"workspace": layout.workspace,
"workspace_hash": layout.workspace_hash,
"lane": layout.lane,
"role": role,
"path": str(canonical_path(path)),
"target_dir": layout.target_dir,
"build_dir": layout.build_dir,
"signature": signature,
"created_at": now,
"last_use": now,
"last_profile": layout.profile,
}
_atomic_json(sentinel_path(path), document)
return document
def prepare_lane(layout: LaneLayout) -> None:
lane_root = Path(layout.lane_root)
_prepare_marker(lane_root, layout, "build", allow_create=True)
target = Path(layout.target_dir)
workspace_target = Path(layout.workspace) / "target"
if target != workspace_target and target != Path(layout.build_dir):
_prepare_marker(target, layout, "target", allow_create=True)
def require_prepared_lane(layout: LaneLayout) -> None:
lane_root = Path(layout.lane_root)
marker = read_sentinel(lane_root)
if marker is None:
raise LaneError(
f"lane {layout.lane!r} has not been built through cargo_lane.py"
)
errors = validate_sentinel(
lane_root,
marker,
root=Path(layout.workspace),
lane=layout.lane,
role="build",
)
if errors:
raise LaneError("invalid prepared lane: " + "; ".join(errors))
def layout_document(layout: LaneLayout) -> dict[str, Any]:
document = asdict(layout)
document["environment"] = lane_environment(layout)
document["sentinel"] = str(sentinel_path(Path(layout.lane_root)))
return document
def _shell_environment(values: Mapping[str, str]) -> str:
return "\n".join(
f"export {name}={shlex.quote(value)}" for name, value in sorted(values.items())
)
def _run_command(layout: LaneLayout, command: Sequence[str], *, dry_run: bool) -> int:
if not command:
raise LaneError("missing command after --")
if dry_run:
print(
json.dumps(
{"layout": layout_document(layout), "command": list(command)},
indent=2,
sort_keys=True,
)
)
return 0
prepare_lane(layout)
environment = os.environ.copy()
environment.update(lane_environment(layout))
return subprocess.run(command, cwd=layout.workspace, env=environment).returncode
def _resolve_runtime_command(layout: LaneLayout, command: Sequence[str]) -> list[str]:
if not command:
raise LaneError("missing runtime command after --")
resolved = list(command)
executable = Path(resolved[0])
if not executable.is_absolute():
parts = executable.parts
if parts and parts[0] == "target":
executable = Path(layout.target_dir).joinpath(*parts[1:])
elif "/" in resolved[0]:
executable = Path(layout.workspace) / executable
resolved[0] = str(canonical_path(executable)) if "/" in str(executable) else str(executable)
return resolved
def _run_runtime(layout: LaneLayout, command: Sequence[str], *, dry_run: bool) -> int:
require_prepared_lane(layout)
resolved = _resolve_runtime_command(layout, command)
environment = os.environ.copy()
dependencies = layout.runtime_deps
existing = environment.get("LD_LIBRARY_PATH")
environment["LD_LIBRARY_PATH"] = (
dependencies if not existing else dependencies + os.pathsep + existing
)
environment.update(lane_environment(layout))
if dry_run:
print(
json.dumps(
{
"layout": layout_document(layout),
"command": resolved,
"LD_LIBRARY_PATH": environment["LD_LIBRARY_PATH"],
},
indent=2,
sort_keys=True,
)
)
return 0
os.chdir(layout.workspace)
os.execvpe(resolved[0], resolved, environment)
raise AssertionError("os.execvpe unexpectedly returned") # pragma: no cover
def _self_test() -> dict[str, Any]:
checks: list[str] = []
assert parse_version("cargo 1.97.0 (abc)") == (1, 97, 0)
checks.append("cargo-version-parsing")
assert profile_directory("test") == "debug"
assert profile_directory("package-qa") == "package-qa"
checks.append("profile-directory-mapping")
try:
validate_lane_name("../escape")
except LaneError:
checks.append("lane-name-traversal-rejected")
else: # pragma: no cover
raise AssertionError("unsafe lane name accepted")
with tempfile.TemporaryDirectory(prefix="blacksite-lane-self-test-") as temporary:
root = Path(temporary) / "workspace"
(root / ".git").mkdir(parents=True)
(root / "Cargo.toml").write_text("[workspace]\nmembers = []\n", encoding="utf-8")
workflow = {"build_storage": dict(DEFAULT_BUILD_STORAGE)}
workflow["build_storage"]["cache_root"] = str(Path(temporary) / "cache")
fake_toolchain = {
"cargo": "cargo 1.97.0",
"cargo_version": "1.97.0",
"rustc": "rustc 1.97.0",
"host": "x86_64-unknown-linux-gnu",
"separate_build_dir": True,
}
layout = lane_layout(root, "dev", workflow, ["cargo", "check"], toolchain=fake_toolchain)
prepare_lane(layout)
marker = read_sentinel(Path(layout.lane_root))
assert marker is not None
assert not validate_sentinel(
Path(layout.lane_root), marker, root=root, lane="dev", role="build"
)
checks.append("sentinel-round-trip")
assert Path(layout.target_dir) == root / "target"
assert Path(layout.build_dir) != Path(layout.target_dir)
checks.append("separate-build-dir-layout")
test_layout = lane_layout(
root, "dev", workflow, ["cargo", "test"], toolchain=fake_toolchain
)
prepare_lane(test_layout)
assert read_sentinel(Path(layout.lane_root))["signature"]["profile_signature"] == "dev-test"
checks.append("dev-and-test-share-persistent-lane")
hot_editor = lane_layout(
root,
"hot-reload",
workflow,
["cargo", "build", "-p", "editor", "--features", "dev,hot-reload"],
toolchain=fake_toolchain,
)
hot_game = lane_layout(
root,
"hot-reload",
workflow,
["cargo", "build", "-p", "game_hot", "--features", "dylib"],
toolchain=fake_toolchain,
)
assert hot_editor.feature_signature == hot_game.feature_signature == "hot-reload-family"
checks.append("hot-reload-feature-family-isolated")
package = lane_layout(
root,
"package",
workflow,
["cargo", "package-project", "--project", ".", "--profile", "package-qa"],
toolchain=fake_toolchain,
)
assert package.profile == "dev"
assert package.feature_signature == "package-family"
checks.append("project-alias-options-not-cargo-options")
fallback_toolchain = dict(fake_toolchain)
fallback_toolchain["cargo_version"] = "1.90.0"
fallback_toolchain["separate_build_dir"] = False
fallback = lane_layout(
root,
"candidate",
workflow,
["cargo", "test", "--all-features"],
toolchain=fallback_toolchain,
)
assert fallback.mode == "external-target-dir-fallback"
assert Path(fallback.target_dir) == Path(fallback.lane_root) / "target"
assert fallback.build_dir == fallback.target_dir
checks.append("external-target-dir-fallback")
cross = lane_layout(
root,
"cross-wasm",
workflow,
["cargo", "check", "--target", "wasm32-unknown-unknown"],
toolchain=fake_toolchain,
)
assert cross.target_triple == "wasm32-unknown-unknown"
assert "wasm32-unknown-unknown" in cross.runtime_deps
checks.append("cross-target-partition")
return {"ok": True, "checks": checks}
def _command_tail(values: Iterable[str]) -> list[str]:
result = list(values)
if result and result[0] == "--":
result.pop(0)
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--workspace", type=Path, help="workspace root override")
subparsers = parser.add_subparsers(dest="action", required=True)
env_parser = subparsers.add_parser("env", help="describe a lane environment")
env_parser.add_argument("lane")
env_parser.add_argument("--json", action="store_true")
exec_parser = subparsers.add_parser("exec", help="execute a command in a lane")
exec_parser.add_argument("lane")
exec_parser.add_argument("--dry-run", action="store_true")
exec_parser.add_argument("command", nargs=argparse.REMAINDER)
run_parser = subparsers.add_parser("run", help="run an existing lane artifact")
run_parser.add_argument("lane")
run_parser.add_argument("--dry-run", action="store_true")
run_parser.add_argument("command", nargs=argparse.REMAINDER)
subparsers.add_parser("self-test", help="run temp-directory safety checks")
return parser
def main(argv: Sequence[str] | None = None) -> int:
arguments = build_parser().parse_args(argv)
if arguments.action == "self-test":
print(json.dumps(_self_test(), indent=2, sort_keys=True))
return 0
try:
root = workspace_root(arguments.workspace)
workflow = load_workflow(root)
raw_command = list(getattr(arguments, "command", []))
# argparse.REMAINDER intentionally preserves arbitrary child-command
# flags. Accept our one wrapper flag after the lane as well as before it.
if arguments.action in {"exec", "run"} and "--dry-run" in raw_command:
separator = raw_command.index("--") if "--" in raw_command else len(raw_command)
if raw_command.index("--dry-run") < separator:
arguments.dry_run = True
raw_command.remove("--dry-run")
command = _command_tail(raw_command)
layout = lane_layout(root, arguments.lane, workflow, command)
if arguments.action == "env":
document = layout_document(layout)
if arguments.json:
print(json.dumps(document, indent=2, sort_keys=True))
else:
print(_shell_environment(document["environment"]))
return 0
if arguments.action == "exec":
return _run_command(layout, command, dry_run=arguments.dry_run)
if arguments.action == "run":
return _run_runtime(layout, command, dry_run=arguments.dry_run)
except LaneError as error:
print(f"cargo-lane: {error}", file=sys.stderr)
return 2
return 2
if __name__ == "__main__":
raise SystemExit(main())