#!/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()