Files
2026-07-13 13:05:14 +08:00

394 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Run various rust checks for CI.
You can run the script via pixi:
pixi run rs-check
Alternatively you can also run it directly via python:
python3 scripts/ci/rust_checks.py
To run only a specific test you can use the `--only` argument:
pixi run rs-check --only wasm
To run all tests except a few specific ones you can use the `--skip` argument:
pixi run rs-check --skip wasm docs docs_slow
To see a list of all available tests you can use the `--help` argument:
pixi run rs-check --help
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import time
from functools import partial
from glob import glob
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
class Result:
"""The result of running one test."""
def __init__(self, command: str, success: bool, duration: float) -> None:
self.command = command
self.success = success
self.duration = duration
def run_cargo(
cargo_cmd: str,
cargo_args: str,
clippy_conf: str | None = None,
deny_warnings: bool = True,
output_checks: Callable[[str], str | None] | None = None,
) -> Result:
args = ["cargo", cargo_cmd]
args += cargo_args.split(" ")
cmd_str = subprocess.list2cmdline(args)
print(f"> {cmd_str} ", end="", flush=True)
start_time = time.time()
additional_env_vars = {}
extra_cfgs = ""
if "wasm" in cargo_args:
extra_cfgs = '--cfg=web_sys_unstable_apis --cfg=getrandom_backend="wasm_js"'
additional_env_vars["RUSTFLAGS"] = f"{extra_cfgs} {'--deny warnings' if deny_warnings else ''}"
additional_env_vars["RUSTDOCFLAGS"] = f"{extra_cfgs} {'--deny warnings' if deny_warnings else ''}"
# We shouldn't require the web viewer .wasm to exist before running clippy, unit tests, etc:
additional_env_vars["RERUN_DISABLE_WEB_VIEWER_SERVER"] = "1"
# Disable TRACY to avoid macOS failure on CI, that looks like this:
# > Tracy Profiler initialization failure: CPU doesn't support invariant TSC.
# > Define TRACY_NO_INVARIANT_CHECK=1 to ignore this error, *if you know what you are doing*.
# > Alternatively you may rebuild the application with the TRACY_TIMER_FALLBACK define to use lower resolution timer.
additional_env_vars["TRACY_ENABLED"] = "0"
additional_env_vars["TRACY_NO_INVARIANT_CHECK"] = "1"
if clippy_conf is not None:
additional_env_vars["CLIPPY_CONF_DIR"] = (
# Clippy has issues finding this directory on CI when we're not using an absolute path here.
f"{os.getcwd()}/{clippy_conf}"
)
capture = output_checks is not None
env = os.environ.copy()
env.update(additional_env_vars)
# Use encoding='utf-8' with errors='replace' to handle binary data in cargo/linker output on Windows
result = subprocess.run(
args, env=env, check=False, capture_output=capture, text=True, encoding="utf-8", errors="replace"
)
success = result.returncode == 0
if success:
output_check_error = None
if output_checks is not None:
output_check_error = output_checks(result.stdout)
if output_check_error is not None:
print("❌")
print(output_check_error)
success = False
else:
print("✅")
else:
print("❌")
# Print output right away, so the user can start fixing it while waiting for the rest of the checks to run:
env_var_string = " ".join([f'{env_var}="{value}"' for env_var, value in additional_env_vars.items()])
print(
f"'{env_var_string} {cmd_str}' failed with exit-code {result.returncode}. Output:\n{result.stdout}\n{result.stderr}",
)
duration = time.time() - start_time
return Result(cmd_str, success, duration)
def package_name_from_cargo_toml(cargo_toml_path: str) -> str:
with open(cargo_toml_path, encoding="utf8") as file:
cargo_toml_contents = file.read()
package_name_result = re.search(r'name\s+=\s"([\w\-_]+)"', cargo_toml_contents)
if package_name_result is None:
raise Exception(f"Failed to find package name in '{cargo_toml_path}'")
return package_name_result.group(1)
def main() -> None:
# Ensure we can print unicode characters. Has been historically an issue on Windows CI.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
# On CI, we split these checks into groups to reduce the time it takes to run all of this.
# Make sure the `reusable_checks_rust` workflow stays up-to-date with this list.
checks = [
("base_checks", base_checks),
("sdk_variations", sdk_variations),
("cargo_deny", cargo_deny),
("denied_sdk_deps", denied_sdk_deps),
("wasm", wasm),
("individual_examples", individual_examples),
("individual_crates", individual_crates),
("docs", docs),
("docs_slow", docs_slow),
("tests", tests),
("tests_without_all_features", tests_without_all_features),
]
check_names = [check[0] for check in checks]
parser = argparse.ArgumentParser(description="Run Rust checks and tests.")
parser.add_argument(
"--skip",
help="Skip all specified given checks but runs everything else.",
nargs="+",
type=str,
choices=check_names,
)
parser.add_argument(
"--only",
help="Runs only the specified checks (ignores --skip argument).",
nargs="+",
type=str,
choices=check_names,
)
args = parser.parse_args()
enabled_check_names = []
if args.only is not None:
enabled_check_names = args.only
else:
enabled_check_names = [check[0] for check in checks if check[0] not in (args.skip or [])]
print("Enabled checks:")
for check in enabled_check_names:
print(f" - {check}")
print()
# ----------------------
# NOTE: a lot of these jobs use very little CPU, but we cannot parallelize them because they all take a lock on the `target` directory.
results: list[Result] = []
start_time = time.time()
for enabled_check_name in enabled_check_names:
checks[check_names.index(enabled_check_name)][1](results)
total_duration = time.time() - start_time
# ----------------------
# Print timings overview
print("-----------------")
print(f"Ran {len(results)} checks in {total_duration:.0f}s")
print("Individual timings, slowest first:")
results.sort(key=lambda result: result.duration, reverse=True)
for result in results:
print(f"{result.duration:.2f}s \t {result.command}")
# ----------------------
# Count failures
num_failures = sum(1 for result in results if not result.success)
if num_failures == 0:
print()
print("✅ All checks passed!")
sys.exit(0)
else:
print()
print(f"❌ {num_failures} checks / {len(results)} failed:")
for result in results:
if not result.success:
print(f" ❌ {result.command}")
sys.exit(1)
def base_checks(results: list[Result]) -> None:
# First check with --locked to make sure Cargo.lock is up to date.
results.append(run_cargo("check", "--locked --all-features"))
fmt_result = run_cargo("fmt", "--all -- --check")
if sys.platform == "win32" and not fmt_result.success:
# TODO(rust-lang/rustfmt#6934): cargo-fmt passes all target paths for an edition to one
# rustfmt spawn, which can exceed the Windows command-line length limit in large workspaces.
fmt_result.success = True
results.append(fmt_result)
results.append(run_cargo("clippy", "--all-targets --all-features -- --deny warnings"))
def sdk_variations(results: list[Result]) -> None:
# Check a few important permutations of the feature flags for our `rerun` library:
results.append(run_cargo("check", "-p rerun --no-default-features"))
results.append(run_cargo("check", "-p rerun --no-default-features --features sdk"))
# `re_server` is built without the optional `lance` feature in many configurations
# (e.g. when pulled in by `rerun`'s `--all-features`, which does not propagate `re_server/lance`).
results.append(run_cargo("check", "-p re_server"))
deny_targets = [
"aarch64-apple-darwin",
"wasm32-unknown-unknown",
"x86_64-apple-darwin",
"x86_64-pc-windows-gnu",
"x86_64-pc-windows-msvc",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
]
def cargo_deny(results: list[Result]) -> None:
# Note: running just `cargo deny check` without a `--target` can result in
# false positives due to https://github.com/EmbarkStudios/cargo-deny/issues/324
# Installing is quite quick if it's already installed.
results.append(run_cargo("install", "--locked cargo-deny@^0.19"))
for target in deny_targets:
results.append(
run_cargo("deny", f"--all-features --exclude-dev --log-level error --target {target} check"),
)
def denied_sdk_deps(results: list[Result]) -> None:
"""
Check for disallowed SDK dependencies.
This protects against leaking UI dependencies into the SDK.
"""
# Installing is quite quick if it's already installed.
results.append(run_cargo("install", "--locked cargo-tree@^0.29.0"))
# Sampling of dependencies that should never show up in the SDK, unless the viewer is enabled.
# They are ordered from "big to small" to make sure the bigger leaks are caught & reported first.
# (e.g. `re_viewer` depends on `rfd` which is also disallowed, but if re_viewer is leaking, only report `re_viewer`)
disallowed_dependencies = [
"eframe",
"re_viewer",
"wgpu",
"egui",
"winit",
"rfd", # File dialog library.
"objc2-ui-kit", # MacOS system ui libraries.
"cocoa", # Legacy MacOS system ui libraries.
"wayland-sys", # Linux windowing.
]
def check_sdk_tree_with_default_features(tree_output: str, features: str) -> str | None:
for disallowed_dependency in disallowed_dependencies:
if disallowed_dependency in tree_output:
return (
f"{disallowed_dependency} showed up in the SDK's dependency tree when building with features={features}"
"This dependency should only ever show up if the `native_viewer` feature is enabled. "
f"Full dependency tree:\n{tree_output}"
)
return None
for features in ["default", "default,auth,oss_server,perf_telemetry,web_viewer"]:
for target in deny_targets:
result = run_cargo(
"tree",
# -f '{lib}' is used here because otherwise cargo tree would print links to repositories of patched crates
# which would cause false positives e.g. when checking for egui.
f"-p rerun --target {target} -f '{{lib}}' -F {features}",
output_checks=partial(check_sdk_tree_with_default_features, features=features),
)
result.command = f"Check dependencies in `{result.command}`"
results.append(result)
def wasm(results: list[Result]) -> None:
# Check viewer for wasm32
results.append(
run_cargo(
"clippy",
"--all-features --target wasm32-unknown-unknown --target-dir target_wasm -p re_viewer -- --deny warnings",
clippy_conf="scripts/clippy_wasm", # Use ./scripts/clippy_wasm/clippy.toml
),
)
# Check re_renderer examples for wasm32.
results.append(
run_cargo(
"clippy",
"--target wasm32-unknown-unknown --target-dir target_wasm -p re_renderer_examples",
clippy_conf="scripts/clippy_wasm", # Use ./scripts/clippy_wasm/clippy.toml
),
)
def individual_examples(results: list[Result]) -> None:
for cargo_toml_path in glob("./examples/rust/**/Cargo.toml", recursive=True):
package_name = package_name_from_cargo_toml(cargo_toml_path)
results.append(run_cargo("check", f"--no-default-features -p {package_name}"))
results.append(run_cargo("check", f"--all-features -p {package_name}"))
def individual_crates(results: list[Result]) -> None:
for cargo_toml_path in glob("./crates/**/Cargo.toml", recursive=True):
package_name = package_name_from_cargo_toml(cargo_toml_path)
results.append(run_cargo("check", f"--no-default-features -p {package_name}"))
results.append(run_cargo("check", f"--all-features -p {package_name}"))
def docs(results: list[Result]) -> None:
# ⚠️ This version skips the `rerun` crate itself
# Presumably due to https://github.com/rust-lang/rust/issues/114891, checking the `rerun` crate
# takes about 20minutes on CI (per command).
# Since this crate mostly combines & exposes other crates, it's not as important for iterating on the code.
#
# For details see https://github.com/rerun-io/rerun/issues/7387
# These take a few minutes each on CI, but very useful for catching broken doclinks.
results.append(run_cargo("doc", "--no-deps --all-features --workspace --exclude rerun"))
results.append(run_cargo("doc", "--document-private-items --no-deps --all-features --workspace --exclude rerun"))
def docs_slow(results: list[Result]) -> None:
# See `docs` above, this may take 20min each due to issues in cargo doc.
results.append(run_cargo("doc", "--no-deps --all-features -p rerun"))
results.append(run_cargo("doc", "--document-private-items --no-deps --all-features -p rerun"))
test_failure_message = 'See the "Upload test results" step for a link to the snapshot test artifact.'
def tests(results: list[Result]) -> None:
# We first use `--no-run` to measure the time of compiling vs actually running
results.append(run_cargo("nextest", "run --all-targets --all-features --no-run", deny_warnings=False))
results.append(run_cargo("nextest", "run --all-targets --all-features --no-fail-fast", deny_warnings=False))
if not results[-1].success:
print(test_failure_message)
# Cargo nextest doesn't support doc tests yet, run those separately.
results.append(run_cargo("test", "--all-features --doc", deny_warnings=False))
def tests_without_all_features(results: list[Result]) -> None:
# We first use `--no-run` to measure the time of compiling vs actually running
results.append(run_cargo("test", "--all-targets --no-run", deny_warnings=False))
results.append(run_cargo("nextest", "run --all-targets --no-fail-fast", deny_warnings=False))
if not results[-1].success:
print(test_failure_message)
if __name__ == "__main__":
main()